Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 8 additions & 35 deletions python/cdp/actions/evm/spend_permissions/account_use.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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.
Expand All @@ -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,
Expand Down
44 changes: 8 additions & 36 deletions python/cdp/actions/evm/spend_permissions/smart_account_use.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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.
Expand All @@ -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,
Expand Down
17 changes: 14 additions & 3 deletions python/cdp/spend_permissions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
]
41 changes: 41 additions & 0 deletions python/cdp/spend_permissions/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
77 changes: 77 additions & 0 deletions python/cdp/spend_permissions/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]),
)
Loading
Loading