diff --git a/src/paymasters/FleetTreasuryPaymaster.sol b/src/paymasters/FleetTreasuryPaymaster.sol index d2de3dc..fc972e6 100644 --- a/src/paymasters/FleetTreasuryPaymaster.sol +++ b/src/paymasters/FleetTreasuryPaymaster.sol @@ -12,8 +12,9 @@ import {QuotaControl} from "../QuotaControl.sol"; /// 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. +/// Gas sponsorship: `fleetIdentity` is seeded into `isWhitelistedContract` at +/// deploy; admins add more destinations the same way. Admin-only calls to this +/// contract use `WHITELIST_ADMIN_ROLE` instead of user whitelist. contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { using SafeERC20 for IERC20; @@ -23,16 +24,22 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { IERC20 public immutable bondToken; mapping(address => bool) public isWhitelistedUser; + /// @notice Allowed destinations for sponsored txs; always includes `fleetIdentity` (set in constructor). + 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 DestIsNotWhitelisted(); error DestinationNotAllowed(); error PaymasterBalanceTooLow(); error NotFleetIdentity(); error InsufficientBondBalance(); + error CannotRemoveFleetIdentity(); constructor( address admin, @@ -45,6 +52,10 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { _grantRole(WHITELIST_ADMIN_ROLE, admin); fleetIdentity = fleetIdentity_; bondToken = IERC20(bondToken_); + isWhitelistedContract[fleetIdentity_] = true; + address[] memory seeded = new address[](1); + seeded[0] = fleetIdentity_; + emit WhitelistedContractsAdded(seeded); } // ────────────────────────────────────────────── @@ -86,6 +97,23 @@ 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++) { + 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++) { + if (contracts_[i] == fleetIdentity) revert CannotRemoveFleetIdentity(); + isWhitelistedContract[contracts_[i]] = false; + } + emit WhitelistedContractsRemoved(contracts_); + } + // ────────────────────────────────────────────── // ERC-20 Withdrawal // ────────────────────────────────────────────── @@ -102,14 +130,21 @@ 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(); + if (to == address(this)) { + if (!hasRole(WHITELIST_ADMIN_ROLE, from)) { + revert DestinationNotAllowed(); + } + } else if (isWhitelistedContract[to]) { + if (!isWhitelistedUser[from]) { + revert UserIsNotWhitelisted(); + } } else { - revert DestinationNotAllowed(); + revert DestIsNotWhitelisted(); + } + + if (address(this).balance < requiredETH) { + revert PaymasterBalanceTooLow(); } - if (address(this).balance < requiredETH) revert PaymasterBalanceTooLow(); } function _validateAndPayApprovalBasedFlow(address, address, address, uint256, bytes memory, uint256) diff --git a/src/swarms/doc/spec/swarm-specification.md b/src/swarms/doc/spec/swarm-specification.md index e77030b..a3c170d 100644 --- a/src/swarms/doc/spec/swarm-specification.md +++ b/src/swarms/doc/spec/swarm-specification.md @@ -608,6 +608,8 @@ A sponsor (such as Nodle) can pay **both the gas and the NODL bond** on behalf o 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-fleettreasurypaymaster). + ```solidity // User calls (zero ETH / NODL needed in their wallet): uint256 tokenId = fleetIdentity.claimUuidSponsored( @@ -1439,9 +1441,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** | Any contract in `isWhitelistedContract` (whitelisted users): `fleetIdentity` is seeded at deploy; admins add more via `addWhitelistedContracts` (e.g. SwarmRegistry). `fleetIdentity` cannot be removed. `address(this)` for gas: `WHITELIST_ADMIN_ROLE` only | | **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 | @@ -1457,6 +1459,7 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { IERC20 public immutable bondToken; mapping(address => bool) public isWhitelistedUser; + mapping(address => bool) public isWhitelistedContract; // includes fleetIdentity at deploy // ── Bond Treasury (called by FleetIdentity) ────────────────────── @@ -1467,6 +1470,8 @@ contract FleetTreasuryPaymaster is BasePaymaster, QuotaControl { 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 — cannot remove fleetIdentity // ── Withdrawals ────────────────────────────────────────────────── @@ -1479,9 +1484,9 @@ 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. +- **`to == address(this)`:** only holders of `WHITELIST_ADMIN_ROLE` receive gas coverage (e.g. `addWhitelistedUsers`, `addWhitelistedContracts`, quota updates). +- **`isWhitelistedContract[to]`:** only whitelisted users (`isWhitelistedUser[from]`) receive gas coverage. At deploy, `fleetIdentity` is written into `isWhitelistedContract`; sponsors add further contracts (e.g. `SwarmRegistryUniversal` proxy) with `addWhitelistedContracts`. Removing `fleetIdentity` from the set reverts (`CannotRemoveFleetIdentity()`). +- **Otherwise:** validation reverts with `DestIsNotWhitelisted()`; 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()`). @@ -1522,9 +1527,13 @@ 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 constructor seed for `fleetIdentity`) | +| `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` is not `address(this)` and not in `isWhitelistedContract` | +| `DestinationNotAllowed()` | Error | `to == address(this)` but `from` lacks `WHITELIST_ADMIN_ROLE` | +| `CannotRemoveFleetIdentity()` | Error | `removeWhitelistedContracts` attempted to clear `fleetIdentity` | | `PaymasterBalanceTooLow()` | Error | Insufficient ETH to cover gas | | `NotFleetIdentity()` | Error | `consumeSponsoredBond` called by non-FleetIdentity | | `InsufficientBondBalance()` | Error | Paymaster NODL balance below requested bond amount | @@ -1548,7 +1557,7 @@ 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) diff --git a/test/paymasters/FleetTreasuryPaymaster.t.sol b/test/paymasters/FleetTreasuryPaymaster.t.sol index 5ac804c..01f98af 100644 --- a/test/paymasters/FleetTreasuryPaymaster.t.sol +++ b/test/paymasters/FleetTreasuryPaymaster.t.sol @@ -108,6 +108,10 @@ contract FleetTreasuryPaymasterTest is Test { assertEq(address(paymaster.bondToken()), address(bondToken)); } + function test_fleetIdentitySeededAsWhitelistedContract() public view { + assertTrue(paymaster.isWhitelistedContract(address(fleet))); + } + // ══════════════════════════════════════════════ // Whitelist Management // ══════════════════════════════════════════════ @@ -175,11 +179,60 @@ 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(FleetTreasuryPaymaster.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(FleetTreasuryPaymaster.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 FleetTreasuryPaymaster.WhitelistedContractsAdded(contracts_); + paymaster.addWhitelistedContracts(contracts_); + assertTrue(paymaster.isWhitelistedContract(extra)); + + vm.expectEmit(); + emit FleetTreasuryPaymaster.WhitelistedContractsRemoved(contracts_); + paymaster.removeWhitelistedContracts(contracts_); + assertFalse(paymaster.isWhitelistedContract(extra)); + vm.stopPrank(); + } + + function test_RevertIf_removeFleetIdentityFromContractWhitelist() public { + address[] memory contracts_ = new address[](1); + contracts_[0] = address(fleet); + vm.prank(admin); + vm.expectRevert(FleetTreasuryPaymaster.CannotRemoveFleetIdentity.selector); + paymaster.removeWhitelistedContracts(contracts_); + } + function test_RevertIf_userIsNotWhitelisted_paymaster() public { vm.expectRevert(FleetTreasuryPaymaster.UserIsNotWhitelisted.selector); paymaster.mock_validateAndPayGeneralFlow(bob, address(fleet), 0);