diff --git a/src/paymasters/BondTreasuryPaymaster.sol b/src/paymasters/BondTreasuryPaymaster.sol index 8b0d6f7..213f8ba 100644 --- a/src/paymasters/BondTreasuryPaymaster.sol +++ b/src/paymasters/BondTreasuryPaymaster.sol @@ -9,17 +9,24 @@ import {WhitelistPaymaster} from "./WhitelistPaymaster.sol"; /// @notice ZkSync paymaster plus ERC-20 bond treasury for whitelisted contracts (e.g. `IBondTreasury` implementers). /// @dev Extends `WhitelistPaymaster` (user + destination whitelist, gas sponsorship). Adds bond token balance, -/// `consumeSponsoredBond` with `QuotaControl`, and ERC-20 withdrawal. Constructor seeds `address(this)` as a -/// whitelisted destination so management txs can be sponsored once those EOAs are user-whitelisted. +/// `consumeSponsoredBond` with `QuotaControl`, per-user token allowance, and ERC-20 withdrawal. +/// Constructor seeds `address(this)` as a whitelisted destination so management txs can be sponsored +/// once those EOAs are user-whitelisted. contract BondTreasuryPaymaster is WhitelistPaymaster, QuotaControl { using SafeERC20 for IERC20; IERC20 public immutable bondToken; + /// @notice Per-user remaining token allowance for sponsored bonds. + mapping(address => uint256) public userBondAllowance; + event TokensWithdrawn(address indexed token, address indexed to, uint256 amount); + event UserBondAllowanceSet(address indexed user, uint256 newAllowance); + event UserBondAllowanceIncreased(address indexed user, uint256 addedAmount, uint256 newAllowance); error CallerNotWhitelistedContract(); error InsufficientBondBalance(); + error UserBondAllowanceExceeded(); constructor( address admin, @@ -60,19 +67,40 @@ contract BondTreasuryPaymaster is WhitelistPaymaster, QuotaControl { } } - /// @notice Validate whitelist + consume quota for a sponsored bond. + /// @notice Validate whitelist + consume quota + decrement per-user allowance for a sponsored bond. /// @dev Callable only by `isWhitelistedContract`. Caller pulls via `transferFrom`. function consumeSponsoredBond(address user, uint256 amount) external { if (!isWhitelistedContract[msg.sender]) revert CallerNotWhitelistedContract(); if (!isWhitelistedUser[user]) revert UserIsNotWhitelisted(); if (bondToken.balanceOf(address(this)) < amount) revert InsufficientBondBalance(); + uint256 remaining = userBondAllowance[user]; + if (remaining < amount) revert UserBondAllowanceExceeded(); + unchecked { + userBondAllowance[user] = remaining - amount; + } + _checkedResetClaimed(); _checkedUpdateClaimed(amount); bondToken.forceApprove(msg.sender, amount); } + /// @notice Set the total bond allowance for a user (overwrites previous value). + function setUserBondAllowance(address user, uint256 allowance) external { + _checkRole(WHITELIST_ADMIN_ROLE); + userBondAllowance[user] = allowance; + emit UserBondAllowanceSet(user, allowance); + } + + /// @notice Add to a user's existing bond allowance. + function increaseUserBondAllowance(address user, uint256 amount) external { + _checkRole(WHITELIST_ADMIN_ROLE); + uint256 newAllowance = userBondAllowance[user] + amount; + userBondAllowance[user] = newAllowance; + emit UserBondAllowanceIncreased(user, amount, newAllowance); + } + /// @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); diff --git a/src/swarms/FleetIdentityUpgradeable.sol b/src/swarms/FleetIdentityUpgradeable.sol index 59293f4..8ec92ac 100644 --- a/src/swarms/FleetIdentityUpgradeable.sol +++ b/src/swarms/FleetIdentityUpgradeable.sol @@ -2,8 +2,9 @@ pragma solidity ^0.8.24; -import {ERC721EnumerableUpgradeable} from - "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; +import { + ERC721EnumerableUpgradeable +} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol"; import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol"; import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; @@ -198,6 +199,16 @@ contract FleetIdentityUpgradeable is mapping(uint16 => uint32[]) internal _countryAdminAreas; mapping(uint32 => uint256) internal _countryAdminAreaIndex; + // ────────────────────────────────────────────── + // V2 Storage: Bond payer tracking + // ────────────────────────────────────────────── + + /// @notice UUID -> address that paid the ownership bond. + /// @dev For self-funded claims this is msg.sender; for sponsored claims + /// this is the treasury. On burn the bond refunds to this address. + /// Zero (pre-upgrade tokens) falls back to uuidOwner. + mapping(bytes16 => address) public uuidOwnershipBondPayer; + // ────────────────────────────────────────────── // Storage Gap (for future upgrades) // ────────────────────────────────────────────── @@ -205,7 +216,7 @@ contract FleetIdentityUpgradeable is /// @dev Reserved storage slots for future upgrades. /// When adding new storage in V2+, reduce this gap accordingly. // solhint-disable-next-line var-name-mixedcase - uint256[50] private __gap; + uint256[49] private __gap; // ────────────────────────────────────────────── // Events @@ -433,10 +444,14 @@ contract FleetIdentityUpgradeable is // Use snapshot for accurate refund uint256 ownershipBond = uuidOwnershipBondPaid[uuid]; + // Refund to whoever paid the bond (treasury for sponsored, owner for self-funded). + // Zero (pre-upgrade tokens) falls back to the UUID owner. + address bondPayer = uuidOwnershipBondPayer[uuid]; + if (bondPayer == address(0)) bondPayer = owner; _burn(tokenId); _clearUuidOwnership(uuid); - _refundBond(owner, ownershipBond); + _refundBond(bondPayer, ownershipBond); emit FleetBurned(tokenHolder, tokenId, region, 0, ownershipBond); } else { @@ -486,6 +501,7 @@ contract FleetIdentityUpgradeable is _mint(msg.sender, tokenId); uuidOwnershipBondPaid[uuid] = _baseBond; + uuidOwnershipBondPayer[uuid] = msg.sender; _pullBond(msg.sender, _baseBond); emit UuidClaimed(msg.sender, uuid, operatorOf(uuid)); @@ -525,6 +541,7 @@ contract FleetIdentityUpgradeable is tokenId = uint256(uint128(uuid)); uuidOwnershipBondPaid[uuid] = _baseBond; + uuidOwnershipBondPayer[uuid] = treasury; _mint(msg.sender, tokenId); // Bond transfer uses the trusted _bondToken — cannot be faked by the treasury. @@ -660,11 +677,7 @@ contract FleetIdentityUpgradeable is } /// @notice Builds a bundle containing ONLY country-level fleets. - function buildCountryOnlyBundle(uint16 countryCode) - external - view - returns (bytes16[] memory uuids, uint256 count) - { + function buildCountryOnlyBundle(uint16 countryCode) external view returns (bytes16[] memory uuids, uint256 count) { if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode(); uint32 countryKey = uint32(countryCode); @@ -761,6 +774,7 @@ contract FleetIdentityUpgradeable is delete uuidOperator[uuid]; delete uuidTotalTierBonds[uuid]; delete uuidOwnershipBondPaid[uuid]; + delete uuidOwnershipBondPayer[uuid]; } function _decrementUuidCount(bytes16 uuid) internal returns (uint256 newCount) { @@ -832,6 +846,7 @@ contract FleetIdentityUpgradeable is tokenId = _mintFleetToken(uuid, region, targetTier); tokenTier0Bond[tokenId] = tier0Bond; uuidOwnershipBondPaid[uuid] = _baseBond; + uuidOwnershipBondPayer[uuid] = msg.sender; _pullBond(msg.sender, _baseBond + targetTierBond); @@ -975,8 +990,7 @@ contract FleetIdentityUpgradeable is uint32 adminKey = makeAdminRegion(countryCode, adminCode); uint32 candidateRegion = isCountry ? countryKey : adminKey; - (, uint256 count, uint256 highestTier, uint256 lowestTier) = - _buildHighestBondedUuidBundle(countryKey, adminKey); + (, uint256 count, uint256 highestTier, uint256 lowestTier) = _buildHighestBondedUuidBundle(countryKey, adminKey); for (uint256 tier = lowestTier; tier <= highestTier; ++tier) { bool tierHasCapacity = _regionTierMembers[candidateRegion][tier].length < TIER_CAPACITY; @@ -1114,7 +1128,13 @@ contract FleetIdentityUpgradeable is uint32 region = tokenRegion(tokenId); if (region == OWNED_REGION_KEY && from != address(0) && to != address(0)) { - uuidOwner[tokenUuid(tokenId)] = to; + bytes16 uuid = tokenUuid(tokenId); + uuidOwner[uuid] = to; + // Transfer bond-refund right only if it was the previous owner (self-funded). + // Sponsored bonds (treasury != from) stay with the original payer. + if (uuidOwnershipBondPayer[uuid] == from) { + uuidOwnershipBondPayer[uuid] = to; + } } return from; diff --git a/test/FleetIdentity.t.sol b/test/FleetIdentity.t.sol index 7b04373..c78b2e3 100644 --- a/test/FleetIdentity.t.sol +++ b/test/FleetIdentity.t.sol @@ -97,10 +97,7 @@ contract FleetIdentityTest is Test { address operator ); event OperatorSet( - bytes16 indexed uuid, - address indexed oldOperator, - address indexed newOperator, - uint256 tierExcessTransferred + bytes16 indexed uuid, address indexed oldOperator, address indexed newOperator, uint256 tierExcessTransferred ); event FleetPromoted( uint256 indexed tokenId, uint256 indexed fromTier, uint256 indexed toTier, uint256 additionalBond @@ -406,7 +403,7 @@ contract FleetIdentityTest is Test { // No fleets anywhere — localInclusionHint returns tier 0. (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 0); - + // Register at tier 0 (inclusionTier is 0, so no promotion needed) vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); @@ -421,7 +418,7 @@ contract FleetIdentityTest is Test { // localInclusionHint should return tier 1 (cheapest tier with capacity). (uint256 inclusionTier,) = fleet.localInclusionHint(US, ADMIN_CA); assertEq(inclusionTier, 1); - + // Register directly at inclusionTier as tier 0 is full vm.prank(bob); uint256 tokenId = fleet.registerFleetLocal(_uuid(100), US, ADMIN_CA, inclusionTier); @@ -628,7 +625,7 @@ contract FleetIdentityTest is Test { assertEq(bondToken.balanceOf(alice), balBefore + fleet.tierBond(0, false)); assertEq(bondToken.balanceOf(address(fleet)), BASE_BOND); // owned-only token holds BASE_BOND assertEq(fleet.bonds(tokenId), 0); - + // Verify owned-only token was minted uint256 ownedTokenId = uint256(uint128(UUID_1)); assertEq(fleet.ownerOf(ownedTokenId), alice); @@ -657,7 +654,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.burn(tokenId); assertEq(fleet.regionTierCount(_regionUS()), 0); - + // Verify transitioned to owned-only assertTrue(fleet.isOwnedOnly(UUID_1)); } @@ -668,7 +665,7 @@ contract FleetIdentityTest is Test { vm.prank(alice); fleet.burn(tokenId); - + // Now in owned-only state - burn that too to fully release uint256 ownedTokenId = uint256(uint128(UUID_1)); vm.prank(alice); @@ -971,13 +968,19 @@ contract FleetIdentityTest is Test { fleet.burn(c2); uint256 ownedTokenBob = uint256(uint128(UUID_2)); // After burning c2, remaining: c1 + l1 + owned-only token for UUID_2 - assertEq(bondToken.balanceOf(address(fleet)), (BASE_BOND + fleet.tierBond(0, true)) + (BASE_BOND + fleet.tierBond(0, false)) + BASE_BOND); + assertEq( + bondToken.balanceOf(address(fleet)), + (BASE_BOND + fleet.tierBond(0, true)) + (BASE_BOND + fleet.tierBond(0, false)) + BASE_BOND + ); // Burn the owned-only token for UUID_2 vm.prank(bob); fleet.burn(ownedTokenBob); // Now: c1 + l1 - assertEq(bondToken.balanceOf(address(fleet)), (BASE_BOND + fleet.tierBond(0, true)) + (BASE_BOND + fleet.tierBond(0, false))); + assertEq( + bondToken.balanceOf(address(fleet)), + (BASE_BOND + fleet.tierBond(0, true)) + (BASE_BOND + fleet.tierBond(0, false)) + ); // Burn remaining tokens (and their resulting owned-only tokens) vm.prank(alice); @@ -1010,13 +1013,12 @@ contract FleetIdentityTest is Test { function test_RevertIf_bondToken_transferFromReturnsFalse() public { BadERC20 badToken = new BadERC20(); - + // Deploy implementation FleetIdentityUpgradeable impl = new FleetIdentityUpgradeable(); // Deploy proxy with initialize call ERC1967Proxy proxy = new ERC1967Proxy( - address(impl), - abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(badToken), BASE_BOND, 0)) + address(impl), abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(badToken), BASE_BOND, 0)) ); FleetIdentityUpgradeable f = FleetIdentityUpgradeable(address(proxy)); @@ -1087,8 +1089,7 @@ contract FleetIdentityTest is Test { FleetIdentityUpgradeable impl = new FleetIdentityUpgradeable(); // Deploy proxy with zero base bond - should use DEFAULT_BASE_BOND ERC1967Proxy proxy = new ERC1967Proxy( - address(impl), - abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(bondToken), 0, 0)) + address(impl), abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(bondToken), 0, 0)) ); FleetIdentityUpgradeable f = FleetIdentityUpgradeable(address(proxy)); assertEq(f.BASE_BOND(), f.DEFAULT_BASE_BOND()); @@ -1112,8 +1113,7 @@ contract FleetIdentityTest is Test { // Attempt to deploy proxy with zero bond token - should revert vm.expectRevert(FleetIdentityUpgradeable.InvalidBondToken.selector); new ERC1967Proxy( - address(impl), - abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(0), BASE_BOND, 0)) + address(impl), abi.encodeCall(FleetIdentityUpgradeable.initialize, (owner, address(0), BASE_BOND, 0)) ); } @@ -1247,11 +1247,11 @@ contract FleetIdentityTest is Test { // Burn the registered token -> transitions to owned-only vm.prank(alice); fleet.burn(tokenId); - + // UUID owner should NOT be cleared yet (now in owned-only state) assertEq(fleet.uuidOwner(UUID_1), alice, "UUID owner preserved in owned-only state"); assertTrue(fleet.isOwnedOnly(UUID_1)); - + // Burn the owned-only token to fully clear ownership uint256 ownedTokenId = uint256(uint128(UUID_1)); vm.prank(alice); @@ -1283,7 +1283,7 @@ contract FleetIdentityTest is Test { // Burn second token -> transitions to owned-only vm.prank(alice); fleet.burn(id2); - + // Still owned (in owned-only state) assertEq(fleet.uuidOwner(UUID_1), alice, "UUID owner preserved in owned-only state"); assertTrue(fleet.isOwnedOnly(UUID_1)); @@ -1292,7 +1292,7 @@ contract FleetIdentityTest is Test { uint256 ownedTokenId = uint256(uint128(UUID_1)); vm.prank(alice); fleet.burn(ownedTokenId); - + // Now UUID owner should be cleared assertEq(fleet.uuidOwner(UUID_1), address(0), "UUID owner cleared after owned-only burned"); assertEq(fleet.uuidTokenCount(UUID_1), 0); @@ -1317,7 +1317,7 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.burn(tokenId); - + // Now in owned-only state, burn that too uint256 ownedTokenId = uint256(uint128(UUID_1)); vm.prank(alice); @@ -1493,7 +1493,7 @@ contract FleetIdentityTest is Test { fleet.burn(tokenId); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1, "Level is Owned after burning last registered token"); - + // Burn owned-only token to fully clear uint256 ownedTokenId = uint256(uint128(UUID_1)); vm.prank(alice); @@ -1539,24 +1539,24 @@ contract FleetIdentityTest is Test { function test_claimUuid_basic() public { uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + // Token minted assertEq(fleet.ownerOf(tokenId), alice); assertEq(fleet.tokenUuid(tokenId), UUID_1); assertEq(fleet.tokenRegion(tokenId), 0); // OWNED_REGION_KEY - + // UUID ownership set assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); assertTrue(fleet.isOwnedOnly(UUID_1)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Owned - + // Bond pulled assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), BASE_BOND); - + // bonds() returns BASE_BOND for owned-only assertEq(fleet.bonds(tokenId), BASE_BOND); } @@ -1564,7 +1564,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_claimUuid_alreadyOwned() public { vm.prank(alice); fleet.claimUuid(UUID_1, address(0)); - + vm.prank(bob); vm.expectRevert(FleetIdentityUpgradeable.UuidAlreadyOwned.selector); fleet.claimUuid(UUID_1, address(0)); @@ -1573,7 +1573,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_claimUuid_alreadyRegistered() public { vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + vm.prank(bob); vm.expectRevert(FleetIdentityUpgradeable.UuidAlreadyOwned.selector); fleet.claimUuid(UUID_1, address(0)); @@ -1589,28 +1589,28 @@ contract FleetIdentityTest is Test { // First claim vm.prank(alice); uint256 ownedTokenId = fleet.claimUuid(UUID_1, address(0)); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + // Register from owned state - operator (alice) pays tierBond vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + // Old owned token burned vm.expectRevert(); fleet.ownerOf(ownedTokenId); - + // New token exists assertEq(fleet.ownerOf(tokenId), alice); assertEq(fleet.tokenRegion(tokenId), _regionUSCA()); assertEq(fleet.fleetTier(tokenId), 0); - + // UUID state updated assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.uuidTokenCount(UUID_1), 1); // still 1 assertFalse(fleet.isOwnedOnly(UUID_1)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 2); // Local - + // Operator pays tierBond (owner already paid BASE_BOND via claim) assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), fleet.tierBond(0, false)); } @@ -1618,16 +1618,16 @@ contract FleetIdentityTest is Test { function test_registerFromOwned_country() public { vm.prank(alice); fleet.claimUuid(UUID_1, address(0)); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); - + assertEq(fleet.ownerOf(tokenId), alice); assertEq(fleet.tokenRegion(tokenId), uint32(US)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country - + // Operator pays tierBond for country tier 0 = 16*BASE_BOND assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), fleet.tierBond(0, true)); } @@ -1635,14 +1635,14 @@ contract FleetIdentityTest is Test { function test_registerFromOwned_higherTier() public { vm.prank(alice); fleet.claimUuid(UUID_1, address(0)); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + // Register at tier 0 local - operator pays tierBond(0, false) = BASE_BOND vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(aliceBalanceBefore - bondToken.balanceOf(alice), fleet.tierBond(0, false)); - + // Promote to tier 2: additional bond = tierBond(2) - tierBond(0) = 4*BASE_BOND - BASE_BOND = 3*BASE_BOND uint256 balBeforePromote = bondToken.balanceOf(alice); vm.prank(alice); @@ -1653,25 +1653,25 @@ contract FleetIdentityTest is Test { function test_burn_lastRegisteredToken_transitionsToOwned() public { vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.burn(tokenId); - + // Old token burned vm.expectRevert(); fleet.ownerOf(tokenId); - + // New owned-only token exists uint256 ownedTokenId = uint256(uint128(UUID_1)); assertEq(fleet.ownerOf(ownedTokenId), alice); assertEq(fleet.tokenRegion(ownedTokenId), 0); - + // UUID state updated to Owned assertTrue(fleet.isOwnedOnly(UUID_1)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); // Owned - + // Operator (alice) gets tierBond refunded assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, fleet.tierBond(0, false)); } @@ -1682,15 +1682,15 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.reassignTier(tokenId, 2); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.burn(tokenId); - + // Operator (alice) gets full tierBond(2, false) refunded assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, fleet.tierBond(2, false)); - + // Transitioned to owned-only assertTrue(fleet.isOwnedOnly(UUID_1)); } @@ -1699,15 +1699,15 @@ contract FleetIdentityTest is Test { // Register country tier 0 vm.prank(alice); uint256 tokenId = fleet.registerFleetCountry(UUID_1, US, 0); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.burn(tokenId); - + // Operator (alice) gets full tierBond(0, true) refunded assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, fleet.tierBond(0, true)); - + // Level changed to Owned assertEq(uint8(fleet.uuidLevel(UUID_1)), 1); assertTrue(fleet.isOwnedOnly(UUID_1)); @@ -1719,22 +1719,22 @@ contract FleetIdentityTest is Test { uint256 id1 = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); uint256 id2 = fleet.registerFleetLocal(UUID_1, DE, ADMIN_CA, 0); - + // Burn first token - should NOT transition to owned vm.prank(alice); fleet.burn(id1); - + // Still registered level, not owned assertFalse(fleet.isOwnedOnly(UUID_1)); assertEq(fleet.uuidTokenCount(UUID_1), 1); - + // Second token still exists assertEq(fleet.ownerOf(id2), alice); - + // Burn second token - NOW should transition to owned vm.prank(alice); fleet.burn(id2); - + assertTrue(fleet.isOwnedOnly(UUID_1)); uint256 ownedTokenId = uint256(uint128(UUID_1)); assertEq(fleet.ownerOf(ownedTokenId), alice); @@ -1743,21 +1743,21 @@ contract FleetIdentityTest is Test { function test_burn_ownedOnly_clearsUuid() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.burn(tokenId); - + // Token burned vm.expectRevert(); fleet.ownerOf(tokenId); - + // UUID cleared assertEq(fleet.uuidOwner(UUID_1), address(0)); assertEq(fleet.uuidTokenCount(UUID_1), 0); assertEq(uint8(fleet.uuidLevel(UUID_1)), 0); // None - + // Refund received assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, BASE_BOND); } @@ -1765,19 +1765,19 @@ contract FleetIdentityTest is Test { function test_burn_ownedOnly_afterTransfer() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + // Transfer to bob vm.prank(alice); fleet.transferFrom(alice, bob, tokenId); - + // uuidOwner should have updated assertEq(fleet.uuidOwner(UUID_1), bob); - + // Alice cannot burn (not token owner) vm.prank(alice); vm.expectRevert(FleetIdentityUpgradeable.NotTokenOwner.selector); fleet.burn(tokenId); - + // Bob can burn uint256 bobBalanceBefore = bondToken.balanceOf(bob); vm.prank(bob); @@ -1788,7 +1788,7 @@ contract FleetIdentityTest is Test { function test_RevertIf_burn_ownedOnly_notOwner() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + // Bob cannot burn owned-only token (not owner) vm.prank(bob); vm.expectRevert(FleetIdentityUpgradeable.NotTokenOwner.selector); @@ -1798,12 +1798,12 @@ contract FleetIdentityTest is Test { function test_ownedOnly_transfer_updatesUuidOwner() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + assertEq(fleet.uuidOwner(UUID_1), alice); - + vm.prank(alice); fleet.transferFrom(alice, bob, tokenId); - + // uuidOwner updated on transfer for owned-only tokens assertEq(fleet.uuidOwner(UUID_1), bob); assertEq(fleet.ownerOf(tokenId), bob); @@ -1815,15 +1815,15 @@ contract FleetIdentityTest is Test { fleet.claimUuid(UUID_1, address(0)); vm.prank(alice); fleet.claimUuid(UUID_2, address(0)); - + // Bundle should be empty (bytes16[] memory uuids, uint256 count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 0); - + // Now register one vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + // Bundle should contain only the registered one (uuids, count) = fleet.buildHighestBondedUuidBundle(US, ADMIN_CA); assertEq(count, 1); @@ -1833,19 +1833,19 @@ contract FleetIdentityTest is Test { function test_burn_ownedOnly() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + uint256 aliceBalanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.burn(tokenId); - + // Token burned vm.expectRevert(); fleet.ownerOf(tokenId); - + // UUID cleared assertEq(fleet.uuidOwner(UUID_1), address(0)); - + // Refund received assertEq(bondToken.balanceOf(alice) - aliceBalanceBefore, BASE_BOND); } @@ -1853,40 +1853,40 @@ contract FleetIdentityTest is Test { function test_ownedOnly_canReRegisterAfterBurn() public { vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); - + vm.prank(alice); fleet.burn(tokenId); - + // Bob can now claim or register vm.prank(bob); uint256 newTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + assertEq(fleet.ownerOf(newTokenId), bob); assertEq(fleet.uuidOwner(UUID_1), bob); } function test_migration_viaBurnAndReregister() public { // This test shows the migration pattern using burn - + // Register local in US vm.prank(alice); uint256 oldTokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + uint256 aliceBalanceAfterRegister = bondToken.balanceOf(alice); - + // Burn registered token -> transitions to owned-only, refunds tierBond(0, false) vm.prank(alice); fleet.burn(oldTokenId); - + // Now in owned-only state, re-register in DE as country // Pays tierBond(0, true) = 16*BASE_BOND for country registration vm.prank(alice); uint256 newTokenId = fleet.registerFleetCountry(UUID_1, DE, 0); - + assertEq(fleet.ownerOf(newTokenId), alice); assertEq(fleet.tokenRegion(newTokenId), uint32(DE)); assertEq(uint8(fleet.uuidLevel(UUID_1)), 3); // Country - + // Net bond change: tierBond(0, true) - tierBond(0, false) = 16*BASE_BOND - BASE_BOND = 15*BASE_BOND assertEq(aliceBalanceAfterRegister - bondToken.balanceOf(alice), 15 * BASE_BOND); } @@ -1988,7 +1988,7 @@ contract FleetIdentityTest is Test { // Now have 3 owned-only tokens, each with BASE_BOND assertEq(bondToken.balanceOf(address(fleet)), 3 * BASE_BOND); - + // Burn all owned-only tokens vm.prank(alice); fleet.burn(uint256(uint128(UUID_1))); @@ -2298,7 +2298,8 @@ contract FleetIdentityTest is Test { // Tier 1: local=cap. Tier 0: local=cap + country=cap. // Total = 3*cap, capped at MAX_BONDED_UUID_BUNDLE_SIZE. uint256 total = 3 * cap; - uint256 expectedCount = total > fleet.MAX_BONDED_UUID_BUNDLE_SIZE() ? fleet.MAX_BONDED_UUID_BUNDLE_SIZE() : total; + uint256 expectedCount = + total > fleet.MAX_BONDED_UUID_BUNDLE_SIZE() ? fleet.MAX_BONDED_UUID_BUNDLE_SIZE() : total; assertEq(count, expectedCount); // Verify country UUIDs ARE in the result (if bundle has room) @@ -2311,8 +2312,8 @@ contract FleetIdentityTest is Test { // With cap=10, bundle=20: tier 1 local (10) + tier 0 local (10) = 20, no room for country // With cap=4, bundle=20: tier 1 local (4) + tier 0 local (4) + country (4) = 12 uint256 localSlots = 2 * cap; // tier 0 and tier 1 locals - uint256 remainingRoom = fleet.MAX_BONDED_UUID_BUNDLE_SIZE() > localSlots ? - fleet.MAX_BONDED_UUID_BUNDLE_SIZE() - localSlots : 0; + uint256 remainingRoom = + fleet.MAX_BONDED_UUID_BUNDLE_SIZE() > localSlots ? fleet.MAX_BONDED_UUID_BUNDLE_SIZE() - localSlots : 0; uint256 expectedCountry = remainingRoom > cap ? cap : remainingRoom; assertEq(countryCount, expectedCountry, "country members included based on remaining room"); } @@ -3594,7 +3595,7 @@ contract FleetIdentityTest is Test { // Bob (operator) gets full tierBond. Alice gets owned-only token minted. assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, false)); - + // Verify owned-only token was minted to owner uint256 ownedTokenId = uint256(uint128(UUID_1)); assertEq(fleet.ownerOf(ownedTokenId), alice); @@ -3643,7 +3644,7 @@ contract FleetIdentityTest is Test { uint256 registeredToken = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); assertEq(fleet.operatorOf(UUID_1), alice); - + // Now alice can set an operator vm.prank(alice); fleet.setOperator(UUID_1, bob); @@ -3653,16 +3654,14 @@ contract FleetIdentityTest is Test { function testFuzz_setOperator_tierExcessCalculation(uint8 tier1, uint8 tier2) public { tier1 = uint8(bound(tier1, 0, 7)); tier2 = uint8(bound(tier2, 0, 7)); - + // Register in two local regions vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, tier1); vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_NY, tier2); - uint256 expectedTierBonds = - fleet.tierBond(tier1, false) + - fleet.tierBond(tier2, false); + uint256 expectedTierBonds = fleet.tierBond(tier1, false) + fleet.tierBond(tier2, false); uint256 aliceBefore = bondToken.balanceOf(alice); uint256 bobBefore = bondToken.balanceOf(bob); @@ -3682,7 +3681,7 @@ contract FleetIdentityTest is Test { // Alice claims UUID with bob as operator vm.prank(alice); uint256 tokenId = fleet.claimUuid(UUID_1, bob); - + assertEq(fleet.operatorOf(UUID_1), bob); assertEq(fleet.uuidOwner(UUID_1), alice); assertEq(fleet.ownerOf(tokenId), alice); @@ -3692,15 +3691,15 @@ contract FleetIdentityTest is Test { // Alice claims UUID with bob as operator vm.prank(alice); fleet.claimUuid(UUID_1, bob); - + assertEq(fleet.operatorOf(UUID_1), bob); - + // When registering, OPERATOR (bob) must call and pays tier bond uint256 bobBefore = bondToken.balanceOf(bob); - + vm.prank(bob); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - + uint256 tierBond = fleet.tierBond(2, false); assertEq(bondToken.balanceOf(bob), bobBefore - tierBond); assertEq(fleet.operatorOf(UUID_1), bob); @@ -3709,7 +3708,7 @@ contract FleetIdentityTest is Test { function test_claimUuid_emitsEventWithOperator() public { vm.expectEmit(true, true, false, true); emit FleetIdentityUpgradeable.UuidClaimed(alice, UUID_1, bob); - + vm.prank(alice); fleet.claimUuid(UUID_1, bob); } @@ -3718,7 +3717,7 @@ contract FleetIdentityTest is Test { // Using msg.sender should normalize to address(0) internally vm.prank(alice); fleet.claimUuid(UUID_1, alice); - + // operatorOf should return owner when stored operator is address(0) assertEq(fleet.operatorOf(UUID_1), alice); assertEq(fleet.uuidOperator(UUID_1), address(0)); // stored as 0 @@ -3728,17 +3727,17 @@ contract FleetIdentityTest is Test { // Claim UUID in owned-only mode vm.prank(alice); fleet.claimUuid(UUID_1, address(0)); - + // Set operator while owned-only vm.prank(alice); fleet.setOperator(UUID_1, bob); - + uint256 bobBefore = bondToken.balanceOf(bob); - + // Register - OPERATOR (bob) must call and pays tier bond vm.prank(bob); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - + assertEq(fleet.operatorOf(UUID_1), bob); uint256 tierBond = fleet.tierBond(2, false); assertEq(bondToken.balanceOf(bob), bobBefore - tierBond); @@ -3750,11 +3749,11 @@ contract FleetIdentityTest is Test { uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); vm.prank(alice); fleet.setOperator(UUID_1, bob); - + // Operator burns the last registered token -> transitions to owned-only vm.prank(bob); fleet.burn(tokenId); - + // Operator should still be bob assertEq(fleet.operatorOf(UUID_1), bob); assertTrue(fleet.isOwnedOnly(UUID_1)); @@ -3766,15 +3765,15 @@ contract FleetIdentityTest is Test { fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); vm.prank(alice); fleet.setOperator(UUID_1, bob); - + uint256 bobBefore = bondToken.balanceOf(bob); - + // Register second region - OPERATOR (bob) must call and pays tier bond vm.prank(bob); fleet.registerFleetLocal(UUID_1, US, ADMIN_NY, 2); - + assertEq(fleet.operatorOf(UUID_1), bob); - + // Bob pays full tier bond for new region uint256 tierBond = fleet.tierBond(2, false); assertEq(bondToken.balanceOf(bob), bobBefore - tierBond); @@ -3783,14 +3782,14 @@ contract FleetIdentityTest is Test { function test_freshRegistration_ownerIsOperator() public { // Fresh registration without claim - owner pays BASE_BOND + tierBond uint256 aliceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - + // Owner is operator when fresh registration assertEq(fleet.operatorOf(UUID_1), alice); assertEq(fleet.uuidOperator(UUID_1), address(0)); // stored as 0 - + // Owner paid BASE_BOND + tierBond uint256 fullBond = BASE_BOND + fleet.tierBond(2, false); assertEq(bondToken.balanceOf(alice), aliceBefore - fullBond); @@ -3807,31 +3806,31 @@ contract FleetIdentityTest is Test { // Alice claims with bob as operator vm.prank(alice); fleet.claimUuid(UUID_1, bob); - + uint256 ownedTokenId = uint256(uint128(UUID_1)); - + // Alice transfers owned token to carol vm.prank(alice); fleet.transferFrom(alice, carol, ownedTokenId); - + // Carol is now owner (uuidOwner transferred with token) assertEq(fleet.uuidOwner(UUID_1), carol); - + // Bob (operator) registers - only operator can register owned UUID uint256 bobBefore = bondToken.balanceOf(bob); vm.prank(bob); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 1); - + // Bob paid full tier bond (owner already paid BASE_BOND via claim) uint256 tierBond = fleet.tierBond(1, false); assertEq(bondToken.balanceOf(bob), bobBefore - tierBond); - + // Bob can promote as operator uint256 tokenId = fleet.computeTokenId(UUID_1, fleet.makeAdminRegion(US, ADMIN_CA)); - + vm.prank(bob); fleet.promote(tokenId); - + assertEq(fleet.fleetTier(tokenId), 2); } @@ -3839,19 +3838,19 @@ contract FleetIdentityTest is Test { // Fresh registration at tier 2 vm.prank(alice); uint256 tokenId = fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 2); - + // Set operator vm.prank(alice); fleet.setOperator(UUID_1, bob); - + uint256 bobBefore = bondToken.balanceOf(bob); - + // OPERATOR burns -> transitions to owned-only, bob gets tier bond refund vm.prank(bob); fleet.burn(tokenId); - + assertEq(bondToken.balanceOf(bob), bobBefore + fleet.tierBond(2, false)); - + // Owned-only token minted to owner uint256 ownedTokenId = uint256(uint128(UUID_1)); assertEq(fleet.ownerOf(ownedTokenId), alice); @@ -4065,7 +4064,7 @@ contract FleetIdentityTest is Test { fleet.setCountryBondMultiplier(32); uint256 balanceBefore = bondToken.balanceOf(alice); - + vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); @@ -4080,17 +4079,17 @@ contract FleetIdentityTest is Test { function test_getCountryAdminAreas_returnsRegisteredAreas() public { vm.prank(alice); fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); - + vm.prank(alice); fleet.registerFleetLocal(UUID_2, US, ADMIN_NY, 0); - + uint32[] memory areas = fleet.getCountryAdminAreas(US); assertEq(areas.length, 2); - + // Areas should contain both admin area region keys uint32 caRegion = fleet.makeAdminRegion(US, ADMIN_CA); uint32 nyRegion = fleet.makeAdminRegion(US, ADMIN_NY); - + bool foundCA = false; bool foundNY = false; for (uint256 i = 0; i < areas.length; i++) { @@ -4216,23 +4215,145 @@ contract FleetIdentityTest is Test { assertEq(fleet.uuidOwnershipBondPaid(UUID_1), BASE_BOND); } - function test_claimUuidSponsored_burnRefundsToSender() public { + function test_claimUuidSponsored_burnRefundsToTreasury() public { MockBondTreasury treasury = _deployTreasury(); treasury.setWhitelisted(alice, true); vm.prank(alice); uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + uint256 treasuryBefore = bondToken.balanceOf(address(treasury)); uint256 aliceBefore = bondToken.balanceOf(alice); // Alice (token holder and uuid owner) burns the owned-only token vm.prank(alice); fleet.burn(tokenId); - // Refund goes to alice (uuidOwner = msg.sender always) + // Refund goes to treasury (bond payer), NOT alice + assertEq(bondToken.balanceOf(address(treasury)), treasuryBefore + BASE_BOND); + assertEq(bondToken.balanceOf(alice), aliceBefore); // alice untouched + } + + function test_claimUuidSponsored_bondPayerRecorded() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), address(treasury)); + } + + function test_claimUuid_bondPayerRecordedAsSender() public { + vm.prank(alice); + fleet.claimUuid(UUID_1, address(0)); + + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), alice); + } + + function test_claimUuid_burnRefundsToSender() public { + vm.prank(alice); + uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); + + uint256 aliceBefore = bondToken.balanceOf(alice); + + vm.prank(alice); + fleet.burn(tokenId); + + // Self-funded: refund goes to alice assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); } + function test_claimUuidSponsored_burnClearsPayerMapping() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + vm.prank(alice); + fleet.burn(tokenId); + + // All UUID data cleared including bond payer + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), address(0)); + assertEq(fleet.uuidOwner(UUID_1), address(0)); + } + + function test_claimUuidSponsored_claimBurnCycleDoesNotExtractFunds() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + uint256 treasuryStart = bondToken.balanceOf(address(treasury)); + uint256 aliceStart = bondToken.balanceOf(alice); + + // Simulate 5 claim+burn cycles with different UUIDs + bytes16[5] memory uuids; + for (uint256 i = 0; i < 5; i++) { + uuids[i] = bytes16(keccak256(abi.encodePacked("exploit-uuid", i))); + + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(uuids[i], address(0), address(treasury)); + + vm.prank(alice); + fleet.burn(tokenId); + } + + // Alice gained nothing, treasury lost nothing + assertEq(bondToken.balanceOf(alice), aliceStart); + assertEq(bondToken.balanceOf(address(treasury)), treasuryStart); + } + + function test_registerFleetDirectly_bondPayerRecordedAsSender() public { + vm.prank(alice); + fleet.registerFleetLocal(UUID_1, US, ADMIN_CA, 0); + + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), alice); + } + + function test_claimUuidSponsored_transferDoesNotMoveBondPayer() public { + MockBondTreasury treasury = _deployTreasury(); + treasury.setWhitelisted(alice, true); + + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(treasury)); + + // Transfer token to bob + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Bond payer stays as treasury (not updated to bob) + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), address(treasury)); + + // Bob burns — refund goes to treasury, not bob + uint256 treasuryBefore = bondToken.balanceOf(address(treasury)); + uint256 bobBefore = bondToken.balanceOf(bob); + vm.prank(bob); + fleet.burn(tokenId); + assertEq(bondToken.balanceOf(address(treasury)), treasuryBefore + BASE_BOND); + assertEq(bondToken.balanceOf(bob), bobBefore); // bob gets nothing + } + + function test_claimUuid_transferMovesBondPayerToNewOwner() public { + vm.prank(alice); + uint256 tokenId = fleet.claimUuid(UUID_1, address(0)); + + // Self-funded: bond payer is alice + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), alice); + + // Transfer to bob + vm.prank(alice); + fleet.transferFrom(alice, bob, tokenId); + + // Bond payer updated to bob (self-funded follows token) + assertEq(fleet.uuidOwnershipBondPayer(UUID_1), bob); + + // Bob burns — refund goes to bob + uint256 bobBefore = bondToken.balanceOf(bob); + vm.prank(bob); + fleet.burn(tokenId); + assertEq(bondToken.balanceOf(bob), bobBefore + BASE_BOND); + } + function test_claimUuidSponsored_thenRegisterWorksForOperator() public { MockBondTreasury treasury = _deployTreasury(); treasury.setWhitelisted(alice, true); diff --git a/test/paymasters/BondTreasuryPaymaster.t.sol b/test/paymasters/BondTreasuryPaymaster.t.sol index 9da8733..864a92f 100644 --- a/test/paymasters/BondTreasuryPaymaster.t.sol +++ b/test/paymasters/BondTreasuryPaymaster.t.sol @@ -145,6 +145,10 @@ contract BondTreasuryPaymasterTest is Test { bondToken.mint(address(paymaster), 10_000 ether); whitelistTargets = new address[](1); whitelistTargets[0] = alice; + + // Give alice a generous bond allowance for existing tests + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 10_000 ether); } // ══════════════════════════════════════════════ @@ -216,7 +220,14 @@ contract BondTreasuryPaymasterTest is Test { users[2] = charlie; MockBondTreasuryPaymaster pm = new MockBondTreasuryPaymaster( - admin, admin, withdrawer, _initialContractWhitelist(address(fleet)), users, address(bondToken), QUOTA, PERIOD + admin, + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + users, + address(bondToken), + QUOTA, + PERIOD ); assertTrue(pm.isWhitelistedUser(alice)); assertTrue(pm.isWhitelistedUser(bob)); @@ -231,7 +242,14 @@ contract BondTreasuryPaymasterTest is Test { vm.expectEmit(); emit WhitelistPaymaster.WhitelistedUsersAdded(users); new MockBondTreasuryPaymaster( - admin, admin, withdrawer, _initialContractWhitelist(address(fleet)), users, address(bondToken), QUOTA, PERIOD + admin, + admin, + withdrawer, + _initialContractWhitelist(address(fleet)), + users, + address(bondToken), + QUOTA, + PERIOD ); } @@ -464,8 +482,10 @@ contract BondTreasuryPaymasterTest is Test { function test_sponsoredClaim_multipleClaims() public { address[] memory bobList = new address[](1); bobList[0] = bob; - vm.prank(admin); + vm.startPrank(admin); paymaster.addWhitelistedUsers(bobList); + paymaster.setUserBondAllowance(bob, 10_000 ether); + vm.stopPrank(); vm.prank(alice); uint256 tokenId1 = fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); @@ -488,16 +508,19 @@ contract BondTreasuryPaymasterTest is Test { fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); } - function test_burnAfterSponsoredClaim_refundsToOwner() public { + function test_burnAfterSponsoredClaim_refundsToTreasury() public { vm.prank(alice); uint256 tokenId = fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + uint256 paymasterBefore = bondToken.balanceOf(address(paymaster)); uint256 aliceBefore = bondToken.balanceOf(alice); vm.prank(alice); fleet.burn(tokenId); - assertEq(bondToken.balanceOf(alice), aliceBefore + BASE_BOND); + // Refund goes to paymaster (the bond payer), not alice + assertEq(bondToken.balanceOf(address(paymaster)), paymasterBefore + BASE_BOND); + assertEq(bondToken.balanceOf(alice), aliceBefore); // alice untouched } // ══════════════════════════════════════════════ @@ -536,6 +559,8 @@ contract BondTreasuryPaymasterTest is Test { ); bondToken.mint(address(tightPaymaster), 10_000 ether); + vm.prank(admin); + tightPaymaster.setUserBondAllowance(alice, 10_000 ether); vm.prank(alice); vm.expectRevert(QuotaControl.QuotaExceeded.selector); @@ -571,8 +596,10 @@ contract BondTreasuryPaymasterTest is Test { address[] memory bobList = new address[](1); bobList[0] = bob; - vm.prank(admin); + vm.startPrank(admin); paymaster.addWhitelistedUsers(bobList); + paymaster.setUserBondAllowance(bob, 10_000 ether); + vm.stopPrank(); vm.prank(bob); fleet.claimUuidSponsored(UUID_2, address(0), address(paymaster)); @@ -721,4 +748,228 @@ contract BondTreasuryPaymasterTest is Test { vm.prank(address(puller)); paymaster.addWhitelistedUsers(whitelistTargets); } + + // ══════════════════════════════════════════════ + // Per-user bond allowance + // ══════════════════════════════════════════════ + + function test_setUserBondAllowance_basic() public { + vm.prank(admin); + paymaster.setUserBondAllowance(bob, 500 ether); + assertEq(paymaster.userBondAllowance(bob), 500 ether); + } + + function test_setUserBondAllowance_emitsEvent() public { + vm.expectEmit(true, true, true, true); + emit BondTreasuryPaymaster.UserBondAllowanceSet(bob, 500 ether); + vm.prank(admin); + paymaster.setUserBondAllowance(bob, 500 ether); + } + + function test_setUserBondAllowance_overwritesPrevious() public { + vm.startPrank(admin); + paymaster.setUserBondAllowance(bob, 500 ether); + paymaster.setUserBondAllowance(bob, 200 ether); + vm.stopPrank(); + assertEq(paymaster.userBondAllowance(bob), 200 ether); + } + + function test_setUserBondAllowance_canSetToZero() public { + vm.startPrank(admin); + paymaster.setUserBondAllowance(bob, 500 ether); + paymaster.setUserBondAllowance(bob, 0); + vm.stopPrank(); + assertEq(paymaster.userBondAllowance(bob), 0); + } + + function test_RevertIf_setUserBondAllowance_notWhitelistAdmin() public { + vm.expectRevert_AccessControlUnauthorizedAccount(alice, paymaster.WHITELIST_ADMIN_ROLE()); + vm.prank(alice); + paymaster.setUserBondAllowance(bob, 500 ether); + } + + function test_RevertIf_setUserBondAllowance_notWithdrawer() public { + vm.expectRevert_AccessControlUnauthorizedAccount(withdrawer, paymaster.WHITELIST_ADMIN_ROLE()); + vm.prank(withdrawer); + paymaster.setUserBondAllowance(bob, 500 ether); + } + + function test_increaseUserBondAllowance_basic() public { + vm.startPrank(admin); + paymaster.setUserBondAllowance(bob, 500 ether); + paymaster.increaseUserBondAllowance(bob, 200 ether); + vm.stopPrank(); + assertEq(paymaster.userBondAllowance(bob), 700 ether); + } + + function test_increaseUserBondAllowance_fromZero() public { + assertEq(paymaster.userBondAllowance(bob), 0); + vm.prank(admin); + paymaster.increaseUserBondAllowance(bob, 300 ether); + assertEq(paymaster.userBondAllowance(bob), 300 ether); + } + + function test_increaseUserBondAllowance_emitsEvent() public { + vm.prank(admin); + paymaster.setUserBondAllowance(bob, 100 ether); + + vm.expectEmit(true, true, true, true); + emit BondTreasuryPaymaster.UserBondAllowanceIncreased(bob, 200 ether, 300 ether); + vm.prank(admin); + paymaster.increaseUserBondAllowance(bob, 200 ether); + } + + function test_RevertIf_increaseUserBondAllowance_notWhitelistAdmin() public { + vm.expectRevert_AccessControlUnauthorizedAccount(alice, paymaster.WHITELIST_ADMIN_ROLE()); + vm.prank(alice); + paymaster.increaseUserBondAllowance(bob, 100 ether); + } + + function test_userBondAllowance_defaultIsZero() public view { + assertEq(paymaster.userBondAllowance(bob), 0); + } + + function test_consumeSponsoredBond_decrementsAllowance() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 300 ether); + + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + + assertEq(paymaster.userBondAllowance(alice), 300 ether - BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_allowanceExceeded() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, BASE_BOND - 1); + + vm.prank(address(fleet)); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + function test_RevertIf_consumeSponsoredBond_zeroAllowance() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 0); + + vm.prank(address(fleet)); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + function test_userAllowance_consumedExactly() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, BASE_BOND); + + // First call succeeds + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + assertEq(paymaster.userBondAllowance(alice), 0); + + // Second call fails + vm.prank(address(fleet)); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + function test_userAllowance_multipleConsumptions() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 3 * BASE_BOND); + + for (uint256 i = 0; i < 3; i++) { + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + assertEq(paymaster.userBondAllowance(alice), 0); + + vm.prank(address(fleet)); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + } + + function test_userAllowance_topUpAfterPartialUse() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 2 * BASE_BOND); + + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + assertEq(paymaster.userBondAllowance(alice), BASE_BOND); + + // Admin tops up + vm.prank(admin); + paymaster.increaseUserBondAllowance(alice, 3 * BASE_BOND); + assertEq(paymaster.userBondAllowance(alice), 4 * BASE_BOND); + } + + function test_userAllowance_independentPerUser() public { + vm.startPrank(admin); + paymaster.setUserBondAllowance(alice, 500 ether); + paymaster.addWhitelistedUsers(_singleAddress(bob)); + paymaster.setUserBondAllowance(bob, 200 ether); + vm.stopPrank(); + + // Alice consumes + vm.prank(address(fleet)); + paymaster.consumeSponsoredBond(alice, BASE_BOND); + assertEq(paymaster.userBondAllowance(alice), 400 ether); + assertEq(paymaster.userBondAllowance(bob), 200 ether); // unchanged + } + + function test_userAllowance_e2e_claimBlockedByAllowance() public { + // Alice has enough global quota but zero user allowance + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 0); + + vm.prank(alice); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + } + + function test_userAllowance_e2e_claimSucceedsWithAllowance() public { + vm.prank(admin); + paymaster.setUserBondAllowance(alice, BASE_BOND); + + vm.prank(alice); + fleet.claimUuidSponsored(UUID_1, address(0), address(paymaster)); + + assertEq(fleet.uuidOwner(UUID_1), alice); + assertEq(paymaster.userBondAllowance(alice), 0); + } + + function test_userAllowance_e2e_exploitBlockedByAllowance() public { + // Give alice exactly 2 claims worth + vm.prank(admin); + paymaster.setUserBondAllowance(alice, 2 * BASE_BOND); + + bondToken.mint(address(paymaster), 100_000 ether); + + // First two claims succeed + for (uint256 i = 0; i < 2; i++) { + bytes16 uuid = bytes16(keccak256(abi.encodePacked("uuid-cap-", i))); + vm.prank(alice); + uint256 tokenId = fleet.claimUuidSponsored(uuid, address(0), address(paymaster)); + vm.prank(alice); + fleet.burn(tokenId); // refund goes to paymaster, not alice + } + + // Third claim blocked by user allowance (even though quota may have reset) + bytes16 uuid3 = bytes16(keccak256("uuid-cap-blocked")); + vm.prank(alice); + vm.expectRevert(BondTreasuryPaymaster.UserBondAllowanceExceeded.selector); + fleet.claimUuidSponsored(uuid3, address(0), address(paymaster)); + } + + function testFuzz_setUserBondAllowance(address user, uint256 amount) public { + vm.prank(admin); + paymaster.setUserBondAllowance(user, amount); + assertEq(paymaster.userBondAllowance(user), amount); + } + + function testFuzz_increaseUserBondAllowance(uint128 initial, uint128 added) public { + vm.startPrank(admin); + paymaster.setUserBondAllowance(bob, initial); + paymaster.increaseUserBondAllowance(bob, added); + vm.stopPrank(); + assertEq(paymaster.userBondAllowance(bob), uint256(initial) + uint256(added)); + } }