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/README.md b/README.md index 9d4cc8e..dd27208 100644 --- a/README.md +++ b/README.md @@ -138,16 +138,54 @@ npx hardhat run scripts/mintDocumentGasless.ts --network sepolia ## Environment variables +Deploy/registry/paymaster addresses are network-scoped — suffix the variable with `_SEPOLIA` or `_AMOY` (e.g. `FACTORY_ADDRESS_SEPOLIA`, `FACTORY_ADDRESS_AMOY`). `scripts/lib/network.ts` resolves the suffix from `--network ` (hardhat scripts) or the `NETWORK` env var (viem/permissionless scripts). See `.env.example` for the full annotated list. + +### Wallets & RPC + | Variable | Description | | --- | --- | -| `PRIVATE_KEY` | Deployer wallet private key | +| `PRIVATE_KEY` | Deployer/gas-payer wallet — pays for deployments, delegation txs, staking | +| `PRIVATE_KEY2` | Secondary wallet (optional — testing with a second account) | +| `OWNER_PRIVATE_KEY` | Platform owner / whitelisted user — signs UserOps, needs no ETH for gasless ops | | `SEPOLIA_RPC_URL` | Sepolia RPC endpoint | +| `AMOY_RPC_URL` | Polygon Amoy RPC endpoint | | `PIMLICO_API_KEY` | Pimlico bundler API key | -| `TDOC_DEPLOYER_ADDRESS` | Deployed TDocDeployer address | -| `PAYMASTER_IMPLEMENTATION` | PlatformPaymaster implementation address | -| `FACTORY_ADDRESS` | PlatformAccountFactory address | -| `PAYMASTER_ADDRESS` | Deployed paymaster clone address | -| `EIP7702_IMPL_ADDRESS` | EIP7702Implementation address | +| `NETWORK` | `sepolia` \| `amoy` — target network for viem/permissionless scripts (default: `sepolia`) | +| `ENTRY_POINT` | EntryPoint v0.8 address (default: `0x4337084D9E255Ff0702461CF8895CE9E3b5Ff108`, same on all supported chains) | + +### Deployed addresses (network-suffixed) + +| Variable | Description | +| --- | --- | +| `EIP7702_IMPL_ADDRESS_` | Deployed `EIP7702Implementation` address | +| `PAYMASTER_IMPLEMENTATION_` | Deployed `PlatformPaymaster` implementation address | +| `FACTORY_ADDRESS_` | Deployed `PlatformAccountFactory` address | +| `PAYMASTER_ADDRESS_` | Deployed paymaster clone address | +| `TDOC_DEPLOYER_ADDRESS_` | TrustVC `TDocDeployer` address (pre-deployed infra) | +| `TDOC_IMPLEMENTATION_` | TDoc implementation to clone via `deployRegistry` | +| `REGISTRY_ADDRESS_` | Registry deployed via `deployRegistryGasless.ts` | +| `TITLE_ESCROW_ADDRESS_` | Title escrow captured via `mintDocumentGasless.ts` | + +### Gasless script inputs + +| Variable | Description | +| --- | --- | +| `TOKEN_NAME` / `TOKEN_SYMBOL` | Name/symbol for the TradeTrust token registry (`deployRegistryGasless.ts`) | +| `TOKEN_ID` | Document token ID as `uint256` (`mintDocumentGasless.ts`) | +| `BENEFICIARY_ADDRESS` / `HOLDER_ADDRESS` | Document beneficiary/holder (`mintDocumentGasless.ts`) | +| `REMARK` | Optional remark bytes/text attached to the document | +| `NOMINEE_ADDR` / `NEW_HOLDER_ADDR` | Used by the `scripts/trFunctions/*` title-escrow helpers | + +### Optional deploy/stake overrides + +| Variable | Description | +| --- | --- | +| `PLATFORM_ADDRESS` | Paymaster owner EOA for `deployPlatformPaymaster.ts` (default: deployer) | +| `DAILY_LIMIT_ETH` | Per-user daily gas limit in ETH (default: `0` = unlimited) | +| `DEPLOY_SALT` | Hex `bytes32` CREATE2 salt (default: random) | +| `STAKE_AMOUNT_ETH` | ETH locked as EntryPoint stake (default: `0.01`) | +| `DEPOSIT_AMOUNT_ETH` | ETH deposited into the gas pool (default: `0.05`) | +| `UNSTAKE_DELAY_SEC` | Stake lock period in seconds (default: `86400` = 1 day) | ## Tech stack diff --git a/contracts/Factory.sol b/contracts/Factory.sol index 2d47870..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, @@ -21,17 +20,12 @@ contract PlatformAccountFactory is Ownable { address _tdocDeployer, address _paymasterImplementation ) Ownable(msg.sender) { + require(_tdocDeployer != address(0), "Zero address"); + require(_paymasterImplementation != address(0), "Zero address"); tdocDeployer = _tdocDeployer; 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 ────────────────────────────────────────────────────