diff --git a/src/paymasters/FleetTreasuryPaymaster.sol b/src/paymasters/BondTreasuryPaymaster.sol similarity index 55% rename from src/paymasters/FleetTreasuryPaymaster.sol rename to src/paymasters/BondTreasuryPaymaster.sol index d2de3dc..c27591a 100644 --- a/src/paymasters/FleetTreasuryPaymaster.sol +++ b/src/paymasters/BondTreasuryPaymaster.sol @@ -7,63 +7,81 @@ import {SafeERC20} from "@openzeppelin/contracts/token/ERC20/utils/SafeERC20.sol import {BasePaymaster} from "./BasePaymaster.sol"; import {QuotaControl} from "../QuotaControl.sol"; -/// @notice Combined paymaster + bond treasury for FleetIdentity operations. -/// @dev Holds ETH (to sponsor gas) and NODL (to sponsor bonds). Whitelisted -/// users call FleetIdentityUpgradeable.claimUuidSponsored(), which calls -/// this contract's `consumeSponsoredBond` to validate + consume quota, -/// then pulls the NODL bond via `transferFrom`. -/// The ZkSync paymaster validation ensures only whitelisted users calling -/// FleetIdentity get gas-sponsored. -contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { +/// @notice ZkSync paymaster plus ERC-20 bond treasury for whitelisted contracts (e.g. `IBondTreasury` implementers). +/// @dev Holds ETH (sponsored gas) and bond token balance. Whitelisted users interact with `isWhitelistedContract` +/// destinations; bond consumers call `consumeSponsoredBond`, which checks caller + user whitelist, quota, balance, +/// then `forceApprove(msg.sender, amount)` for `transferFrom`. +/// Gas: `isWhitelistedContract[to] && isWhitelistedUser[from]`. Constructor seeds `address(this)` so management +/// txs can be sponsored once those EOAs are user-whitelisted. On-chain mutators use `WHITELIST_ADMIN_ROLE` / +/// `WITHDRAWER_ROLE` / `DEFAULT_ADMIN_ROLE`. +contract BondTreasuryPaymaster is BasePaymaster, QuotaControl { using SafeERC20 for IERC20; bytes32 public constant WHITELIST_ADMIN_ROLE = keccak256("WHITELIST_ADMIN_ROLE"); - address public immutable fleetIdentity; IERC20 public immutable bondToken; mapping(address => bool) public isWhitelistedUser; + /// @notice Sponsored tx destinations and allowed `consumeSponsoredBond` callers. + mapping(address => bool) public isWhitelistedContract; event WhitelistedUsersAdded(address[] users); event WhitelistedUsersRemoved(address[] users); + event WhitelistedContractsAdded(address[] contracts); + event WhitelistedContractsRemoved(address[] contracts); event TokensWithdrawn(address indexed token, address indexed to, uint256 amount); error UserIsNotWhitelisted(); - error DestinationNotAllowed(); + error DestIsNotWhitelisted(); error PaymasterBalanceTooLow(); - error NotFleetIdentity(); + error CallerNotWhitelistedContract(); error InsufficientBondBalance(); + error ZeroAddress(); constructor( address admin, address withdrawer, - address fleetIdentity_, + address[] memory initialWhitelistedContracts, address bondToken_, uint256 initialQuota, uint256 initialPeriod ) BasePaymaster(admin, withdrawer) QuotaControl(initialQuota, initialPeriod, admin) { + if (admin == address(0) || withdrawer == address(0)) revert ZeroAddress(); + if (bondToken_ == address(0)) revert ZeroAddress(); + _grantRole(WHITELIST_ADMIN_ROLE, admin); - fleetIdentity = fleetIdentity_; bondToken = IERC20(bondToken_); + uint256 n = initialWhitelistedContracts.length; + for (uint256 i = 0; i < n; i++) { + if (initialWhitelistedContracts[i] == address(0)) revert ZeroAddress(); + isWhitelistedContract[initialWhitelistedContracts[i]] = true; + } + if (n > 0) { + emit WhitelistedContractsAdded(initialWhitelistedContracts); + } + if (!isWhitelistedContract[address(this)]) { + isWhitelistedContract[address(this)] = true; + address[] memory selfDest = new address[](1); + selfDest[0] = address(this); + emit WhitelistedContractsAdded(selfDest); + } } // ────────────────────────────────────────────── - // Bond Treasury (called by FleetIdentity) + // Bond Treasury (whitelisted contracts) // ────────────────────────────────────────────── /// @notice Validate whitelist + consume quota for a sponsored bond. - /// @dev Only callable by the FleetIdentity contract during claimUuidSponsored. - /// The actual NODL transfer is done separately by FleetIdentity via transferFrom. + /// @dev Callable only by `isWhitelistedContract`. Caller pulls via `transferFrom`. function consumeSponsoredBond(address user, uint256 amount) external { - if (msg.sender != fleetIdentity) revert NotFleetIdentity(); + if (!isWhitelistedContract[msg.sender]) revert CallerNotWhitelistedContract(); if (!isWhitelistedUser[user]) revert UserIsNotWhitelisted(); if (bondToken.balanceOf(address(this)) < amount) revert InsufficientBondBalance(); _checkedResetClaimed(); _checkedUpdateClaimed(amount); - // Approve only the exact amount needed for this claim - bondToken.forceApprove(fleetIdentity, amount); + bondToken.forceApprove(msg.sender, amount); } // ────────────────────────────────────────────── @@ -73,6 +91,7 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { function addWhitelistedUsers(address[] calldata users) external { _checkRole(WHITELIST_ADMIN_ROLE); for (uint256 i = 0; i < users.length; i++) { + if (users[i] == address(0)) revert ZeroAddress(); isWhitelistedUser[users[i]] = true; } emit WhitelistedUsersAdded(users); @@ -86,13 +105,31 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { emit WhitelistedUsersRemoved(users); } + function addWhitelistedContracts(address[] calldata contracts_) external { + _checkRole(WHITELIST_ADMIN_ROLE); + for (uint256 i = 0; i < contracts_.length; i++) { + if (contracts_[i] == address(0)) revert ZeroAddress(); + isWhitelistedContract[contracts_[i]] = true; + } + emit WhitelistedContractsAdded(contracts_); + } + + function removeWhitelistedContracts(address[] calldata contracts_) external { + _checkRole(WHITELIST_ADMIN_ROLE); + for (uint256 i = 0; i < contracts_.length; i++) { + isWhitelistedContract[contracts_[i]] = false; + } + emit WhitelistedContractsRemoved(contracts_); + } + // ────────────────────────────────────────────── // ERC-20 Withdrawal // ────────────────────────────────────────────── - /// @notice Withdraw ERC-20 tokens (e.g. excess NODL) from this contract. + /// @notice Withdraw ERC-20 tokens (e.g. excess bond token) from this contract. function withdrawTokens(address token, address to, uint256 amount) external { _checkRole(WITHDRAWER_ROLE); + if (token == address(0) || to == address(0)) revert ZeroAddress(); IERC20(token).safeTransfer(to, amount); emit TokensWithdrawn(token, to, amount); } @@ -102,13 +139,8 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { // ────────────────────────────────────────────── function _validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) internal view override { - if (to == fleetIdentity) { - if (!isWhitelistedUser[from]) revert UserIsNotWhitelisted(); - } else if (to == address(this)) { - if (!hasRole(WHITELIST_ADMIN_ROLE, from)) revert DestinationNotAllowed(); - } else { - revert DestinationNotAllowed(); - } + if (!isWhitelistedContract[to]) revert DestIsNotWhitelisted(); + if (!isWhitelistedUser[from]) revert UserIsNotWhitelisted(); if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); } diff --git a/src/swarms/doc/spec/swarm-specification.md b/src/swarms/doc/spec/swarm-specification.md index e77030b..e866de9 100644 --- a/src/swarms/doc/spec/swarm-specification.md +++ b/src/swarms/doc/spec/swarm-specification.md @@ -31,7 +31,7 @@ Version 1.0 — March 2026 8. [Client Discovery](#8-client-discovery) 9. [Fleet Maintenance](#9-fleet-maintenance) 10. [Upgradeable Contract Architecture](#10-upgradeable-contract-architecture) -11. [FleetTreasuryPaymaster](#11-fleettreasurypaymaster) +11. [BondTreasuryPaymaster](#11-bondtreasurypaymaster) 12. [Appendix A: ISO 3166 Geographic Reference](#appendix-a-iso-3166-geographic-reference)
@@ -89,7 +89,7 @@ graph TB | **ServiceProvider** | Backend URL registry (ERC-721) | `keccak256(url)` | SSV | | **SwarmRegistryL1** | Tag group registry (Ethereum L1) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | | **SwarmRegistryUniversal** | Tag group registry (ZkSync Era, all EVM) | `keccak256(fleetUuid, filter, fpSize, tagType)` | — | -| **FleetTreasuryPaymaster** | ZkSync paymaster + bond treasury | — | — | +| **BondTreasuryPaymaster** | ZkSync paymaster + bond treasury | — | — | All contracts are **DAO-owned** (UUPS upgradeable) during initial operation, allowing parameter tuning and bug fixes. Once mature and stable, an upgrade can renounce ownership to make them fully **permissionless**. Access control is via NFT ownership; FleetIdentity requires an ERC-20 bond (e.g., NODL) as an anti-spam mechanism. @@ -603,17 +603,19 @@ A sponsor (such as Nodle) can pay **both the gas and the NODL bond** on behalf o #### How It Works -1. The sponsor deploys and funds a `FleetTreasuryPaymaster` with ETH (for gas) and NODL (for bonds). +1. The sponsor deploys and funds a `BondTreasuryPaymaster` with ETH (for gas) and NODL (for bonds). 2. The sponsor whitelists the user's address. 3. The user calls `claimUuidSponsored()` on FleetIdentity, passing the treasury address. 4. The ZkSync paymaster covers the gas; the treasury covers the bond. +The paymaster can also sponsor gas to **other** on-chain contracts (for example `SwarmRegistryUniversal`) if the sponsor adds their proxy addresses via `addWhitelistedContracts` — same user whitelist as for `FleetIdentity`. See [Section 11](#11-bondtreasurypaymaster). + ```solidity // User calls (zero ETH / NODL needed in their wallet): uint256 tokenId = fleetIdentity.claimUuidSponsored( uuid, operatorAddress, // address(0) = self-operate - treasuryAddress // FleetTreasuryPaymaster + treasuryAddress // BondTreasuryPaymaster ); // UUID is now owned by msg.sender — provably, permanently, on-chain ``` @@ -622,7 +624,7 @@ uint256 tokenId = fleetIdentity.claimUuidSponsored( sequenceDiagram actor User as User (fresh AA wallet) actor Sponsor as Sponsor (Nodle) - participant PM as FleetTreasuryPaymaster + participant PM as BondTreasuryPaymaster participant FI as FleetIdentity participant TOKEN as NODL Token @@ -632,8 +634,8 @@ sequenceDiagram User->>+FI: claimUuidSponsored(uuid, operator, PM) [gas paid by PM] FI->>+PM: consumeSponsoredBond(user, BASE_BOND) - Note over PM: Check whitelist + quota + NODL balance - PM->>TOKEN: forceApprove(FleetIdentity, BASE_BOND) + Note over PM: whitelisted caller + user + quota + NODL balance + PM->>TOKEN: forceApprove(msg.sender, BASE_BOND) PM-->>-FI: ok FI->>TOKEN: transferFrom(PM, FI, BASE_BOND) FI-->>-User: tokenId (UUID owned by user) @@ -648,21 +650,23 @@ sequenceDiagram #### Treasury Quota Control -`FleetTreasuryPaymaster` inherits `QuotaControl`, which limits total NODL disbursed per period: +`BondTreasuryPaymaster` inherits `QuotaControl`, which limits total NODL disbursed per period: ```solidity // Deployment example (Nodle onboarding treasury) -new FleetTreasuryPaymaster( +address[] memory initialContracts = new address[](1); +initialContracts[0] = fleetIdentityProxy; +new BondTreasuryPaymaster( admin, withdrawer, - fleetIdentityAddress, + initialContracts, nodlTokenAddress, 100_000e18, // quota: 100,000 NODL per period 7 days // period length ); ``` -See [Section 11](#11-fleettreasurypaymaster) for the full paymaster specification. +See [Section 11](#11-bondtreasurypaymaster) for the full paymaster specification. ### 4.5 Operator Model @@ -1401,7 +1405,7 @@ Deployment order is dictated by contract dependencies: 1. **ServiceProviderUpgradeable** — No dependencies 2. **FleetIdentityUpgradeable** — Requires bond token address 3. **SwarmRegistry (L1 or Universal)** — Requires both ServiceProvider and FleetIdentity -4. **FleetTreasuryPaymaster** — Requires FleetIdentity address and bond token address +4. **BondTreasuryPaymaster** — Requires initial `isWhitelistedContract` list (often FleetIdentity proxy) and bond token address Each step deploys an implementation contract followed by an ERC1967Proxy pointing to it. @@ -1424,11 +1428,11 @@ Each step deploys an implementation contract followed by an ERC1967Proxy pointin -## 11. FleetTreasuryPaymaster +## 11. BondTreasuryPaymaster ### 11.1 Overview -`FleetTreasuryPaymaster` is a **ZkSync paymaster combined with a bond treasury** that enables fully sponsored fleet UUID claims. A single contract holds: +`BondTreasuryPaymaster` is a **ZkSync paymaster combined with a bond treasury** that enables fully sponsored fleet UUID claims. A single contract holds: - **ETH** — used to pay ZkSync gas fees on behalf of whitelisted users. - **NODL** — used to pay the `BASE_BOND` required by `FleetIdentity.claimUuidSponsored()`. @@ -1439,9 +1443,9 @@ This enables a **Web2-style onboarding experience with full Web3 ownership**: a | Property | Value | | :------------------- | :--------------------------------------------------------------------------------------------------- | -| **Gas sponsorship** | Pays ZkSync gas for calls to FleetIdentity by whitelisted users; also sponsors admin calls to itself | +| **Gas sponsorship** | Pays ZkSync gas for calls to whitelisted destinations by whitelisted users; also sponsors admin calls to itself | | **Bond sponsorship** | Pays `BASE_BOND` NODL from its own balance via `claimUuidSponsored` | -| **Allowed targets** | `fleetIdentity` (whitelisted users) and `address(this)` (whitelist admins) | +| **Allowed targets** | `isWhitelistedContract[to] && isWhitelistedUser[from]`. Constructor seeds `initialWhitelistedContracts` and always seeds `address(this)` for sponsored admin txs. Bond pullers use the same contract whitelist. | | **Access control** | `admin`, `WHITELIST_ADMIN_ROLE`, `WITHDRAWER_ROLE` | | **Quota control** | Inherits `QuotaControl` — configurable daily/weekly NODL cap | | **Paymaster flow** | General flow only — approval-based flow not supported | @@ -1449,24 +1453,26 @@ This enables a **Web2-style onboarding experience with full Web3 ownership**: a ### 11.3 Contract Interface ```solidity -contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { +contract BondTreasuryPaymaster is BasePaymaster, QuotaControl { bytes32 public constant WHITELIST_ADMIN_ROLE = keccak256("WHITELIST_ADMIN_ROLE"); - address public immutable fleetIdentity; IERC20 public immutable bondToken; mapping(address => bool) public isWhitelistedUser; + mapping(address => bool) public isWhitelistedContract; // gas destinations + bond pullers - // ── Bond Treasury (called by FleetIdentity) ────────────────────── + // ── Bond Treasury (called by any whitelisted contract) ───────── - /// @notice Validates whitelist + quota; approves exact bond amount for FleetIdentity. + /// @notice Validates whitelist + quota; approves exact bond amount for msg.sender. function consumeSponsoredBond(address user, uint256 amount) external; // ── Whitelist Management ───────────────────────────────────────── function addWhitelistedUsers(address[] calldata users) external; // WHITELIST_ADMIN_ROLE function removeWhitelistedUsers(address[] calldata users) external; // WHITELIST_ADMIN_ROLE + function addWhitelistedContracts(address[] calldata contracts) external; // WHITELIST_ADMIN_ROLE + function removeWhitelistedContracts(address[] calldata contracts) external; // WHITELIST_ADMIN_ROLE // ── Withdrawals ────────────────────────────────────────────────── @@ -1479,21 +1485,20 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { ZkSync calls `validateAndPayForPaymasterTransaction` before executing the user operation. The paymaster applies destination-based routing: -- **`to == fleetIdentity`:** only whitelisted users (`isWhitelistedUser[from]`) receive gas coverage. -- **`to == address(this)`:** only holders of `WHITELIST_ADMIN_ROLE` receive gas coverage. This allows the sponsor to submit `addWhitelistedUsers`, `removeWhitelistedUsers`, and other admin operations gas-free. -- **Any other destination:** validation reverts; the sender is responsible for their own gas. +- **Rule:** `isWhitelistedContract[to]` and `isWhitelistedUser[from]`. The constructor always adds `address(this)` to `isWhitelistedContract` so calls to the paymaster (e.g. whitelist updates) can be sponsored once the caller is in `isWhitelistedUsers`. `initialWhitelistedContracts` seeds further destinations (e.g. FleetIdentity proxy); sponsors add or remove with `addWhitelistedContracts` / `removeWhitelistedContracts`. +- **Otherwise:** `DestIsNotWhitelisted()` or `UserIsNotWhitelisted()`; the sender pays their own gas unless using another paymaster. In all cases the paymaster also verifies `address(this).balance >= requiredETH`. Using the paymaster is always opt-in — admins can submit ordinary transactions (without `paymasterParams`) and pay gas from their own wallet at any time. The approval-based paymaster flow is explicitly rejected (`PaymasterFlowNotSupported()`). ### 11.5 Bond Treasury Flow -Called by `FleetIdentity.claimUuidSponsored()` during execution: +Called by `FleetIdentity.claimUuidSponsored()` (or any whitelisted bond consumer) during execution: ``` -1. FleetIdentity → consumeSponsoredBond(user, BASE_BOND) -2. Paymaster checks: isWhitelistedUser[user] ✓, balance ≥ BASE_BOND ✓, quota not exhausted ✓ -3. Paymaster calls: bondToken.forceApprove(fleetIdentity, BASE_BOND) -4. FleetIdentity calls: bondToken.transferFrom(paymaster, fleetIdentity, BASE_BOND) +1. Caller (e.g. FleetIdentity) → consumeSponsoredBond(user, BASE_BOND) +2. Paymaster checks: isWhitelistedContract[msg.sender] ✓, isWhitelistedUser[user] ✓, balance ≥ BASE_BOND ✓, quota not exhausted ✓ +3. Paymaster calls: bondToken.forceApprove(msg.sender, BASE_BOND) +4. Caller calls: bondToken.transferFrom(paymaster, caller, BASE_BOND) 5. UUID minted to user — provably owned, zero friction ``` @@ -1514,7 +1519,7 @@ interface IBondTreasury { } ``` -This allows different sponsors with different policies (access lists, geographic restrictions, per-period quotas, enterprise accounts) to coexist. `FleetTreasuryPaymaster` is the reference implementation. +This allows different sponsors with different policies (access lists, geographic restrictions, per-period quotas, enterprise accounts) to coexist. `BondTreasuryPaymaster` is the reference implementation. ### 11.7 Events & Errors @@ -1522,12 +1527,15 @@ This allows different sponsors with different policies (access lists, geographic | :----------------------------------- | :---- | :--------------------------------------------------------------------------------------------------------------------------------------- | | `WhitelistedUsersAdded(users)` | Event | Emitted when users are added to the whitelist | | `WhitelistedUsersRemoved(users)` | Event | Emitted when users are removed from the whitelist | +| `WhitelistedContractsAdded(contracts)` | Event | Emitted when contract destinations are added (including non-empty constructor `initialWhitelistedContracts`) | +| `WhitelistedContractsRemoved(contracts)` | Event | Emitted when contract destinations are removed | | `TokensWithdrawn(token, to, amount)` | Event | Emitted on ERC-20 withdrawal | | `UserIsNotWhitelisted()` | Error | User not in whitelist (bond or gas validation) | -| `DestinationNotAllowed()` | Error | Gas sponsorship attempted for a destination other than FleetIdentity or the paymaster itself, or admin-role check failed for a self-call | +| `DestIsNotWhitelisted()` | Error | `to` not in `isWhitelistedContract` | | `PaymasterBalanceTooLow()` | Error | Insufficient ETH to cover gas | -| `NotFleetIdentity()` | Error | `consumeSponsoredBond` called by non-FleetIdentity | +| `CallerNotWhitelistedContract()` | Error | `consumeSponsoredBond` caller not in `isWhitelistedContract` | | `InsufficientBondBalance()` | Error | Paymaster NODL balance below requested bond amount | +| `ZeroAddress()` | Error | Zero `admin`, `withdrawer`, `bondToken`, whitelist entry, ERC-20 withdraw token/recipient, or user/contract added via admin functions | ### 11.8 Complete Sponsored Onboarding Flow @@ -1535,7 +1543,7 @@ This allows different sponsors with different policies (access lists, geographic sequenceDiagram actor User as User (fresh / AA wallet) actor Sponsor as Sponsor (Nodle) - participant PM as FleetTreasuryPaymaster + participant PM as BondTreasuryPaymaster participant FI as FleetIdentity participant TOKEN as NODL Token participant ZK as ZkSync Sequencer @@ -1548,13 +1556,13 @@ sequenceDiagram Note over User: At onboarding time User->>ZK: submit claimUuidSponsored(uuid, operator, PM) ZK->>+PM: validateAndPayForPaymasterTransaction - Note over PM: to == FI ✓, isWhitelisted[user] ✓, ETH balance ✓ + Note over PM: isWhitelistedContract[FI] ✓, isWhitelisted[user] ✓, ETH balance ✓ PM-->>-ZK: ok (PM pays gas) ZK->>+FI: claimUuidSponsored(uuid, operator, PM) FI->>+PM: consumeSponsoredBond(user, BASE_BOND) Note over PM: whitelist ✓, quota ✓, NODL balance ✓ - PM->>TOKEN: forceApprove(FI, BASE_BOND) + PM->>TOKEN: forceApprove(FI, BASE_BOND) // approves msg.sender (FleetIdentity) PM-->>-FI: ok FI->>TOKEN: transferFrom(PM, FI, BASE_BOND) FI-->>-User: tokenId minted — UUID owned by user diff --git a/test/paymasters/FleetTreasuryPaymaster.t.sol b/test/paymasters/BondTreasuryPaymaster.t.sol similarity index 56% rename from test/paymasters/FleetTreasuryPaymaster.t.sol rename to test/paymasters/BondTreasuryPaymaster.t.sol index 5ac804c..f52c190 100644 --- a/test/paymasters/FleetTreasuryPaymaster.t.sol +++ b/test/paymasters/BondTreasuryPaymaster.t.sol @@ -6,9 +6,10 @@ import {Test} from "forge-std/Test.sol"; import {Vm} from "forge-std/Vm.sol"; import {ERC1967Proxy} from "@openzeppelin/contracts/proxy/ERC1967/ERC1967Proxy.sol"; import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol"; +import {IERC20} from "@openzeppelin/contracts/token/ERC20/IERC20.sol"; import {AccessControlUtils} from "../__helpers__/AccessControlUtils.sol"; import {BasePaymaster} from "../../src/paymasters/BasePaymaster.sol"; -import {FleetTreasuryPaymaster} from "../../src/paymasters/FleetTreasuryPaymaster.sol"; +import {BondTreasuryPaymaster} from "../../src/paymasters/BondTreasuryPaymaster.sol"; import {QuotaControl} from "../../src/QuotaControl.sol"; import {FleetIdentityUpgradeable} from "../../src/swarms/FleetIdentityUpgradeable.sol"; @@ -20,16 +21,32 @@ contract MockERC20SCP is ERC20 { } } +/// @dev Any whitelisted contract can pull bond after `consumeSponsoredBond` (integration helper). +contract SponsoredBondPuller { + BondTreasuryPaymaster public immutable paymaster; + IERC20 public immutable token; + + constructor(BondTreasuryPaymaster paymaster_, IERC20 token_) { + paymaster = paymaster_; + token = token_; + } + + function pullBond(address user, uint256 amount) external { + paymaster.consumeSponsoredBond(user, amount); + token.transferFrom(address(paymaster), address(this), amount); + } +} + /// @dev Exposes internal paymaster validation for unit testing. -contract MockFleetTreasuryPaymaster is FleetTreasuryPaymaster { +contract MockBondTreasuryPaymaster is BondTreasuryPaymaster { constructor( address admin, address withdrawer, - address fleetIdentity_, + address[] memory initialWhitelistedContracts, address bondToken_, uint256 initialQuota, uint256 initialPeriod - ) FleetTreasuryPaymaster(admin, withdrawer, fleetIdentity_, bondToken_, initialQuota, initialPeriod) {} + ) BondTreasuryPaymaster(admin, withdrawer, initialWhitelistedContracts, bondToken_, initialQuota, initialPeriod) {} function mock_validateAndPayGeneralFlow(address from, address to, uint256 requiredETH) public view { _validateAndPayGeneralFlow(from, to, requiredETH); @@ -38,41 +55,47 @@ contract MockFleetTreasuryPaymaster is FleetTreasuryPaymaster { function mock_validateAndPayApprovalBasedFlow( address from, address to, - address token, + address token_, uint256 amount, bytes memory data, uint256 requiredETH ) public pure { - _validateAndPayApprovalBasedFlow(from, to, token, amount, data, requiredETH); + _validateAndPayApprovalBasedFlow(from, to, token_, amount, data, requiredETH); } } -contract FleetTreasuryPaymasterTest is Test { +contract BondTreasuryPaymasterTest is Test { using AccessControlUtils for Vm; FleetIdentityUpgradeable fleet; - MockFleetTreasuryPaymaster paymaster; + MockBondTreasuryPaymaster paymaster; MockERC20SCP bondToken; address internal admin = address(0x1111); address internal withdrawer = address(0x2222); address internal alice = address(0xA); address internal bob = address(0xB); + address internal attacker = address(0xB33F); bytes16 constant UUID_1 = bytes16(keccak256("fleet-alpha")); bytes16 constant UUID_2 = bytes16(keccak256("fleet-bravo")); bytes16 constant UUID_3 = bytes16(keccak256("fleet-charlie")); uint256 constant BASE_BOND = 100 ether; - uint256 constant QUOTA = 1000 ether; // allows 10 claims at BASE_BOND each + uint256 constant QUOTA = 1000 ether; uint256 constant PERIOD = 1 days; address[] internal whitelistTargets; + function _initialContractWhitelist(address fleetAddr) internal pure returns (address[] memory) { + address[] memory c = new address[](1); + c[0] = fleetAddr; + return c; + } + function setUp() public { bondToken = new MockERC20SCP(); - // Deploy FleetIdentity via proxy FleetIdentityUpgradeable impl = new FleetIdentityUpgradeable(); ERC1967Proxy proxy = new ERC1967Proxy( address(impl), @@ -80,17 +103,19 @@ contract FleetTreasuryPaymasterTest is Test { ); fleet = FleetIdentityUpgradeable(address(proxy)); - // Deploy merged paymaster/treasury - paymaster = new MockFleetTreasuryPaymaster(admin, withdrawer, address(fleet), address(bondToken), QUOTA, PERIOD); + paymaster = new MockBondTreasuryPaymaster( + admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, PERIOD + ); - // Fund paymaster with NODL for bonds bondToken.mint(address(paymaster), 10_000 ether); - // Whitelist alice + address[] memory initialUsers = new address[](2); + initialUsers[0] = alice; + initialUsers[1] = admin; + vm.prank(admin); + paymaster.addWhitelistedUsers(initialUsers); whitelistTargets = new address[](1); whitelistTargets[0] = alice; - vm.prank(admin); - paymaster.addWhitelistedUsers(whitelistTargets); } // ══════════════════════════════════════════════ @@ -104,10 +129,17 @@ contract FleetTreasuryPaymasterTest is Test { } function test_immutables() public view { - assertEq(paymaster.fleetIdentity(), address(fleet)); assertEq(address(paymaster.bondToken()), address(bondToken)); } + function test_initialWhitelistedContractIncludesFleet() public view { + assertTrue(paymaster.isWhitelistedContract(address(fleet))); + } + + function test_paymasterSelfSeededAsWhitelistedContract() public view { + assertTrue(paymaster.isWhitelistedContract(address(paymaster))); + } + // ══════════════════════════════════════════════ // Whitelist Management // ══════════════════════════════════════════════ @@ -121,12 +153,12 @@ contract FleetTreasuryPaymasterTest is Test { assertFalse(paymaster.isWhitelistedUser(bob)); vm.expectEmit(); - emit FleetTreasuryPaymaster.WhitelistedUsersAdded(targets); + emit BondTreasuryPaymaster.WhitelistedUsersAdded(targets); paymaster.addWhitelistedUsers(targets); assertTrue(paymaster.isWhitelistedUser(bob)); vm.expectEmit(); - emit FleetTreasuryPaymaster.WhitelistedUsersRemoved(targets); + emit BondTreasuryPaymaster.WhitelistedUsersRemoved(targets); paymaster.removeWhitelistedUsers(targets); assertFalse(paymaster.isWhitelistedUser(bob)); @@ -159,14 +191,14 @@ contract FleetTreasuryPaymasterTest is Test { paymaster.mock_validateAndPayGeneralFlow(admin, address(paymaster), 1 ether); } - function test_RevertIf_nonAdminToSelf_destinationNotAllowed() public { + function test_RevertIf_nonWhitelistedUser_toPaymaster() public { vm.deal(address(paymaster), 10 ether); - vm.expectRevert(FleetTreasuryPaymaster.DestinationNotAllowed.selector); - paymaster.mock_validateAndPayGeneralFlow(alice, address(paymaster), 1 ether); + vm.expectRevert(BondTreasuryPaymaster.UserIsNotWhitelisted.selector); + paymaster.mock_validateAndPayGeneralFlow(bob, address(paymaster), 1 ether); } function test_RevertIf_adminToSelf_paymasterBalanceTooLow() public { - vm.expectRevert(FleetTreasuryPaymaster.PaymasterBalanceTooLow.selector); + vm.expectRevert(BondTreasuryPaymaster.PaymasterBalanceTooLow.selector); paymaster.mock_validateAndPayGeneralFlow(admin, address(paymaster), 1 ether); } @@ -175,18 +207,67 @@ contract FleetTreasuryPaymasterTest is Test { paymaster.mock_validateAndPayApprovalBasedFlow(alice, address(fleet), address(0), 1, "0x", 0); } - function test_RevertIf_destinationNotAllowed() public { - vm.expectRevert(FleetTreasuryPaymaster.DestinationNotAllowed.selector); + function test_RevertIf_destIsNotWhitelisted() public { + vm.expectRevert(BondTreasuryPaymaster.DestIsNotWhitelisted.selector); paymaster.mock_validateAndPayGeneralFlow(alice, address(0xDEAD), 0); } + function test_generalFlowValidation_whitelistedContract_success() public { + address extra = address(0xCAFE); + address[] memory contracts_ = new address[](1); + contracts_[0] = extra; + vm.prank(admin); + paymaster.addWhitelistedContracts(contracts_); + assertTrue(paymaster.isWhitelistedContract(extra)); + + vm.deal(address(paymaster), 10 ether); + paymaster.mock_validateAndPayGeneralFlow(alice, extra, 1 ether); + } + + function test_RevertIf_whitelistedContract_userNotWhitelisted() public { + address extra = address(0xCAFE); + address[] memory contracts_ = new address[](1); + contracts_[0] = extra; + vm.prank(admin); + paymaster.addWhitelistedContracts(contracts_); + + vm.expectRevert(BondTreasuryPaymaster.UserIsNotWhitelisted.selector); + paymaster.mock_validateAndPayGeneralFlow(bob, extra, 0); + } + + function test_whitelistAdminUpdatesContractWhitelist() public { + address extra = address(0xBEEF); + address[] memory contracts_ = new address[](1); + contracts_[0] = extra; + + vm.startPrank(admin); + vm.expectEmit(); + emit BondTreasuryPaymaster.WhitelistedContractsAdded(contracts_); + paymaster.addWhitelistedContracts(contracts_); + assertTrue(paymaster.isWhitelistedContract(extra)); + + vm.expectEmit(); + emit BondTreasuryPaymaster.WhitelistedContractsRemoved(contracts_); + paymaster.removeWhitelistedContracts(contracts_); + assertFalse(paymaster.isWhitelistedContract(extra)); + vm.stopPrank(); + } + + function test_removeWhitelistedContract_canRemoveFleet() public { + address[] memory contracts_ = new address[](1); + contracts_[0] = address(fleet); + vm.prank(admin); + paymaster.removeWhitelistedContracts(contracts_); + assertFalse(paymaster.isWhitelistedContract(address(fleet))); + } + function test_RevertIf_userIsNotWhitelisted_paymaster() public { - vm.expectRevert(FleetTreasuryPaymaster.UserIsNotWhitelisted.selector); + vm.expectRevert(BondTreasuryPaymaster.UserIsNotWhitelisted.selector); paymaster.mock_validateAndPayGeneralFlow(bob, address(fleet), 0); } function test_RevertIf_paymasterBalanceTooLow() public { - vm.expectRevert(FleetTreasuryPaymaster.PaymasterBalanceTooLow.selector); + vm.expectRevert(BondTreasuryPaymaster.PaymasterBalanceTooLow.selector); paymaster.mock_validateAndPayGeneralFlow(alice, address(fleet), 1 ether); } @@ -195,32 +276,44 @@ contract FleetTreasuryPaymasterTest is Test { // ══════════════════════════════════════════════ function test_consumeSponsoredBond_success() public { - // Only FleetIdentity can call consumeSponsoredBond vm.prank(address(fleet)); paymaster.consumeSponsoredBond(alice, BASE_BOND); assertEq(paymaster.claimed(), BASE_BOND); } - function test_RevertIf_consumeSponsoredBond_notFleetIdentity() public { + function test_consumeSponsoredBond_anyWhitelistedContract() public { + SponsoredBondPuller puller = new SponsoredBondPuller(paymaster, IERC20(address(bondToken))); + address[] memory contracts_ = new address[](1); + contracts_[0] = address(puller); + vm.prank(admin); + paymaster.addWhitelistedContracts(contracts_); + + uint256 beforePm = bondToken.balanceOf(address(paymaster)); + puller.pullBond(alice, BASE_BOND); + assertEq(bondToken.balanceOf(address(puller)), BASE_BOND); + assertEq(bondToken.balanceOf(address(paymaster)), beforePm - BASE_BOND); + assertEq(paymaster.claimed(), BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_callerNotWhitelistedContract() public { vm.prank(alice); - vm.expectRevert(FleetTreasuryPaymaster.NotFleetIdentity.selector); + vm.expectRevert(BondTreasuryPaymaster.CallerNotWhitelistedContract.selector); paymaster.consumeSponsoredBond(alice, BASE_BOND); } function test_RevertIf_consumeSponsoredBond_notWhitelisted() public { vm.prank(address(fleet)); - vm.expectRevert(FleetTreasuryPaymaster.UserIsNotWhitelisted.selector); + vm.expectRevert(BondTreasuryPaymaster.UserIsNotWhitelisted.selector); paymaster.consumeSponsoredBond(bob, BASE_BOND); } function test_RevertIf_consumeSponsoredBond_insufficientBalance() public { - // Withdraw all NODL from paymaster vm.startPrank(withdrawer); paymaster.withdrawTokens(address(bondToken), withdrawer, bondToken.balanceOf(address(paymaster))); vm.stopPrank(); vm.prank(address(fleet)); - vm.expectRevert(FleetTreasuryPaymaster.InsufficientBondBalance.selector); + vm.expectRevert(BondTreasuryPaymaster.InsufficientBondBalance.selector); paymaster.consumeSponsoredBond(alice, BASE_BOND); } @@ -290,7 +383,6 @@ contract FleetTreasuryPaymasterTest is Test { vm.prank(alice); fleet.burn(tokenId); - // Refund goes to alice (uuidOwner = msg.sender) assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); } @@ -306,26 +398,22 @@ contract FleetTreasuryPaymasterTest is Test { function test_RevertIf_quotaExceeded() public { bondToken.mint(address(paymaster), 100_000 ether); - // Quota is 1000 ether, each claim costs BASE_BOND (100 ether), so 10 claims exhaust it for (uint256 i = 0; i < 10; i++) { bytes16 uuid = bytes16(keccak256(abi.encodePacked("uuid-", i))); vm.prank(alice); fleet.claimUuidSponsored(uuid, address(0), address(paymaster)); } - // 11th claim should exceed quota vm.prank(alice); vm.expectRevert(QuotaControl.QuotaExceeded.selector); fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); } function test_quotaTracksBaseBondNotClaimCount() public { - // Deploy paymaster with quota smaller than a single BASE_BOND - MockFleetTreasuryPaymaster tightPaymaster = new MockFleetTreasuryPaymaster( - admin, withdrawer, address(fleet), address(bondToken), BASE_BOND / 2, PERIOD + MockBondTreasuryPaymaster tightPaymaster = new MockBondTreasuryPaymaster( + admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), BASE_BOND / 2, PERIOD ); - // Whitelist alice on the tight paymaster address[] memory targets = new address[](1); targets[0] = alice; vm.prank(admin); @@ -333,7 +421,6 @@ contract FleetTreasuryPaymasterTest is Test { bondToken.mint(address(tightPaymaster), 10_000 ether); - // BASE_BOND (100 ether) > quota (50 ether), so first claim must revert vm.prank(alice); vm.expectRevert(QuotaControl.QuotaExceeded.selector); fleet.claimUuidSponsored(UUID_1, address(0), address(tightPaymaster)); @@ -342,22 +429,18 @@ contract FleetTreasuryPaymasterTest is Test { function test_quotaResetsAfterPeriod() public { bondToken.mint(address(paymaster), 100_000 ether); - // Exhaust quota (10 claims × 100 ether = 1000 ether) for (uint256 i = 0; i < 10; i++) { bytes16 uuid = bytes16(keccak256(abi.encodePacked("uuid-", i))); vm.prank(alice); fleet.claimUuidSponsored(uuid, address(0), address(paymaster)); } - // Verify quota is exhausted vm.prank(alice); vm.expectRevert(QuotaControl.QuotaExceeded.selector); fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); - // Advance past period vm.warp(block.timestamp + PERIOD + 1); - // Should succeed again after reset vm.prank(alice); fleet.claimUuidSponsored(UUID_3, address(0), address(paymaster)); assertEq(fleet.uuidOwner(UUID_3), alice); @@ -406,12 +489,14 @@ contract FleetTreasuryPaymasterTest is Test { function test_RevertIf_constructorZeroPeriod() public { vm.expectRevert(QuotaControl.ZeroPeriod.selector); - new MockFleetTreasuryPaymaster(admin, withdrawer, address(fleet), address(bondToken), QUOTA, 0); + new MockBondTreasuryPaymaster(admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, 0); } function test_RevertIf_constructorTooLongPeriod() public { vm.expectRevert(QuotaControl.TooLongPeriod.selector); - new MockFleetTreasuryPaymaster(admin, withdrawer, address(fleet), address(bondToken), QUOTA, 31 days); + new MockBondTreasuryPaymaster( + admin, withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, 31 days + ); } // ══════════════════════════════════════════════ @@ -437,9 +522,125 @@ contract FleetTreasuryPaymasterTest is Test { uint256 amount = 500 ether; vm.expectEmit(true, true, true, true); - emit FleetTreasuryPaymaster.TokensWithdrawn(address(bondToken), withdrawer, amount); + emit BondTreasuryPaymaster.TokensWithdrawn(address(bondToken), withdrawer, amount); vm.prank(withdrawer); paymaster.withdrawTokens(address(bondToken), withdrawer, amount); } + + // ══════════════════════════════════════════════ + // Security: deployment & access boundaries + // ══════════════════════════════════════════════ + + function test_RevertIf_constructor_zeroBondToken() public { + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + new MockBondTreasuryPaymaster(admin, withdrawer, _initialContractWhitelist(address(fleet)), address(0), QUOTA, PERIOD); + } + + function test_RevertIf_constructor_zeroAdmin() public { + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + new MockBondTreasuryPaymaster( + address(0), withdrawer, _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, PERIOD + ); + } + + function test_RevertIf_constructor_zeroWithdrawer() public { + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + new MockBondTreasuryPaymaster( + admin, address(0), _initialContractWhitelist(address(fleet)), address(bondToken), QUOTA, PERIOD + ); + } + + function test_RevertIf_constructor_initialWhitelistContainsZeroAddress() public { + address[] memory bad = new address[](1); + bad[0] = address(0); + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + new MockBondTreasuryPaymaster(admin, withdrawer, bad, address(bondToken), QUOTA, PERIOD); + } + + function test_RevertIf_addWhitelistedUsers_zeroAddress() public { + address[] memory bad = new address[](1); + bad[0] = address(0); + vm.prank(admin); + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + paymaster.addWhitelistedUsers(bad); + } + + function test_RevertIf_addWhitelistedContracts_zeroAddress() public { + address[] memory bad = new address[](1); + bad[0] = address(0); + vm.prank(admin); + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + paymaster.addWhitelistedContracts(bad); + } + + function test_RevertIf_withdrawTokens_zeroToken() public { + vm.prank(withdrawer); + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + paymaster.withdrawTokens(address(0), withdrawer, 1 ether); + } + + function test_RevertIf_withdrawTokens_zeroRecipient() public { + vm.prank(withdrawer); + vm.expectRevert(BondTreasuryPaymaster.ZeroAddress.selector); + paymaster.withdrawTokens(address(bondToken), address(0), 1 ether); + } + + function test_RevertIf_attacker_cannot_mutate_whitelists() public { + vm.startPrank(attacker); + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.addWhitelistedUsers(whitelistTargets); + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.removeWhitelistedUsers(whitelistTargets); + address[] memory c = new address[](1); + c[0] = address(fleet); + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.addWhitelistedContracts(c); + vm.expectRevert_AccessControlUnauthorizedAccount(attacker, paymaster.WHITELIST_ADMIN_ROLE()); + paymaster.removeWhitelistedContracts(c); + vm.stopPrank(); + } + + function test_RevertIf_attacker_cannot_withdrawTokens() public { + vm.prank(attacker); + vm.expectRevert(); + paymaster.withdrawTokens(address(bondToken), attacker, 1 ether); + } + + function test_RevertIf_attacker_cannot_withdrawETH() public { + vm.deal(address(paymaster), 1 ether); + vm.prank(attacker); + vm.expectRevert(); + paymaster.withdraw(attacker, 1 ether); + } + + function test_RevertIf_admin_cannot_withdrawETH_without_withdrawer_role() public { + vm.deal(address(paymaster), 1 ether); + vm.prank(admin); + vm.expectRevert(); + paymaster.withdraw(admin, 1 ether); + } + + function test_RevertIf_removePaymasterFromWhitelist_blocksSponsoredSelfValidation() public { + vm.deal(address(paymaster), 10 ether); + address[] memory self = new address[](1); + self[0] = address(paymaster); + vm.prank(admin); + paymaster.removeWhitelistedContracts(self); + + vm.expectRevert(BondTreasuryPaymaster.DestIsNotWhitelisted.selector); + paymaster.mock_validateAndPayGeneralFlow(admin, address(paymaster), 1 ether); + } + + function test_RevertIf_whitelistedBondPuller_cannot_change_whitelists() public { + SponsoredBondPuller puller = new SponsoredBondPuller(paymaster, IERC20(address(bondToken))); + address[] memory contracts_ = new address[](1); + contracts_[0] = address(puller); + vm.prank(admin); + paymaster.addWhitelistedContracts(contracts_); + + vm.expectRevert_AccessControlUnauthorizedAccount(address(puller), paymaster.WHITELIST_ADMIN_ROLE()); + vm.prank(address(puller)); + paymaster.addWhitelistedUsers(whitelistTargets); + } }