Coinbase

Coinbase: Spend Router 2

Cantina Security Report

Organization

@coinbase

Engagement Type

Cantina Reviews

Period

-


Findings

Low Risk

2 findings

0 fixed

2 acknowledged

Informational

5 findings

1 fixed

4 acknowledged


Low Risk2 findings

  1. SpendRouter is incompatible with fee-on-transfer tokens

    State

    Acknowledged

    Severity

    Severity: Low

    Details

    All routing functions in SpendRouter follow the same pattern: they call SpendPermissionManager.spend to pull value tokens into the router, then forward exactly value tokens to the recipient.

    SpendPermissionManager.spend internally calls _transferFrom, which transfers value tokens from the user's account to the SpendRouter.

    For fee-on-transfer tokens, the actual amount received by the router is less than value due to the fee deducted during transfer. The router then attempts to forward the full value to the recipient, which will revert due to insufficient balance.

    Proof of Concept

    Add the following content to a test/src/SpendRouter/FeeOnTransferForkPoCTest.t.sol file and execute it with forge 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 SpendRouter does 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.

  2. 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._transferFrom sets an exact allowance before pulling ERC-20 tokens from the account. Tokens such as USDT revert on approve if 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 forceApprove from OpenZeppelin's SafeERC20 to 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 SPM a USDT allowance, which requires an intentional non-standard action.

Informational8 findings

  1. Repeated validation and transfer logic across SpendRouter functions

    State

    Fixed

    PR #82

    Severity

    Severity: Informational

    Description

    The four routing functions in SpendRouter.sol (spendAndRoute, spendAndRouteWithSignature, spendWithWithdrawAndRoute, spendWithWithdrawAndRouteWithSignature) each independently repeat two blocks of logic:

    1. Authorization and validation: decoding extraData, checking msg.sender == executor, and validating the recipient is non-zero.

    2. Token forwarding: branching on native ETH vs ERC-20 for the outbound transfer.

    Recommendation

    Extract the repeated logic into internal helpers: _validateExtraData for the authorization checks, and _transferOut for the token forwarding branch.

    Coinbase: Fixed: https://github.com/coinbase/spend-permissions/pull/82.

    Cantina: Verified the fixes.

  2. SpendRouter does not ensure the requested value of stETH is delivered to the recipient

    State

    Acknowledged

    Severity

    Severity: Informational

    Description

    SpendRouter routes ERC-20 tokens through a two-hop transfer path: SpendPermissionManager._transferFrom pulls tokens from the user's account into the router, then the router forwards the same value to 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/transferFrom calls, the cumulative precision loss is up to 4 wei per routed spend.

    As a result, value is fully debited from the user's spend allowance and emitted in the SpendRouted event, but the recipient receives up to 4 wei less than value. The discrepancy stems from the router using transfer and transferFrom rather than stETH's transferShares, 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, and spendWithWithdrawAndRouteWithSignature.

    Proof of Concept

    Add the following content to a test/src/SpendRouter/stETHSpendRouter.t.sol file and execute the test with forge 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 transfer interface. If exact stETH delivery is a requirement, the router would need to integrate stETH's transferShares method 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.

  3. 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.sol

    Code Overview

    SpendRouter is a singleton router contract developed by Coinbase that sits on top of the SpendPermissionManager (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 with CoinbaseSmartWallet accounts and supports both native ETH (using the ERC-7528 sentinel address) and ERC-20 tokens.

    The core abstraction SpendRouter introduces 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. SpendRouter acts as the registered spender on-chain while embedding the actual intended recipient inside the permission's extraData field as a 64-byte ABI-encoded (executor, recipient) pair. The executor is the address authorized to trigger the spend, and the recipient is 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.

    SpendRouter exposes 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, spendWithWithdrawAndRoute and spendWithWithdrawAndRouteWithSignature, extend the above with an atomic MagicSpend withdrawal 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 validate extraData, verify msg.sender == executor, optionally approve the permission, call into SPM to pull tokens to SpendRouter, and finally forward them to the recipient.

    The contract inherits from Solady's Multicallable, allowing multiple routing operations to be batched into a single transaction. Permission revocation is also supported through revokeAsSpender, which allows the authorized executor to permanently invalidate a permission on behalf of the spender role held by SpendRouter.

    SpendRouter interacts with three external contracts. SPM manages permission state, enforces allowance periods, and executes token transfers from user accounts to SpendRouter. MagicSpend is an ERC-4337 paymaster that can fund accounts on demand via signed withdraw requests. PublicERC6492Validator handles signature validation for counterfactual accounts using ERC-6492, enabling permissions to be approved before an account is deployed on-chain.

  4. Trust Assumptions

    State

    New

    Severity

    Severity: Informational

    Submitted by

    Alireza Arjmand


    Trust Assumptions

    • account in a permission is assumed to be a CoinbaseSmartWallet. SPM's _execute calls CoinbaseSmartWallet(payable(account)).execute(...) directly. Any address can be set as account as long as it exposes a compatible execute interface, meaning a malicious account implementation could behave unexpectedly during token transfers.

    • PERMISSION_MANAGER is assumed to be the legitimate, unmodified Coinbase SPM deployment. SpendRouter performs no logic validation of the SPM, only a persistent code check at deploy time.

    • MagicSpend is assumed to correctly validate withdraw request signatures and enforce nonce uniqueness. SpendRouter passes WithdrawRequest through to SPM without any independent validation.

    • The user is assumed to have reviewed both executor and recipient before signing, as neither is validated for legitimacy on-chain beyond recipient != address(0).

    • Multicallable callers are assumed to understand that msg.value is shared across all batched sub-calls.

  5. Code Quality and Naming Inconsistencies

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Alireza Arjmand


    Description

    • The natspec for encodeExtraData documents executor as the address that will call spendAndRoute or spendAndRouteWithSignature, omitting the other executor-gated functions (spendWithWithdrawAndRoute, spendWithWithdrawAndRouteWithSignature, and revokeAsSpender). Consider updating the natspec to reference all executor-gated functions.
    • The error PermissionApprovalFailed is misleading as it can only be triggered when the permission is already revoked. Consider renaming it to PermissionRevoked to accurately reflect the only condition under which it fires.
    • SpendRouter defines the native token sentinel as NATIVE_TOKEN_ADDRESS while SPM defines the same constant as NATIVE_TOKEN. Consider aligning the constant name with SPM's NATIVE_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.

  6. Strict extraData Length Check Limits Future Composability

    State

    Acknowledged

    Severity

    Severity: Informational

    Submitted by

    Alireza Arjmand


    Description

    decodeExtraData enforces extraData.length == 64, restricting the field to exclusively encoding the (executor, recipient) pair. Any protocol attempting to integrate with SpendRouter while embedding additional metadata in extraData for consumption by their own logic would be rejected with MalformedExtraData, precluding any extensible usage of the field.

    Recommendation

    Relaxing the check to revert only when extraData.length < 64 would permit integrations to append arbitrary data beyond the first 64 bytes while SpendRouter continues to consume only the (executor, recipient) pair. With this change, however, executor must 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 through SpendRouter, 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.

  7. 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._transferFrom implicitly assumes the contract holds no ETH balance prior to execution. The receive() guard validates the amount received per call, but safeTransferETH will 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 _transferFrom explicitly stating that SpendPermissionManager is 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.

  8. 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 SpendRouter contract. 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.