Skip to content

Commit 49ea72e

Browse files
authored
fix: close sponsored claim+burn bond extraction vulnerability (#107)
* fix: refund ownership bond to original payer, not UUID owner Track who paid the ownership bond via uuidOwnershipBondPayer mapping. For self-funded claims the payer is msg.sender; for sponsored claims it is the treasury. On burn the bond refunds to the recorded payer, closing a vulnerability where a whitelisted user could claim+burn sponsored UUIDs to extract treasury funds to their own wallet. Backward compatible: pre-upgrade tokens (bondPayer==address(0)) fall back to uuidOwner. On token transfer, bondPayer follows only if the previous owner was the payer (self-funded); sponsored bonds stay with the treasury. Storage: adds 1 mapping before __gap (reduced from 50 to 49 slots). * feat: add per-user NODL bond allowance to BondTreasuryPaymaster Defense-in-depth for the sponsored claim exploit: each whitelisted user now has a token-denominated allowance that is decremented on every consumeSponsoredBond call. When the allowance hits zero the user cannot consume any more sponsored bonds regardless of the global periodic quota. Amount-based (not claim-count-based) so the cap stays correct even if baseBond parameters change. Non-periodic: whitelist admin can top up via increaseUserBondAllowance or overwrite via setUserBondAllowance. New storage: mapping(address => uint256) public userBondAllowance New functions (WHITELIST_ADMIN_ROLE): setUserBondAllowance(address user, uint256 allowance) increaseUserBondAllowance(address user, uint256 amount) New error: UserBondAllowanceExceeded New events: UserBondAllowanceSet, UserBondAllowanceIncreased * chore: fix spellcheck and formatting issues
1 parent 153c20d commit 49ea72e

4 files changed

Lines changed: 584 additions & 164 deletions

File tree

src/paymasters/BondTreasuryPaymaster.sol

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,24 @@ import {WhitelistPaymaster} from "./WhitelistPaymaster.sol";
99

1010
/// @notice ZkSync paymaster plus ERC-20 bond treasury for whitelisted contracts (e.g. `IBondTreasury` implementers).
1111
/// @dev Extends `WhitelistPaymaster` (user + destination whitelist, gas sponsorship). Adds bond token balance,
12-
/// `consumeSponsoredBond` with `QuotaControl`, and ERC-20 withdrawal. Constructor seeds `address(this)` as a
13-
/// whitelisted destination so management txs can be sponsored once those EOAs are user-whitelisted.
12+
/// `consumeSponsoredBond` with `QuotaControl`, per-user token allowance, and ERC-20 withdrawal.
13+
/// Constructor seeds `address(this)` as a whitelisted destination so management txs can be sponsored
14+
/// once those EOAs are user-whitelisted.
1415
contract BondTreasuryPaymaster is WhitelistPaymaster, QuotaControl {
1516
using SafeERC20 for IERC20;
1617

1718
IERC20 public immutable bondToken;
1819

20+
/// @notice Per-user remaining token allowance for sponsored bonds.
21+
mapping(address => uint256) public userBondAllowance;
22+
1923
event TokensWithdrawn(address indexed token, address indexed to, uint256 amount);
24+
event UserBondAllowanceSet(address indexed user, uint256 newAllowance);
25+
event UserBondAllowanceIncreased(address indexed user, uint256 addedAmount, uint256 newAllowance);
2026

2127
error CallerNotWhitelistedContract();
2228
error InsufficientBondBalance();
29+
error UserBondAllowanceExceeded();
2330

2431
constructor(
2532
address admin,
@@ -60,19 +67,40 @@ contract BondTreasuryPaymaster is WhitelistPaymaster, QuotaControl {
6067
}
6168
}
6269

63-
/// @notice Validate whitelist + consume quota for a sponsored bond.
70+
/// @notice Validate whitelist + consume quota + decrement per-user allowance for a sponsored bond.
6471
/// @dev Callable only by `isWhitelistedContract`. Caller pulls via `transferFrom`.
6572
function consumeSponsoredBond(address user, uint256 amount) external {
6673
if (!isWhitelistedContract[msg.sender]) revert CallerNotWhitelistedContract();
6774
if (!isWhitelistedUser[user]) revert UserIsNotWhitelisted();
6875
if (bondToken.balanceOf(address(this)) < amount) revert InsufficientBondBalance();
6976

77+
uint256 remaining = userBondAllowance[user];
78+
if (remaining < amount) revert UserBondAllowanceExceeded();
79+
unchecked {
80+
userBondAllowance[user] = remaining - amount;
81+
}
82+
7083
_checkedResetClaimed();
7184
_checkedUpdateClaimed(amount);
7285

7386
bondToken.forceApprove(msg.sender, amount);
7487
}
7588

89+
/// @notice Set the total bond allowance for a user (overwrites previous value).
90+
function setUserBondAllowance(address user, uint256 allowance) external {
91+
_checkRole(WHITELIST_ADMIN_ROLE);
92+
userBondAllowance[user] = allowance;
93+
emit UserBondAllowanceSet(user, allowance);
94+
}
95+
96+
/// @notice Add to a user's existing bond allowance.
97+
function increaseUserBondAllowance(address user, uint256 amount) external {
98+
_checkRole(WHITELIST_ADMIN_ROLE);
99+
uint256 newAllowance = userBondAllowance[user] + amount;
100+
userBondAllowance[user] = newAllowance;
101+
emit UserBondAllowanceIncreased(user, amount, newAllowance);
102+
}
103+
76104
/// @notice Withdraw ERC-20 tokens (e.g. excess bond token) from this contract.
77105
function withdrawTokens(address token, address to, uint256 amount) external {
78106
_checkRole(WITHDRAWER_ROLE);

src/swarms/FleetIdentityUpgradeable.sol

Lines changed: 32 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,9 @@
22

33
pragma solidity ^0.8.24;
44

5-
import {ERC721EnumerableUpgradeable} from
6-
"@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
5+
import {
6+
ERC721EnumerableUpgradeable
7+
} from "@openzeppelin/contracts-upgradeable/token/ERC721/extensions/ERC721EnumerableUpgradeable.sol";
78
import {Ownable2StepUpgradeable} from "@openzeppelin/contracts-upgradeable/access/Ownable2StepUpgradeable.sol";
89
import {UUPSUpgradeable} from "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol";
910
import {Initializable} from "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol";
@@ -198,14 +199,24 @@ contract FleetIdentityUpgradeable is
198199
mapping(uint16 => uint32[]) internal _countryAdminAreas;
199200
mapping(uint32 => uint256) internal _countryAdminAreaIndex;
200201

202+
// ──────────────────────────────────────────────
203+
// V2 Storage: Bond payer tracking
204+
// ──────────────────────────────────────────────
205+
206+
/// @notice UUID -> address that paid the ownership bond.
207+
/// @dev For self-funded claims this is msg.sender; for sponsored claims
208+
/// this is the treasury. On burn the bond refunds to this address.
209+
/// Zero (pre-upgrade tokens) falls back to uuidOwner.
210+
mapping(bytes16 => address) public uuidOwnershipBondPayer;
211+
201212
// ──────────────────────────────────────────────
202213
// Storage Gap (for future upgrades)
203214
// ──────────────────────────────────────────────
204215

205216
/// @dev Reserved storage slots for future upgrades.
206217
/// When adding new storage in V2+, reduce this gap accordingly.
207218
// solhint-disable-next-line var-name-mixedcase
208-
uint256[50] private __gap;
219+
uint256[49] private __gap;
209220

210221
// ──────────────────────────────────────────────
211222
// Events
@@ -433,10 +444,14 @@ contract FleetIdentityUpgradeable is
433444

434445
// Use snapshot for accurate refund
435446
uint256 ownershipBond = uuidOwnershipBondPaid[uuid];
447+
// Refund to whoever paid the bond (treasury for sponsored, owner for self-funded).
448+
// Zero (pre-upgrade tokens) falls back to the UUID owner.
449+
address bondPayer = uuidOwnershipBondPayer[uuid];
450+
if (bondPayer == address(0)) bondPayer = owner;
436451

437452
_burn(tokenId);
438453
_clearUuidOwnership(uuid);
439-
_refundBond(owner, ownershipBond);
454+
_refundBond(bondPayer, ownershipBond);
440455

441456
emit FleetBurned(tokenHolder, tokenId, region, 0, ownershipBond);
442457
} else {
@@ -486,6 +501,7 @@ contract FleetIdentityUpgradeable is
486501
_mint(msg.sender, tokenId);
487502

488503
uuidOwnershipBondPaid[uuid] = _baseBond;
504+
uuidOwnershipBondPayer[uuid] = msg.sender;
489505
_pullBond(msg.sender, _baseBond);
490506

491507
emit UuidClaimed(msg.sender, uuid, operatorOf(uuid));
@@ -525,6 +541,7 @@ contract FleetIdentityUpgradeable is
525541

526542
tokenId = uint256(uint128(uuid));
527543
uuidOwnershipBondPaid[uuid] = _baseBond;
544+
uuidOwnershipBondPayer[uuid] = treasury;
528545
_mint(msg.sender, tokenId);
529546

530547
// Bond transfer uses the trusted _bondToken — cannot be faked by the treasury.
@@ -660,11 +677,7 @@ contract FleetIdentityUpgradeable is
660677
}
661678

662679
/// @notice Builds a bundle containing ONLY country-level fleets.
663-
function buildCountryOnlyBundle(uint16 countryCode)
664-
external
665-
view
666-
returns (bytes16[] memory uuids, uint256 count)
667-
{
680+
function buildCountryOnlyBundle(uint16 countryCode) external view returns (bytes16[] memory uuids, uint256 count) {
668681
if (countryCode == 0 || countryCode > MAX_COUNTRY_CODE) revert InvalidCountryCode();
669682

670683
uint32 countryKey = uint32(countryCode);
@@ -761,6 +774,7 @@ contract FleetIdentityUpgradeable is
761774
delete uuidOperator[uuid];
762775
delete uuidTotalTierBonds[uuid];
763776
delete uuidOwnershipBondPaid[uuid];
777+
delete uuidOwnershipBondPayer[uuid];
764778
}
765779

766780
function _decrementUuidCount(bytes16 uuid) internal returns (uint256 newCount) {
@@ -832,6 +846,7 @@ contract FleetIdentityUpgradeable is
832846
tokenId = _mintFleetToken(uuid, region, targetTier);
833847
tokenTier0Bond[tokenId] = tier0Bond;
834848
uuidOwnershipBondPaid[uuid] = _baseBond;
849+
uuidOwnershipBondPayer[uuid] = msg.sender;
835850

836851
_pullBond(msg.sender, _baseBond + targetTierBond);
837852

@@ -975,8 +990,7 @@ contract FleetIdentityUpgradeable is
975990
uint32 adminKey = makeAdminRegion(countryCode, adminCode);
976991
uint32 candidateRegion = isCountry ? countryKey : adminKey;
977992

978-
(, uint256 count, uint256 highestTier, uint256 lowestTier) =
979-
_buildHighestBondedUuidBundle(countryKey, adminKey);
993+
(, uint256 count, uint256 highestTier, uint256 lowestTier) = _buildHighestBondedUuidBundle(countryKey, adminKey);
980994

981995
for (uint256 tier = lowestTier; tier <= highestTier; ++tier) {
982996
bool tierHasCapacity = _regionTierMembers[candidateRegion][tier].length < TIER_CAPACITY;
@@ -1114,7 +1128,13 @@ contract FleetIdentityUpgradeable is
11141128

11151129
uint32 region = tokenRegion(tokenId);
11161130
if (region == OWNED_REGION_KEY && from != address(0) && to != address(0)) {
1117-
uuidOwner[tokenUuid(tokenId)] = to;
1131+
bytes16 uuid = tokenUuid(tokenId);
1132+
uuidOwner[uuid] = to;
1133+
// Transfer bond-refund right only if it was the previous owner (self-funded).
1134+
// Sponsored bonds (treasury != from) stay with the original payer.
1135+
if (uuidOwnershipBondPayer[uuid] == from) {
1136+
uuidOwnershipBondPayer[uuid] = to;
1137+
}
11181138
}
11191139

11201140
return from;

0 commit comments

Comments
 (0)