Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions src/paymasters/FleetTreasuryPaymaster.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you take my other suggestions, then consider renaming FleetTreasuryPaymaster to TreasuryPaymaster or use your AI assistant to suggest a better name that matches the contract logic.

using SafeERC20 for IERC20;

Expand All @@ -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,
Expand All @@ -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);
}

// ──────────────────────────────────────────────
Expand Down Expand Up @@ -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
// ──────────────────────────────────────────────
Expand All @@ -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();
}
Comment on lines +133 to +140
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest a simplification as follows

Suggested change
if (to == address(this)) {
if (!hasRole(WHITELIST_ADMIN_ROLE, from)) {
revert DestinationNotAllowed();
}
} else if (isWhitelistedContract[to]) {
if (!isWhitelistedUser[from]) {
revert UserIsNotWhitelisted();
}
if (!isWhitelistedUser[from]) {
revert UserIsNotWhitelisted();
} else if (!isWhitelistedContract[to]) {
revert DestinationNotAllowed();
}

Then make sure that in the constructor you add address(this) to the whitelisted contracts and admin to the whitelisted users.

} 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)
Expand Down
23 changes: 16 additions & 7 deletions src/swarms/doc/spec/swarm-specification.md
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 |
Expand All @@ -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) ──────────────────────

Expand All @@ -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 ──────────────────────────────────────────────────

Expand All @@ -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()`).

Expand Down Expand Up @@ -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 |
Expand All @@ -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)
Expand Down
57 changes: 55 additions & 2 deletions test/paymasters/FleetTreasuryPaymaster.t.sol
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ══════════════════════════════════════════════
Expand Down Expand Up @@ -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);
Expand Down
Loading