From c13d8758df2301927df6d2d2612ab0c36c0c1f6e Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Wed, 6 May 2026 20:11:02 -0700 Subject: [PATCH 1/3] feat: dispatch spend permissions to SpendRouter when onchain spender matches --- .../evm/spend_permissions/account_use.py | 43 +----- .../spend_permissions/smart_account_use.py | 44 +----- python/cdp/spend_permissions/__init__.py | 17 +- python/cdp/spend_permissions/constants.py | 41 +++++ python/cdp/spend_permissions/utils.py | 77 ++++++++++ .../evm/spend_permissions/test_account_use.py | 119 +++++++------- .../test_smart_account_use.py | 145 +++++++++--------- .../evm/spend-permissions/account.use.test.ts | 42 +++++ .../evm/spend-permissions/account.use.ts | 22 +-- .../smartAccount.use.test.ts | 31 ++++ .../evm/spend-permissions/smartAccount.use.ts | 19 +-- .../src/spend-permissions/constants.ts | 47 ++++++ .../cdp-sdk/src/spend-permissions/utils.ts | 62 +++++++- 13 files changed, 466 insertions(+), 243 deletions(-) diff --git a/python/cdp/actions/evm/spend_permissions/account_use.py b/python/cdp/actions/evm/spend_permissions/account_use.py index 9e20dbabe..1c4505aca 100644 --- a/python/cdp/actions/evm/spend_permissions/account_use.py +++ b/python/cdp/actions/evm/spend_permissions/account_use.py @@ -1,11 +1,9 @@ """Use a spend permission to spend tokens from a regular account.""" -from web3 import Web3 - from cdp.actions.evm.send_transaction import send_transaction from cdp.api_clients import ApiClients from cdp.evm_transaction_types import TransactionRequestEIP1559 -from cdp.spend_permissions import SPEND_PERMISSION_MANAGER_ABI, SPEND_PERMISSION_MANAGER_ADDRESS +from cdp.spend_permissions import build_spend_call from cdp.spend_permissions.types import SpendPermission @@ -18,6 +16,11 @@ async def account_use_spend_permission( ) -> str: """Use a spend permission to spend tokens. + Dispatches automatically to either `SpendPermissionManager.spend` (legacy permissions) + or `SpendRouter.spendAndRoute` (CDP-created permissions whose onchain spender is the + SpendRouter contract). See cdp.spend_permissions.utils.build_spend_call for the dispatch + rule. + Args: api_clients (ApiClients): The API client to use. address (str): The address of the account to use the spend permission on. @@ -29,39 +32,9 @@ async def account_use_spend_permission( Hash: The transaction hash of the spend permission. """ - # Use the spend permission directly - resolved_permission = spend_permission - - # Encode the function call data using web3.py - w3 = Web3() - contract = w3.eth.contract( - address=SPEND_PERMISSION_MANAGER_ADDRESS, abi=SPEND_PERMISSION_MANAGER_ABI - ) + to, encoded_data = build_spend_call(spend_permission, value) - # Convert SpendPermission to a tuple matching the contract's struct - # Ensure all numeric values are integers (API may return strings) - # Convert addresses to checksum format for Web3.py compatibility - permission_tuple = ( - Web3.to_checksum_address(resolved_permission.account), - Web3.to_checksum_address(resolved_permission.spender), - Web3.to_checksum_address(resolved_permission.token), - int(resolved_permission.allowance), # Convert to int - int(resolved_permission.period), # Convert to int - int(resolved_permission.start), # Convert to int - int(resolved_permission.end), # Convert to int - int(resolved_permission.salt), # Convert to int - bytes.fromhex(resolved_permission.extra_data[2:]) - if resolved_permission.extra_data.startswith("0x") - else bytes.fromhex(resolved_permission.extra_data), - ) - - encoded_data = contract.encode_abi("spend", args=[permission_tuple, value]) - - # Create transaction as a DynamicFeeTransaction - transaction = TransactionRequestEIP1559( - to=Web3.to_checksum_address(SPEND_PERMISSION_MANAGER_ADDRESS), - data=encoded_data, - ) + transaction = TransactionRequestEIP1559(to=to, data=encoded_data) return await send_transaction( evm_accounts=api_clients.evm_accounts, diff --git a/python/cdp/actions/evm/spend_permissions/smart_account_use.py b/python/cdp/actions/evm/spend_permissions/smart_account_use.py index 97c8bf109..b8a180918 100644 --- a/python/cdp/actions/evm/spend_permissions/smart_account_use.py +++ b/python/cdp/actions/evm/spend_permissions/smart_account_use.py @@ -1,13 +1,11 @@ """Use a spend permission to spend tokens from a smart account.""" -from web3 import Web3 - from cdp.actions.evm.send_user_operation import send_user_operation from cdp.api_clients import ApiClients from cdp.evm_call_types import EncodedCall from cdp.evm_smart_account import EvmSmartAccount from cdp.openapi_client.models.evm_user_operation import EvmUserOperation -from cdp.spend_permissions import SPEND_PERMISSION_MANAGER_ABI, SPEND_PERMISSION_MANAGER_ADDRESS +from cdp.spend_permissions import build_spend_call from cdp.spend_permissions.types import SpendPermission @@ -21,6 +19,11 @@ async def smart_account_use_spend_permission( ) -> EvmUserOperation: """Use a spend permission to spend tokens from a smart account. + Dispatches automatically to either `SpendPermissionManager.spend` (legacy permissions) + or `SpendRouter.spendAndRoute` (CDP-created permissions whose onchain spender is the + SpendRouter contract). See cdp.spend_permissions.utils.build_spend_call for the dispatch + rule. + Args: api_clients (ApiClients): The API client to use. smart_account (EvmSmartAccount): The smart account to use the spend permission on. @@ -33,41 +36,10 @@ async def smart_account_use_spend_permission( EvmUserOperation: The user operation response. """ - # Use the spend permission directly - resolved_permission = spend_permission - - # Encode the function call data using web3.py - w3 = Web3() - contract = w3.eth.contract( - address=w3.to_checksum_address(SPEND_PERMISSION_MANAGER_ADDRESS), - abi=SPEND_PERMISSION_MANAGER_ABI, - ) + to, encoded_data = build_spend_call(spend_permission, value) - # Convert SpendPermission to a tuple matching the contract's struct - # Ensure all numeric values are integers (API may return strings) - # Convert addresses to checksum format for Web3.py compatibility - permission_tuple = ( - w3.to_checksum_address(resolved_permission.account), - w3.to_checksum_address(resolved_permission.spender), - w3.to_checksum_address(resolved_permission.token), - int(resolved_permission.allowance), # Convert to int - int(resolved_permission.period), # Convert to int - int(resolved_permission.start), # Convert to int - int(resolved_permission.end), # Convert to int - int(resolved_permission.salt), # Convert to int - bytes.fromhex(resolved_permission.extra_data[2:]) - if resolved_permission.extra_data.startswith("0x") - else bytes.fromhex(resolved_permission.extra_data), - ) - - encoded_data = contract.encode_abi("spend", args=[permission_tuple, value]) - - # Create the call - call = EncodedCall( - to=w3.to_checksum_address(SPEND_PERMISSION_MANAGER_ADDRESS), data=encoded_data, value=0 - ) + call = EncodedCall(to=to, data=encoded_data, value=0) - # Send the user operation return await send_user_operation( api_clients=api_clients, address=smart_account.address, diff --git a/python/cdp/spend_permissions/__init__.py b/python/cdp/spend_permissions/__init__.py index bb234cb7d..9ceb3124a 100644 --- a/python/cdp/spend_permissions/__init__.py +++ b/python/cdp/spend_permissions/__init__.py @@ -3,18 +3,29 @@ from cdp.spend_permissions.constants import ( SPEND_PERMISSION_MANAGER_ABI, SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ABI, + SPEND_ROUTER_ADDRESS, ) from cdp.spend_permissions.types import ( SpendPermission, SpendPermissionInput, ) -from cdp.spend_permissions.utils import resolve_spend_permission, resolve_token_address +from cdp.spend_permissions.utils import ( + build_spend_call, + is_spend_router_permission, + resolve_spend_permission, + resolve_token_address, +) __all__ = [ - "SPEND_PERMISSION_MANAGER_ADDRESS", "SPEND_PERMISSION_MANAGER_ABI", + "SPEND_PERMISSION_MANAGER_ADDRESS", + "SPEND_ROUTER_ABI", + "SPEND_ROUTER_ADDRESS", "SpendPermission", "SpendPermissionInput", - "resolve_token_address", + "build_spend_call", + "is_spend_router_permission", "resolve_spend_permission", + "resolve_token_address", ] diff --git a/python/cdp/spend_permissions/constants.py b/python/cdp/spend_permissions/constants.py index 71f6771c4..7a78f8e66 100644 --- a/python/cdp/spend_permissions/constants.py +++ b/python/cdp/spend_permissions/constants.py @@ -2,6 +2,47 @@ SPEND_PERMISSION_MANAGER_ADDRESS = "0xf85210B21cC50302F477BA56686d2019dC9b67Ad" +# SpendRouter is a singleton contract deployed at the same address on every supported chain +# (Base, Base Sepolia, Ethereum, Ethereum Sepolia, Optimism, OP Sepolia, Arbitrum, Avalanche, +# Polygon, BNB) via CREATE2. CDP-created spend permissions have this contract as their onchain +# spender; the executor and recipient are encoded in `permission.extra_data`. To execute one, +# callers invoke `spendAndRoute` on this contract instead of `spend` on SpendPermissionManager. +# The SDK dispatches based on `permission.spender`. +# +# Source: https://github.com/coinbase/spend-permissions/blob/main/src/SpendRouter.sol +SPEND_ROUTER_ADDRESS = "0x1a672dE48c82278b2F1BB68d7b9141634dD6BE29" + +# Minimal SpendRouter ABI containing just `spendAndRoute`, the only entry point the SDK calls +# today. spendAndRouteWithSignature, MagicSpend variants, and revokeAsSpender are intentionally +# omitted; add them when the corresponding SDK actions ship. +SPEND_ROUTER_ABI = [ + { + "inputs": [ + { + "components": [ + {"internalType": "address", "name": "account", "type": "address"}, + {"internalType": "address", "name": "spender", "type": "address"}, + {"internalType": "address", "name": "token", "type": "address"}, + {"internalType": "uint160", "name": "allowance", "type": "uint160"}, + {"internalType": "uint48", "name": "period", "type": "uint48"}, + {"internalType": "uint48", "name": "start", "type": "uint48"}, + {"internalType": "uint48", "name": "end", "type": "uint48"}, + {"internalType": "uint256", "name": "salt", "type": "uint256"}, + {"internalType": "bytes", "name": "extraData", "type": "bytes"}, + ], + "internalType": "struct SpendPermissionManager.SpendPermission", + "name": "permission", + "type": "tuple", + }, + {"internalType": "uint160", "name": "value", "type": "uint160"}, + ], + "name": "spendAndRoute", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] + SPEND_PERMISSION_MANAGER_ABI = [ { "inputs": [ diff --git a/python/cdp/spend_permissions/utils.py b/python/cdp/spend_permissions/utils.py index f6a53f958..304a93c70 100644 --- a/python/cdp/spend_permissions/utils.py +++ b/python/cdp/spend_permissions/utils.py @@ -4,8 +4,16 @@ from datetime import datetime from typing import Literal +from web3 import Web3 + from cdp.errors import UserInputValidationError from cdp.openapi_client import SpendPermissionNetwork +from cdp.spend_permissions.constants import ( + SPEND_PERMISSION_MANAGER_ABI, + SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ABI, + SPEND_ROUTER_ADDRESS, +) from cdp.spend_permissions.types import ( SpendPermission, SpendPermissionInput, @@ -113,3 +121,72 @@ def resolve_spend_permission( salt=spend_permission_input.salt or generate_random_salt(), extra_data=spend_permission_input.extra_data or "0x", ) + + +def is_spend_router_permission(spender: str) -> bool: + """Report whether a permission's onchain spender is the SpendRouter contract. + + Permissions created via the CDP API after SpendRouter integration set spender to + SPEND_ROUTER_ADDRESS; pre-router permissions set it to the developer-provided address + directly. Comparison is case-insensitive because mixed-case addresses (EIP-55 checksums) + and all-lowercase addresses both occur on API responses. + + Args: + spender: The permission's onchain spender address. + + Returns: + True if the spender is the SpendRouter contract. + + """ + return spender.lower() == SPEND_ROUTER_ADDRESS.lower() + + +def build_spend_call(spend_permission: SpendPermission, value: int) -> tuple[str, str]: + """Build the (target contract address, encoded calldata) pair for a spend. + + Dispatches based on the permission's onchain spender: SpendRouter permissions go to + `SpendRouter.spendAndRoute`, legacy permissions go to `SpendPermissionManager.spend`. + Centralized so account_use and smart_account_use share a single dispatch decision and + stay forward-compatible with any future router functions added to the SpendRouter ABI. + + Args: + spend_permission: The permission to spend against. + value: The amount to spend (must be <= remaining allowance for the current period). + + Returns: + Tuple of (checksummed target contract address, hex-encoded calldata). + + """ + w3 = Web3() + permission_tuple = ( + Web3.to_checksum_address(spend_permission.account), + Web3.to_checksum_address(spend_permission.spender), + Web3.to_checksum_address(spend_permission.token), + int(spend_permission.allowance), + int(spend_permission.period), + int(spend_permission.start), + int(spend_permission.end), + int(spend_permission.salt), + bytes.fromhex(spend_permission.extra_data[2:]) + if spend_permission.extra_data.startswith("0x") + else bytes.fromhex(spend_permission.extra_data), + ) + + if is_spend_router_permission(spend_permission.spender): + contract = w3.eth.contract( + address=Web3.to_checksum_address(SPEND_ROUTER_ADDRESS), + abi=SPEND_ROUTER_ABI, + ) + return ( + Web3.to_checksum_address(SPEND_ROUTER_ADDRESS), + contract.encode_abi("spendAndRoute", args=[permission_tuple, value]), + ) + + contract = w3.eth.contract( + address=Web3.to_checksum_address(SPEND_PERMISSION_MANAGER_ADDRESS), + abi=SPEND_PERMISSION_MANAGER_ABI, + ) + return ( + Web3.to_checksum_address(SPEND_PERMISSION_MANAGER_ADDRESS), + contract.encode_abi("spend", args=[permission_tuple, value]), + ) diff --git a/python/cdp/test/actions/evm/spend_permissions/test_account_use.py b/python/cdp/test/actions/evm/spend_permissions/test_account_use.py index 78b743b1d..e49ba9f3e 100644 --- a/python/cdp/test/actions/evm/spend_permissions/test_account_use.py +++ b/python/cdp/test/actions/evm/spend_permissions/test_account_use.py @@ -5,39 +5,24 @@ import pytest from cdp.actions.evm.spend_permissions.account_use import account_use_spend_permission -from cdp.spend_permissions import SpendPermission +from cdp.spend_permissions import ( + SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ADDRESS, + SpendPermission, +) +# Sentinel calldata distinct enough to round-trip through the handler unambiguously and +# verify it ends up on the outgoing transaction. +_LEGACY_CALLDATA = "0x33211c30deadbeef" +_ROUTED_CALLDATA = "0x12345678cafebabe" -@pytest.mark.asyncio -@patch("cdp.actions.evm.spend_permissions.account_use.Web3") -async def test_account_use_spend_permission(mock_web3): - """Test using a spend permission with a regular account.""" - # Mock Web3 contract encoding - mock_contract = MagicMock() - mock_contract.encode_abi.return_value = "0xabcdef123456" # Mock encoded data - mock_web3.return_value.eth.contract.return_value = mock_contract - - # Mock Web3.to_checksum_address to return the same address (for simplicity in tests) - def checksum_side_effect(address): - # For tests, just return the address as-is - return str(address) if isinstance(address, str) else str(address) - - mock_web3.to_checksum_address.side_effect = checksum_side_effect - - # Create mock API clients - mock_api_clients = AsyncMock() - mock_evm_accounts = AsyncMock() - mock_api_clients.evm_accounts = mock_evm_accounts - - # Mock the send transaction response - mock_evm_accounts.send_evm_transaction.return_value = MagicMock(transaction_hash="0xabc123") - # Create a spend permission - spend_permission = SpendPermission( +def _make_permission(spender: str) -> SpendPermission: + return SpendPermission( account="0x1111111111111111111111111111111111111111", - spender="0x2222222222222222222222222222222222222222", + spender=spender, token="0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - allowance=1000000000000000000, # 1 ETH + allowance=1000000000000000000, period=86400, start=1700000000, end=1700086400, @@ -45,49 +30,53 @@ def checksum_side_effect(address): extra_data="0x", ) - # Call the function + +@pytest.mark.asyncio +@patch("cdp.actions.evm.spend_permissions.account_use.build_spend_call") +async def test_account_use_spend_permission_legacy(mock_build_spend_call): + """Legacy permission spender routes the transaction to SpendPermissionManager.""" + mock_build_spend_call.return_value = (SPEND_PERMISSION_MANAGER_ADDRESS, _LEGACY_CALLDATA) + + mock_api_clients = AsyncMock() + mock_api_clients.evm_accounts = AsyncMock() + mock_api_clients.evm_accounts.send_evm_transaction.return_value = MagicMock( + transaction_hash="0xabc123" + ) + + permission = _make_permission(spender="0x2222222222222222222222222222222222222222") result = await account_use_spend_permission( api_clients=mock_api_clients, address="0x2222222222222222222222222222222222222222", - spend_permission=spend_permission, - value=500000000000000000, # 0.5 ETH + spend_permission=permission, + value=500000000000000000, network="base-sepolia", ) - # Verify the result assert result == "0xabc123" + mock_build_spend_call.assert_called_once_with(permission, 500000000000000000) + mock_api_clients.evm_accounts.send_evm_transaction.assert_called_once() + - # Verify the API was called - mock_evm_accounts.send_evm_transaction.assert_called_once() - call_args = mock_evm_accounts.send_evm_transaction.call_args - - # Check the address - assert call_args.kwargs["address"] == "0x2222222222222222222222222222222222222222" - - # Check the request contains a transaction - send_request = call_args.kwargs["send_evm_transaction_request"] - assert send_request.transaction.startswith("0x02") # EIP-1559 transaction - assert send_request.network == "base-sepolia" - - # Verify Web3.to_checksum_address was called with addresses - # It should be called for: account, spender, token, and manager address - assert mock_web3.to_checksum_address.call_count >= 3 # At least for the 3 permission addresses - - # Verify Web3 contract encoding was called correctly - mock_contract.encode_abi.assert_called_once_with( - "spend", - args=[ - ( - "0x1111111111111111111111111111111111111111", - "0x2222222222222222222222222222222222222222", - "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - 1000000000000000000, - 86400, - 1700000000, - 1700086400, - 12345, - b"", - ), - 500000000000000000, - ], +@pytest.mark.asyncio +@patch("cdp.actions.evm.spend_permissions.account_use.build_spend_call") +async def test_account_use_spend_permission_routed(mock_build_spend_call): + """SpendRouter spender routes the transaction to the SpendRouter contract.""" + mock_build_spend_call.return_value = (SPEND_ROUTER_ADDRESS, _ROUTED_CALLDATA) + + mock_api_clients = AsyncMock() + mock_api_clients.evm_accounts = AsyncMock() + mock_api_clients.evm_accounts.send_evm_transaction.return_value = MagicMock( + transaction_hash="0xrouted456" + ) + + permission = _make_permission(spender=SPEND_ROUTER_ADDRESS) + result = await account_use_spend_permission( + api_clients=mock_api_clients, + address="0x2222222222222222222222222222222222222222", + spend_permission=permission, + value=500000000000000000, + network="base-sepolia", ) + + assert result == "0xrouted456" + mock_build_spend_call.assert_called_once_with(permission, 500000000000000000) diff --git a/python/cdp/test/actions/evm/spend_permissions/test_smart_account_use.py b/python/cdp/test/actions/evm/spend_permissions/test_smart_account_use.py index dbd898100..1d1d87c59 100644 --- a/python/cdp/test/actions/evm/spend_permissions/test_smart_account_use.py +++ b/python/cdp/test/actions/evm/spend_permissions/test_smart_account_use.py @@ -6,48 +6,24 @@ from cdp.actions.evm.spend_permissions.smart_account_use import smart_account_use_spend_permission from cdp.evm_smart_account import EvmSmartAccount -from cdp.spend_permissions import SpendPermission +from cdp.spend_permissions import ( + SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ADDRESS, + SpendPermission, +) +# Sentinel calldata distinct enough to round-trip through the handler unambiguously and +# verify it ends up on the outgoing user-op call. +_LEGACY_CALLDATA = "0x33211c30deadbeef" +_ROUTED_CALLDATA = "0x12345678cafebabe" -@pytest.mark.asyncio -@patch("cdp.actions.evm.spend_permissions.smart_account_use.send_user_operation") -@patch("cdp.actions.evm.spend_permissions.smart_account_use.Web3") -async def test_smart_account_use_spend_permission(mock_web3, mock_send_user_operation): - """Test using a spend permission with a smart account.""" - # Mock Web3 contract encoding - mock_contract = MagicMock() - mock_contract.encode_abi.return_value = "0xabcdef123456" # Mock encoded data - mock_web3.return_value.eth.contract.return_value = mock_contract - - # Mock Web3.to_checksum_address to return proper checksum addresses - def checksum_side_effect(address): - # Return a proper checksum version of the address - return address.upper() if isinstance(address, str) else str(address).upper() - - mock_web3.return_value.to_checksum_address.side_effect = checksum_side_effect - - # Create mock API clients - mock_api_clients = AsyncMock() - - # Create a mock smart account - mock_owner = MagicMock() - smart_account = EvmSmartAccount( - address="0x3333333333333333333333333333333333333333", - owner=mock_owner, - name="test-account", - ) - # No need to mock owner_account separately, it's the same as owner - # Mock the user operation response - mock_user_operation = MagicMock() - mock_send_user_operation.return_value = mock_user_operation - - # Create a spend permission - spend_permission = SpendPermission( +def _make_permission(spender: str) -> SpendPermission: + return SpendPermission( account="0x3333333333333333333333333333333333333333", - spender="0x5555555555555555555555555555555555555555", + spender=spender, token="0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE", - allowance=1000000000000000000, # 1 ETH + allowance=1000000000000000000, period=86400, start=1700000000, end=1700086400, @@ -55,54 +31,73 @@ def checksum_side_effect(address): extra_data="0x", ) - # Call the function + +def _make_smart_account() -> EvmSmartAccount: + return EvmSmartAccount( + address="0x3333333333333333333333333333333333333333", + owner=MagicMock(), + name="test-account", + ) + + +@pytest.mark.asyncio +@patch("cdp.actions.evm.spend_permissions.smart_account_use.send_user_operation") +@patch("cdp.actions.evm.spend_permissions.smart_account_use.build_spend_call") +async def test_smart_account_use_spend_permission_legacy( + mock_build_spend_call, mock_send_user_operation +): + """Legacy permission spender targets SpendPermissionManager in the user-op call.""" + mock_build_spend_call.return_value = (SPEND_PERMISSION_MANAGER_ADDRESS, _LEGACY_CALLDATA) + mock_user_operation = MagicMock() + mock_send_user_operation.return_value = mock_user_operation + + permission = _make_permission(spender="0x5555555555555555555555555555555555555555") + smart_account = _make_smart_account() + result = await smart_account_use_spend_permission( - api_clients=mock_api_clients, + api_clients=AsyncMock(), smart_account=smart_account, - spend_permission=spend_permission, - value=500000000000000000, # 0.5 ETH + spend_permission=permission, + value=500000000000000000, network="base-sepolia", paymaster_url="https://paymaster.example.com", ) - # Verify the result - assert result == mock_user_operation - - # Verify send_user_operation was called correctly - mock_send_user_operation.assert_called_once() + assert result is mock_user_operation + mock_build_spend_call.assert_called_once_with(permission, 500000000000000000) call_args = mock_send_user_operation.call_args - - assert call_args.kwargs["api_clients"] == mock_api_clients - assert call_args.kwargs["address"] == "0x3333333333333333333333333333333333333333" - assert call_args.kwargs["owner"] == smart_account.owners[0] assert len(call_args.kwargs["calls"]) == 1 - assert call_args.kwargs["calls"][0].to == "0XF85210B21CC50302F477BA56686D2019DC9B67AD" - assert call_args.kwargs["calls"][0].data == "0xabcdef123456" + assert call_args.kwargs["calls"][0].to == SPEND_PERMISSION_MANAGER_ADDRESS + assert call_args.kwargs["calls"][0].data == _LEGACY_CALLDATA assert call_args.kwargs["calls"][0].value == 0 assert call_args.kwargs["network"] == "base-sepolia" assert call_args.kwargs["paymaster_url"] == "https://paymaster.example.com" - # Verify Web3.to_checksum_address was called with addresses - # It should be called for: account, spender, token, and manager address - assert ( - mock_web3.return_value.to_checksum_address.call_count >= 3 - ) # At least for the 3 permission addresses - - # Verify Web3 contract encoding was called correctly - mock_contract.encode_abi.assert_called_once_with( - "spend", - args=[ - ( - "0X3333333333333333333333333333333333333333", - "0X5555555555555555555555555555555555555555", - "0XEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE", - 1000000000000000000, - 86400, - 1700000000, - 1700086400, - 12345, - b"", - ), - 500000000000000000, - ], + +@pytest.mark.asyncio +@patch("cdp.actions.evm.spend_permissions.smart_account_use.send_user_operation") +@patch("cdp.actions.evm.spend_permissions.smart_account_use.build_spend_call") +async def test_smart_account_use_spend_permission_routed( + mock_build_spend_call, mock_send_user_operation +): + """SpendRouter spender targets the SpendRouter contract in the user-op call.""" + mock_build_spend_call.return_value = (SPEND_ROUTER_ADDRESS, _ROUTED_CALLDATA) + mock_user_operation = MagicMock() + mock_send_user_operation.return_value = mock_user_operation + + permission = _make_permission(spender=SPEND_ROUTER_ADDRESS) + smart_account = _make_smart_account() + + result = await smart_account_use_spend_permission( + api_clients=AsyncMock(), + smart_account=smart_account, + spend_permission=permission, + value=500000000000000000, + network="base-sepolia", ) + + assert result is mock_user_operation + mock_build_spend_call.assert_called_once_with(permission, 500000000000000000) + call_args = mock_send_user_operation.call_args + assert call_args.kwargs["calls"][0].to == SPEND_ROUTER_ADDRESS + assert call_args.kwargs["calls"][0].data == _ROUTED_CALLDATA diff --git a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.test.ts b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.test.ts index 570723109..b3bbc38b7 100644 --- a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.test.ts +++ b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.test.ts @@ -6,6 +6,8 @@ import { useSpendPermission } from "./account.use.js"; import { SPEND_PERMISSION_MANAGER_ABI, SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ABI, + SPEND_ROUTER_ADDRESS, } from "../../../spend-permissions/constants.js"; import { serializeEIP1559Transaction } from "../../../utils/serializeTransaction.js"; @@ -96,6 +98,46 @@ describe("useSpendPermission", () => { }); }); + it("dispatches to SpendRouter.spendAndRoute when the permission spender is the router", async () => { + const routedPermission: SpendPermission = { + ...mockSpendPermission, + spender: SPEND_ROUTER_ADDRESS as Address, + }; + + await useSpendPermission(mockClient, mockAddress, { + ...mockOptions, + spendPermission: routedPermission, + }); + + expect(encodeFunctionData).toHaveBeenCalledWith({ + abi: SPEND_ROUTER_ABI, + functionName: "spendAndRoute", + args: [routedPermission, mockOptions.value], + }); + expect(serializeEIP1559Transaction).toHaveBeenCalledWith({ + to: SPEND_ROUTER_ADDRESS, + data: mockEncodedData, + }); + }); + + it("matches the SpendRouter address case-insensitively", async () => { + const routedPermission: SpendPermission = { + ...mockSpendPermission, + spender: SPEND_ROUTER_ADDRESS.toLowerCase() as Address, + }; + + await useSpendPermission(mockClient, mockAddress, { + ...mockOptions, + spendPermission: routedPermission, + }); + + expect(encodeFunctionData).toHaveBeenCalledWith({ + abi: SPEND_ROUTER_ABI, + functionName: "spendAndRoute", + args: [routedPermission, mockOptions.value], + }); + }); + it("should throw API error when sendEvmTransaction fails", async () => { const apiError = new Error("API request failed"); mockClient.sendEvmTransaction = vi.fn().mockRejectedValue(apiError); diff --git a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.ts b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.ts index 373a0ab2f..91ddc62ad 100644 --- a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.ts +++ b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/account.use.ts @@ -1,9 +1,4 @@ -import { encodeFunctionData } from "viem"; - -import { - SPEND_PERMISSION_MANAGER_ABI, - SPEND_PERMISSION_MANAGER_ADDRESS, -} from "../../../spend-permissions/constants.js"; +import { buildSpendCalldata } from "../../../spend-permissions/utils.js"; import { serializeEIP1559Transaction } from "../../../utils/serializeTransaction.js"; import type { UseSpendPermissionOptions } from "./types.js"; @@ -15,7 +10,9 @@ import type { Address, Hex } from "../../../types/misc.js"; import type { TransactionResult } from "../sendTransaction.js"; /** - * Use a spend permission to spend tokens. + * Use a spend permission to spend tokens. Dispatches automatically to either + * `SpendPermissionManager.spend` (legacy permissions) or `SpendRouter.spendAndRoute` + * (CDP-created permissions whose onchain spender is the SpendRouter contract). * * @param apiClient - The API client to use. * @param address - The address of the account to use the spend permission on. @@ -30,15 +27,10 @@ export async function useSpendPermission( ): Promise { const { spendPermission, value, network } = options; + const { to, data } = buildSpendCalldata(spendPermission, value); + const result = await apiClient.sendEvmTransaction(address, { - transaction: serializeEIP1559Transaction({ - to: SPEND_PERMISSION_MANAGER_ADDRESS, - data: encodeFunctionData({ - abi: SPEND_PERMISSION_MANAGER_ABI, - functionName: "spend", - args: [spendPermission, value], - }), - }), + transaction: serializeEIP1559Transaction({ to, data }), network: network as SendEvmTransactionBodyNetwork, }); diff --git a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.test.ts b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.test.ts index 3325b18fe..ea64d74f0 100644 --- a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.test.ts +++ b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.test.ts @@ -6,6 +6,8 @@ import { useSpendPermission } from "./smartAccount.use.js"; import { SPEND_PERMISSION_MANAGER_ABI, SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ABI, + SPEND_ROUTER_ADDRESS, } from "../../../spend-permissions/constants.js"; import { sendUserOperation } from "../sendUserOperation.js"; @@ -105,6 +107,35 @@ describe("useSpendPermission for smart accounts", () => { }); }); + it("dispatches to SpendRouter.spendAndRoute when the permission spender is the router", async () => { + const routedPermission: SpendPermission = { + ...mockSpendPermission, + spender: SPEND_ROUTER_ADDRESS as Address, + }; + + await useSpendPermission(mockClient, mockSmartAccount, { + ...mockOptions, + spendPermission: routedPermission, + }); + + expect(encodeFunctionData).toHaveBeenCalledWith({ + abi: SPEND_ROUTER_ABI, + functionName: "spendAndRoute", + args: [routedPermission, mockOptions.value], + }); + expect(sendUserOperation).toHaveBeenCalledWith(mockClient, { + smartAccount: mockSmartAccount, + network: "base", + calls: [ + { + to: SPEND_ROUTER_ADDRESS, + data: mockEncodedData, + value: 0n, + }, + ], + }); + }); + it("should throw error when sendUserOperation fails", async () => { const error = new Error("UserOperation failed"); (sendUserOperation as MockedFunction).mockRejectedValue(error); diff --git a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.ts b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.ts index 34069e8e5..2e4f1a385 100644 --- a/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.ts +++ b/typescript/packages/cdp-sdk/src/actions/evm/spend-permissions/smartAccount.use.ts @@ -1,9 +1,4 @@ -import { encodeFunctionData } from "viem"; - -import { - SPEND_PERMISSION_MANAGER_ABI, - SPEND_PERMISSION_MANAGER_ADDRESS, -} from "../../../spend-permissions/constants.js"; +import { buildSpendCalldata } from "../../../spend-permissions/utils.js"; import { type SendUserOperationReturnType, sendUserOperation } from "../sendUserOperation.js"; import type { UseSpendPermissionOptions } from "./types.js"; @@ -14,7 +9,9 @@ import type { } from "../../../openapi-client/index.js"; /** - * Use a spend permission to spend tokens. + * Use a spend permission to spend tokens. Dispatches automatically to either + * `SpendPermissionManager.spend` (legacy permissions) or `SpendRouter.spendAndRoute` + * (CDP-created permissions whose onchain spender is the SpendRouter contract). * * @param apiClient - The API client to use. * @param account - The smart account to use. @@ -29,18 +26,14 @@ export function useSpendPermission( ): Promise { const { spendPermission, value, network } = options; - const data = encodeFunctionData({ - abi: SPEND_PERMISSION_MANAGER_ABI, - functionName: "spend", - args: [spendPermission, value], - }); + const { to, data } = buildSpendCalldata(spendPermission, value); return sendUserOperation(apiClient, { smartAccount: account, network: network as EvmUserOperationNetwork, calls: [ { - to: SPEND_PERMISSION_MANAGER_ADDRESS, + to, data, value: 0n, }, diff --git a/typescript/packages/cdp-sdk/src/spend-permissions/constants.ts b/typescript/packages/cdp-sdk/src/spend-permissions/constants.ts index bb244e729..af755309f 100644 --- a/typescript/packages/cdp-sdk/src/spend-permissions/constants.ts +++ b/typescript/packages/cdp-sdk/src/spend-permissions/constants.ts @@ -1,5 +1,52 @@ export const SPEND_PERMISSION_MANAGER_ADDRESS = "0xf85210B21cC50302F477BA56686d2019dC9b67Ad"; +/** + * SpendRouter is a singleton contract deployed at the same address on every supported chain + * (Base, Base Sepolia, Ethereum, Ethereum Sepolia, Optimism, OP Sepolia, Arbitrum, Avalanche, + * Polygon, BNB) via CREATE2. CDP-created spend permissions have this contract as their onchain + * spender; the executor and recipient are encoded in `permission.extraData`. + * + * To execute a SpendRouter permission, callers invoke `spendAndRoute` on this contract instead + * of `spend` on SpendPermissionManager. The SDK dispatches based on `permission.spender`. + * + * Source: https://github.com/coinbase/spend-permissions/blob/main/src/SpendRouter.sol + */ +export const SPEND_ROUTER_ADDRESS = "0x1a672dE48c82278b2F1BB68d7b9141634dD6BE29"; + +/** + * Minimal SpendRouter ABI containing just `spendAndRoute`, the only entry point the SDK + * calls today. Approve-and-spend (`spendAndRouteWithSignature`), MagicSpend variants, and + * `revokeAsSpender` are intentionally omitted: the SDK does not currently expose paths that + * use them. Add them here when the corresponding SDK actions ship. + */ +export const SPEND_ROUTER_ABI = [ + { + inputs: [ + { + components: [ + { internalType: "address", name: "account", type: "address" }, + { internalType: "address", name: "spender", type: "address" }, + { internalType: "address", name: "token", type: "address" }, + { internalType: "uint160", name: "allowance", type: "uint160" }, + { internalType: "uint48", name: "period", type: "uint48" }, + { internalType: "uint48", name: "start", type: "uint48" }, + { internalType: "uint48", name: "end", type: "uint48" }, + { internalType: "uint256", name: "salt", type: "uint256" }, + { internalType: "bytes", name: "extraData", type: "bytes" }, + ], + internalType: "struct SpendPermissionManager.SpendPermission", + name: "permission", + type: "tuple", + }, + { internalType: "uint160", name: "value", type: "uint160" }, + ], + name: "spendAndRoute", + outputs: [], + stateMutability: "nonpayable", + type: "function", + }, +] as const; + export const SPEND_PERMISSION_MANAGER_ABI = [ { inputs: [ diff --git a/typescript/packages/cdp-sdk/src/spend-permissions/utils.ts b/typescript/packages/cdp-sdk/src/spend-permissions/utils.ts index db6699f8e..d59a3efd7 100644 --- a/typescript/packages/cdp-sdk/src/spend-permissions/utils.ts +++ b/typescript/packages/cdp-sdk/src/spend-permissions/utils.ts @@ -1,9 +1,18 @@ +import { encodeFunctionData } from "viem"; + import { getErc20Address } from "../actions/evm/transfer/utils.js"; import { UserInputValidationError } from "../errors.js"; +import { + SPEND_PERMISSION_MANAGER_ABI, + SPEND_PERMISSION_MANAGER_ADDRESS, + SPEND_ROUTER_ABI, + SPEND_ROUTER_ADDRESS, +} from "./constants.js"; +import type { SpendPermission } from "./types.js"; import type { Network } from "../actions/evm/transfer/types.js"; import type { SpendPermissionNetwork } from "../openapi-client/index.js"; -import type { Address } from "../types/misc.js"; +import type { Address, Hex } from "../types/misc.js"; /** * Resolve the address of a token for a given network. @@ -33,3 +42,54 @@ export function resolveTokenAddress( return token; } + +/** + * Reports whether a permission's onchain spender is the SpendRouter contract. Permissions + * created via the CDP API after SpendRouter integration set spender to this address; pre-router + * permissions set it to the developer-provided address directly. Comparison is case-insensitive + * because mixed-case addresses (EIP-55 checksums) and all-lowercase addresses both occur in the + * wild on API responses. + * + * @param spender - The permission's onchain spender address. + * @returns True if the spender is the SpendRouter contract. + */ +export function isSpendRouterPermission(spender: Address): boolean { + return spender.toLowerCase() === SPEND_ROUTER_ADDRESS.toLowerCase(); +} + +/** + * Builds the (target contract address, encoded calldata) pair to invoke a spend permission, + * dispatching based on the permission's onchain spender. SpendRouter permissions are routed + * to `SpendRouter.spendAndRoute`; legacy permissions to `SpendPermissionManager.spend`. + * + * Centralized so account.use and smartAccount.use share a single dispatch decision and stay + * forward-compatible with any future router functions (e.g. spendAndRouteWithWithdraw) that + * we add to the SpendRouter ABI. + * + * @param spendPermission - The permission to spend against. + * @param value - The amount to spend (must be <= remaining allowance for the current period). + * @returns The contract address and encoded calldata to send. + */ +export function buildSpendCalldata( + spendPermission: SpendPermission, + value: bigint, +): { to: Address; data: Hex } { + if (isSpendRouterPermission(spendPermission.spender)) { + return { + to: SPEND_ROUTER_ADDRESS, + data: encodeFunctionData({ + abi: SPEND_ROUTER_ABI, + functionName: "spendAndRoute", + args: [spendPermission, value], + }), + }; + } + return { + to: SPEND_PERMISSION_MANAGER_ADDRESS, + data: encodeFunctionData({ + abi: SPEND_PERMISSION_MANAGER_ABI, + functionName: "spend", + args: [spendPermission, value], + }), + }; +} From 909f549619397bec71029c535f5f99c301f33912 Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Thu, 7 May 2026 15:32:17 -0700 Subject: [PATCH 2/3] feat: export SpendRouter constants and dispatch helpers from public SDK surface --- typescript/packages/cdp-sdk/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/typescript/packages/cdp-sdk/src/index.ts b/typescript/packages/cdp-sdk/src/index.ts index bf6c4cf49..2d1f6fd1e 100644 --- a/typescript/packages/cdp-sdk/src/index.ts +++ b/typescript/packages/cdp-sdk/src/index.ts @@ -48,7 +48,10 @@ export type { export { SPEND_PERMISSION_MANAGER_ABI as spendPermissionManagerAbi, SPEND_PERMISSION_MANAGER_ADDRESS as spendPermissionManagerAddress, + SPEND_ROUTER_ABI as spendRouterAbi, + SPEND_ROUTER_ADDRESS as spendRouterAddress, } from "./spend-permissions/constants.js"; +export { isSpendRouterPermission, buildSpendCalldata } from "./spend-permissions/utils.js"; export type { CreateWebhookSubscriptionOptions, From 7bd0522d1dde40aefe26aef13d62839958940ba2 Mon Sep 17 00:00:00 2001 From: Amie Corso Date: Thu, 7 May 2026 17:35:14 -0700 Subject: [PATCH 3/3] ci: retrigger workflows after flaky StepSecurity eBPF tracer failure