diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml index 9c9a7ca..ba8bf12 100644 --- a/.github/workflows/linters.yml +++ b/.github/workflows/linters.yml @@ -23,6 +23,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Install Commit Lint Dependencies - run: npm install @commitlint/config-conventional + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - run: npm ci --ignore-scripts - uses: JulienKode/pull-request-name-linter-action@v0.5.0 diff --git a/.github/workflows/pull_requests.yml b/.github/workflows/pull_requests.yml index deb9e9e..4830386 100644 --- a/.github/workflows/pull_requests.yml +++ b/.github/workflows/pull_requests.yml @@ -2,6 +2,9 @@ on: pull_request: types: [opened, reopened, synchronize] +permissions: + contents: read + env: NODE_ENV: ci @@ -24,6 +27,10 @@ jobs: pull-requests: write steps: - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24.x + - run: npm ci --ignore-scripts - uses: reviewdog/action-eslint@v1 with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1f351d1..6b13b24 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,9 @@ on: - main - dev +permissions: + contents: read + env: NODE_ENV: ci diff --git a/contracts/Factory.sol b/contracts/Factory.sol index e89e617..5a1fcc5 100644 --- a/contracts/Factory.sol +++ b/contracts/Factory.sol @@ -8,7 +8,6 @@ import {Clones} from "@openzeppelin/contracts/proxy/Clones.sol"; contract PlatformAccountFactory is Ownable { address public tdocDeployer; address public paymasterImplementation; - mapping(address => address) public attachedPaymaster; event PlatformOnboarded( address indexed platformAddress, @@ -27,13 +26,6 @@ contract PlatformAccountFactory is Ownable { paymasterImplementation = _paymasterImplementation; } - function setAttachedPaymaster( - address platformAddress - ) external view returns (address) { - address paymaster = attachedPaymaster[platformAddress]; - return paymaster; - } - function updateTdocDeployer(address _tdocDeployer) external onlyOwner { require(_tdocDeployer != address(0), "Zero address"); tdocDeployer = _tdocDeployer; diff --git a/contracts/PlatformPaymaster.sol b/contracts/PlatformPaymaster.sol index 7b10829..3decd0c 100644 --- a/contracts/PlatformPaymaster.sol +++ b/contracts/PlatformPaymaster.sol @@ -170,6 +170,12 @@ contract PlatformPaymaster is BasePaymaster { bytes calldata remark ) external returns (address titleEscrow) { require(authorizedRegistries[registry], "registry not authorized"); + require( + authorizedCallers[msg.sender] || + userWhitelist[msg.sender] > 0 || + msg.sender == owner(), + "caller not authorized" + ); titleEscrow = ITradeTrustToken(registry).mint( beneficiary, @@ -291,7 +297,18 @@ contract PlatformPaymaster is BasePaymaster { } if (innerSel == MINT_DOCUMENT_SEL) { - // mintDocument: registry enforces MINTER_ROLE — no extra whitelist needed + if ( + !authorizedCallers[sender] && + userWhitelist[sender] == 0 && + sender != owner() + ) { + emit UserOpRejected(sender, "caller not authorized"); + return ("", _packValidationData(true, 0, 0)); + } + if (dailyLimit > 0 && dailySpend[sender] + maxCost > dailyLimit) { + emit UserOpRejected(sender, "daily limit exceeded"); + return ("", _packValidationData(true, 0, 0)); + } return ( abi.encode(sender, maxCost, false), _packValidationData(false, 0, 0) diff --git a/contracts/mocks/MockRegistry.sol b/contracts/mocks/MockRegistry.sol index 5542a59..1a62ca2 100644 --- a/contracts/mocks/MockRegistry.sol +++ b/contracts/mocks/MockRegistry.sol @@ -53,6 +53,9 @@ contract MockRegistry { uint256, bytes calldata ) external returns (address titleEscrow) { + if (!_roles[MINTER_ROLE][msg.sender]) { + revert AccessControlUnauthorizedAccount(msg.sender, MINTER_ROLE); + } titleEscrow = address(new MockTitleEscrow()); lastTitleEscrow = titleEscrow; } diff --git a/scripts/deployFactory.ts b/scripts/deployFactory.ts index 5d5c744..fae60ef 100644 --- a/scripts/deployFactory.ts +++ b/scripts/deployFactory.ts @@ -16,7 +16,7 @@ import { createPublicClient, createWalletClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import hre from "hardhat"; import * as dotenv from "dotenv"; -import { getNetworkConfig, getEnv } from "./lib/network"; +import { getNetworkConfig, getEnv, getFeeOverrides } from "./lib/network"; dotenv.config(); async function main() { @@ -44,6 +44,7 @@ async function main() { abi: artifact.abi, bytecode: artifact.bytecode as `0x${string}`, args: [tdocDeployer, paymasterImpl], + ...getFeeOverrides(hre.network.name), }); console.log(" tx:", txHash); diff --git a/scripts/deployImplementation.ts b/scripts/deployImplementation.ts index 894a119..8c835d8 100644 --- a/scripts/deployImplementation.ts +++ b/scripts/deployImplementation.ts @@ -17,7 +17,7 @@ import { createPublicClient, createWalletClient, http } from "viem"; import { privateKeyToAccount } from "viem/accounts"; import hre from "hardhat"; import * as dotenv from "dotenv"; -import { getNetworkConfig } from "./lib/network"; +import { getNetworkConfig, getFeeOverrides } from "./lib/network"; dotenv.config(); const DEFAULT_ENTRY_POINT = "0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108" as `0x${string}`; @@ -45,6 +45,7 @@ async function main() { abi: artifact.abi, bytecode: artifact.bytecode as `0x${string}`, args: [entryPoint], + ...getFeeOverrides(hre.network.name), }); console.log(" tx:", txHash); diff --git a/scripts/deployPlatformPaymaster.ts b/scripts/deployPlatformPaymaster.ts index 2c3ce76..82141fe 100644 --- a/scripts/deployPlatformPaymaster.ts +++ b/scripts/deployPlatformPaymaster.ts @@ -28,7 +28,7 @@ import { randomBytes } from "crypto"; import { privateKeyToAccount } from "viem/accounts"; import hre from "hardhat"; import * as dotenv from "dotenv"; -import { getNetworkConfig, getEnv } from "./lib/network"; +import { getNetworkConfig, getEnv, getFeeOverrides } from "./lib/network"; dotenv.config(); const factoryAbi = parseAbi([ @@ -64,6 +64,7 @@ async function main() { abi: factoryAbi, functionName: "deployPlatformPaymaster", args: [platformAddress, dailyLimit, salt], + ...getFeeOverrides(hre.network.name), }); console.log(" tx:", txHash); diff --git a/scripts/lib/network.ts b/scripts/lib/network.ts index 8acaa92..0e3b884 100644 --- a/scripts/lib/network.ts +++ b/scripts/lib/network.ts @@ -3,6 +3,7 @@ // network is added to hardhat.config.ts. import { sepolia, polygonAmoy } from "viem/chains"; +import { parseGwei } from "viem"; import type { Chain } from "viem"; interface NetworkEntry { @@ -41,3 +42,20 @@ export function getEnv(suffix: string, name: string, required = true): string { if (!val && required) throw new Error(`${key} is not set in .env`); return val ?? ""; } + +/** + * Amoy's Infura endpoint has been observed returning a broken EIP-1559 fee + * suggestion (maxPriorityFeePerGas ≈ maxFeePerGas, leaving ~0 margin for the + * base fee), which makes eth_estimateGas reject the call outright. Override + * with fixed fees on Amoy to bypass viem's automatic estimation; Sepolia's + * estimation isn't known to have this problem, so leave it untouched there. + */ +export function getFeeOverrides( + networkName: string, +): { maxFeePerGas: bigint; maxPriorityFeePerGas: bigint } | undefined { + if (networkName !== "amoy") return undefined; + return { + maxFeePerGas: parseGwei(process.env.AMOY_MAX_FEE_GWEI ?? "100"), + maxPriorityFeePerGas: parseGwei(process.env.AMOY_MAX_PRIORITY_FEE_GWEI ?? "30"), + }; +} diff --git a/src/constants/index.ts b/src/constants/index.ts index bf574c8..c64f2e2 100644 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -1,10 +1,16 @@ export const ChainId = { Sepolia: 11155111, + Amoy: 80002, } as const; /** Deployed contract addresses indexed by chainId */ export const contractAddress = { + PaymasterImplementation: { + [ChainId.Sepolia]: "0x5ca5652025ca77d13323ed4887b4cbee6098dd8f", + [ChainId.Amoy]: "0xf47d58D3adc642DaD23966698A7A60b8b34D72f8", + }, PlatformAccountFactory: { - [ChainId.Sepolia]: "0x5dcDf7fA6Ab8323F67FD66E89b6CeD4564f9F4Ff", + [ChainId.Sepolia]: "0x1fe801f6af6e9a6c76431db08b121a7de70bc895", + [ChainId.Amoy]: "0x2762abf6fa22314ebcab41dd4666836038d29341", }, -} as const; +} as const; \ No newline at end of file diff --git a/test/PlatformPaymaster.ts b/test/PlatformPaymaster.ts index 5a4ae19..30ea43e 100644 --- a/test/PlatformPaymaster.ts +++ b/test/PlatformPaymaster.ts @@ -317,8 +317,9 @@ describe("PlatformPaymaster", function () { const { paymaster, paymasterAsOther, mockRegistry, other, user } = await loadFixture(deployFixture); - // Authorize the registry + // Authorize the registry and whitelist the caller await paymaster.write.addRegistry([mockRegistry.address]); + await paymaster.write.setUserWhitelist([other.account.address, 1n]); await paymasterAsOther.write.mintDocument([ mockRegistry.address, @@ -356,6 +357,7 @@ describe("PlatformPaymaster", function () { const { paymaster, paymasterAsOther, mockRegistry, other, user } = await loadFixture(deployFixture); await paymaster.write.addRegistry([mockRegistry.address]); + await paymaster.write.setUserWhitelist([other.account.address, 1n]); await paymasterAsOther.write.mintDocument([ mockRegistry.address, user.account.address, other.account.address, 1n, "0x" as `0x${string}`, @@ -366,6 +368,51 @@ describe("PlatformPaymaster", function () { expect(await paymaster.read.documentsMinted([other.account.address])).to.equal(2n); }); + + it("reverts for an unauthorized caller (not whitelisted, not authorizedCaller, not owner)", async function () { + const { paymasterAsOther, mockRegistry, paymaster, other, user } = + await loadFixture(deployFixture); + await paymaster.write.addRegistry([mockRegistry.address]); + + await expect( + paymasterAsOther.write.mintDocument([ + mockRegistry.address, + user.account.address, + other.account.address, + 1n, + "0x" as `0x${string}`, + ]), + ).to.be.rejectedWith("caller not authorized"); + }); + + it("allows the owner to mint without being whitelisted", async function () { + const { paymaster, mockRegistry, platform, user, other } = await loadFixture(deployFixture); + await paymaster.write.addRegistry([mockRegistry.address]); + + // `paymaster` is connected as `platform`, the clone's owner (see deployFixture) + await paymaster.write.mintDocument([ + mockRegistry.address, + user.account.address, + other.account.address, + 1n, + "0x" as `0x${string}`, + ]); + + expect(await paymaster.read.documentsMinted([platform.account.address])).to.equal(1n); + }); + + it("allows an already-authorizedCaller to mint without whitelist credits", async function () { + const { paymaster, paymasterAsOther, mockRegistry, other, user } = + await loadFixture(deployFixture); + await paymaster.write.addRegistry([mockRegistry.address]); + await paymaster.write.addAuthorizedCaller([other.account.address]); + + await paymasterAsOther.write.mintDocument([ + mockRegistry.address, user.account.address, other.account.address, 1n, "0x" as `0x${string}`, + ]); + + expect(await paymaster.read.documentsMinted([other.account.address])).to.equal(1n); + }); }); // ─── getUserDailySpend ────────────────────────────────────────────────────