Organization
- @coinbase
Engagement Type
Cantina Reviews
Period
-
Repositories
Researchers
Findings
Low Risk
2 findings
0 fixed
2 acknowledged
Informational
5 findings
1 fixed
4 acknowledged
Low Risk2 findings
SpendRouter is incompatible with fee-on-transfer tokens
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Drastic Watermelon
Details
All routing functions in
SpendRouterfollow the same pattern: they callSpendPermissionManager.spendto pullvaluetokens into the router, then forward exactlyvaluetokens to therecipient.SpendPermissionManager.spendinternally calls_transferFrom, which transfersvaluetokens from the user's account to theSpendRouter.For fee-on-transfer tokens, the actual amount received by the router is less than
valuedue to the fee deducted during transfer. The router then attempts to forward the fullvalueto the recipient, which will revert due to insufficient balance.Proof of Concept
Add the following content to a
test/src/SpendRouter/FeeOnTransferForkPoCTest.t.solfile and execute it withforge t --mc FeeOnTransferForkPoCTest:// SPDX-License-Identifier: MITpragma solidity ^0.8.28; import {ERC20} from "openzeppelin-contracts/contracts/token/ERC20/ERC20.sol";import {SpendPermissionManager} from "src/SpendPermissionManager.sol";import {SpendRouterTestBase} from "test/src/SpendRouter/SpendRouterTestBase.sol"; contract FeeOnTransferForkPoCTest is SpendRouterTestBase { uint256 internal constant FEE_BPS = 100; // 1% MockFeeOnTransferToken internal feeToken; address internal constant FEE_COLLECTOR = address(0xFEE); function setUp() public virtual override { bool enableFork = vm.envOr("SPEND_PERMISSIONS_ENABLE_FORK", false); if (enableFork) { string memory forkUrl = vm.envString("SPEND_PERMISSIONS_FORK_URL"); vm.createSelectFork(forkUrl); } super.setUp(); feeToken = new MockFeeOnTransferToken(FEE_BPS, FEE_COLLECTOR); } function _createFeePermission(uint160 allowance) internal view returns (SpendPermissionManager.SpendPermission memory) { uint48 start = uint48(block.timestamp); uint48 period = 1 days; uint48 end = start + period; return _createPermission(address(feeToken), allowance, period, start, end, 0); } function test_fork_feeOnTransfer_routerRevertsWithoutPrefund() public { uint160 spendAmount = 100e18; SpendPermissionManager.SpendPermission memory permission = _createFeePermission(spendAmount); _approvePermission(permission); feeToken.mint(address(account), spendAmount); vm.prank(executor); vm.expectRevert(); router.spendAndRoute(permission, spendAmount); // Whole tx reverts: state remains unchanged. assertEq(feeToken.balanceOf(address(account)), spendAmount); assertEq(feeToken.balanceOf(address(router)), 0); assertEq(feeToken.balanceOf(recipient), 0); assertEq(feeToken.balanceOf(FEE_COLLECTOR), 0); } function test_fork_feeOnTransfer_doubleFeeWhenRouterPrefunded() public { uint160 spendAmount = 100e18; uint256 singleFee = feeToken.feeFor(spendAmount); SpendPermissionManager.SpendPermission memory permission = _createFeePermission(spendAmount); _approvePermission(permission); // Fund account with exactly spendAmount and pre-fund router with the expected first-hop shortfall. feeToken.mint(address(account), spendAmount); feeToken.mint(address(router), singleFee); vm.prank(executor); router.spendAndRoute(permission, spendAmount); uint256 expectedRecipient = uint256(spendAmount) - singleFee; // User account is debited by full spendAmount on first hop. assertEq(feeToken.balanceOf(address(account)), 0); // Recipient receives less than requested because second hop is taxed again. assertEq(feeToken.balanceOf(recipient), expectedRecipient); // Tax is collected on both hops: account->router and router->recipient. assertEq(feeToken.balanceOf(FEE_COLLECTOR), singleFee * 2); }} contract MockFeeOnTransferToken is ERC20 { uint256 public immutable feeBps; address public immutable feeCollector; constructor(uint256 _feeBps, address _feeCollector) ERC20("Fee On Transfer", "FOT") { feeBps = _feeBps; feeCollector = _feeCollector; } function mint(address to, uint256 amount) external { _mint(to, amount); } function feeFor(uint256 amount) public view returns (uint256) { return (amount * feeBps) / 10_000; } function transfer(address to, uint256 amount) public virtual override returns (bool) { _transferWithFee(_msgSender(), to, amount); return true; } function transferFrom(address from, address to, uint256 amount) public virtual override returns (bool) { _spendAllowance(from, _msgSender(), amount); _transferWithFee(from, to, amount); return true; } function _transferWithFee(address from, address to, uint256 amount) internal { uint256 fee = feeFor(amount); uint256 net = amount - fee; super._transfer(from, feeCollector, fee); super._transfer(from, to, net); }}Recommendation
Document that
SpendRouterdoes not support fee-on-transfer tokens.Alternatively if support is desired, the router should check its actual token balance change caused by the spend call and forward the received amount rather than the requested
value.Coinbase: Acknowledged and documented via natspec. https://github.com/coinbase/spend-permissions/pull/82
Cantina: Acknowledged.
approve Pattern Incompatible with USDT-like Tokens if Residual Allowance Exists
State
- Acknowledged
Severity
- Severity: Low
Submitted by
Alireza Arjmand
Disclaimer: This issue was identified in
SpendPermissionManager, which was out of scope for this audit.Description
SpendPermissionManager._transferFromsets an exact allowance before pulling ERC-20 tokens from the account. Tokens such as USDT revert onapproveif the spender's current allowance is non-zero. Under normal SPM usage this path is not reachable, however if an account had previously granted SPM a non-zero ERC-20 allowance through any means, the approval call would revert, permanently blocking spends for that token until the allowance is manually cleared to zero.Recommendation
Use
forceApprovefrom OpenZeppelin'sSafeERC20to handle this edge case defensively.Coinbase: Ack: no change as pertains to
SpendPermissionManager.Cantina: Acknowledged. Per the issue writeup this only happens if the user deliberately grants the
SPMaUSDTallowance, which requires an intentional non-standard action.
Informational8 findings
Repeated validation and transfer logic across SpendRouter functions
State
- Fixed
PR #82
Severity
- Severity: Informational
Submitted by
Drastic Watermelon
Description
The four routing functions in
SpendRouter.sol(spendAndRoute,spendAndRouteWithSignature,spendWithWithdrawAndRoute,spendWithWithdrawAndRouteWithSignature) each independently repeat two blocks of logic:-
Authorization and validation: decoding
extraData, checkingmsg.sender == executor, and validating the recipient is non-zero. -
Token forwarding: branching on native ETH vs ERC-20 for the outbound transfer.
Recommendation
Extract the repeated logic into internal helpers:
_validateExtraDatafor the authorization checks, and_transferOutfor the token forwarding branch.Coinbase: Fixed: https://github.com/coinbase/spend-permissions/pull/82.
Cantina: Verified the fixes.
SpendRouter does not ensure the requested value of stETH is delivered to the recipient
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Drastic Watermelon
Description
SpendRouterroutes ERC-20 tokens through a two-hop transfer path:SpendPermissionManager._transferFrompulls tokens from the user's account into the router, then the router forwards the samevalueto the final recipient.stETH uses a shares-based accounting model internally. Each transfer may incur a rounding error of up to 1 or 2 wei due to the share-to-balance conversion. Since the routing path involves two
transfer/transferFromcalls, the cumulative precision loss is up to 4 wei per routed spend.As a result,
valueis fully debited from the user's spend allowance and emitted in theSpendRoutedevent, but the recipient receives up to 4 wei less thanvalue. The discrepancy stems from the router usingtransferandtransferFromrather than stETH'stransferShares, which as noted in Lido's integration guide, enables exact-amount transfers by operating directly on the underlying shares.The same issue applies to all four routing functions:
spendAndRoute,spendAndRouteWithSignature,spendWithWithdrawAndRoute, andspendWithWithdrawAndRouteWithSignature.Proof of Concept
Add the following content to a
test/src/SpendRouter/stETHSpendRouter.t.solfile and execute the test withforge t --mc StETHSpendRouterTest:// SPDX-License-Identifier: MITpragma solidity ^0.8.28; import {console2} from "forge-std/Test.sol";import {IERC20} from "openzeppelin-contracts/contracts/token/ERC20/IERC20.sol";import {SpendPermissionManager} from "src/SpendPermissionManager.sol";import {SpendRouterTestBase} from "test/src/SpendRouter/SpendRouterTestBase.sol"; contract StETHSpendRouterTest is SpendRouterTestBase { IERC20 constant STETH = IERC20(0xae7ab96520DE3A18E5e111B5EaAb095312D7fE84); /// @notice Large stETH holder to impersonate for funding. address constant STETH_WHALE = 0x5fAD9e2eE7aEbFFAc0a909264F85b1eEB216e3dd; function setUp() public override { vm.createSelectFork(vm.envString("ETH_RPC_URL"), 24686985); super.setUp(); } function _approvePermissionDirect(SpendPermissionManager.SpendPermission memory permission) internal { vm.prank(permission.account); permissionManager.approve(permission); } function test_stETH_spendAndRoute_precisionLoss() public { uint160 allowance = 10 ether; uint48 period = 1 days; uint48 start = uint48(block.timestamp); uint48 end = start + period; // Create and approve a permission for stETH SpendPermissionManager.SpendPermission memory permission = _createPermission(address(STETH), allowance, period, start, end, 0); _approvePermissionDirect(permission); // Fund the account with stETH from a whale vm.prank(STETH_WHALE); STETH.transfer(address(account), uint256(allowance)); // Rounding issue triggers on stETH transfer to account, yield 1 less wei assertEq(STETH.balanceOf(address(account)), allowance -1, "rounding issue did not trigger"); uint160 spendAmount = 1 ether; // Record balances before spend uint256 recipientBalBefore = STETH.balanceOf(recipient); uint256 routerBalBefore = STETH.balanceOf(address(router)); // Execute the spend-and-route vm.prank(executor); router.spendAndRoute(permission, spendAmount); // Measure actual delivery uint256 recipientBalAfter = STETH.balanceOf(recipient); uint256 routerBalAfter = STETH.balanceOf(address(router)); uint256 received = recipientBalAfter - recipientBalBefore; // Recipient receives at most the spend amount (possible 0-2 wei loss) assertEq(received, uint256(spendAmount) - 2, "didnt lose 2 wei"); }}Recommendation
The router currently cannot avoid this rounding since it relies on the standard ERC-20
transferinterface. If exact stETH delivery is a requirement, the router would need to integrate stETH'stransferSharesmethod for stETH-specific routing. Alternatively, document that rebasing tokens like stETH are subject to a 1-2 wei precision loss per routed spend.Coinbase: Acknowledged and documented via natspec. https://github.com/coinbase/spend-permissions/pull/82.
Cantina: Acknowledged.
Code Overview
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Scope
Cantina reviewed the coinbase/spend-permissions repository at branch
amie/spend-router, commit 0470fa9.The following file was in scope:
src└── SpendRouter.solCode Overview
SpendRouteris a singleton router contract developed by Coinbase that sits on top of theSpendPermissionManager(SPM) to enable authorized third parties to pull tokens from a user's smart account and forward them to a designated recipient in a single atomic transaction. The contract is designed to work exclusively withCoinbaseSmartWalletaccounts and supports both native ETH (using the ERC-7528 sentinel address) and ERC-20 tokens.The core abstraction
SpendRouterintroduces is the separation of the executing party from the token recipient. In SPM's native design, the spender and the ultimate recipient of funds are the same address.SpendRouteracts as the registered spender on-chain while embedding the actual intended recipient inside the permission'sextraDatafield as a 64-byte ABI-encoded(executor, recipient)pair. Theexecutoris the address authorized to trigger the spend, and therecipientis the address that ultimately receives the tokens. This allows protocols and off-chain operators to route user funds to arbitrary destinations while still satisfying SPM's permission model.SpendRouterexposes four primary execution paths. The first,spendAndRoute, operates against a permission that has already been approved on-chain. The second,spendAndRouteWithSignature, combines on-chain approval via the user's EIP-712 signature with the spend in a single transaction. The third and fourth paths,spendWithWithdrawAndRouteandspendWithWithdrawAndRouteWithSignature, extend the above with an atomicMagicSpendwithdrawal that pre-funds the user's account from an off-chain paymaster before the spend is executed, enabling spending of funds not yet resident on-chain. All four functions share the same execution structure: decode and validateextraData, verifymsg.sender == executor, optionally approve the permission, call into SPM to pull tokens toSpendRouter, and finally forward them to therecipient.The contract inherits from Solady's
Multicallable, allowing multiple routing operations to be batched into a single transaction. Permission revocation is also supported throughrevokeAsSpender, which allows the authorizedexecutorto permanently invalidate a permission on behalf of the spender role held bySpendRouter.SpendRouterinteracts with three external contracts. SPM manages permission state, enforces allowance periods, and executes token transfers from user accounts toSpendRouter.MagicSpendis an ERC-4337 paymaster that can fund accounts on demand via signed withdraw requests.PublicERC6492Validatorhandles signature validation for counterfactual accounts using ERC-6492, enabling permissions to be approved before an account is deployed on-chain.Trust Assumptions
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Trust Assumptions
-
accountin a permission is assumed to be aCoinbaseSmartWallet. SPM's_executecallsCoinbaseSmartWallet(payable(account)).execute(...)directly. Any address can be set asaccountas long as it exposes a compatibleexecuteinterface, meaning a malicious account implementation could behave unexpectedly during token transfers. -
PERMISSION_MANAGERis assumed to be the legitimate, unmodified Coinbase SPM deployment.SpendRouterperforms no logic validation of the SPM, only a persistent code check at deploy time. -
MagicSpendis assumed to correctly validate withdraw request signatures and enforce nonce uniqueness.SpendRouterpassesWithdrawRequestthrough to SPM without any independent validation. -
The user is assumed to have reviewed both
executorandrecipientbefore signing, as neither is validated for legitimacy on-chain beyondrecipient != address(0). -
Multicallablecallers are assumed to understand thatmsg.valueis shared across all batched sub-calls.
Code Quality and Naming Inconsistencies
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
- The natspec for
encodeExtraDatadocumentsexecutoras the address that will callspendAndRouteorspendAndRouteWithSignature, omitting the other executor-gated functions (spendWithWithdrawAndRoute,spendWithWithdrawAndRouteWithSignature, andrevokeAsSpender). Consider updating the natspec to reference all executor-gated functions. - The error
PermissionApprovalFailedis misleading as it can only be triggered when the permission is already revoked. Consider renaming it toPermissionRevokedto accurately reflect the only condition under which it fires. SpendRouterdefines the native token sentinel asNATIVE_TOKEN_ADDRESSwhile SPM defines the same constant asNATIVE_TOKEN. Consider aligning the constant name with SPM'sNATIVE_TOKEN.
Coinbase: Fixed first bullet suggestion, will leave bullets 2 and 3 as is: https://github.com/coinbase/spend-permissions/pull/82.
Cantina: Verified the fix for the first issue. The second and third issues do not require fixing.
Strict extraData Length Check Limits Future Composability
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Description
decodeExtraDataenforcesextraData.length == 64, restricting the field to exclusively encoding the(executor, recipient)pair. Any protocol attempting to integrate withSpendRouterwhile embedding additional metadata inextraDatafor consumption by their own logic would be rejected withMalformedExtraData, precluding any extensible usage of the field.Recommendation
Relaxing the check to revert only when
extraData.length < 64would permit integrations to append arbitrary data beyond the first 64 bytes whileSpendRoutercontinues to consume only the(executor, recipient)pair. With this change, however,executormust be set to the integrating contract itself or be subject to equivalent access control. Failure to enforce this would allow the permission to be invoked directly throughSpendRouter, bypassing any higher-level accounting or state management the integration depends on.Coinbase: Ack: no change. After some debate, deciding that the sanity check outweighs speculative composability given that there are other ways to cryptographically bound additional data to a spend permission (including putting a hash of other extra data in the salt) that could be implemented by integrators. Furthermore, a peripheral protocol that may want to make use of additional extraData could either also build in any necessary routing needs there, or create a new version of the SpendRouter as it is stateless and easy to migrate.
Cantina: Acknowledged. This issues does not need fixing and introduced no risk to the router itself.
SpendPermissionManager ETH Balance Assumption is Undocumented
State
- Acknowledged
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Disclaimer: This issue was identified in
SpendPermissionManager, which was out of scope for this audit.Description
The native ETH path in
SpendPermissionManager._transferFromimplicitly assumes the contract holds no ETH balance prior to execution. Thereceive()guard validates the amount received per call, butsafeTransferETHwill draw from any pre-existing balance if present. This means any ETH accidentally sent to the contract can be drained by anyone triggering a native ETH spend of the corresponding amount. This behavior is intentional given SPM is not designed to hold funds, but it is undocumented.Recommendation
Add a natspec comment to
_transferFromexplicitly stating thatSpendPermissionManageris not intended to hold ETH and verifying that the functionality is indeed intended, and that the correctness of the native ETH path depends on this invariant being maintained.Coinbase: Ack: no change as pertains to
SpendPermissionManager.Cantina: Acknowledged.
Security Review Statement
State
- New
Severity
- Severity: Informational
Submitted by
Alireza Arjmand
Security Review Statement
Coinbase engaged Cantina to conduct a security review of the
SpendRoutercontract. We would like to thank the Coinbase team for their responsiveness and constructive engagement throughout the review process. No significant issues were identified during the assessment, and the protocol is expected to operate as intended.