diff --git a/python/coinbase-agentkit/README.md b/python/coinbase-agentkit/README.md index 39165638d..8fde2738d 100644 --- a/python/coinbase-agentkit/README.md +++ b/python/coinbase-agentkit/README.md @@ -487,6 +487,44 @@ This section provides a detailed list of all available action providers and thei +
+Spraay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
spraay_ethSends equal amounts of ETH to up to 200 recipients in a single atomic transaction via the Spraay batch contract on Base.
spraay_tokenSends equal amounts of an ERC-20 token to up to 200 recipients atomically, with EIP-2612 permit-optimized approvals (approve fallback).
spraay_eth_variableSends different ETH amounts to each recipient in a single atomic batch transaction.
spraay_token_variableSends different ERC-20 amounts to each recipient in a single atomic batch transaction.
spraay_validate_batchValidates a batch against the free Spraay gateway pre-flight endpoint before signing anything.
spraay_estimate_batchEstimates batch execution cost via the free Spraay gateway endpoint.
spraay_execute_batch_gatewayExecutes a batch through the x402-metered Spraay gateway (POST /api/v1/batch/execute; pricing via 402 challenge).
spraay_create_escrowCreates an escrow through the x402-metered Spraay gateway (POST /api/v1/escrow/create; pricing via 402 challenge).
+
+
SSH diff --git a/python/coinbase-agentkit/changelog.d/944.feature.md b/python/coinbase-agentkit/changelog.d/944.feature.md new file mode 100644 index 000000000..4e514abd8 --- /dev/null +++ b/python/coinbase-agentkit/changelog.d/944.feature.md @@ -0,0 +1 @@ +Added Spraay action provider: atomic batch payments (ETH/ERC-20, up to 200 recipients) on Base with EIP-2612 permit-optimized approvals, free gateway pre-flight validation and cost estimation, x402-metered gateway batch execution, and escrow creation diff --git a/python/coinbase-agentkit/coinbase_agentkit/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/__init__.py index e31253873..c11feb413 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/__init__.py @@ -4,6 +4,7 @@ from .action_providers import ( Action, ActionProvider, + SpraayConfig, X402Config, aave_action_provider, basename_action_provider, @@ -19,6 +20,7 @@ nillion_action_provider, onramp_action_provider, pyth_action_provider, + spraay_action_provider, ssh_action_provider, superfluid_action_provider, twitter_action_provider, @@ -55,6 +57,7 @@ "EthAccountWalletProvider", "EthAccountWalletProviderConfig", "EvmWalletProvider", + "SpraayConfig", "WalletProvider", "X402Config", "__version__", @@ -72,6 +75,7 @@ "nillion_action_provider", "onramp_action_provider", "pyth_action_provider", + "spraay_action_provider", "ssh_action_provider", "superfluid_action_provider", "twitter_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index 68573da62..94a675ad9 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -27,6 +27,8 @@ from .nillion.nillion_action_provider import NillionActionProvider, nillion_action_provider from .onramp.onramp_action_provider import OnrampActionProvider, onramp_action_provider from .pyth.pyth_action_provider import PythActionProvider, pyth_action_provider +from .spraay.schemas import SpraayConfig +from .spraay.spraay_action_provider import SpraayActionProvider, spraay_action_provider from .ssh.ssh_action_provider import SshActionProvider, ssh_action_provider from .superfluid.superfluid_action_provider import ( SuperfluidActionProvider, @@ -55,6 +57,8 @@ "NillionActionProvider", "OnrampActionProvider", "PythActionProvider", + "SpraayActionProvider", + "SpraayConfig", "SshActionProvider", "SuperfluidActionProvider", "TwitterActionProvider", @@ -76,6 +80,7 @@ "nillion_action_provider", "onramp_action_provider", "pyth_action_provider", + "spraay_action_provider", "ssh_action_provider", "superfluid_action_provider", "twitter_action_provider", diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/README.md b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/README.md new file mode 100644 index 000000000..2bf014b54 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/README.md @@ -0,0 +1,73 @@ +# Spraay Action Provider + +This directory contains the **SpraayActionProvider**, payment coordination for AgentKit agents on Base. Batch payments are the core capability — send ETH or any ERC-20 token to up to 200 recipients with per-recipient amounts, atomically, in a single transaction — with escrow creation via the Spraay gateway as the complementary second pillar. + +Contract: [`0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC`](https://basescan.org/address/0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC) (Base Mainnet) · Gateway: `https://gateway.spraay.app` · Website: [spraay.app](https://spraay.app) + +## Directory Structure + +``` +spraay/ +├── spraay_action_provider.py # Provider: on-chain batch, permit handling, gateway actions +├── schemas.py # Pydantic schemas and provider config +├── constants.py # Contract address/ABI, gateway endpoints +├── utils.py # Unit parsing/formatting, signature splitting +├── __init__.py # Public exports +└── README.md # This file +``` + +## Setup + +```python +from coinbase_agentkit import AgentKit, AgentKitConfig, SpraayConfig, spraay_action_provider + +agent_kit = AgentKit(AgentKitConfig( + wallet_provider=wallet_provider, + action_providers=[ + spraay_action_provider(SpraayConfig( + # Max x402 payment per gateway request in whole USDC (default 1.0, + # or SPRAAY_MAX_GATEWAY_PAYMENT_USDC env var) + max_gateway_payment_usdc=0.5, + # Optional pre-funded x402 payment header, sent instead of signing + # a payment with the wallet provider + # x402_payment_header=os.getenv("SPRAAY_X402_PAYMENT_HEADER"), + )), + ], +)) +``` + +## Batch payment actions (direct on-chain) + +The agent signs the batch transaction and pays gas itself on Base. All batches are atomic: every transfer succeeds or the whole transaction reverts. Up to 200 recipients per transaction; the protocol fee (default 0.3%, read live from the contract) is added on top. Recipient lists are validated for case-insensitive duplicates before anything is signed. + +- `spraay_eth`: Equal ETH amounts to every recipient. +- `spraay_token`: Equal ERC-20 amounts to every recipient. +- `spraay_eth_variable`: Per-recipient ETH amounts. +- `spraay_token_variable`: Per-recipient ERC-20 amounts. + +**EIP-2612 permit:** for ERC-20 batches, allowance handling prefers a signed permit when the token supports it (detected at runtime via `nonces()`/`version()` — no hardcoded token list; USDC on Base qualifies). The permit grants an exact, deadline-bounded allowance instead of a standing approve. Non-permit tokens fall back cleanly to `approve`. + +**Pre-flight (`preflight=True`):** validates the batch against the free gateway endpoint before signing. An explicit "invalid" verdict aborts before any signature; gateway unavailability never blocks the on-chain path. + +## Gateway pre-flight actions (free, no payment) + +- `spraay_validate_batch`: `POST https://gateway.spraay.app/free/validate-batch` — validates a batch (BPA 1.0 body; the gateway expects a `recipients` array of `{to, amount}` entries, which the provider maps from its uniform `{recipient, amount}` input) and returns `valid`/`errors`/`warnings`/`summary`. +- `spraay_estimate_batch`: `GET https://gateway.spraay.app/free/estimate-batch?recipients=&chain=&amount=` — rough gas and protocol-fee estimate for a batch of a given size (the optional total `amount` enables the fee figures). + +These make agents safer: validate and cost a batch before signing anything. + +## Gateway execution and escrow (x402-metered, paid) + +- `spraay_execute_batch_gateway`: `POST https://gateway.spraay.app/api/v1/batch/execute` — the gateway executes the batch (body: `token`, `recipients` as `{address, amount}` entries mapped from the provider's uniform input, and the wallet's address as `sender`); the agent pays a metered USDC fee via the [x402 protocol](https://x402.org) (pricing returned via 402 challenge; `POST /api/v1/batch/estimate` quotes the same way). Multi-chain capable, no gas management for the agent. +- `spraay_create_escrow`: `POST https://gateway.spraay.app/api/v1/escrow/create` — locks funds from a depositor (defaults to the connected wallet) for a beneficiary, with optional arbiter, release conditions, and expiry in hours. Creation only; the gateway's `POST /api/v1/escrow/fund`, `/release`, and `/cancel` endpoints handle the rest of the lifecycle. + +Payments are settled either by signing with the wallet provider (`x402` package) or with a pre-funded `x402_payment_header` from config, and are capped by `max_gateway_payment_usdc`. Payment is never faked or stubbed. + +## Network Support + +Base mainnet (`base-mainnet`) only — that is where the batch contract is deployed. Gateway execution is multi-chain capable on the gateway side, but the provider itself registers on Base EVM wallets. + +## Notes + +- Amounts are whole units (e.g. `"0.01"` ETH, `"100"` USDC); decimals are read from the token contract. +- Works with any `EvmWalletProvider` (CDP EVM wallets, smart wallets, eth-account wallets). If a wallet cannot sign EIP-712 typed data, permit silently falls back to `approve`. diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py new file mode 100644 index 000000000..fbee4ee72 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py @@ -0,0 +1,6 @@ +"""Spraay Action Provider for Coinbase AgentKit.""" + +from .schemas import SpraayConfig +from .spraay_action_provider import SpraayActionProvider, spraay_action_provider + +__all__ = ["SpraayActionProvider", "SpraayConfig", "spraay_action_provider"] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/constants.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/constants.py new file mode 100644 index 000000000..d89352533 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/constants.py @@ -0,0 +1,175 @@ +"""Constants for the Spraay action provider.""" + +# Spraay batch payment contract (SprayContract) address on Base Mainnet. +# Verified on BaseScan / Blockscout. +SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" + +# Default Spraay protocol fee in basis points (0.3% = 30 bps). +# The live value is read from the contract's `feeBps` view; this is the fallback. +SPRAAY_PROTOCOL_FEE_BPS = 30 + +# Maximum number of recipients per transaction (contract-enforced MAX_RECIPIENTS). +SPRAAY_MAX_RECIPIENTS = 200 + +# Zero address, used by `sprayEqual` to select native ETH transfers. +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + +# Spraay gateway base URL. Hosts free pre-flight endpoints (validate/estimate) +# and x402-metered execution endpoints (batch execute/estimate, escrow create). +SPRAAY_GATEWAY_BASE_URL = "https://gateway.spraay.app" + +# Free (no payment required) gateway endpoint paths. +SPRAAY_FREE_VALIDATE_BATCH_PATH = "/free/validate-batch" +SPRAAY_FREE_ESTIMATE_BATCH_PATH = "/free/estimate-batch" + +# x402-metered (paid) gateway endpoint paths. Pricing is returned via a +# 402 Payment Required challenge on first request. +SPRAAY_GATEWAY_BATCH_EXECUTE_PATH = "/api/v1/batch/execute" +SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH = "/api/v1/batch/estimate" +SPRAAY_GATEWAY_ESCROW_CREATE_PATH = "/api/v1/escrow/create" + +# Batch Payment Aggregate (BPA) schema version used by the Spraay gateway. +SPRAAY_BPA_VERSION = "1.0" + +# Validity window for EIP-2612 permit signatures, in seconds. +PERMIT_DEADLINE_SECONDS = 1800 + +# SprayContract ABI — matches the verified contract deployed at +# SPRAAY_CONTRACT_ADDRESS. Variable-amount functions take an array of +# (recipient, amount) structs; `sprayEqual` covers the uniform-amount case +# for both ETH (token = zero address) and ERC-20 tokens. +SPRAAY_ABI = [ + { + "name": "sprayETH", + "type": "function", + "stateMutability": "payable", + "inputs": [ + { + "name": "recipients", + "type": "tuple[]", + "components": [ + {"name": "recipient", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + }, + ], + "outputs": [], + }, + { + "name": "sprayToken", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "token", "type": "address"}, + { + "name": "recipients", + "type": "tuple[]", + "components": [ + {"name": "recipient", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + }, + ], + "outputs": [], + }, + { + "name": "sprayEqual", + "type": "function", + "stateMutability": "payable", + "inputs": [ + {"name": "token", "type": "address"}, + {"name": "recipients", "type": "address[]"}, + {"name": "amountPerRecipient", "type": "uint256"}, + ], + "outputs": [], + }, + { + "name": "feeBps", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "calculateTotalCost", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "totalAmount", "type": "uint256"}], + "outputs": [{"name": "", "type": "uint256"}], + }, +] + +# ERC-20 ABI fragments needed for approvals, metadata lookups, and +# EIP-2612 permit support (nonces/version/permit). +ERC20_ABI = [ + { + "name": "approve", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "outputs": [{"name": "", "type": "bool"}], + }, + { + "name": "allowance", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "decimals", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint8"}], + }, + { + "name": "symbol", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "string"}], + }, + { + "name": "name", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "string"}], + }, + { + "name": "version", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "string"}], + }, + { + "name": "nonces", + "type": "function", + "stateMutability": "view", + "inputs": [{"name": "owner", "type": "address"}], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "permit", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + {"name": "v", "type": "uint8"}, + {"name": "r", "type": "bytes32"}, + {"name": "s", "type": "bytes32"}, + ], + "outputs": [], + }, +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/schemas.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/schemas.py new file mode 100644 index 000000000..2a4f8b383 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/schemas.py @@ -0,0 +1,344 @@ +"""Schemas for the Spraay action provider.""" + +import re +from dataclasses import dataclass, field + +from pydantic import BaseModel, Field, field_validator + +from .constants import SPRAAY_GATEWAY_BASE_URL, SPRAAY_MAX_RECIPIENTS + +EVM_ADDRESS_PATTERN = re.compile(r"^0x[a-fA-F0-9]{40}$") +DECIMAL_AMOUNT_PATTERN = re.compile(r"^\d+(\.\d+)?$") + + +@dataclass +class SpraayConfig: + """Configuration options for SpraayActionProvider.""" + + # Maximum x402 payment per gateway request, in USDC whole units. + # Default: 1.0 (or SPRAAY_MAX_GATEWAY_PAYMENT_USDC env var). + max_gateway_payment_usdc: float = 1.0 + + # Optional pre-funded x402 payment header. When set, gateway requests send + # this value in the X-PAYMENT header instead of signing a payment with the + # wallet provider. Useful when payments are settled out-of-band. + x402_payment_header: str | None = None + + # Override for the Spraay gateway base URL. Defaults to the production + # gateway; intended for testing and staging environments. + gateway_base_url: str = field(default=SPRAAY_GATEWAY_BASE_URL) + + +def _validate_addresses(addresses: list[str]) -> list[str]: + """Validate address format and reject case-insensitive duplicates. + + Args: + addresses: The recipient addresses to validate. + + Returns: + list[str]: The validated addresses. + + Raises: + ValueError: If an address is malformed or duplicated. + + """ + seen: set[str] = set() + for address in addresses: + if not EVM_ADDRESS_PATTERN.match(address): + raise ValueError(f"Invalid Ethereum address: {address}") + normalized = address.lower() + if normalized in seen: + raise ValueError(f"Duplicate recipient address: {address}") + seen.add(normalized) + return addresses + + +def _validate_amount(value: str) -> str: + """Validate that an amount is a positive decimal number string. + + Args: + value: The amount string to validate. + + Returns: + str: The validated amount. + + Raises: + ValueError: If the amount is malformed or not positive. + + """ + if not DECIMAL_AMOUNT_PATTERN.match(value): + raise ValueError(f"Amount must be a positive decimal number string, got: {value}") + if float(value) <= 0: + raise ValueError(f"Amount must be greater than zero, got: {value}") + return value + + +class SprayEthInput(BaseModel): + """Input schema for spraying ETH to multiple recipients.""" + + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses (e.g. ['0xABC...', '0xDEF...'])", + min_length=1, + max_length=SPRAAY_MAX_RECIPIENTS, + ) + amount_per_recipient: str = Field( + ..., + description="Amount of ETH to send to each recipient in whole units (e.g. '0.01')", + ) + preflight: bool = Field( + default=False, + description=( + "When true, validate the batch against the free Spraay gateway pre-flight " + "endpoint before signing. Gateway unavailability never blocks the on-chain path." + ), + ) + + @field_validator("recipients") + @classmethod + def validate_recipients(cls, v: list[str]) -> list[str]: + """Validate recipient addresses and reject duplicates.""" + return _validate_addresses(v) + + @field_validator("amount_per_recipient") + @classmethod + def validate_amount(cls, v: str) -> str: + """Validate the per-recipient amount.""" + return _validate_amount(v) + + +class SprayTokenInput(SprayEthInput): + """Input schema for spraying ERC-20 tokens to multiple recipients.""" + + token_address: str = Field(..., description="The ERC-20 token contract address") + amount_per_recipient: str = Field( + ..., + description="Amount of tokens to send to each recipient in whole units (e.g. '100')", + ) + + @field_validator("token_address") + @classmethod + def validate_token_address(cls, v: str) -> str: + """Validate the token contract address format.""" + if not EVM_ADDRESS_PATTERN.match(v): + raise ValueError(f"Invalid token contract address: {v}") + return v + + +class SprayEthVariableInput(BaseModel): + """Input schema for spraying variable ETH amounts to multiple recipients.""" + + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses", + min_length=1, + max_length=SPRAAY_MAX_RECIPIENTS, + ) + amounts: list[str] = Field( + ..., + description="Array of ETH amounts corresponding to each recipient (e.g. ['0.01', '0.05'])", + min_length=1, + ) + preflight: bool = Field( + default=False, + description=( + "When true, validate the batch against the free Spraay gateway pre-flight " + "endpoint before signing. Gateway unavailability never blocks the on-chain path." + ), + ) + + @field_validator("recipients") + @classmethod + def validate_recipients(cls, v: list[str]) -> list[str]: + """Validate recipient addresses and reject duplicates.""" + return _validate_addresses(v) + + @field_validator("amounts") + @classmethod + def validate_amounts(cls, v: list[str]) -> list[str]: + """Validate every per-recipient amount.""" + return [_validate_amount(a) for a in v] + + +class SprayTokenVariableInput(SprayEthVariableInput): + """Input schema for spraying variable token amounts to multiple recipients.""" + + token_address: str = Field(..., description="The ERC-20 token contract address") + amounts: list[str] = Field( + ..., + description="Array of token amounts corresponding to each recipient (e.g. ['100', '50'])", + min_length=1, + ) + + @field_validator("token_address") + @classmethod + def validate_token_address(cls, v: str) -> str: + """Validate the token contract address format.""" + if not EVM_ADDRESS_PATTERN.match(v): + raise ValueError(f"Invalid token contract address: {v}") + return v + + +class BatchRecipient(BaseModel): + """A single (recipient, amount) entry in a gateway batch.""" + + recipient: str = Field(..., description="Recipient wallet address") + amount: str = Field( + ..., description="Amount for this recipient, in whole token units (e.g. '1.00')" + ) + + @field_validator("recipient") + @classmethod + def validate_recipient(cls, v: str) -> str: + """Validate the recipient address format.""" + if not EVM_ADDRESS_PATTERN.match(v): + raise ValueError(f"Invalid Ethereum address: {v}") + return v + + @field_validator("amount") + @classmethod + def validate_amount(cls, v: str) -> str: + """Validate the recipient amount.""" + return _validate_amount(v) + + +def _validate_batch_recipients(entries: list[BatchRecipient]) -> list[BatchRecipient]: + """Reject case-insensitive duplicate recipients in a gateway batch. + + Args: + entries: The batch entries to validate. + + Returns: + list[BatchRecipient]: The validated entries. + + """ + _validate_addresses([entry.recipient for entry in entries]) + return entries + + +class SpraayValidateBatchInput(BaseModel): + """Input schema for validating a batch via the free Spraay gateway endpoint.""" + + token: str = Field(..., description="Token symbol for the batch (e.g. 'USDC' or 'ETH')") + recipients: list[BatchRecipient] = Field( + ..., + description="Batch entries as (recipient, amount) pairs", + min_length=1, + max_length=SPRAAY_MAX_RECIPIENTS, + ) + chain: str = Field(default="base", description="Target chain identifier (default 'base')") + + @field_validator("recipients") + @classmethod + def validate_recipients(cls, v: list[BatchRecipient]) -> list[BatchRecipient]: + """Reject duplicate recipients.""" + return _validate_batch_recipients(v) + + +class SpraayEstimateBatchInput(BaseModel): + """Input schema for estimating batch cost via the free Spraay gateway endpoint.""" + + recipients: int = Field( + ..., + description="Number of recipients in the batch (positive integer count)", + gt=0, + le=SPRAAY_MAX_RECIPIENTS, + ) + chain: str = Field(default="base", description="Target chain identifier (default 'base')") + amount: str | None = Field( + default=None, + description=( + "Optional total batch amount in whole units; when provided, the estimate " + "includes the protocol fee in USD" + ), + ) + + @field_validator("amount") + @classmethod + def validate_amount(cls, v: str | None) -> str | None: + """Validate the optional total amount.""" + if v is None: + return v + return _validate_amount(v) + + +class SpraayExecuteBatchGatewayInput(SpraayValidateBatchInput): + """Input schema for executing a batch through the x402-metered Spraay gateway.""" + + +class SpraayCreateEscrowInput(BaseModel): + """Input schema for creating an escrow via the x402-metered Spraay gateway.""" + + token: str = Field( + ..., + description=( + "Escrow token: a symbol (USDC, USDT, DAI, EURC, WETH) or an ERC-20 contract " + "address on Base" + ), + ) + amount: str = Field(..., description="Escrow amount, in whole token units (e.g. '250.00')") + beneficiary: str = Field( + ..., description="Wallet address that can receive the escrowed funds on release" + ) + depositor: str | None = Field( + default=None, + description=( + "Wallet address funding the escrow; defaults to the connected wallet address. " + "Must differ from the beneficiary." + ), + ) + arbiter: str | None = Field( + default=None, + description="Optional third-party address allowed to release or cancel the escrow", + ) + description: str | None = Field( + default=None, + max_length=500, + description="Optional human-readable description of the escrow terms", + ) + conditions: list[str] | None = Field( + default=None, + max_length=20, + description=( + "Optional list of release conditions (e.g. ['Design approved', 'Dev complete'])" + ), + ) + expires_in: float | None = Field( + default=None, + gt=0, + description=( + "Optional expiry in hours after which the escrow can no longer be funded or " + "released (gateway default: 168)" + ), + ) + + @field_validator("beneficiary", "depositor", "arbiter") + @classmethod + def validate_addresses(cls, v: str | None) -> str | None: + """Validate address format for beneficiary, depositor, and arbiter.""" + if v is None: + return v + if not EVM_ADDRESS_PATTERN.match(v): + raise ValueError(f"Invalid Ethereum address: {v}") + return v + + @field_validator("amount") + @classmethod + def validate_amount(cls, v: str) -> str: + """Validate the escrow amount.""" + return _validate_amount(v) + + +__all__ = [ + "SpraayConfig", + "SprayEthInput", + "SprayTokenInput", + "SprayEthVariableInput", + "SprayTokenVariableInput", + "BatchRecipient", + "SpraayValidateBatchInput", + "SpraayEstimateBatchInput", + "SpraayExecuteBatchGatewayInput", + "SpraayCreateEscrowInput", + "SPRAAY_GATEWAY_BASE_URL", +] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py new file mode 100644 index 000000000..9dfa19b97 --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py @@ -0,0 +1,1287 @@ +"""Spraay Action Provider — payment coordination for AgentKit agents on Base. + +Batch payments are the core capability: send ETH or any ERC-20 token to up to +200 recipients with per-recipient amounts, atomically, in a single transaction +against the deployed Spraay batch contract. Escrow creation via the Spraay +gateway is the complementary second pillar. + +Contract: 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC (Base Mainnet) +Gateway: https://gateway.spraay.app +Website: https://spraay.app +""" + +import base64 +import contextlib +import json +import os +import time +from typing import Any + +import requests +from web3 import Web3 +from x402 import x402ClientSync +from x402.http.clients.requests import x402_requests +from x402.mechanisms.evm import EthAccountSigner +from x402.mechanisms.evm.exact.register import register_exact_evm_client + +from ...network import Network +from ...wallet_providers.evm_wallet_provider import EvmWalletProvider +from ..action_decorator import create_action +from ..action_provider import ActionProvider +from ..x402.utils import validate_payment_limit +from .constants import ( + ERC20_ABI, + PERMIT_DEADLINE_SECONDS, + SPRAAY_ABI, + SPRAAY_BPA_VERSION, + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_FREE_ESTIMATE_BATCH_PATH, + SPRAAY_FREE_VALIDATE_BATCH_PATH, + SPRAAY_GATEWAY_BASE_URL, + SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + SPRAAY_GATEWAY_ESCROW_CREATE_PATH, + SPRAAY_PROTOCOL_FEE_BPS, + ZERO_ADDRESS, +) +from .schemas import ( + SpraayConfig, + SpraayCreateEscrowInput, + SpraayEstimateBatchInput, + SpraayExecuteBatchGatewayInput, + SpraayValidateBatchInput, + SprayEthInput, + SprayEthVariableInput, + SprayTokenInput, + SprayTokenVariableInput, +) +from .utils import format_units, parse_units, split_signature + + +class SpraayActionProvider(ActionProvider[EvmWalletProvider]): + """Spraay Action Provider — batch crypto payments and escrow on Base. + + Capabilities: + - Direct on-chain batch execution (agent signs, agent pays gas): equal or + variable amounts, ETH or ERC-20, up to 200 recipients atomically + - EIP-2612 permit-optimized approvals with clean approve fallback + - Free gateway pre-flight: validate a batch and estimate its cost before + signing anything + - x402-metered gateway execution and escrow creation (paid endpoints) + """ + + def __init__(self, config: SpraayConfig | None = None) -> None: + """Initialize the Spraay action provider. + + Args: + config: Optional configuration for gateway payment limits and endpoints. + + """ + super().__init__("spraay", []) + + if config is None: + config = SpraayConfig() + + self._config = SpraayConfig( + max_gateway_payment_usdc=( + config.max_gateway_payment_usdc + if config.max_gateway_payment_usdc != 1.0 + else float(os.getenv("SPRAAY_MAX_GATEWAY_PAYMENT_USDC", "1.0")) + ), + x402_payment_header=config.x402_payment_header, + gateway_base_url=config.gateway_base_url, + ) + + def supports_network(self, network: Network) -> bool: + """Check network support — the Spraay batch contract is deployed on Base mainnet. + + Args: + network: The network to check support for. + + Returns: + bool: Whether the network is supported. + + """ + return network.protocol_family == "evm" and network.network_id == "base-mainnet" + + # ── Batch payments: direct on-chain execution ────────────────────────── + + @create_action( + name="spraay_eth", + description=( + "Send equal amounts of ETH to multiple recipients in a single atomic transaction " + "via the Spraay batch contract on Base. Ideal for team payments, airdrops, or " + "rewards. Up to 200 recipients per transaction; the protocol fee (default 0.3%) is " + "added on top. This is the direct on-chain path: the agent signs the transaction " + "and pays gas itself. For x402-metered gateway execution instead, use " + "spraay_execute_batch_gateway. Set preflight=true to validate the batch against " + "the free Spraay gateway endpoint before signing." + ), + schema=SprayEthInput, + ) + def spraay_eth(self, wallet_provider: EvmWalletProvider, args: dict[str, Any]) -> str: + """Spray equal amounts of ETH to multiple recipients in one transaction. + + Args: + wallet_provider: The wallet provider to send the transaction. + args: The input arguments (recipients, amount_per_recipient, preflight). + + Returns: + str: A message describing the result of the transaction. + + """ + try: + validated = SprayEthInput(**args) + + preflight_report = None + if validated.preflight: + proceed, preflight_report = self._run_preflight( + "ETH", + [ + {"recipient": r, "amount": validated.amount_per_recipient} + for r in validated.recipients + ], + ) + if not proceed: + return ( + "Batch failed Spraay gateway pre-flight validation; no transaction " + f"was signed.\n{preflight_report}" + ) + + amount_wei = parse_units(validated.amount_per_recipient, 18) + subtotal = amount_wei * len(validated.recipients) + fee_bps = self._get_fee_bps(wallet_provider) + fee = (subtotal * fee_bps) // 10000 + total_value = subtotal + fee + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + data = contract.encode_abi( + "sprayEqual", + [ + w3.to_checksum_address(ZERO_ADDRESS), + [w3.to_checksum_address(r) for r in validated.recipients], + amount_wei, + ], + ) + + tx_hash = wallet_provider.send_transaction( + { + "to": w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), + "data": data, + "value": total_value, + } + ) + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return self._format_spray_result( + headline=( + f"Successfully sprayed {validated.amount_per_recipient} ETH to " + f"{len(validated.recipients)} recipients via Spraay." + ), + subtotal=subtotal, + fee=fee, + fee_bps=fee_bps, + decimals=18, + symbol="ETH", + tx_hash=tx_hash, + receipt=receipt, + preflight_report=preflight_report, + ) + except Exception as e: + return f"Error spraying ETH via Spraay: {e}" + + @create_action( + name="spraay_token", + description=( + "Send equal amounts of an ERC-20 token (like USDC) to multiple recipients in a " + "single atomic transaction via the Spraay batch contract on Base. Up to 200 " + "recipients per transaction; the protocol fee (default 0.3%) is added on top. " + "Allowance handling is automatic: for tokens that support EIP-2612 permit (USDC on " + "Base does), a signed permit grants an exact, deadline-bounded allowance instead " + "of a standard approve; non-permit tokens fall back to approve. This is the direct " + "on-chain path: the agent signs and pays gas itself. For x402-metered gateway " + "execution instead, use spraay_execute_batch_gateway. Set preflight=true to " + "validate the batch against the free Spraay gateway endpoint before signing." + ), + schema=SprayTokenInput, + ) + def spraay_token(self, wallet_provider: EvmWalletProvider, args: dict[str, Any]) -> str: + """Spray equal amounts of an ERC-20 token to multiple recipients. + + Args: + wallet_provider: The wallet provider to send the transaction. + args: The input arguments (token_address, recipients, amount_per_recipient, preflight). + + Returns: + str: A message describing the result of the transaction. + + """ + try: + validated = SprayTokenInput(**args) + + decimals = self._get_token_decimals(wallet_provider, validated.token_address) + symbol = self._get_token_symbol(wallet_provider, validated.token_address) + + preflight_report = None + if validated.preflight: + proceed, preflight_report = self._run_preflight( + symbol, + [ + {"recipient": r, "amount": validated.amount_per_recipient} + for r in validated.recipients + ], + ) + if not proceed: + return ( + "Batch failed Spraay gateway pre-flight validation; no transaction " + f"was signed.\n{preflight_report}" + ) + + amount_wei = parse_units(validated.amount_per_recipient, decimals) + subtotal = amount_wei * len(validated.recipients) + fee_bps = self._get_fee_bps(wallet_provider) + fee = (subtotal * fee_bps) // 10000 + total_amount = subtotal + fee + + allowance_msg = self._ensure_token_allowance( + wallet_provider, validated.token_address, total_amount + ) + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + data = contract.encode_abi( + "sprayEqual", + [ + w3.to_checksum_address(validated.token_address), + [w3.to_checksum_address(r) for r in validated.recipients], + amount_wei, + ], + ) + + tx_hash = wallet_provider.send_transaction( + { + "to": w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), + "data": data, + } + ) + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return self._format_spray_result( + headline=( + f"Successfully sprayed {validated.amount_per_recipient} {symbol} to " + f"{len(validated.recipients)} recipients via Spraay." + ), + allowance_msg=allowance_msg, + subtotal=subtotal, + fee=fee, + fee_bps=fee_bps, + decimals=decimals, + symbol=symbol, + tx_hash=tx_hash, + receipt=receipt, + preflight_report=preflight_report, + ) + except Exception as e: + return f"Error spraying tokens via Spraay: {e}" + + @create_action( + name="spraay_eth_variable", + description=( + "Send different amounts of ETH to multiple recipients in a single atomic " + "transaction via the Spraay batch contract on Base. Each recipient gets its own " + "specified amount — ideal for bounty payouts or tiered distributions. Up to 200 " + "recipients per transaction; the protocol fee (default 0.3%) is added on top. " + "This is the direct on-chain path: the agent signs and pays gas itself. For " + "x402-metered gateway execution instead, use spraay_execute_batch_gateway. Set " + "preflight=true to validate the batch against the free Spraay gateway endpoint " + "before signing." + ), + schema=SprayEthVariableInput, + ) + def spraay_eth_variable(self, wallet_provider: EvmWalletProvider, args: dict[str, Any]) -> str: + """Spray variable amounts of ETH to multiple recipients. + + Args: + wallet_provider: The wallet provider to send the transaction. + args: The input arguments (recipients, amounts, preflight). + + Returns: + str: A message describing the result of the transaction. + + """ + try: + validated = SprayEthVariableInput(**args) + + if len(validated.recipients) != len(validated.amounts): + return ( + f"Error: recipients length ({len(validated.recipients)}) must match " + f"amounts length ({len(validated.amounts)})." + ) + + preflight_report = None + if validated.preflight: + proceed, preflight_report = self._run_preflight( + "ETH", + [ + {"recipient": r, "amount": a} + for r, a in zip(validated.recipients, validated.amounts, strict=True) + ], + ) + if not proceed: + return ( + "Batch failed Spraay gateway pre-flight validation; no transaction " + f"was signed.\n{preflight_report}" + ) + + amounts = [parse_units(a, 18) for a in validated.amounts] + subtotal = sum(amounts) + fee_bps = self._get_fee_bps(wallet_provider) + fee = (subtotal * fee_bps) // 10000 + total_value = subtotal + fee + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + data = contract.encode_abi( + "sprayETH", + [ + [ + (w3.to_checksum_address(r), amount) + for r, amount in zip(validated.recipients, amounts, strict=True) + ] + ], + ) + + tx_hash = wallet_provider.send_transaction( + { + "to": w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), + "data": data, + "value": total_value, + } + ) + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return self._format_spray_result( + headline=( + f"Successfully sprayed variable ETH amounts to " + f"{len(validated.recipients)} recipients via Spraay." + ), + subtotal=subtotal, + fee=fee, + fee_bps=fee_bps, + decimals=18, + symbol="ETH", + tx_hash=tx_hash, + receipt=receipt, + preflight_report=preflight_report, + ) + except Exception as e: + return f"Error spraying variable ETH via Spraay: {e}" + + @create_action( + name="spraay_token_variable", + description=( + "Send different amounts of an ERC-20 token to multiple recipients in a single " + "atomic transaction via the Spraay batch contract on Base. Each recipient gets its " + "own specified amount. Up to 200 recipients per transaction; the protocol fee " + "(default 0.3%) is added on top. Allowance handling is automatic: EIP-2612 permit " + "for tokens that support it (USDC on Base does), approve fallback otherwise. This " + "is the direct on-chain path: the agent signs and pays gas itself. For x402-metered " + "gateway execution instead, use spraay_execute_batch_gateway. Set preflight=true " + "to validate the batch against the free Spraay gateway endpoint before signing." + ), + schema=SprayTokenVariableInput, + ) + def spraay_token_variable( + self, wallet_provider: EvmWalletProvider, args: dict[str, Any] + ) -> str: + """Spray variable amounts of an ERC-20 token to multiple recipients. + + Args: + wallet_provider: The wallet provider to send the transaction. + args: The input arguments (token_address, recipients, amounts, preflight). + + Returns: + str: A message describing the result of the transaction. + + """ + try: + validated = SprayTokenVariableInput(**args) + + if len(validated.recipients) != len(validated.amounts): + return ( + f"Error: recipients length ({len(validated.recipients)}) must match " + f"amounts length ({len(validated.amounts)})." + ) + + decimals = self._get_token_decimals(wallet_provider, validated.token_address) + symbol = self._get_token_symbol(wallet_provider, validated.token_address) + + preflight_report = None + if validated.preflight: + proceed, preflight_report = self._run_preflight( + symbol, + [ + {"recipient": r, "amount": a} + for r, a in zip(validated.recipients, validated.amounts, strict=True) + ], + ) + if not proceed: + return ( + "Batch failed Spraay gateway pre-flight validation; no transaction " + f"was signed.\n{preflight_report}" + ) + + amounts = [parse_units(a, decimals) for a in validated.amounts] + subtotal = sum(amounts) + fee_bps = self._get_fee_bps(wallet_provider) + fee = (subtotal * fee_bps) // 10000 + total_amount = subtotal + fee + + allowance_msg = self._ensure_token_allowance( + wallet_provider, validated.token_address, total_amount + ) + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + data = contract.encode_abi( + "sprayToken", + [ + w3.to_checksum_address(validated.token_address), + [ + (w3.to_checksum_address(r), amount) + for r, amount in zip(validated.recipients, amounts, strict=True) + ], + ], + ) + + tx_hash = wallet_provider.send_transaction( + { + "to": w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), + "data": data, + } + ) + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return self._format_spray_result( + headline=( + f"Successfully sprayed variable {symbol} amounts to " + f"{len(validated.recipients)} recipients via Spraay." + ), + allowance_msg=allowance_msg, + subtotal=subtotal, + fee=fee, + fee_bps=fee_bps, + decimals=decimals, + symbol=symbol, + tx_hash=tx_hash, + receipt=receipt, + preflight_report=preflight_report, + ) + except Exception as e: + return f"Error spraying variable tokens via Spraay: {e}" + + # ── Gateway pre-flight actions (free, no payment) ────────────────────── + + @create_action( + name="spraay_validate_batch", + description=( + "Validate a batch payment against the free Spraay gateway pre-flight endpoint " + f"(POST {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_FREE_VALIDATE_BATCH_PATH}). No payment " + "and no transaction signing required. Checks recipients and amounts and returns " + "valid/errors/warnings/summary. Use this before the on-chain spray actions or " + "spraay_execute_batch_gateway to catch malformed batches before signing anything." + ), + schema=SpraayValidateBatchInput, + ) + def spraay_validate_batch( + self, wallet_provider: EvmWalletProvider, args: dict[str, Any] + ) -> str: + """Validate a batch via the free Spraay gateway pre-flight endpoint. + + Args: + wallet_provider: The wallet provider (unused; validation is off-chain and free). + args: The batch to validate (token, recipients, chain). + + Returns: + str: JSON string with the gateway validation result. + + """ + try: + validated = SpraayValidateBatchInput(**args) + response = requests.post( + f"{self._config.gateway_base_url}{SPRAAY_FREE_VALIDATE_BATCH_PATH}", + json=self._build_validate_body( + validated.chain, + validated.token, + [entry.model_dump() for entry in validated.recipients], + ), + timeout=30, + ) + data = self._parse_response_data(response) + + if not response.ok: + return json.dumps( + { + "error": True, + "message": ( + "Spraay gateway validation request failed with status " + f"{response.status_code}" + ), + "data": data, + }, + indent=2, + ) + + return json.dumps( + { + "success": True, + "endpoint": SPRAAY_FREE_VALIDATE_BATCH_PATH, + "validation": data, + }, + indent=2, + ) + except Exception as e: + return json.dumps( + { + "error": True, + "message": "Failed to reach the Spraay gateway for batch validation", + "details": str(e), + "note": ( + "Validation is an optional pre-flight step; the direct on-chain " + "batch actions remain available." + ), + }, + indent=2, + ) + + @create_action( + name="spraay_estimate_batch", + description=( + "Estimate the cost of a batch payment via the free Spraay gateway endpoint " + f"(GET {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_FREE_ESTIMATE_BATCH_PATH}" + "?recipients=&chain=&amount=). No payment and no transaction " + "signing required. Returns rough per-chain gas and protocol-fee estimates (protocol " + "fee requires the optional total amount). Use this to preview costs for a batch of " + "a given size before executing on-chain or via the gateway; for a live quote use " + f"the paid POST {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH}." + ), + schema=SpraayEstimateBatchInput, + ) + def spraay_estimate_batch( + self, wallet_provider: EvmWalletProvider, args: dict[str, Any] + ) -> str: + """Estimate batch execution cost via the free Spraay gateway endpoint. + + Args: + wallet_provider: The wallet provider (unused; estimation is off-chain and free). + args: The estimate parameters (recipients count, chain, optional amount). + + Returns: + str: JSON string with the gateway cost estimate. + + """ + try: + validated = SpraayEstimateBatchInput(**args) + params: dict[str, Any] = { + "recipients": validated.recipients, + "chain": validated.chain, + } + if validated.amount: + params["amount"] = validated.amount + response = requests.get( + f"{self._config.gateway_base_url}{SPRAAY_FREE_ESTIMATE_BATCH_PATH}", + params=params, + timeout=30, + ) + data = self._parse_response_data(response) + + if not response.ok: + return json.dumps( + { + "error": True, + "message": ( + "Spraay gateway estimate request failed with status " + f"{response.status_code}" + ), + "data": data, + }, + indent=2, + ) + + return json.dumps( + { + "success": True, + "endpoint": SPRAAY_FREE_ESTIMATE_BATCH_PATH, + "estimate": data, + }, + indent=2, + ) + except Exception as e: + return json.dumps( + { + "error": True, + "message": "Failed to reach the Spraay gateway for batch estimation", + "details": str(e), + "note": ( + "Estimation is an optional pre-flight step; the direct on-chain " + "batch actions remain available." + ), + }, + indent=2, + ) + + # ── Gateway execution and escrow (x402-metered, paid) ────────────────── + + @create_action( + name="spraay_execute_batch_gateway", + description=( + "Execute a batch payment through the x402-metered Spraay gateway " + f"(POST {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_BATCH_EXECUTE_PATH}). This is a " + "PAID endpoint: pricing is returned via an x402 402 Payment Required challenge and " + "settled in USDC before execution. A live quote is available the same way from " + f"POST {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH}. Tradeoff vs " + "the direct on-chain actions: direct on-chain means the agent signs the batch " + "transaction and pays gas itself on Base; gateway execution is x402-metered and " + "multi-chain capable — the gateway handles submission and the agent pays a metered " + "USDC fee instead of managing gas. Payments respect the provider's " + "max_gateway_payment_usdc limit. Use spraay_validate_batch (free) first to catch " + "malformed batches." + ), + schema=SpraayExecuteBatchGatewayInput, + ) + def spraay_execute_batch_gateway( + self, wallet_provider: EvmWalletProvider, args: dict[str, Any] + ) -> str: + """Execute a batch payment through the x402-metered Spraay gateway. + + Args: + wallet_provider: The wallet provider used to sign the x402 payment. + args: The batch to execute (token, recipients, chain). + + Returns: + str: JSON string with the gateway execution result and payment details. + + """ + try: + validated = SpraayExecuteBatchGatewayInput(**args) + except Exception as e: + return json.dumps( + {"error": True, "message": "Invalid batch input", "details": str(e)}, indent=2 + ) + + return self._request_with_x402( + wallet_provider, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + self._build_execute_body( + validated.token, + [entry.model_dump() for entry in validated.recipients], + wallet_provider.get_address(), + ), + ) + + @create_action( + name="spraay_create_escrow", + description=( + "Create an escrow through the x402-metered Spraay gateway " + f"(POST {SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_ESCROW_CREATE_PATH}). This is a " + "PAID endpoint: pricing is returned via an x402 402 Payment Required challenge and " + "settled in USDC. Escrow complements Spraay batch payments: lock funds for a " + "beneficiary (with an optional arbiter, release conditions, and expiry in hours), " + "then release or refund later. The depositor defaults to the connected wallet and " + "must differ from the beneficiary. This action covers creation only — the " + "gateway's POST /api/v1/escrow/fund, /release, and /cancel endpoints handle the " + f"rest of the lifecycle (see {SPRAAY_GATEWAY_BASE_URL}). Payments respect the " + "provider's max_gateway_payment_usdc limit." + ), + schema=SpraayCreateEscrowInput, + ) + def spraay_create_escrow(self, wallet_provider: EvmWalletProvider, args: dict[str, Any]) -> str: + """Create an escrow through the x402-metered Spraay gateway. + + Args: + wallet_provider: The wallet provider used to sign the x402 payment. + args: The escrow parameters (token, amount, beneficiary, depositor, arbiter, + description, conditions, expires_in). + + Returns: + str: JSON string with the gateway escrow creation result and payment details. + + """ + try: + validated = SpraayCreateEscrowInput(**args) + except Exception as e: + return json.dumps( + {"error": True, "message": "Invalid escrow input", "details": str(e)}, indent=2 + ) + + depositor = validated.depositor or wallet_provider.get_address() + + # The gateway rejects depositor == beneficiary, but only after the x402 + # payment has settled — catch it locally before paying anything. + if depositor.lower() == validated.beneficiary.lower(): + return json.dumps( + { + "error": True, + "message": "Depositor and beneficiary cannot be the same address", + "details": "No payment was made.", + }, + indent=2, + ) + + body: dict[str, Any] = { + "depositor": depositor, + "beneficiary": validated.beneficiary, + "token": validated.token, + "amount": validated.amount, + } + if validated.arbiter: + body["arbiter"] = validated.arbiter + if validated.description: + body["description"] = validated.description + if validated.conditions: + body["conditions"] = validated.conditions + if validated.expires_in: + body["expiresIn"] = validated.expires_in + + return self._request_with_x402(wallet_provider, SPRAAY_GATEWAY_ESCROW_CREATE_PATH, body) + + # ── Private helpers ──────────────────────────────────────────────────── + + def _build_validate_body( + self, chain: str, token: str, recipients: list[dict[str, str]] + ) -> dict[str, Any]: + """Build the request body for the free BPA 1.0 validation endpoint. + + The gateway's validator expects entries keyed as {to, amount} inside a + "recipients" array (not "payments"), plus chain and token (symbol or + contract address). + + Args: + chain: Target chain identifier. + token: Token symbol or contract address. + recipients: Batch entries as (recipient, amount) dicts. + + Returns: + dict[str, Any]: The validation request body. + + """ + return { + "bpa_version": SPRAAY_BPA_VERSION, + "chain": chain, + "token": token, + "recipients": [ + {"to": entry["recipient"], "amount": entry["amount"]} for entry in recipients + ], + } + + def _build_execute_body( + self, token: str, recipients: list[dict[str, str]], sender: str + ) -> dict[str, Any]: + """Build the request body for the paid batch execution endpoint. + + The gateway's execute handler expects entries keyed as {address, amount} + (human-decimal amounts) plus token (symbol or contract address, default + USDC) and an optional sender used for approval encoding. + + Args: + token: Token symbol or contract address. + recipients: Batch entries as (recipient, amount) dicts. + sender: The sending wallet address. + + Returns: + dict[str, Any]: The execution request body. + + """ + return { + "token": token, + "recipients": [ + {"address": entry["recipient"], "amount": entry["amount"]} for entry in recipients + ], + "sender": sender, + } + + def _run_preflight(self, token: str, entries: list[dict[str, str]]) -> tuple[bool, str]: + """Run the free gateway pre-flight validation for an on-chain batch. + + Gateway unavailability never blocks the on-chain path; an explicit + "valid: false" verdict does. + + Args: + token: Token symbol for the batch. + entries: Batch entries as (recipient, amount) dicts. + + Returns: + tuple[bool, str]: Whether to proceed, plus a report for the action result. + + """ + try: + response = requests.post( + f"{self._config.gateway_base_url}{SPRAAY_FREE_VALIDATE_BATCH_PATH}", + json=self._build_validate_body("base", token, entries), + timeout=30, + ) + if not response.ok: + return True, ( + "Pre-flight validation skipped: gateway responded with status " + f"{response.status_code}." + ) + + data = self._parse_response_data(response) + report = f"Pre-flight validation: {json.dumps(data)}" + + if isinstance(data, dict) and data.get("valid") is False: + return False, report + + return True, report + except Exception as e: + return True, f"Pre-flight validation skipped: gateway unreachable ({e})." + + def _format_spray_result( + self, + headline: str, + subtotal: int, + fee: int, + fee_bps: int, + decimals: int, + symbol: str, + tx_hash: str, + receipt: dict[str, Any], + allowance_msg: str | None = None, + preflight_report: str | None = None, + ) -> str: + """Format a successful spray result into the shared multi-line shape. + + Args: + headline: First line describing the outcome. + subtotal: Total amount sent, excluding fee, in atomic units. + fee: Protocol fee amount in atomic units. + fee_bps: Protocol fee in basis points. + decimals: Token decimals for formatting. + symbol: Token symbol for formatting. + tx_hash: The spray transaction hash. + receipt: The transaction receipt. + allowance_msg: Optional allowance message (permit/approve). + preflight_report: Optional pre-flight report to surface. + + Returns: + str: The formatted result string. + + """ + lines: list[str] = [] + if preflight_report: + lines.append(preflight_report) + if allowance_msg: + lines.append(allowance_msg) + lines.extend( + [ + headline, + f"Total sent: {format_units(subtotal, decimals)} {symbol}", + f"Protocol fee ({fee_bps / 100}%): {format_units(fee, decimals)} {symbol}", + f"Transaction hash: {tx_hash}", + f"Block: {receipt.get('blockNumber', 'pending')}", + f"View on BaseScan: https://basescan.org/tx/{tx_hash}", + ] + ) + return "\n".join(lines) + + def _get_fee_bps(self, wallet_provider: EvmWalletProvider) -> int: + """Read the live protocol fee from the contract, with a constant fallback. + + Args: + wallet_provider: The wallet provider to read with. + + Returns: + int: The protocol fee in basis points. + + """ + try: + return int( + wallet_provider.read_contract(SPRAAY_CONTRACT_ADDRESS, SPRAAY_ABI, "feeBps", []) + ) + except Exception: + return SPRAAY_PROTOCOL_FEE_BPS + + def _ensure_token_allowance( + self, wallet_provider: EvmWalletProvider, token_address: str, required: int + ) -> str | None: + """Ensure the Spraay contract has a sufficient token allowance. + + Prefers an EIP-2612 permit (exact value, deadline-bounded) when the + token supports it, falling back to a standard approve otherwise. + + Args: + wallet_provider: The wallet provider to sign and send with. + token_address: The ERC-20 token contract address. + required: The allowance required, in atomic units. + + Returns: + str | None: A message describing what was done, or None if nothing was needed. + + """ + wallet_address = wallet_provider.get_address() + current_allowance = int( + wallet_provider.read_contract( + token_address, ERC20_ABI, "allowance", [wallet_address, SPRAAY_CONTRACT_ADDRESS] + ) + ) + if current_allowance >= required: + return None + + permit_msg = self._try_permit(wallet_provider, token_address, required) + if permit_msg: + return permit_msg + + w3 = Web3() + contract = w3.eth.contract(address=w3.to_checksum_address(token_address), abi=ERC20_ABI) + data = contract.encode_abi( + "approve", [w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), required] + ) + tx_hash = wallet_provider.send_transaction( + {"to": w3.to_checksum_address(token_address), "data": data} + ) + wallet_provider.wait_for_transaction_receipt(tx_hash) + return ( + "Token approval granted to Spraay contract (token does not support EIP-2612 " + f"permit). Approval tx: {tx_hash}" + ) + + def _try_permit( + self, wallet_provider: EvmWalletProvider, token_address: str, required: int + ) -> str | None: + """Attempt to grant the allowance via an EIP-2612 permit. + + Support is detected at runtime (nonces/name reads) rather than from a + hardcoded token list. Returns None when the token does not support + permit or any step fails, so the caller can fall back to approve. + + Args: + wallet_provider: The wallet provider to sign and send with. + token_address: The ERC-20 token contract address. + required: The allowance value to permit, in atomic units. + + Returns: + str | None: A message describing the permit, or None to signal fallback. + + """ + try: + owner = wallet_provider.get_address() + + # Detect permit support: EIP-2612 tokens expose nonces(owner). + nonce = int(wallet_provider.read_contract(token_address, ERC20_ABI, "nonces", [owner])) + name = str(wallet_provider.read_contract(token_address, ERC20_ABI, "name", [])) + + # EIP-2612 domain version: USDC on Base reports "2"; default to "1". + try: + version = str( + wallet_provider.read_contract(token_address, ERC20_ABI, "version", []) + ) + except Exception: + version = "1" + + network = wallet_provider.get_network() + chain_id = int(network.chain_id or 8453) + deadline = int(time.time()) + PERMIT_DEADLINE_SECONDS + + typed_data = { + "types": { + "EIP712Domain": [ + {"name": "name", "type": "string"}, + {"name": "version", "type": "string"}, + {"name": "chainId", "type": "uint256"}, + {"name": "verifyingContract", "type": "address"}, + ], + "Permit": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + {"name": "value", "type": "uint256"}, + {"name": "nonce", "type": "uint256"}, + {"name": "deadline", "type": "uint256"}, + ], + }, + "primaryType": "Permit", + "domain": { + "name": name, + "version": version, + "chainId": chain_id, + "verifyingContract": token_address, + }, + "message": { + "owner": owner, + "spender": SPRAAY_CONTRACT_ADDRESS, + "value": required, + "nonce": nonce, + "deadline": deadline, + }, + } + + signature = wallet_provider.sign_typed_data(typed_data) + r, s, v = split_signature(signature) + + w3 = Web3() + contract = w3.eth.contract(address=w3.to_checksum_address(token_address), abi=ERC20_ABI) + data = contract.encode_abi( + "permit", + [ + w3.to_checksum_address(owner), + w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), + required, + deadline, + v, + bytes.fromhex(r[2:]), + bytes.fromhex(s[2:]), + ], + ) + tx_hash = wallet_provider.send_transaction( + {"to": w3.to_checksum_address(token_address), "data": data} + ) + wallet_provider.wait_for_transaction_receipt(tx_hash) + + # Verify the permit took effect (e.g. smart-wallet ERC-1271 signatures + # do not pass permit's ecrecover); otherwise fall back to approve. + allowance_after = int( + wallet_provider.read_contract( + token_address, + ERC20_ABI, + "allowance", + [owner, SPRAAY_CONTRACT_ADDRESS], + ) + ) + if allowance_after < required: + return None + + return ( + "Token allowance granted via EIP-2612 permit (exact value, " + f"{PERMIT_DEADLINE_SECONDS // 60}-minute deadline, no standing unlimited " + f"approval). Permit tx: {tx_hash}" + ) + except Exception: + # Token does not support EIP-2612 permit (or signing failed) — fall back to approve. + return None + + def _request_with_x402( + self, wallet_provider: EvmWalletProvider, path: str, body: dict[str, Any] + ) -> str: + """Make a request to an x402-metered Spraay gateway endpoint. + + The first request is unpaid; on a 402 challenge the payment is + validated against the configured limit and settled either with a + pre-funded payment header or by signing with the wallet provider via + the x402 client. Payment is never faked or stubbed. + + Args: + wallet_provider: The wallet provider used to sign the x402 payment. + path: The gateway endpoint path. + body: The JSON request body. + + Returns: + str: JSON string with the result and payment details. + + """ + url = f"{self._config.gateway_base_url}{path}" + + try: + initial_response = requests.post(url, json=body, timeout=30) + + if initial_response.status_code != 402: + data = self._parse_response_data(initial_response) + return json.dumps( + { + "success": initial_response.ok, + "url": url, + "status": initial_response.status_code, + "data": data, + }, + indent=2, + ) + + # Parse the 402 challenge: v2 sends requirements in the + # PAYMENT-REQUIRED header; v1 sends them in the body. + accepts_array: list[dict[str, Any]] = [] + payment_required_header = initial_response.headers.get("payment-required") + if payment_required_header: + try: + decoded = json.loads(base64.b64decode(payment_required_header)) + accepts_array = decoded.get("accepts", []) + except Exception: + pass + if not accepts_array: + with contextlib.suppress(Exception): + accepts_array = initial_response.json().get("accepts", []) + + # Enforce the configured payment limit against the cheapest quoted option. + quoted_amounts = [ + option.get("maxAmountRequired") or option.get("amount") or option.get("price") + for option in accepts_array + ] + quoted_amounts = [amount for amount in quoted_amounts if amount] + if quoted_amounts: + cheapest = min(quoted_amounts, key=int) + payment_validation = validate_payment_limit( + cheapest, self._config.max_gateway_payment_usdc + ) + if not payment_validation["is_valid"]: + return json.dumps( + { + "error": True, + "message": "Gateway payment exceeds limit", + "details": ( + f"The Spraay gateway quoted " + f"{payment_validation['requested_amount']} USDC, which exceeds " + f"the maximum gateway payment limit of " + f"{payment_validation['max_amount']} USDC. No payment was made." + ), + "maxGatewayPaymentUsdc": self._config.max_gateway_payment_usdc, + "acceptablePaymentOptions": accepts_array, + }, + indent=2, + ) + + # Settle the payment: pre-funded header if configured, otherwise + # sign with the wallet provider via the x402 client. The gateway's + # x402 v2 middleware reads Payment-Signature, with X-PAYMENT kept + # as the v1 fallback. + if self._config.x402_payment_header: + paid_response = requests.post( + url, + json=body, + headers={ + "Payment-Signature": self._config.x402_payment_header, + "X-PAYMENT": self._config.x402_payment_header, + }, + timeout=30, + ) + else: + client = x402ClientSync() + register_exact_evm_client(client, EthAccountSigner(wallet_provider.to_signer())) + session = x402_requests(client) + paid_response = session.post(url, json=body, timeout=30) + + data = self._parse_response_data(paid_response) + + payment_response_header = paid_response.headers.get( + "payment-response" + ) or paid_response.headers.get("x-payment-response") + payment_proof: dict[str, Any] | None = None + if payment_response_header: + try: + payment_proof = json.loads(base64.b64decode(payment_response_header)) + except Exception: + payment_proof = {"raw": payment_response_header} + + if paid_response.status_code != 200: + return json.dumps( + { + "error": True, + "message": ( + f"Gateway request failed with status {paid_response.status_code}. " + "Payment was not settled." + ), + "url": url, + "status": paid_response.status_code, + "data": data, + }, + indent=2, + ) + + return json.dumps( + { + "success": True, + "message": "Gateway request completed with x402 payment", + "url": url, + "status": paid_response.status_code, + "data": data, + "paymentProof": payment_proof, + }, + indent=2, + ) + except Exception as e: + return json.dumps( + { + "error": True, + "message": f"Error calling the Spraay gateway at {url}", + "details": str(e), + }, + indent=2, + ) + + def _parse_response_data(self, response: requests.Response) -> Any: + """Parse response data based on content type. + + Args: + response: The requests Response object. + + Returns: + Any: Parsed response data. + + """ + content_type = response.headers.get("content-type", "") + if "application/json" in content_type: + try: + return response.json() + except Exception: + return response.text + return response.text + + def _get_token_decimals(self, wallet_provider: EvmWalletProvider, token_address: str) -> int: + """Get the number of decimals for an ERC-20 token. + + Args: + wallet_provider: The wallet provider to read with. + token_address: The ERC-20 token contract address. + + Returns: + int: The token decimals, defaulting to 18 on failure. + + """ + try: + return int(wallet_provider.read_contract(token_address, ERC20_ABI, "decimals", [])) + except Exception: + return 18 + + def _get_token_symbol(self, wallet_provider: EvmWalletProvider, token_address: str) -> str: + """Get the symbol for an ERC-20 token. + + Args: + wallet_provider: The wallet provider to read with. + token_address: The ERC-20 token contract address. + + Returns: + str: The token symbol, defaulting to "TOKEN" on failure. + + """ + try: + return str(wallet_provider.read_contract(token_address, ERC20_ABI, "symbol", [])) + except Exception: + return "TOKEN" + + +def spraay_action_provider(config: SpraayConfig | None = None) -> SpraayActionProvider: + """Create a new SpraayActionProvider instance. + + Args: + config: Optional configuration for gateway payment limits and endpoints. + + Returns: + SpraayActionProvider: A new Spraay action provider instance. + + Example:: + + from coinbase_agentkit import AgentKit, AgentKitConfig + from coinbase_agentkit.action_providers.spraay import spraay_action_provider + + agent_kit = AgentKit(AgentKitConfig( + wallet_provider=wallet_provider, + action_providers=[spraay_action_provider()], + )) + + """ + return SpraayActionProvider(config) diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/utils.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/utils.py new file mode 100644 index 000000000..7a7ee423e --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/utils.py @@ -0,0 +1,58 @@ +"""Utility helpers for the Spraay action provider.""" + + +def parse_units(value: str, decimals: int) -> int: + """Convert a human-readable number string to an integer with the given decimals. + + Args: + value: The human-readable amount (e.g. "0.01"). + decimals: The token decimals. + + Returns: + int: The amount in atomic units. + + """ + parts = value.split(".") + if len(parts) == 1: + return int(parts[0]) * (10**decimals) + integer_part = parts[0] + decimal_part = parts[1][:decimals].ljust(decimals, "0") + return int(integer_part) * (10**decimals) + int(decimal_part) + + +def format_units(value: int, decimals: int) -> str: + """Convert an atomic-unit integer back to a human-readable string. + + Args: + value: The amount in atomic units. + decimals: The token decimals. + + Returns: + str: The human-readable amount. + + """ + whole = value // (10**decimals) + fraction = value % (10**decimals) + if fraction == 0: + return str(whole) + frac_str = str(fraction).zfill(decimals).rstrip("0") + return f"{whole}.{frac_str}" + + +def split_signature(signature: str) -> tuple[str, str, int]: + """Split a 65-byte hex signature into its r, s, v components. + + Args: + signature: The 0x-prefixed 65-byte signature. + + Returns: + tuple[str, str, int]: The r, s, and v components. + + """ + sig = signature[2:] if signature.startswith("0x") else signature + r = "0x" + sig[0:64] + s = "0x" + sig[64:128] + v = int(sig[128:130], 16) + if v < 27: + v += 27 + return r, s, v diff --git a/python/coinbase-agentkit/tests/action_providers/spraay/__init__.py b/python/coinbase-agentkit/tests/action_providers/spraay/__init__.py new file mode 100644 index 000000000..07174fee9 --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/spraay/__init__.py @@ -0,0 +1 @@ +"""Tests for the Spraay action provider.""" diff --git a/python/coinbase-agentkit/tests/action_providers/spraay/conftest.py b/python/coinbase-agentkit/tests/action_providers/spraay/conftest.py new file mode 100644 index 000000000..80a1c944a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/spraay/conftest.py @@ -0,0 +1,70 @@ +"""Test fixtures for Spraay action provider tests.""" + +from unittest.mock import Mock + +import pytest + +from coinbase_agentkit.network import Network +from coinbase_agentkit.wallet_providers.evm_wallet_provider import EvmWalletProvider + +MOCK_WALLET_ADDRESS = "0x1234567890123456789012345678901234567890" +MOCK_RECIPIENT_A = "0xAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaAa" +MOCK_RECIPIENT_B = "0xBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBb" +MOCK_TOKEN_ADDRESS = "0xCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCcCc" +MOCK_TX_HASH = "0xmocktxhash123" +MOCK_SIGNATURE = "0x" + "11" * 32 + "22" * 32 + "1b" + +# Default read_contract return values, keyed by function name. +DEFAULT_READS = { + "feeBps": 30, + "decimals": 6, + "symbol": "USDC", + "name": "USD Coin", + "version": "2", + "nonces": 0, +} + + +def make_read_contract(overrides: dict | None = None): + """Build a read_contract side effect keyed by function name. + + The default allowance is stateful: 0 before the permit/approve lands, + effectively unlimited afterwards. Override "allowance" for a fixed value. + + Args: + overrides: Return values (or Exception instances to raise) per function name. + + Returns: + Callable: The side effect for the read_contract mock. + + """ + overrides = overrides or {} + values = {**DEFAULT_READS, **overrides} + allowance_reads = {"count": 0} + + def _read_contract(contract_address, abi, function_name, args=None, **kwargs): + if function_name == "allowance" and "allowance" not in overrides: + allowance_reads["count"] += 1 + return 0 if allowance_reads["count"] == 1 else 2**255 + value = values.get(function_name) + if isinstance(value, Exception): + raise value + return value + + return _read_contract + + +@pytest.fixture +def mock_wallet(): + """Create a mock EVM wallet provider.""" + mock = Mock(spec=EvmWalletProvider) + mock.get_address.return_value = MOCK_WALLET_ADDRESS + mock.get_network.return_value = Network( + protocol_family="evm", network_id="base-mainnet", chain_id="8453" + ) + mock.send_transaction.return_value = MOCK_TX_HASH + mock.wait_for_transaction_receipt.return_value = {"blockNumber": 12345} + mock.sign_typed_data.return_value = MOCK_SIGNATURE + mock.to_signer.return_value = Mock() + mock.read_contract.side_effect = make_read_contract() + return mock diff --git a/python/coinbase-agentkit/tests/action_providers/spraay/test_spraay_action_provider.py b/python/coinbase-agentkit/tests/action_providers/spraay/test_spraay_action_provider.py new file mode 100644 index 000000000..6a0e9297a --- /dev/null +++ b/python/coinbase-agentkit/tests/action_providers/spraay/test_spraay_action_provider.py @@ -0,0 +1,800 @@ +"""Tests for the Spraay action provider.""" + +import base64 +import json +from unittest.mock import Mock, patch + +import pytest +from pydantic import ValidationError +from web3 import Web3 + +from coinbase_agentkit.action_providers.spraay.constants import ( + SPRAAY_ABI, + SPRAAY_BPA_VERSION, + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_FREE_ESTIMATE_BATCH_PATH, + SPRAAY_FREE_VALIDATE_BATCH_PATH, + SPRAAY_GATEWAY_BASE_URL, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + SPRAAY_GATEWAY_ESCROW_CREATE_PATH, + ZERO_ADDRESS, +) +from coinbase_agentkit.action_providers.spraay.schemas import ( + SpraayConfig, + SpraayCreateEscrowInput, + SpraayEstimateBatchInput, + SpraayValidateBatchInput, + SprayEthInput, + SprayTokenInput, +) +from coinbase_agentkit.action_providers.spraay.spraay_action_provider import ( + spraay_action_provider, +) +from coinbase_agentkit.network import Network + +from .conftest import ( + MOCK_RECIPIENT_A, + MOCK_RECIPIENT_B, + MOCK_TOKEN_ADDRESS, + MOCK_WALLET_ADDRESS, + make_read_contract, +) + +PROVIDER_MODULE = "coinbase_agentkit.action_providers.spraay.spraay_action_provider" + + +def make_response(status_code: int, data: dict | None = None, headers: dict | None = None): + """Create a mock requests.Response.""" + response = Mock() + response.status_code = status_code + response.ok = 200 <= status_code < 300 + response.headers = {"content-type": "application/json", **(headers or {})} + response.json.return_value = data + response.text = json.dumps(data) + return response + + +# ========================================================= +# Schema tests +# ========================================================= + + +def test_spray_eth_schema_valid(): + """Accept a valid ETH batch.""" + schema = SprayEthInput( + recipients=[MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], amount_per_recipient="0.01" + ) + assert len(schema.recipients) == 2 + assert schema.preflight is False + + +def test_spray_eth_schema_rejects_empty_recipients(): + """Reject an empty recipient list.""" + with pytest.raises(ValidationError): + SprayEthInput(recipients=[], amount_per_recipient="0.01") + + +def test_spray_eth_schema_rejects_over_200_recipients(): + """Reject more than 200 recipients.""" + recipients = [f"0x{i:040x}" for i in range(201)] + with pytest.raises(ValidationError): + SprayEthInput(recipients=recipients, amount_per_recipient="0.01") + + +def test_spray_eth_schema_rejects_malformed_addresses(): + """Reject malformed addresses.""" + with pytest.raises(ValidationError): + SprayEthInput(recipients=["0xnotanaddress"], amount_per_recipient="0.01") + + +def test_spray_eth_schema_rejects_case_normalized_duplicates(): + """Reject duplicate recipients that differ only in case.""" + with pytest.raises(ValidationError): + SprayEthInput( + recipients=[MOCK_RECIPIENT_A, MOCK_RECIPIENT_A.lower()], + amount_per_recipient="0.01", + ) + + +def test_spray_eth_schema_rejects_bad_amounts(): + """Reject non-positive and malformed amounts.""" + for bad in ["0", "-1", "abc"]: + with pytest.raises(ValidationError): + SprayEthInput(recipients=[MOCK_RECIPIENT_A], amount_per_recipient=bad) + + +def test_spray_token_schema_rejects_bad_token_address(): + """Reject a malformed token address.""" + with pytest.raises(ValidationError): + SprayTokenInput( + token_address="nope", recipients=[MOCK_RECIPIENT_A], amount_per_recipient="1" + ) + + +def test_validate_batch_schema_rejects_duplicates_and_defaults_chain(): + """Reject duplicate gateway batch recipients; default chain to base.""" + with pytest.raises(ValidationError): + SpraayValidateBatchInput( + token="USDC", + recipients=[ + {"recipient": MOCK_RECIPIENT_A, "amount": "1.00"}, + {"recipient": MOCK_RECIPIENT_A.lower(), "amount": "2.00"}, + ], + ) + + schema = SpraayValidateBatchInput( + token="USDC", recipients=[{"recipient": MOCK_RECIPIENT_A, "amount": "1.00"}] + ) + assert schema.chain == "base" + + +def test_estimate_batch_schema_bounds(): + """Bound the estimate recipient count and validate the optional amount.""" + for bad in [0, 201]: + with pytest.raises(ValidationError): + SpraayEstimateBatchInput(recipients=bad) + assert SpraayEstimateBatchInput(recipients=50).recipients == 50 + assert SpraayEstimateBatchInput(recipients=50, amount="1000.00").amount == "1000.00" + with pytest.raises(ValidationError): + SpraayEstimateBatchInput(recipients=50, amount="-1") + + +def test_create_escrow_schema(): + """Validate escrow input.""" + schema = SpraayCreateEscrowInput( + token="USDC", + amount="250.00", + beneficiary=MOCK_RECIPIENT_A, + depositor=MOCK_RECIPIENT_B, + arbiter=MOCK_TOKEN_ADDRESS, + conditions=["Design approved"], + expires_in=72, + ) + assert schema.expires_in == 72 + with pytest.raises(ValidationError): + SpraayCreateEscrowInput(token="USDC", amount="0", beneficiary=MOCK_RECIPIENT_A) + with pytest.raises(ValidationError): + SpraayCreateEscrowInput(token="USDC", amount="1", beneficiary="0xbad") + with pytest.raises(ValidationError): + SpraayCreateEscrowInput( + token="USDC", amount="1", beneficiary=MOCK_RECIPIENT_A, expires_in=-5 + ) + + +# ========================================================= +# Network support +# ========================================================= + + +def test_supports_network(): + """Support Base mainnet only.""" + provider = spraay_action_provider() + assert provider.supports_network(Network(protocol_family="evm", network_id="base-mainnet")) + assert not provider.supports_network(Network(protocol_family="evm", network_id="base-sepolia")) + assert not provider.supports_network( + Network(protocol_family="svm", network_id="solana-mainnet") + ) + + +# ========================================================= +# Direct on-chain batch execution +# ========================================================= + + +def test_spraay_eth_uses_spray_equal_with_zero_address(mock_wallet): + """Spray ETH via sprayEqual with the zero address and fee-inclusive value.""" + provider = spraay_action_provider() + result = provider.spraay_eth( + mock_wallet, + {"recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], "amount_per_recipient": "0.01"}, + ) + + assert mock_wallet.send_transaction.call_count == 1 + tx = mock_wallet.send_transaction.call_args[0][0] + assert tx["to"] == Web3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS) + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + expected_data = contract.encode_abi( + "sprayEqual", + [ + w3.to_checksum_address(ZERO_ADDRESS), + [ + w3.to_checksum_address(MOCK_RECIPIENT_A), + w3.to_checksum_address(MOCK_RECIPIENT_B), + ], + 10**16, + ], + ) + assert tx["data"] == expected_data + + # 0.02 ETH subtotal + 0.3% fee + subtotal = 2 * 10**16 + assert tx["value"] == subtotal + (subtotal * 30) // 10000 + + assert "Successfully sprayed" in result + assert "2 recipients" in result + assert "basescan.org" in result + + +def test_spraay_eth_fee_fallback_on_read_error(mock_wallet): + """Fall back to the default fee when the feeBps read fails.""" + mock_wallet.read_contract.side_effect = make_read_contract({"feeBps": RuntimeError("no rpc")}) + provider = spraay_action_provider() + result = provider.spraay_eth( + mock_wallet, {"recipients": [MOCK_RECIPIENT_A], "amount_per_recipient": "1"} + ) + + tx = mock_wallet.send_transaction.call_args[0][0] + assert tx["value"] == 10**18 + (10**18 * 30) // 10000 + assert "Successfully sprayed" in result + + +def test_spraay_eth_error_result(mock_wallet): + """Return an error message when the transaction fails.""" + mock_wallet.send_transaction.side_effect = RuntimeError("Insufficient funds") + provider = spraay_action_provider() + result = provider.spraay_eth( + mock_wallet, {"recipients": [MOCK_RECIPIENT_A], "amount_per_recipient": "1"} + ) + assert "Error spraying ETH" in result + assert "Insufficient funds" in result + + +def test_spraay_eth_rejects_duplicates_via_schema(mock_wallet): + """Reject duplicate recipients before signing.""" + provider = spraay_action_provider() + result = provider.spraay_eth( + mock_wallet, + { + "recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_A.lower()], + "amount_per_recipient": "1", + }, + ) + assert "Error" in result + assert "Duplicate recipient" in result + mock_wallet.send_transaction.assert_not_called() + + +def test_spraay_token_uses_permit_when_supported(mock_wallet): + """Use an EIP-2612 permit when the token supports it.""" + provider = spraay_action_provider() + result = provider.spraay_token( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], + "amount_per_recipient": "100", + }, + ) + + # permit tx + spray tx + assert mock_wallet.send_transaction.call_count == 2 + assert mock_wallet.sign_typed_data.call_count == 1 + typed_data = mock_wallet.sign_typed_data.call_args[0][0] + assert typed_data["primaryType"] == "Permit" + assert typed_data["domain"]["name"] == "USD Coin" + assert typed_data["domain"]["version"] == "2" + assert typed_data["domain"]["chainId"] == 8453 + assert typed_data["message"]["spender"] == SPRAAY_CONTRACT_ADDRESS + assert "EIP-2612 permit" in result + assert "Successfully sprayed" in result + assert "USDC" in result + + +def test_spraay_token_falls_back_to_approve_for_non_permit_tokens(mock_wallet): + """Fall back to approve when the token has no nonces().""" + mock_wallet.read_contract.side_effect = make_read_contract( + {"nonces": RuntimeError("execution reverted"), "symbol": "DAI", "decimals": 18} + ) + provider = spraay_action_provider() + result = provider.spraay_token( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "100", + }, + ) + + # approve tx + spray tx + assert mock_wallet.send_transaction.call_count == 2 + mock_wallet.sign_typed_data.assert_not_called() + assert "Token approval granted" in result + assert "does not support EIP-2612 permit" in result + assert "Successfully sprayed" in result + + +def test_spraay_token_falls_back_to_approve_when_permit_ineffective(mock_wallet): + """Fall back to approve when the permit does not take effect on-chain.""" + # Allowance stays 0 even after the permit tx (e.g. an ERC-1271 smart-wallet + # signature that permit's ecrecover does not accept). + mock_wallet.read_contract.side_effect = make_read_contract({"allowance": 0}) + provider = spraay_action_provider() + result = provider.spraay_token( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "100", + }, + ) + + # permit tx + approve tx + spray tx + assert mock_wallet.send_transaction.call_count == 3 + assert "Token approval granted" in result + assert "Successfully sprayed" in result + + +def test_spraay_token_falls_back_to_approve_when_signing_fails(mock_wallet): + """Fall back to approve when typed-data signing fails.""" + mock_wallet.sign_typed_data.side_effect = RuntimeError("signing not supported") + provider = spraay_action_provider() + result = provider.spraay_token( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "100", + }, + ) + + assert mock_wallet.send_transaction.call_count == 2 + assert "Token approval granted" in result + assert "Successfully sprayed" in result + + +def test_spraay_token_skips_allowance_when_sufficient(mock_wallet): + """Skip allowance handling when the allowance is already sufficient.""" + mock_wallet.read_contract.side_effect = make_read_contract({"allowance": 10**18}) + provider = spraay_action_provider() + result = provider.spraay_token( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "10", + }, + ) + + assert mock_wallet.send_transaction.call_count == 1 + mock_wallet.sign_typed_data.assert_not_called() + assert "permit" not in result + assert "approval" not in result + + +def test_spraay_eth_variable_uses_struct_encoding(mock_wallet): + """Encode variable ETH sprays as (recipient, amount) structs.""" + provider = spraay_action_provider() + result = provider.spraay_eth_variable( + mock_wallet, + {"recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], "amounts": ["0.01", "0.05"]}, + ) + + assert mock_wallet.send_transaction.call_count == 1 + tx = mock_wallet.send_transaction.call_args[0][0] + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + expected_data = contract.encode_abi( + "sprayETH", + [ + [ + (w3.to_checksum_address(MOCK_RECIPIENT_A), 10**16), + (w3.to_checksum_address(MOCK_RECIPIENT_B), 5 * 10**16), + ] + ], + ) + assert tx["data"] == expected_data + assert "Successfully sprayed variable ETH" in result + + +def test_spraay_eth_variable_rejects_mismatched_arrays(mock_wallet): + """Reject mismatched recipients/amounts arrays.""" + provider = spraay_action_provider() + result = provider.spraay_eth_variable( + mock_wallet, + {"recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], "amounts": ["0.01"]}, + ) + assert "Error: recipients length" in result + mock_wallet.send_transaction.assert_not_called() + + +def test_spraay_token_variable_struct_encoding_with_permit(mock_wallet): + """Spray variable token amounts with permit and struct encoding.""" + provider = spraay_action_provider() + result = provider.spraay_token_variable( + mock_wallet, + { + "token_address": MOCK_TOKEN_ADDRESS, + "recipients": [MOCK_RECIPIENT_A, MOCK_RECIPIENT_B], + "amounts": ["100", "200"], + }, + ) + + # permit tx + spray tx + assert mock_wallet.send_transaction.call_count == 2 + spray_tx = mock_wallet.send_transaction.call_args_list[1][0][0] + + w3 = Web3() + contract = w3.eth.contract( + address=w3.to_checksum_address(SPRAAY_CONTRACT_ADDRESS), abi=SPRAAY_ABI + ) + expected_data = contract.encode_abi( + "sprayToken", + [ + w3.to_checksum_address(MOCK_TOKEN_ADDRESS), + [ + (w3.to_checksum_address(MOCK_RECIPIENT_A), 100 * 10**6), + (w3.to_checksum_address(MOCK_RECIPIENT_B), 200 * 10**6), + ], + ], + ) + assert spray_tx["data"] == expected_data + assert "Successfully sprayed variable USDC" in result + + +# ========================================================= +# Pre-flight +# ========================================================= + + +def test_preflight_gateway_unreachable_does_not_block(mock_wallet): + """Proceed on-chain when the pre-flight gateway is unreachable.""" + provider = spraay_action_provider() + with patch(f"{PROVIDER_MODULE}.requests.post", side_effect=ConnectionError("refused")): + result = provider.spraay_eth( + mock_wallet, + { + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "0.01", + "preflight": True, + }, + ) + + assert mock_wallet.send_transaction.call_count == 1 + assert "Pre-flight validation skipped" in result + assert "Successfully sprayed" in result + + +def test_preflight_invalid_verdict_blocks_signing(mock_wallet): + """Abort before signing when the gateway reports the batch invalid.""" + provider = spraay_action_provider() + with patch( + f"{PROVIDER_MODULE}.requests.post", + return_value=make_response(200, {"valid": False, "errors": ["bad recipient"]}), + ): + result = provider.spraay_eth( + mock_wallet, + { + "recipients": [MOCK_RECIPIENT_A], + "amount_per_recipient": "0.01", + "preflight": True, + }, + ) + + mock_wallet.send_transaction.assert_not_called() + assert "failed Spraay gateway pre-flight validation" in result + assert "no transaction was signed" in result + + +# ========================================================= +# Free gateway actions +# ========================================================= + + +def test_validate_batch_posts_bpa_body(mock_wallet): + """Post a BPA 1.0 body with a recipients key and return the verdict.""" + provider = spraay_action_provider() + with patch( + f"{PROVIDER_MODULE}.requests.post", + return_value=make_response( + 200, {"valid": True, "errors": [], "warnings": [], "summary": {"total": "1.00"}} + ), + ) as mock_post: + result = provider.spraay_validate_batch( + mock_wallet, + { + "token": "USDC", + "recipients": [{"recipient": MOCK_RECIPIENT_A, "amount": "1.00"}], + }, + ) + + call = mock_post.call_args + assert call[0][0] == f"{SPRAAY_GATEWAY_BASE_URL}{SPRAAY_FREE_VALIDATE_BATCH_PATH}" + body = call[1]["json"] + assert body == { + "bpa_version": SPRAAY_BPA_VERSION, + "chain": "base", + "token": "USDC", + "recipients": [{"to": MOCK_RECIPIENT_A, "amount": "1.00"}], + } + assert "payments" not in body + + parsed = json.loads(result) + assert parsed["success"] is True + assert parsed["validation"]["valid"] is True + + +def test_validate_batch_gateway_unreachable(mock_wallet): + """Report gateway unreachability without throwing.""" + provider = spraay_action_provider() + with patch(f"{PROVIDER_MODULE}.requests.post", side_effect=ConnectionError("ENOTFOUND")): + result = provider.spraay_validate_batch( + mock_wallet, + { + "token": "USDC", + "recipients": [{"recipient": MOCK_RECIPIENT_A, "amount": "1.00"}], + }, + ) + + parsed = json.loads(result) + assert parsed["error"] is True + assert "on-chain batch actions remain available" in parsed["note"] + + +def test_estimate_batch_uses_query_params(mock_wallet): + """Call the free estimate endpoint with query parameters.""" + provider = spraay_action_provider() + with patch( + f"{PROVIDER_MODULE}.requests.get", + return_value=make_response(200, {"estimate": {"estimatedGasUSD": 0.15}}), + ) as mock_get: + result = provider.spraay_estimate_batch( + mock_wallet, {"recipients": 150, "chain": "base", "amount": "1000.00"} + ) + + call = mock_get.call_args + assert call[0][0] == f"{SPRAAY_GATEWAY_BASE_URL}{SPRAAY_FREE_ESTIMATE_BATCH_PATH}" + assert call[1]["params"] == {"recipients": 150, "chain": "base", "amount": "1000.00"} + + parsed = json.loads(result) + assert parsed["success"] is True + assert parsed["estimate"]["estimate"]["estimatedGasUSD"] == 0.15 + + +def test_estimate_batch_omits_amount_when_absent(mock_wallet): + """Omit the amount query parameter when not provided.""" + provider = spraay_action_provider() + with patch( + f"{PROVIDER_MODULE}.requests.get", + return_value=make_response(200, {"estimate": {"estimatedGasUSD": 0.15}}), + ) as mock_get: + provider.spraay_estimate_batch(mock_wallet, {"recipients": 10, "chain": "base"}) + + assert mock_get.call_args[1]["params"] == {"recipients": 10, "chain": "base"} + + +# ========================================================= +# x402-metered gateway actions +# ========================================================= + +BATCH_ARGS = { + "token": "USDC", + "recipients": [{"recipient": MOCK_RECIPIENT_A, "amount": "1.00"}], + "chain": "base", +} + +CHALLENGE_402 = {"accepts": [{"network": "base", "asset": "0xusdc", "maxAmountRequired": "10000"}]} + + +def test_execute_batch_gateway_no_payment_needed(mock_wallet): + """Return directly when no payment is required.""" + provider = spraay_action_provider() + with patch( + f"{PROVIDER_MODULE}.requests.post", + return_value=make_response(200, {"executed": True, "batchId": "b-1"}), + ) as mock_post: + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + assert mock_post.call_count == 1 + assert ( + mock_post.call_args[0][0] == f"{SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_BATCH_EXECUTE_PATH}" + ) + # The gateway's execute handler expects {address, amount} entries plus sender + assert mock_post.call_args[1]["json"] == { + "token": "USDC", + "recipients": [{"address": MOCK_RECIPIENT_A, "amount": "1.00"}], + "sender": MOCK_WALLET_ADDRESS, + } + parsed = json.loads(result) + assert parsed["success"] is True + assert parsed["data"]["batchId"] == "b-1" + + +def test_execute_batch_gateway_respects_payment_limit(mock_wallet): + """Refuse to pay quotes above the configured limit.""" + provider = spraay_action_provider() + over_limit = { + "accepts": [{"network": "base", "asset": "0xusdc", "maxAmountRequired": "2000000"}] + } + with ( + patch(f"{PROVIDER_MODULE}.requests.post", return_value=make_response(402, over_limit)), + patch(f"{PROVIDER_MODULE}.x402_requests") as mock_x402, + ): + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + mock_x402.assert_not_called() + parsed = json.loads(result) + assert parsed["error"] is True + assert parsed["message"] == "Gateway payment exceeds limit" + assert "No payment was made" in parsed["details"] + + +def test_execute_batch_gateway_settles_with_wallet(mock_wallet): + """Settle the 402 challenge by signing with the wallet provider.""" + provider = spraay_action_provider() + proof = {"transaction": "0xproof", "network": "base"} + paid_response = make_response( + 200, + {"executed": True, "batchId": "b-2"}, + headers={"payment-response": base64.b64encode(json.dumps(proof).encode()).decode()}, + ) + session = Mock() + session.post.return_value = paid_response + + with ( + patch(f"{PROVIDER_MODULE}.requests.post", return_value=make_response(402, CHALLENGE_402)), + patch(f"{PROVIDER_MODULE}.x402ClientSync"), + patch(f"{PROVIDER_MODULE}.register_exact_evm_client"), + patch(f"{PROVIDER_MODULE}.EthAccountSigner"), + patch(f"{PROVIDER_MODULE}.x402_requests", return_value=session), + ): + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + session.post.assert_called_once() + parsed = json.loads(result) + assert parsed["success"] is True + assert "x402 payment" in parsed["message"] + assert parsed["paymentProof"] == proof + + +def test_execute_batch_gateway_prefunded_header(mock_wallet): + """Use a pre-funded payment header when configured.""" + provider = spraay_action_provider(SpraayConfig(x402_payment_header="prefunded-header")) + with ( + patch( + f"{PROVIDER_MODULE}.requests.post", + side_effect=[ + make_response(402, CHALLENGE_402), + make_response(200, {"executed": True}), + ], + ) as mock_post, + patch(f"{PROVIDER_MODULE}.x402_requests") as mock_x402, + ): + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + mock_x402.assert_not_called() + assert mock_post.call_count == 2 + retry_headers = mock_post.call_args_list[1][1]["headers"] + # v2 header with v1 fallback — the gateway middleware reads both + assert retry_headers["Payment-Signature"] == "prefunded-header" + assert retry_headers["X-PAYMENT"] == "prefunded-header" + assert "PAYMENT" not in retry_headers + parsed = json.loads(result) + assert parsed["success"] is True + + +def test_execute_batch_gateway_unreachable(mock_wallet): + """Report gateway errors without paying.""" + provider = spraay_action_provider() + with patch(f"{PROVIDER_MODULE}.requests.post", side_effect=ConnectionError("socket hang up")): + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + parsed = json.loads(result) + assert parsed["error"] is True + assert "Error calling the Spraay gateway" in parsed["message"] + + +def test_execute_batch_gateway_unsettled_payment(mock_wallet): + """Flag unsettled payments when the paid retry fails.""" + provider = spraay_action_provider() + session = Mock() + session.post.return_value = make_response(500, {"error": "internal"}) + + with ( + patch(f"{PROVIDER_MODULE}.requests.post", return_value=make_response(402, CHALLENGE_402)), + patch(f"{PROVIDER_MODULE}.x402ClientSync"), + patch(f"{PROVIDER_MODULE}.register_exact_evm_client"), + patch(f"{PROVIDER_MODULE}.EthAccountSigner"), + patch(f"{PROVIDER_MODULE}.x402_requests", return_value=session), + ): + result = provider.spraay_execute_batch_gateway(mock_wallet, BATCH_ARGS) + + parsed = json.loads(result) + assert parsed["error"] is True + assert "Payment was not settled" in parsed["message"] + + +def test_create_escrow_happy_path(mock_wallet): + """Create an escrow through the paid gateway endpoint.""" + provider = spraay_action_provider() + session = Mock() + session.post.return_value = make_response(200, {"escrow": {"id": "ESC-1"}, "status": "created"}) + + with ( + patch( + f"{PROVIDER_MODULE}.requests.post", return_value=make_response(402, CHALLENGE_402) + ) as mock_post, + patch(f"{PROVIDER_MODULE}.x402ClientSync"), + patch(f"{PROVIDER_MODULE}.register_exact_evm_client"), + patch(f"{PROVIDER_MODULE}.EthAccountSigner"), + patch(f"{PROVIDER_MODULE}.x402_requests", return_value=session), + ): + result = provider.spraay_create_escrow( + mock_wallet, + { + "token": "USDC", + "amount": "250.00", + "beneficiary": MOCK_RECIPIENT_A, + "arbiter": MOCK_TOKEN_ADDRESS, + "description": "Milestone 1", + "conditions": ["Design approved", "Dev complete"], + "expires_in": 72, + }, + ) + + assert ( + mock_post.call_args[0][0] == f"{SPRAAY_GATEWAY_BASE_URL}{SPRAAY_GATEWAY_ESCROW_CREATE_PATH}" + ) + # The gateway requires depositor/beneficiary/token/amount; depositor + # defaults to the connected wallet address + assert mock_post.call_args[1]["json"] == { + "depositor": MOCK_WALLET_ADDRESS, + "beneficiary": MOCK_RECIPIENT_A, + "token": "USDC", + "amount": "250.00", + "arbiter": MOCK_TOKEN_ADDRESS, + "description": "Milestone 1", + "conditions": ["Design approved", "Dev complete"], + "expiresIn": 72, + } + + parsed = json.loads(result) + assert parsed["success"] is True + assert parsed["data"]["escrow"]["id"] == "ESC-1" + + +def test_create_escrow_rejects_same_depositor_and_beneficiary(mock_wallet): + """Reject depositor == beneficiary before paying anything.""" + provider = spraay_action_provider() + with ( + patch(f"{PROVIDER_MODULE}.requests.post") as mock_post, + patch(f"{PROVIDER_MODULE}.x402_requests") as mock_x402, + ): + result = provider.spraay_create_escrow( + mock_wallet, + {"token": "USDC", "amount": "250.00", "beneficiary": MOCK_WALLET_ADDRESS}, + ) + + mock_post.assert_not_called() + mock_x402.assert_not_called() + parsed = json.loads(result) + assert parsed["error"] is True + assert "cannot be the same" in parsed["message"] + assert "No payment was made" in parsed["details"] + + +def test_create_escrow_respects_payment_limit(mock_wallet): + """Respect the payment limit for escrow creation.""" + provider = spraay_action_provider() + over_limit = { + "accepts": [{"network": "base", "asset": "0xusdc", "maxAmountRequired": "5000000"}] + } + with ( + patch(f"{PROVIDER_MODULE}.requests.post", return_value=make_response(402, over_limit)), + patch(f"{PROVIDER_MODULE}.x402_requests") as mock_x402, + ): + result = provider.spraay_create_escrow( + mock_wallet, + {"token": "USDC", "amount": "250.00", "beneficiary": MOCK_RECIPIENT_A}, + ) + + mock_x402.assert_not_called() + parsed = json.loads(result) + assert parsed["error"] is True + assert parsed["message"] == "Gateway payment exceeds limit" diff --git a/typescript/.changeset/spraay-batch-payments.md b/typescript/.changeset/spraay-batch-payments.md new file mode 100644 index 000000000..ca588e862 --- /dev/null +++ b/typescript/.changeset/spraay-batch-payments.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Added Spraay action provider: atomic batch payments (ETH/ERC-20, up to 200 recipients) on Base with EIP-2612 permit-optimized approvals, free gateway pre-flight validation and cost estimation, x402-metered gateway batch execution, and escrow creation diff --git a/typescript/agentkit/README.md b/typescript/agentkit/README.md index 37b14207f..966bd90eb 100644 --- a/typescript/agentkit/README.md +++ b/typescript/agentkit/README.md @@ -555,6 +555,43 @@ const agent = createAgent({
+Spraay + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
spraay_ethSends equal amounts of ETH to up to 200 recipients in a single atomic transaction via the Spraay batch contract on Base.
spraay_tokenSends equal amounts of an ERC-20 token to up to 200 recipients atomically, with EIP-2612 permit-optimized approvals (approve fallback).
spraay_eth_variableSends different ETH amounts to each recipient in a single atomic batch transaction.
spraay_token_variableSends different ERC-20 amounts to each recipient in a single atomic batch transaction.
spraay_validate_batchValidates a batch against the free Spraay gateway pre-flight endpoint before signing anything.
spraay_estimate_batchEstimates batch execution cost via the free Spraay gateway endpoint.
spraay_execute_batch_gatewayExecutes a batch through the x402-metered Spraay gateway (POST /api/v1/batch/execute; pricing via 402 challenge).
spraay_create_escrowCreates an escrow through the x402-metered Spraay gateway (POST /api/v1/escrow/create; pricing via 402 challenge).
+
+
Superfluid diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 9f7164086..85bd850f1 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -41,3 +41,5 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; + +export { spraayActionProvider, SpraayActionProvider } from "./spraay"; diff --git a/typescript/agentkit/src/action-providers/spraay/README.md b/typescript/agentkit/src/action-providers/spraay/README.md new file mode 100644 index 000000000..76d71cd42 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/README.md @@ -0,0 +1,73 @@ +# Spraay Action Provider + +This directory contains **SpraayActionProvider**, payment coordination for AgentKit agents on Base. Batch payments are the core capability — send ETH or any ERC-20 token to up to 200 recipients with per-recipient amounts, atomically, in a single transaction — with escrow creation via the Spraay gateway as the complementary second pillar. + +Contract: [`0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC`](https://basescan.org/address/0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC) (Base Mainnet) · Gateway: `https://gateway.spraay.app` · Website: [spraay.app](https://spraay.app) + +## Directory Structure + +``` +spraay/ +├── spraayActionProvider.ts # Provider: on-chain batch, permit handling, gateway actions +├── spraayActionProvider.test.ts +├── schemas.ts # Zod schemas and provider config +├── constants.ts # Contract address/ABI, gateway endpoints, EIP-2612 types +├── index.ts # Public exports +└── README.md # This file +``` + +## Setup + +```typescript +import { spraayActionProvider } from "@coinbase/agentkit"; + +const agentKit = await AgentKit.from({ + walletProvider, + actionProviders: [ + spraayActionProvider({ + // Max x402 payment per gateway request in whole USDC (default 1.0, + // or SPRAAY_MAX_GATEWAY_PAYMENT_USDC env var) + maxGatewayPaymentUsdc: 0.5, + // Optional pre-funded x402 payment header, sent instead of signing + // a payment with the wallet provider + // x402PaymentHeader: process.env.SPRAAY_X402_PAYMENT_HEADER, + }), + ], +}); +``` + +## Batch payment actions (direct on-chain) + +The agent signs the batch transaction and pays gas itself on Base. All batches are atomic: every transfer succeeds or the whole transaction reverts. Up to 200 recipients per transaction; the protocol fee (default 0.3%, read live from the contract) is added on top. Recipient lists are validated for case-insensitive duplicates before anything is signed. + +- `spraay_eth`: Equal ETH amounts to every recipient. +- `spraay_token`: Equal ERC-20 amounts to every recipient. +- `spraay_eth_variable`: Per-recipient ETH amounts. +- `spraay_token_variable`: Per-recipient ERC-20 amounts. + +**EIP-2612 permit:** for ERC-20 batches, allowance handling prefers a signed permit when the token supports it (detected at runtime via `nonces()`/`version()` — no hardcoded token list; USDC on Base qualifies). The permit grants an exact, deadline-bounded allowance instead of a standing approve. Non-permit tokens fall back cleanly to `approve`. + +**Pre-flight (`preflight: true`):** validates the batch against the free gateway endpoint before signing. An explicit "invalid" verdict aborts before any signature; gateway unavailability never blocks the on-chain path. + +## Gateway pre-flight actions (free, no payment) + +- `spraay_validate_batch`: `POST https://gateway.spraay.app/free/validate-batch` — validates a batch (BPA 1.0 body; the gateway expects a `recipients` array of `{to, amount}` entries, which the provider maps from its uniform `{recipient, amount}` input) and returns `valid`/`errors`/`warnings`/`summary`. +- `spraay_estimate_batch`: `GET https://gateway.spraay.app/free/estimate-batch?recipients=&chain=&amount=` — rough gas and protocol-fee estimate for a batch of a given size (the optional total `amount` enables the fee figures). + +These make agents safer: validate and cost a batch before signing anything. + +## Gateway execution and escrow (x402-metered, paid) + +- `spraay_execute_batch_gateway`: `POST https://gateway.spraay.app/api/v1/batch/execute` — the gateway executes the batch (body: `token`, `recipients` as `{address, amount}` entries mapped from the provider's uniform input, and the wallet's address as `sender`); the agent pays a metered USDC fee via the [x402 protocol](https://x402.org) (pricing returned via 402 challenge; `POST /api/v1/batch/estimate` quotes the same way). Multi-chain capable, no gas management for the agent. +- `spraay_create_escrow`: `POST https://gateway.spraay.app/api/v1/escrow/create` — locks funds from a depositor (defaults to the connected wallet) for a beneficiary, with optional arbiter, release conditions, and expiry in hours. Creation only; the gateway's `POST /api/v1/escrow/fund`, `/release`, and `/cancel` endpoints handle the rest of the lifecycle. + +Payments are settled either by signing with the wallet provider (`@x402/fetch`) or with a pre-funded `x402PaymentHeader` from config, and are capped by `maxGatewayPaymentUsdc`. Payment is never faked or stubbed. + +## Network Support + +Base mainnet (`base-mainnet`) only — that is where the batch contract is deployed. Gateway execution is multi-chain capable on the gateway side, but the provider itself registers on Base EVM wallets. + +## Notes + +- Amounts are whole units (e.g. `"0.01"` ETH, `"100"` USDC); decimals are read from the token contract. +- Works with any `EvmWalletProvider` (CDP EVM wallets, smart wallets, viem wallets). If a wallet cannot sign EIP-712 typed data, permit silently falls back to `approve`. diff --git a/typescript/agentkit/src/action-providers/spraay/constants.ts b/typescript/agentkit/src/action-providers/spraay/constants.ts new file mode 100644 index 000000000..607780569 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/constants.ts @@ -0,0 +1,208 @@ +/** + * Spraay batch payment contract (SprayContract) address on Base Mainnet. + * Verified on BaseScan / Blockscout. + */ +export const SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" as const; + +/** + * Default Spraay protocol fee in basis points (0.3% = 30 bps). + * The live value is read from the contract's `feeBps` view; this constant is the fallback. + */ +export const SPRAAY_PROTOCOL_FEE_BPS = 30; + +/** + * Maximum number of recipients per transaction (contract-enforced MAX_RECIPIENTS). + */ +export const SPRAAY_MAX_RECIPIENTS = 200; + +/** + * Zero address, used by `sprayEqual` to select native ETH transfers. + */ +export const ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" as const; + +/** + * Spraay gateway base URL. Hosts free pre-flight endpoints (validate/estimate) + * and x402-metered execution endpoints (batch execute/estimate, escrow create). + */ +export const SPRAAY_GATEWAY_BASE_URL = "https://gateway.spraay.app"; + +/** + * Free (no payment required) gateway endpoint paths. + */ +export const SPRAAY_FREE_VALIDATE_BATCH_PATH = "/free/validate-batch"; +export const SPRAAY_FREE_ESTIMATE_BATCH_PATH = "/free/estimate-batch"; + +/** + * x402-metered (paid) gateway endpoint paths. Pricing is returned via a + * 402 Payment Required challenge on first request. + */ +export const SPRAAY_GATEWAY_BATCH_EXECUTE_PATH = "/api/v1/batch/execute"; +export const SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH = "/api/v1/batch/estimate"; +export const SPRAAY_GATEWAY_ESCROW_CREATE_PATH = "/api/v1/escrow/create"; + +/** + * Batch Payment Aggregate (BPA) schema version used by the Spraay gateway. + */ +export const SPRAAY_BPA_VERSION = "1.0"; + +/** + * SprayContract ABI — matches the verified contract deployed at + * SPRAAY_CONTRACT_ADDRESS. Variable-amount functions take an array of + * (recipient, amount) structs; `sprayEqual` covers the uniform-amount case + * for both ETH (token = zero address) and ERC-20 tokens. + */ +export const SPRAAY_ABI = [ + { + name: "sprayETH", + type: "function", + stateMutability: "payable", + inputs: [ + { + name: "recipients", + type: "tuple[]", + components: [ + { name: "recipient", type: "address" }, + { name: "amount", type: "uint256" }, + ], + }, + ], + outputs: [], + }, + { + name: "sprayToken", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "token", type: "address" }, + { + name: "recipients", + type: "tuple[]", + components: [ + { name: "recipient", type: "address" }, + { name: "amount", type: "uint256" }, + ], + }, + ], + outputs: [], + }, + { + name: "sprayEqual", + type: "function", + stateMutability: "payable", + inputs: [ + { name: "token", type: "address" }, + { name: "recipients", type: "address[]" }, + { name: "amountPerRecipient", type: "uint256" }, + ], + outputs: [], + }, + { + name: "feeBps", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "calculateTotalCost", + type: "function", + stateMutability: "view", + inputs: [{ name: "totalAmount", type: "uint256" }], + outputs: [{ name: "", type: "uint256" }], + }, +] as const; + +/** + * ERC-20 ABI fragments needed for approvals, metadata lookups, and + * EIP-2612 permit support (nonces/version/permit). + */ +export const ERC20_ABI = [ + { + name: "approve", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "decimals", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint8" }], + }, + { + name: "symbol", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + name: "name", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + name: "version", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, + { + name: "nonces", + type: "function", + stateMutability: "view", + inputs: [{ name: "owner", type: "address" }], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "permit", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "deadline", type: "uint256" }, + { name: "v", type: "uint8" }, + { name: "r", type: "bytes32" }, + { name: "s", type: "bytes32" }, + ], + outputs: [], + }, +] as const; + +/** + * EIP-2612 Permit typed-data structure. + */ +export const EIP2612_PERMIT_TYPES = { + Permit: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + { name: "value", type: "uint256" }, + { name: "nonce", type: "uint256" }, + { name: "deadline", type: "uint256" }, + ], +} as const; + +/** + * Validity window for EIP-2612 permit signatures, in seconds. + */ +export const PERMIT_DEADLINE_SECONDS = 1800; diff --git a/typescript/agentkit/src/action-providers/spraay/index.ts b/typescript/agentkit/src/action-providers/spraay/index.ts new file mode 100644 index 000000000..132e4b37e --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/index.ts @@ -0,0 +1,19 @@ +export { SpraayActionProvider, spraayActionProvider } from "./spraayActionProvider"; +export { + SprayEthSchema, + SprayTokenSchema, + SprayEthVariableSchema, + SprayTokenVariableSchema, + SpraayValidateBatchSchema, + SpraayEstimateBatchSchema, + SpraayExecuteBatchGatewaySchema, + SpraayCreateEscrowSchema, +} from "./schemas"; +export type { SpraayActionProviderConfig } from "./schemas"; +export { + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_ABI, + SPRAAY_PROTOCOL_FEE_BPS, + SPRAAY_MAX_RECIPIENTS, + SPRAAY_GATEWAY_BASE_URL, +} from "./constants"; diff --git a/typescript/agentkit/src/action-providers/spraay/schemas.ts b/typescript/agentkit/src/action-providers/spraay/schemas.ts new file mode 100644 index 000000000..9efd7f232 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/schemas.ts @@ -0,0 +1,251 @@ +import { z } from "zod"; +import { SPRAAY_MAX_RECIPIENTS } from "./constants"; + +/** + * Configuration options for the SpraayActionProvider. + */ +export interface SpraayActionProviderConfig { + /** + * Maximum x402 payment per gateway request, in USDC whole units. + * Default: 1.0 (or SPRAAY_MAX_GATEWAY_PAYMENT_USDC env var). + */ + maxGatewayPaymentUsdc?: number; + + /** + * Optional pre-funded x402 payment header. When set, gateway requests send + * this value in the X-PAYMENT header instead of signing a payment with the + * wallet provider. Useful when payments are settled out-of-band. + */ + x402PaymentHeader?: string; + + /** + * Override for the Spraay gateway base URL. Defaults to the production + * gateway; intended for testing and staging environments. + */ + gatewayBaseUrl?: string; +} + +const evmAddress = z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address"); + +const positiveDecimalString = z + .string() + .regex(/^\d+(\.\d+)?$/, "Amount must be a positive decimal number string") + .refine(value => parseFloat(value) > 0, "Amount must be greater than zero"); + +/** + * Adds a validation issue for every case-insensitive duplicate address. + * + * @param addresses - The list of addresses to check + * @param ctx - The Zod refinement context to report issues on + */ +const checkNoDuplicateAddresses = (addresses: string[], ctx: z.RefinementCtx) => { + const seen = new Set(); + for (const address of addresses) { + const normalized = address.toLowerCase(); + if (seen.has(normalized)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Duplicate recipient address: ${address}`, + }); + } + seen.add(normalized); + } +}; + +const recipientAddresses = z + .array(evmAddress) + .min(1, "At least one recipient is required") + .max(SPRAAY_MAX_RECIPIENTS, `Maximum ${SPRAAY_MAX_RECIPIENTS} recipients per transaction`) + .superRefine(checkNoDuplicateAddresses) + .describe("Array of recipient wallet addresses (e.g. ['0xABC...', '0xDEF...'])"); + +const preflightFlag = z + .boolean() + .optional() + .describe( + "When true, validate the batch against the free Spraay gateway pre-flight endpoint before signing. Pre-flight failures caused by gateway unavailability do not block the on-chain transaction.", + ); + +/** + * Schema for spraying ETH to multiple recipients. + */ +export const SprayEthSchema = z + .object({ + recipients: recipientAddresses, + amountPerRecipient: positiveDecimalString.describe( + "Amount of ETH to send to each recipient, in whole units (e.g. '0.01' for 0.01 ETH)", + ), + preflight: preflightFlag, + }) + .strip() + .describe("Input schema for spraying ETH to multiple recipients in a single transaction"); + +/** + * Schema for spraying ERC-20 tokens to multiple recipients. + */ +export const SprayTokenSchema = z + .object({ + tokenAddress: evmAddress.describe("The ERC-20 token contract address"), + recipients: recipientAddresses, + amountPerRecipient: positiveDecimalString.describe( + "Amount of tokens to send to each recipient, in whole units (e.g. '100' for 100 USDC)", + ), + preflight: preflightFlag, + }) + .strip() + .describe( + "Input schema for spraying ERC-20 tokens to multiple recipients in a single transaction", + ); + +/** + * Schema for spraying ETH with variable amounts per recipient. + */ +export const SprayEthVariableSchema = z + .object({ + recipients: recipientAddresses, + amounts: z + .array(positiveDecimalString) + .min(1, "At least one amount is required") + .describe( + "Array of ETH amounts corresponding to each recipient, in whole units (e.g. ['0.01', '0.05'])", + ), + preflight: preflightFlag, + }) + .strip() + .describe( + "Input schema for spraying variable amounts of ETH to multiple recipients in a single transaction", + ); + +/** + * Schema for spraying ERC-20 tokens with variable amounts per recipient. + */ +export const SprayTokenVariableSchema = z + .object({ + tokenAddress: evmAddress.describe("The ERC-20 token contract address"), + recipients: recipientAddresses, + amounts: z + .array(positiveDecimalString) + .min(1, "At least one amount is required") + .describe( + "Array of token amounts corresponding to each recipient, in whole units (e.g. ['100', '50'])", + ), + preflight: preflightFlag, + }) + .strip() + .describe( + "Input schema for spraying variable amounts of ERC-20 tokens to multiple recipients in a single transaction", + ); + +const bpaRecipients = z + .array( + z.object({ + recipient: evmAddress.describe("Recipient wallet address"), + amount: positiveDecimalString.describe( + "Amount for this recipient, in whole token units (e.g. '1.00')", + ), + }), + ) + .min(1, "At least one recipient is required") + .max(SPRAAY_MAX_RECIPIENTS, `Maximum ${SPRAAY_MAX_RECIPIENTS} recipients per batch`) + .superRefine((entries, ctx) => + checkNoDuplicateAddresses( + entries.map(entry => entry.recipient), + ctx, + ), + ) + .describe("Batch entries as (recipient, amount) pairs"); + +const bpaToken = z.string().min(1).describe("Token symbol for the batch (e.g. 'USDC' or 'ETH')"); + +const bpaChain = z.string().default("base").describe("Target chain identifier (default 'base')"); + +/** + * Schema for validating a batch via the free Spraay gateway pre-flight endpoint. + */ +export const SpraayValidateBatchSchema = z + .object({ + token: bpaToken, + recipients: bpaRecipients, + chain: bpaChain, + }) + .strip() + .describe("Input schema for validating a batch payment via the free Spraay gateway endpoint"); + +/** + * Schema for estimating batch cost via the free Spraay gateway endpoint. + */ +export const SpraayEstimateBatchSchema = z + .object({ + recipients: z + .number() + .int() + .positive() + .max(SPRAAY_MAX_RECIPIENTS, `Maximum ${SPRAAY_MAX_RECIPIENTS} recipients per batch`) + .describe("Number of recipients in the batch (positive integer count)"), + chain: bpaChain, + amount: positiveDecimalString + .optional() + .describe( + "Optional total batch amount in whole units; when provided, the estimate includes the protocol fee in USD", + ), + }) + .strip() + .describe( + "Input schema for estimating batch execution cost via the free Spraay gateway endpoint", + ); + +/** + * Schema for executing a batch through the x402-metered Spraay gateway. + */ +export const SpraayExecuteBatchGatewaySchema = z + .object({ + token: bpaToken, + recipients: bpaRecipients, + chain: bpaChain, + }) + .strip() + .describe("Input schema for executing a batch payment via the x402-metered Spraay gateway"); + +/** + * Schema for creating an escrow through the x402-metered Spraay gateway. + */ +export const SpraayCreateEscrowSchema = z + .object({ + token: z + .string() + .min(1) + .describe( + "Escrow token: a symbol (USDC, USDT, DAI, EURC, WETH) or an ERC-20 contract address on Base", + ), + amount: positiveDecimalString.describe("Escrow amount, in whole token units (e.g. '250.00')"), + beneficiary: evmAddress.describe( + "Wallet address that can receive the escrowed funds on release", + ), + depositor: evmAddress + .optional() + .describe( + "Wallet address funding the escrow; defaults to the connected wallet address. Must differ from the beneficiary.", + ), + arbiter: evmAddress + .optional() + .describe("Optional third-party address allowed to release or cancel the escrow"), + description: z + .string() + .max(500) + .optional() + .describe("Optional human-readable description of the escrow terms"), + conditions: z + .array(z.string().max(200)) + .max(20) + .optional() + .describe("Optional list of release conditions (e.g. ['Design approved', 'Dev complete'])"), + expiresIn: z + .number() + .positive() + .optional() + .describe( + "Optional expiry in hours after which the escrow can no longer be funded or released (gateway default: 168)", + ), + }) + .strip() + .describe("Input schema for creating an escrow via the x402-metered Spraay gateway"); diff --git a/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.test.ts b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.test.ts new file mode 100644 index 000000000..a08484459 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.test.ts @@ -0,0 +1,851 @@ +import { encodeFunctionData, parseUnits } from "viem"; +import { wrapFetchWithPayment } from "@x402/fetch"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { SpraayActionProvider } from "./spraayActionProvider"; +import { + SprayEthSchema, + SprayTokenSchema, + SpraayValidateBatchSchema, + SpraayEstimateBatchSchema, + SpraayCreateEscrowSchema, +} from "./schemas"; +import { + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_ABI, + ZERO_ADDRESS, + SPRAAY_BPA_VERSION, + SPRAAY_FREE_VALIDATE_BATCH_PATH, + SPRAAY_FREE_ESTIMATE_BATCH_PATH, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + SPRAAY_GATEWAY_ESCROW_CREATE_PATH, + SPRAAY_GATEWAY_BASE_URL, +} from "./constants"; + +jest.mock("@x402/fetch"); +jest.mock("@x402/evm/exact/client"); + +// Mock global fetch +const mockFetch = jest.fn(); +global.fetch = mockFetch; + +const mockFetchWithPayment = jest.fn(); +jest.mocked(wrapFetchWithPayment).mockReturnValue(mockFetchWithPayment); + +// Mock the wallet provider +const mockSendTransaction = jest.fn(); +const mockWaitForTransactionReceipt = jest.fn(); +const mockGetAddress = jest.fn(); +const mockReadContract = jest.fn(); +const mockSignTypedData = jest.fn(); +const mockToSigner = jest.fn(); + +const mockWalletProvider = { + sendTransaction: mockSendTransaction, + waitForTransactionReceipt: mockWaitForTransactionReceipt, + getAddress: mockGetAddress, + readContract: mockReadContract, + signTypedData: mockSignTypedData, + toSigner: mockToSigner, + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + chainId: "8453", + }), +} as unknown as EvmWalletProvider; + +const WALLET_ADDRESS = "0x1234567890123456789012345678901234567890"; +const RECIPIENT_A = "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const RECIPIENT_B = "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const TOKEN_ADDRESS = "0xcccccccccccccccccccccccccccccccccccccccc"; +const MOCK_SIGNATURE = `0x${"11".repeat(32)}${"22".repeat(32)}1b` as `0x${string}`; + +/** + * Creates a mock fetch Response. + * + * @param options - The status, data, and headers for the response. + * @param options.status - The HTTP status code. + * @param options.data - The JSON body. + * @param options.headers - Additional response headers. + * @returns A Response-like object. + */ +const createMockResponse = (options: { + status: number; + data?: unknown; + headers?: Record; +}): Response => { + const headersMap = new Map( + Object.entries({ "content-type": "application/json", ...(options.headers ?? {}) }), + ); + return { + status: options.status, + ok: options.status >= 200 && options.status < 300, + headers: { + get: (name: string) => headersMap.get(name.toLowerCase()) ?? null, + }, + json: jest.fn().mockResolvedValue(options.data), + text: jest.fn().mockResolvedValue(JSON.stringify(options.data)), + } as unknown as Response; +}; + +/** + * Configures readContract responses keyed by functionName. + * + * @param overrides - Return values (or Error to throw) per function name. + */ +const setupReadContract = (overrides: Record = {}) => { + const defaults: Record = { + feeBps: 30n, + decimals: 6, + symbol: "USDC", + name: "USD Coin", + version: "2", + nonces: 0n, + }; + const values = { ...defaults, ...overrides }; + // Default allowance is stateful: 0 before the permit/approve lands, large after. + let allowanceReads = 0; + mockReadContract.mockImplementation(({ functionName }: { functionName: string }) => { + if (functionName === "allowance" && !("allowance" in overrides)) { + allowanceReads += 1; + return Promise.resolve(allowanceReads === 1 ? 0n : 2n ** 255n); + } + const value = values[functionName]; + if (value instanceof Error) { + return Promise.reject(value); + } + return Promise.resolve(value); + }); +}; + +describe("SpraayActionProvider", () => { + let provider: SpraayActionProvider; + + beforeEach(() => { + provider = new SpraayActionProvider(); + jest.clearAllMocks(); + jest.mocked(wrapFetchWithPayment).mockReturnValue(mockFetchWithPayment); + + mockGetAddress.mockReturnValue(WALLET_ADDRESS); + mockSendTransaction.mockResolvedValue("0xmocktxhash123"); + mockWaitForTransactionReceipt.mockResolvedValue({ blockNumber: 12345n }); + mockSignTypedData.mockResolvedValue(MOCK_SIGNATURE); + mockToSigner.mockReturnValue({}); + setupReadContract(); + }); + + describe("supportsNetwork", () => { + it("should support Base mainnet", () => { + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" })).toBe( + true, + ); + }); + + it("should not support other networks", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }), + ).toBe(false); + expect(provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" })).toBe( + false, + ); + expect(provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" })).toBe( + false, + ); + }); + }); + + describe("input schemas", () => { + it("should accept a valid ETH batch", () => { + const result = SprayEthSchema.safeParse({ + recipients: [RECIPIENT_A, RECIPIENT_B], + amountPerRecipient: "0.01", + }); + expect(result.success).toBe(true); + }); + + it("should reject empty recipient lists", () => { + const result = SprayEthSchema.safeParse({ recipients: [], amountPerRecipient: "0.01" }); + expect(result.success).toBe(false); + }); + + it("should reject more than 200 recipients", () => { + const recipients = Array.from( + { length: 201 }, + (_, i) => `0x${i.toString(16).padStart(40, "0")}`, + ); + const result = SprayEthSchema.safeParse({ recipients, amountPerRecipient: "0.01" }); + expect(result.success).toBe(false); + }); + + it("should reject malformed addresses", () => { + const result = SprayEthSchema.safeParse({ + recipients: ["0xnotanaddress"], + amountPerRecipient: "0.01", + }); + expect(result.success).toBe(false); + }); + + it("should reject case-normalized duplicate recipients", () => { + const result = SprayEthSchema.safeParse({ + recipients: [RECIPIENT_A, RECIPIENT_A.toUpperCase().replace("0X", "0x")], + amountPerRecipient: "0.01", + }); + expect(result.success).toBe(false); + }); + + it("should reject non-positive and malformed amounts", () => { + expect( + SprayEthSchema.safeParse({ recipients: [RECIPIENT_A], amountPerRecipient: "0" }).success, + ).toBe(false); + expect( + SprayEthSchema.safeParse({ recipients: [RECIPIENT_A], amountPerRecipient: "-1" }).success, + ).toBe(false); + expect( + SprayEthSchema.safeParse({ recipients: [RECIPIENT_A], amountPerRecipient: "abc" }).success, + ).toBe(false); + }); + + it("should reject duplicate amounts arrays of the wrong shape for token batches", () => { + const result = SprayTokenSchema.safeParse({ + tokenAddress: "not-an-address", + recipients: [RECIPIENT_A], + amountPerRecipient: "1", + }); + expect(result.success).toBe(false); + }); + + it("should reject duplicate recipients in gateway batches", () => { + const result = SpraayValidateBatchSchema.safeParse({ + token: "USDC", + recipients: [ + { recipient: RECIPIENT_A, amount: "1.00" }, + { recipient: RECIPIENT_A.toLowerCase(), amount: "2.00" }, + ], + }); + expect(result.success).toBe(false); + }); + + it("should default chain to base for gateway batches", () => { + const result = SpraayValidateBatchSchema.parse({ + token: "USDC", + recipients: [{ recipient: RECIPIENT_A, amount: "1.00" }], + }); + expect(result.chain).toBe("base"); + }); + + it("should bound the estimate recipient count", () => { + expect(SpraayEstimateBatchSchema.safeParse({ recipients: 0 }).success).toBe(false); + expect(SpraayEstimateBatchSchema.safeParse({ recipients: 201 }).success).toBe(false); + expect(SpraayEstimateBatchSchema.safeParse({ recipients: 50 }).success).toBe(true); + expect( + SpraayEstimateBatchSchema.safeParse({ recipients: 50, amount: "1000.00" }).success, + ).toBe(true); + expect(SpraayEstimateBatchSchema.safeParse({ recipients: 50, amount: "-1" }).success).toBe( + false, + ); + }); + + it("should validate escrow input", () => { + expect( + SpraayCreateEscrowSchema.safeParse({ + token: "USDC", + amount: "250.00", + beneficiary: RECIPIENT_A, + }).success, + ).toBe(true); + expect( + SpraayCreateEscrowSchema.safeParse({ + token: "USDC", + amount: "250.00", + beneficiary: RECIPIENT_A, + depositor: RECIPIENT_B, + arbiter: TOKEN_ADDRESS, + conditions: ["Design approved"], + expiresIn: 72, + }).success, + ).toBe(true); + expect( + SpraayCreateEscrowSchema.safeParse({ + token: "USDC", + amount: "0", + beneficiary: RECIPIENT_A, + }).success, + ).toBe(false); + expect( + SpraayCreateEscrowSchema.safeParse({ + token: "USDC", + amount: "250.00", + beneficiary: RECIPIENT_A, + expiresIn: -5, + }).success, + ).toBe(false); + }); + }); + + describe("sprayEth", () => { + it("should spray ETH via sprayEqual with the zero address", async () => { + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A, RECIPIENT_B], + amountPerRecipient: "0.01", + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + const callArgs = mockSendTransaction.mock.calls[0][0]; + expect(callArgs.to).toBe(SPRAAY_CONTRACT_ADDRESS); + expect(callArgs.data).toBe( + encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayEqual", + args: [ZERO_ADDRESS, [RECIPIENT_A, RECIPIENT_B], parseUnits("0.01", 18)], + }), + ); + expect(mockWaitForTransactionReceipt).toHaveBeenCalledWith("0xmocktxhash123"); + expect(result).toContain("Successfully sprayed"); + expect(result).toContain("2 recipients"); + expect(result).toContain("basescan.org"); + }); + + it("should include the live protocol fee in the total value", async () => { + await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "1", + }); + + const callArgs = mockSendTransaction.mock.calls[0][0]; + // 1 ETH + 0.3% fee = 1.003 ETH in wei + expect(callArgs.value).toBe(parseUnits("1.003", 18)); + }); + + it("should fall back to the default fee when the feeBps read fails", async () => { + setupReadContract({ feeBps: new Error("no rpc") }); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "1", + }); + + const callArgs = mockSendTransaction.mock.calls[0][0]; + expect(callArgs.value).toBe(parseUnits("1.003", 18)); + expect(result).toContain("Successfully sprayed"); + }); + + it("should return error message on failure", async () => { + mockSendTransaction.mockRejectedValue(new Error("Insufficient funds")); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "100", + }); + + expect(result).toContain("Error spraying ETH"); + expect(result).toContain("Insufficient funds"); + }); + }); + + describe("preflight", () => { + it("should proceed on-chain when the gateway is unreachable", async () => { + mockFetch.mockRejectedValue(new Error("connection refused")); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "0.01", + preflight: true, + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(result).toContain("Pre-flight validation skipped"); + expect(result).toContain("Successfully sprayed"); + }); + + it("should abort before signing when the gateway reports the batch invalid", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 200, + data: { valid: false, errors: ["recipient 0 is a contract"] }, + }), + ); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "0.01", + preflight: true, + }); + + expect(mockSendTransaction).not.toHaveBeenCalled(); + expect(result).toContain("failed Spraay gateway pre-flight validation"); + expect(result).toContain("no transaction was signed"); + }); + + it("should surface the pre-flight report on success", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ status: 200, data: { valid: true, warnings: [] } }), + ); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [RECIPIENT_A], + amountPerRecipient: "0.01", + preflight: true, + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(result).toContain("Pre-flight validation"); + expect(result).toContain("Successfully sprayed"); + }); + }); + + describe("sprayToken", () => { + it("should use an EIP-2612 permit when the token supports it", async () => { + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A, RECIPIENT_B], + amountPerRecipient: "100", + }); + + // permit tx + spray tx + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + expect(mockSignTypedData).toHaveBeenCalledTimes(1); + const typedData = mockSignTypedData.mock.calls[0][0]; + expect(typedData.primaryType).toBe("Permit"); + expect(typedData.domain).toMatchObject({ + name: "USD Coin", + version: "2", + chainId: 8453, + verifyingContract: TOKEN_ADDRESS, + }); + expect(typedData.message.spender).toBe(SPRAAY_CONTRACT_ADDRESS); + expect(result).toContain("EIP-2612 permit"); + expect(result).toContain("Successfully sprayed"); + expect(result).toContain("USDC"); + }); + + it("should fall back to approve for non-permit tokens", async () => { + setupReadContract({ + nonces: new Error("execution reverted"), + symbol: "DAI", + name: "Dai Stablecoin", + decimals: 18, + }); + + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A], + amountPerRecipient: "100", + }); + + // approve tx + spray tx + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + expect(mockSignTypedData).not.toHaveBeenCalled(); + expect(result).toContain("Token approval granted"); + expect(result).toContain("does not support EIP-2612 permit"); + expect(result).toContain("Successfully sprayed"); + }); + + it("should fall back to approve when the permit does not take effect on-chain", async () => { + // Allowance stays 0 even after the permit tx (e.g. an ERC-1271 smart-wallet + // signature that permit's ecrecover does not accept). + setupReadContract({ allowance: 0n }); + + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A], + amountPerRecipient: "100", + }); + + // permit tx + approve tx + spray tx + expect(mockSendTransaction).toHaveBeenCalledTimes(3); + expect(result).toContain("Token approval granted"); + expect(result).toContain("Successfully sprayed"); + }); + + it("should fall back to approve when typed-data signing fails", async () => { + mockSignTypedData.mockRejectedValue(new Error("signTypedData not supported")); + + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A], + amountPerRecipient: "100", + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + expect(result).toContain("Token approval granted"); + expect(result).toContain("Successfully sprayed"); + }); + + it("should skip allowance handling if the allowance is sufficient", async () => { + setupReadContract({ allowance: parseUnits("1000000", 6) }); + + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A], + amountPerRecipient: "10", + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(mockSignTypedData).not.toHaveBeenCalled(); + expect(result).not.toContain("permit"); + expect(result).not.toContain("approval"); + }); + }); + + describe("sprayEthVariable", () => { + it("should spray variable ETH amounts using the struct-based sprayETH", async () => { + const result = await provider.sprayEthVariable(mockWalletProvider, { + recipients: [RECIPIENT_A, RECIPIENT_B], + amounts: ["0.01", "0.05"], + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + const callArgs = mockSendTransaction.mock.calls[0][0]; + expect(callArgs.data).toBe( + encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayETH", + args: [ + [ + { recipient: RECIPIENT_A, amount: parseUnits("0.01", 18) }, + { recipient: RECIPIENT_B, amount: parseUnits("0.05", 18) }, + ], + ], + }), + ); + expect(result).toContain("Successfully sprayed variable ETH"); + expect(result).toContain("2 recipients"); + }); + + it("should reject mismatched arrays", async () => { + const result = await provider.sprayEthVariable(mockWalletProvider, { + recipients: [RECIPIENT_A, RECIPIENT_B], + amounts: ["0.01"], + }); + + expect(result).toContain("Error: recipients array length"); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + }); + + describe("sprayTokenVariable", () => { + it("should spray variable token amounts with permit and struct encoding", async () => { + const result = await provider.sprayTokenVariable(mockWalletProvider, { + tokenAddress: TOKEN_ADDRESS, + recipients: [RECIPIENT_A, RECIPIENT_B], + amounts: ["100", "200"], + }); + + // permit tx + spray tx + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + const sprayCall = mockSendTransaction.mock.calls[1][0]; + expect(sprayCall.data).toBe( + encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayToken", + args: [ + TOKEN_ADDRESS, + [ + { recipient: RECIPIENT_A, amount: parseUnits("100", 6) }, + { recipient: RECIPIENT_B, amount: parseUnits("200", 6) }, + ], + ], + }), + ); + expect(result).toContain("Successfully sprayed variable USDC"); + expect(result).toContain("2 recipients"); + }); + }); + + describe("validateBatch", () => { + it("should post a BPA 1.0 body with a recipients key and return the verdict", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 200, + data: { valid: true, errors: [], warnings: [], summary: { total: "1.00" } }, + }), + ); + + const result = await provider.validateBatch(mockWalletProvider, { + token: "USDC", + recipients: [{ recipient: RECIPIENT_A, amount: "1.00" }], + chain: "base", + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_FREE_VALIDATE_BATCH_PATH}`, + expect.objectContaining({ method: "POST" }), + ); + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + bpa_version: SPRAAY_BPA_VERSION, + chain: "base", + token: "USDC", + recipients: [{ to: RECIPIENT_A, amount: "1.00" }], + }); + expect(body.payments).toBeUndefined(); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.validation.valid).toBe(true); + }); + + it("should report gateway unreachability without throwing", async () => { + mockFetch.mockRejectedValue(new Error("getaddrinfo ENOTFOUND")); + + const result = await provider.validateBatch(mockWalletProvider, { + token: "USDC", + recipients: [{ recipient: RECIPIENT_A, amount: "1.00" }], + chain: "base", + }); + + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.note).toContain("on-chain batch actions remain available"); + }); + }); + + describe("estimateBatch", () => { + it("should call the free estimate endpoint with query parameters", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 200, + data: { estimate: { estimatedGasUSD: 0.15, protocolFeeUSD: 3 } }, + }), + ); + + const result = await provider.estimateBatch(mockWalletProvider, { + recipients: 150, + chain: "base", + amount: "1000.00", + }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).toContain(SPRAAY_FREE_ESTIMATE_BATCH_PATH); + expect(calledUrl).toContain("recipients=150"); + expect(calledUrl).toContain("chain=base"); + expect(calledUrl).toContain("amount=1000.00"); + expect(calledUrl).not.toContain("token="); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.estimate.estimate.protocolFeeUSD).toBe(3); + }); + + it("should omit the amount query parameter when not provided", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ status: 200, data: { estimate: { estimatedGasUSD: 0.15 } } }), + ); + + await provider.estimateBatch(mockWalletProvider, { recipients: 10, chain: "base" }); + + const calledUrl = mockFetch.mock.calls[0][0]; + expect(calledUrl).not.toContain("amount="); + }); + }); + + describe("executeBatchGateway", () => { + const batchArgs = { + token: "USDC", + recipients: [{ recipient: RECIPIENT_A, amount: "1.00" }], + chain: "base", + }; + + it("should return directly when no payment is required", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ status: 200, data: { executed: true, batchId: "b-1" } }), + ); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + // The gateway's execute handler expects {address, amount} entries plus sender + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + token: "USDC", + recipients: [{ address: RECIPIENT_A, amount: "1.00" }], + sender: WALLET_ADDRESS, + }); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.data.batchId).toBe("b-1"); + }); + + it("should refuse to pay quotes above the configured limit", async () => { + // 2 USDC quote vs default 1.0 limit + mockFetch.mockResolvedValue( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "2000000" }] }, + }), + ); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.message).toBe("Gateway payment exceeds limit"); + expect(parsed.details).toContain("No payment was made"); + }); + + it("should settle the 402 challenge by signing with the wallet provider", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "10000" }] }, + }), + ); + const paymentProof = { transaction: "0xproof", network: "base" }; + mockFetchWithPayment.mockResolvedValue( + createMockResponse({ + status: 200, + data: { executed: true, batchId: "b-2" }, + headers: { + "payment-response": Buffer.from(JSON.stringify(paymentProof)).toString("base64"), + }, + }), + ); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + expect(mockFetchWithPayment).toHaveBeenCalledWith( + `${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_BATCH_EXECUTE_PATH}`, + expect.objectContaining({ method: "POST" }), + ); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.message).toContain("x402 payment"); + expect(parsed.paymentProof).toEqual(paymentProof); + }); + + it("should use a pre-funded payment header when configured", async () => { + provider = new SpraayActionProvider({ x402PaymentHeader: "prefunded-header" }); + mockFetch + .mockResolvedValueOnce( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "10000" }] }, + }), + ) + .mockResolvedValueOnce(createMockResponse({ status: 200, data: { executed: true } })); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(2); + const retryHeaders = mockFetch.mock.calls[1][1].headers; + // v2 header with v1 fallback — the gateway middleware reads both + expect(retryHeaders["Payment-Signature"]).toBe("prefunded-header"); + expect(retryHeaders["X-PAYMENT"]).toBe("prefunded-header"); + expect(retryHeaders["PAYMENT"]).toBeUndefined(); + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + }); + + it("should report gateway errors without paying", async () => { + mockFetch.mockRejectedValue(new Error("socket hang up")); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.message).toContain("Error calling the Spraay gateway"); + }); + + it("should flag unsettled payments when the paid retry fails", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "10000" }] }, + }), + ); + mockFetchWithPayment.mockResolvedValue( + createMockResponse({ status: 500, data: { error: "internal" } }), + ); + + const result = await provider.executeBatchGateway(mockWalletProvider, batchArgs); + + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.message).toContain("Payment was not settled"); + }); + }); + + describe("createEscrow", () => { + it("should create an escrow through the paid gateway endpoint", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "10000" }] }, + }), + ); + mockFetchWithPayment.mockResolvedValue( + createMockResponse({ status: 200, data: { escrow: { id: "ESC-1" }, status: "created" } }), + ); + + const result = await provider.createEscrow(mockWalletProvider, { + token: "USDC", + amount: "250.00", + beneficiary: RECIPIENT_A, + arbiter: TOKEN_ADDRESS, + description: "Milestone 1", + conditions: ["Design approved", "Dev complete"], + expiresIn: 72, + }); + + expect(mockFetch).toHaveBeenCalledWith( + `${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_ESCROW_CREATE_PATH}`, + expect.objectContaining({ method: "POST" }), + ); + // The gateway requires depositor/beneficiary/token/amount; depositor + // defaults to the connected wallet address + const body = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(body).toEqual({ + depositor: WALLET_ADDRESS, + beneficiary: RECIPIENT_A, + token: "USDC", + amount: "250.00", + arbiter: TOKEN_ADDRESS, + description: "Milestone 1", + conditions: ["Design approved", "Dev complete"], + expiresIn: 72, + }); + + const parsed = JSON.parse(result); + expect(parsed.success).toBe(true); + expect(parsed.data.escrow.id).toBe("ESC-1"); + }); + + it("should reject depositor === beneficiary before paying anything", async () => { + const result = await provider.createEscrow(mockWalletProvider, { + token: "USDC", + amount: "250.00", + beneficiary: WALLET_ADDRESS, + }); + + expect(mockFetch).not.toHaveBeenCalled(); + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.message).toContain("cannot be the same"); + expect(parsed.details).toContain("No payment was made"); + }); + + it("should respect the payment limit for escrow creation", async () => { + mockFetch.mockResolvedValue( + createMockResponse({ + status: 402, + data: { accepts: [{ network: "base", asset: "0xusdc", maxAmountRequired: "5000000" }] }, + }), + ); + + const result = await provider.createEscrow(mockWalletProvider, { + token: "USDC", + amount: "250.00", + beneficiary: RECIPIENT_A, + }); + + expect(mockFetchWithPayment).not.toHaveBeenCalled(); + const parsed = JSON.parse(result); + expect(parsed.error).toBe(true); + expect(parsed.message).toBe("Gateway payment exceeds limit"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts new file mode 100644 index 000000000..bbcf40785 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts @@ -0,0 +1,1240 @@ +import { z } from "zod"; +import { encodeFunctionData, parseUnits, formatUnits } from "viem"; +import { ActionProvider } from "../actionProvider"; +import { CreateAction } from "../actionDecorator"; +import { Network } from "../../network"; +import { EvmWalletProvider } from "../../wallet-providers"; +import { x402Client, wrapFetchWithPayment } from "@x402/fetch"; +import { registerExactEvmScheme } from "@x402/evm/exact/client"; +import { validatePaymentLimit } from "../x402/utils"; +import { + SprayEthSchema, + SprayTokenSchema, + SprayEthVariableSchema, + SprayTokenVariableSchema, + SpraayValidateBatchSchema, + SpraayEstimateBatchSchema, + SpraayExecuteBatchGatewaySchema, + SpraayCreateEscrowSchema, + SpraayActionProviderConfig, +} from "./schemas"; +import { + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_ABI, + ERC20_ABI, + EIP2612_PERMIT_TYPES, + PERMIT_DEADLINE_SECONDS, + SPRAAY_PROTOCOL_FEE_BPS, + SPRAAY_GATEWAY_BASE_URL, + SPRAAY_FREE_VALIDATE_BATCH_PATH, + SPRAAY_FREE_ESTIMATE_BATCH_PATH, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH, + SPRAAY_GATEWAY_ESCROW_CREATE_PATH, + SPRAAY_BPA_VERSION, + ZERO_ADDRESS, +} from "./constants"; + +/** Internal config type with all fields resolved */ +interface ResolvedSpraayConfig { + maxGatewayPaymentUsdc: number; + x402PaymentHeader: string | null; + gatewayBaseUrl: string; +} + +/** A single (recipient, amount) batch entry in whole units */ +interface BatchEntry { + recipient: string; + amount: string; +} + +/** Result of a free gateway pre-flight validation */ +interface PreflightResult { + proceed: boolean; + report: string | null; +} + +/** + * SpraayActionProvider — payment coordination for AgentKit agents on Base. + * + * Batch payments are the core capability: send ETH or any ERC-20 token to up + * to 200 recipients with per-recipient amounts, atomically, in a single + * transaction against the deployed Spraay batch contract. Escrow creation via + * the Spraay gateway is the complementary second pillar. + * + * Capabilities: + * - Direct on-chain batch execution (agent signs, agent pays gas): + * equal or variable amounts, ETH or ERC-20, up to 200 recipients atomically + * - EIP-2612 permit-optimized approvals for tokens that support it (e.g. USDC + * on Base), with clean fallback to approve for non-permit tokens + * - Free gateway pre-flight: validate a batch and estimate its cost before + * signing anything (POST /free/validate-batch, GET /free/estimate-batch) + * - x402-metered gateway execution (gateway signs and settles, agent pays a + * metered USDC fee via the x402 protocol): POST /api/v1/batch/execute + * - Escrow creation via the x402-metered gateway: POST /api/v1/escrow/create + * + * Contract: 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC (Base Mainnet) + * Gateway: https://gateway.spraay.app + * Website: https://spraay.app + */ +export class SpraayActionProvider extends ActionProvider { + private readonly config: ResolvedSpraayConfig; + + /** + * Creates a new SpraayActionProvider instance. + * + * @param config - Optional configuration for gateway payment limits and endpoints + */ + constructor(config: SpraayActionProviderConfig = {}) { + super("spraay", []); + this.config = { + maxGatewayPaymentUsdc: + config.maxGatewayPaymentUsdc ?? + parseFloat(process.env.SPRAAY_MAX_GATEWAY_PAYMENT_USDC ?? "1.0"), + x402PaymentHeader: config.x402PaymentHeader ?? null, + gatewayBaseUrl: config.gatewayBaseUrl ?? SPRAAY_GATEWAY_BASE_URL, + }; + } + + /** + * Spray equal amounts of ETH to multiple recipients in one transaction. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (recipients, amountPerRecipient, preflight). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_eth", + description: ` +Send equal amounts of ETH to multiple recipients in a single atomic transaction via the Spraay batch contract on Base. +Ideal for team payments, airdrops, or distributing rewards. Up to 200 recipients per transaction; the protocol fee (default 0.3%) is added on top. +This is the direct on-chain path: the agent signs the transaction and pays gas itself. For x402-metered gateway execution instead, use spraay_execute_batch_gateway. +Set preflight=true to validate the batch against the free Spraay gateway endpoint before signing. + `.trim(), + schema: SprayEthSchema, + }) + async sprayEth( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const { recipients, amountPerRecipient } = args; + + try { + let preflightReport: string | null = null; + if (args.preflight) { + const preflight = await this.runPreflight( + "ETH", + recipients.map(recipient => ({ recipient, amount: amountPerRecipient })), + ); + if (!preflight.proceed) { + return `Batch failed Spraay gateway pre-flight validation; no transaction was signed.\n${preflight.report}`; + } + preflightReport = preflight.report; + } + + const amountWei = parseUnits(amountPerRecipient, 18); + const subtotal = amountWei * BigInt(recipients.length); + const feeBps = await this.getFeeBps(walletProvider); + const fee = (subtotal * BigInt(feeBps)) / BigInt(10000); + const totalValue = subtotal + fee; + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayEqual", + args: [ZERO_ADDRESS, recipients as `0x${string}`[], amountWei], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS, + data, + value: totalValue, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return this.formatSprayResult({ + headline: `Successfully sprayed ${amountPerRecipient} ETH to ${recipients.length} recipients via Spraay.`, + subtotal, + fee, + feeBps, + decimals: 18, + symbol: "ETH", + txHash, + blockNumber: receipt.blockNumber, + preflightReport, + }); + } catch (error) { + return `Error spraying ETH via Spraay: ${error}`; + } + } + + /** + * Spray equal amounts of an ERC-20 token to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (tokenAddress, recipients, amountPerRecipient, preflight). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_token", + description: ` +Send equal amounts of an ERC-20 token (like USDC) to multiple recipients in a single atomic transaction via the Spraay batch contract on Base. +Up to 200 recipients per transaction; the protocol fee (default 0.3%) is added on top. +Allowance handling is automatic: for tokens that support EIP-2612 permit (USDC on Base does), a signed permit grants an exact, deadline-bounded allowance instead of a standard approve; non-permit tokens fall back to approve. +This is the direct on-chain path: the agent signs and pays gas itself. For x402-metered gateway execution instead, use spraay_execute_batch_gateway. +Set preflight=true to validate the batch against the free Spraay gateway endpoint before signing. + `.trim(), + schema: SprayTokenSchema, + }) + async sprayToken( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const { tokenAddress, recipients, amountPerRecipient } = args; + + try { + const decimals = await this.getTokenDecimals(walletProvider, tokenAddress); + const symbol = await this.getTokenSymbol(walletProvider, tokenAddress); + + let preflightReport: string | null = null; + if (args.preflight) { + const preflight = await this.runPreflight( + symbol, + recipients.map(recipient => ({ recipient, amount: amountPerRecipient })), + ); + if (!preflight.proceed) { + return `Batch failed Spraay gateway pre-flight validation; no transaction was signed.\n${preflight.report}`; + } + preflightReport = preflight.report; + } + + const amountPerRecipientWei = parseUnits(amountPerRecipient, decimals); + const subtotal = amountPerRecipientWei * BigInt(recipients.length); + const feeBps = await this.getFeeBps(walletProvider); + const fee = (subtotal * BigInt(feeBps)) / BigInt(10000); + const totalAmount = subtotal + fee; + + const allowanceResult = await this.ensureTokenAllowance( + walletProvider, + tokenAddress, + totalAmount, + ); + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayEqual", + args: [tokenAddress as `0x${string}`, recipients as `0x${string}`[], amountPerRecipientWei], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS, + data, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return this.formatSprayResult({ + headline: `Successfully sprayed ${amountPerRecipient} ${symbol} to ${recipients.length} recipients via Spraay.`, + preflightReport, + allowanceResult, + subtotal, + fee, + feeBps, + decimals, + symbol, + txHash, + blockNumber: receipt.blockNumber, + }); + } catch (error) { + return `Error spraying tokens via Spraay: ${error}`; + } + } + + /** + * Spray variable amounts of ETH to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (recipients, amounts, preflight). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_eth_variable", + description: ` +Send different amounts of ETH to multiple recipients in a single atomic transaction via the Spraay batch contract on Base. +Each recipient gets its own specified amount — ideal for bounty payouts or tiered distributions. Up to 200 recipients per transaction; the protocol fee (default 0.3%) is added on top. +This is the direct on-chain path: the agent signs and pays gas itself. For x402-metered gateway execution instead, use spraay_execute_batch_gateway. +Set preflight=true to validate the batch against the free Spraay gateway endpoint before signing. + `.trim(), + schema: SprayEthVariableSchema, + }) + async sprayEthVariable( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const { recipients, amounts: amountStrings } = args; + + if (recipients.length !== amountStrings.length) { + return `Error: recipients array length (${recipients.length}) must match amounts array length (${amountStrings.length}).`; + } + + try { + let preflightReport: string | null = null; + if (args.preflight) { + const preflight = await this.runPreflight( + "ETH", + recipients.map((recipient, i) => ({ recipient, amount: amountStrings[i] })), + ); + if (!preflight.proceed) { + return `Batch failed Spraay gateway pre-flight validation; no transaction was signed.\n${preflight.report}`; + } + preflightReport = preflight.report; + } + + const amounts = amountStrings.map(a => parseUnits(a, 18)); + const subtotal = amounts.reduce((sum, a) => sum + a, BigInt(0)); + const feeBps = await this.getFeeBps(walletProvider); + const fee = (subtotal * BigInt(feeBps)) / BigInt(10000); + const totalValue = subtotal + fee; + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayETH", + args: [ + recipients.map((recipient, i) => ({ + recipient: recipient as `0x${string}`, + amount: amounts[i], + })), + ], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS, + data, + value: totalValue, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return this.formatSprayResult({ + headline: `Successfully sprayed variable ETH amounts to ${recipients.length} recipients via Spraay.`, + preflightReport, + subtotal, + fee, + feeBps, + decimals: 18, + symbol: "ETH", + txHash, + blockNumber: receipt.blockNumber, + }); + } catch (error) { + return `Error spraying variable ETH via Spraay: ${error}`; + } + } + + /** + * Spray variable amounts of an ERC-20 token to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (tokenAddress, recipients, amounts, preflight). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_token_variable", + description: ` +Send different amounts of an ERC-20 token to multiple recipients in a single atomic transaction via the Spraay batch contract on Base. +Each recipient gets its own specified amount. Up to 200 recipients per transaction; the protocol fee (default 0.3%) is added on top. +Allowance handling is automatic: EIP-2612 permit for tokens that support it (USDC on Base does), approve fallback otherwise. +This is the direct on-chain path: the agent signs and pays gas itself. For x402-metered gateway execution instead, use spraay_execute_batch_gateway. +Set preflight=true to validate the batch against the free Spraay gateway endpoint before signing. + `.trim(), + schema: SprayTokenVariableSchema, + }) + async sprayTokenVariable( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const { tokenAddress, recipients, amounts: amountStrings } = args; + + if (recipients.length !== amountStrings.length) { + return `Error: recipients array length (${recipients.length}) must match amounts array length (${amountStrings.length}).`; + } + + try { + const decimals = await this.getTokenDecimals(walletProvider, tokenAddress); + const symbol = await this.getTokenSymbol(walletProvider, tokenAddress); + + let preflightReport: string | null = null; + if (args.preflight) { + const preflight = await this.runPreflight( + symbol, + recipients.map((recipient, i) => ({ recipient, amount: amountStrings[i] })), + ); + if (!preflight.proceed) { + return `Batch failed Spraay gateway pre-flight validation; no transaction was signed.\n${preflight.report}`; + } + preflightReport = preflight.report; + } + + const amounts = amountStrings.map(a => parseUnits(a, decimals)); + const subtotal = amounts.reduce((sum, a) => sum + a, BigInt(0)); + const feeBps = await this.getFeeBps(walletProvider); + const fee = (subtotal * BigInt(feeBps)) / BigInt(10000); + const totalAmount = subtotal + fee; + + const allowanceResult = await this.ensureTokenAllowance( + walletProvider, + tokenAddress, + totalAmount, + ); + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayToken", + args: [ + tokenAddress as `0x${string}`, + recipients.map((recipient, i) => ({ + recipient: recipient as `0x${string}`, + amount: amounts[i], + })), + ], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS, + data, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return this.formatSprayResult({ + headline: `Successfully sprayed variable ${symbol} amounts to ${recipients.length} recipients via Spraay.`, + preflightReport, + allowanceResult, + subtotal, + fee, + feeBps, + decimals, + symbol, + txHash, + blockNumber: receipt.blockNumber, + }); + } catch (error) { + return `Error spraying variable tokens via Spraay: ${error}`; + } + } + + /** + * Validate a batch via the free Spraay gateway pre-flight endpoint. + * + * @param _walletProvider - The wallet provider (unused; validation is off-chain and free). + * @param args - The batch to validate (token, recipients, chain). + * @returns A JSON string with the gateway validation result. + */ + @CreateAction({ + name: "spraay_validate_batch", + description: ` +Validate a batch payment against the free Spraay gateway pre-flight endpoint (POST ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_FREE_VALIDATE_BATCH_PATH}). No payment and no transaction signing required. +Checks recipients and amounts and returns valid/errors/warnings/summary. Use this before spraay_eth/spraay_token/spraay_eth_variable/spraay_token_variable or spraay_execute_batch_gateway to catch malformed batches before signing anything. + `.trim(), + schema: SpraayValidateBatchSchema, + }) + async validateBatch( + _walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const response = await fetch( + `${this.config.gatewayBaseUrl}${SPRAAY_FREE_VALIDATE_BATCH_PATH}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.buildValidateBody(args.chain, args.token, args.recipients)), + }, + ); + + const data = await this.parseResponseData(response); + + if (!response.ok) { + return JSON.stringify( + { + error: true, + message: `Spraay gateway validation request failed with status ${response.status}`, + data, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + success: true, + endpoint: SPRAAY_FREE_VALIDATE_BATCH_PATH, + validation: data, + }, + null, + 2, + ); + } catch (error) { + return JSON.stringify( + { + error: true, + message: "Failed to reach the Spraay gateway for batch validation", + details: error instanceof Error ? error.message : String(error), + note: "Validation is an optional pre-flight step; the direct on-chain batch actions remain available.", + }, + null, + 2, + ); + } + } + + /** + * Estimate batch execution cost via the free Spraay gateway endpoint. + * + * @param _walletProvider - The wallet provider (unused; estimation is off-chain and free). + * @param args - The estimate parameters (recipients count, token, chain). + * @returns A JSON string with the gateway cost estimate. + */ + @CreateAction({ + name: "spraay_estimate_batch", + description: ` +Estimate the cost of a batch payment via the free Spraay gateway endpoint (GET ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_FREE_ESTIMATE_BATCH_PATH}?recipients=&chain=&amount=). No payment and no transaction signing required. +Returns rough per-chain gas and protocol-fee estimates (protocol fee requires the optional total amount). Use this to preview costs for a batch of a given size before executing on-chain or via the gateway; for a live quote use the paid POST ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH}. + `.trim(), + schema: SpraayEstimateBatchSchema, + }) + async estimateBatch( + _walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + try { + const url = new URL(`${this.config.gatewayBaseUrl}${SPRAAY_FREE_ESTIMATE_BATCH_PATH}`); + url.searchParams.set("recipients", String(args.recipients)); + url.searchParams.set("chain", args.chain); + if (args.amount) { + url.searchParams.set("amount", args.amount); + } + + const response = await fetch(url.toString()); + const data = await this.parseResponseData(response); + + if (!response.ok) { + return JSON.stringify( + { + error: true, + message: `Spraay gateway estimate request failed with status ${response.status}`, + data, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + success: true, + endpoint: SPRAAY_FREE_ESTIMATE_BATCH_PATH, + estimate: data, + }, + null, + 2, + ); + } catch (error) { + return JSON.stringify( + { + error: true, + message: "Failed to reach the Spraay gateway for batch estimation", + details: error instanceof Error ? error.message : String(error), + note: "Estimation is an optional pre-flight step; the direct on-chain batch actions remain available.", + }, + null, + 2, + ); + } + } + + /** + * Execute a batch payment through the x402-metered Spraay gateway. + * + * @param walletProvider - The wallet provider used to sign the x402 payment. + * @param args - The batch to execute (token, recipients, chain). + * @returns A JSON string with the gateway execution result and payment details. + */ + @CreateAction({ + name: "spraay_execute_batch_gateway", + description: ` +Execute a batch payment through the x402-metered Spraay gateway (POST ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_BATCH_EXECUTE_PATH}). This is a PAID endpoint: pricing is returned via an x402 402 Payment Required challenge and settled in USDC before execution. A live quote is available the same way from POST ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_BATCH_ESTIMATE_PATH}. +Tradeoff vs the direct on-chain actions (spraay_eth / spraay_token / spraay_eth_variable / spraay_token_variable): direct on-chain means the agent signs the batch transaction and pays gas itself on Base; gateway execution is x402-metered and multi-chain capable — the gateway handles submission and the agent pays a metered USDC fee instead of managing gas. +Payments respect the provider's maxGatewayPaymentUsdc limit. Use spraay_validate_batch (free) first to catch malformed batches. + `.trim(), + schema: SpraayExecuteBatchGatewaySchema, + }) + async executeBatchGateway( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + return this.requestWithX402( + walletProvider, + SPRAAY_GATEWAY_BATCH_EXECUTE_PATH, + this.buildExecuteBody(args.token, args.recipients, walletProvider.getAddress()), + ); + } + + /** + * Create an escrow through the x402-metered Spraay gateway. + * + * @param walletProvider - The wallet provider used to sign the x402 payment. + * @param args - The escrow parameters (token, amount, beneficiary, depositor, arbiter, description, conditions, expiresIn). + * @returns A JSON string with the gateway escrow creation result and payment details. + */ + @CreateAction({ + name: "spraay_create_escrow", + description: ` +Create an escrow through the x402-metered Spraay gateway (POST ${SPRAAY_GATEWAY_BASE_URL}${SPRAAY_GATEWAY_ESCROW_CREATE_PATH}). This is a PAID endpoint: pricing is returned via an x402 402 Payment Required challenge and settled in USDC. +Escrow complements Spraay batch payments: lock funds for a beneficiary (with an optional arbiter, release conditions, and expiry in hours), then release or refund later. The depositor defaults to the connected wallet and must differ from the beneficiary. +This action covers creation only — the gateway's POST /api/v1/escrow/fund, /release, and /cancel endpoints handle the rest of the lifecycle (see ${SPRAAY_GATEWAY_BASE_URL}). +Payments respect the provider's maxGatewayPaymentUsdc limit. + `.trim(), + schema: SpraayCreateEscrowSchema, + }) + async createEscrow( + walletProvider: EvmWalletProvider, + args: z.infer, + ): Promise { + const depositor = args.depositor ?? walletProvider.getAddress(); + + // The gateway rejects depositor === beneficiary, but only after the x402 + // payment has settled — catch it locally before paying anything. + if (depositor.toLowerCase() === args.beneficiary.toLowerCase()) { + return JSON.stringify( + { + error: true, + message: "Depositor and beneficiary cannot be the same address", + details: "No payment was made.", + }, + null, + 2, + ); + } + + return this.requestWithX402(walletProvider, SPRAAY_GATEWAY_ESCROW_CREATE_PATH, { + depositor, + beneficiary: args.beneficiary, + token: args.token, + amount: args.amount, + ...(args.arbiter ? { arbiter: args.arbiter } : {}), + ...(args.description ? { description: args.description } : {}), + ...(args.conditions?.length ? { conditions: args.conditions } : {}), + ...(args.expiresIn ? { expiresIn: args.expiresIn } : {}), + }); + } + + /** + * Spraay's batch contract is currently deployed only on Base mainnet. + * + * @param network - The network to check support for. + * @returns True if the network is supported. + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && network.networkId === "base-mainnet"; + + /** + * Builds the request body for the free BPA 1.0 validation endpoint. + * The gateway's validator expects entries keyed as {to, amount} inside a + * "recipients" array (not "payments"), plus chain and token (symbol or + * contract address). + * + * @param chain - Target chain identifier. + * @param token - Token symbol or contract address. + * @param recipients - Batch entries as (recipient, amount) pairs. + * @returns The validation request body. + */ + private buildValidateBody(chain: string, token: string, recipients: BatchEntry[]) { + return { + bpa_version: SPRAAY_BPA_VERSION, + chain, + token, + recipients: recipients.map(entry => ({ + to: entry.recipient, + amount: entry.amount, + })), + }; + } + + /** + * Builds the request body for the paid batch execution endpoint. + * The gateway's execute handler expects entries keyed as {address, amount} + * (human-decimal amounts) plus token (symbol or contract address, default + * USDC) and an optional sender used for approval encoding. + * + * @param token - Token symbol or contract address. + * @param recipients - Batch entries as (recipient, amount) pairs. + * @param sender - The sending wallet address. + * @returns The execution request body. + */ + private buildExecuteBody(token: string, recipients: BatchEntry[], sender: string) { + return { + token, + recipients: recipients.map(entry => ({ + address: entry.recipient, + amount: entry.amount, + })), + sender, + }; + } + + /** + * Runs the free gateway pre-flight validation for an on-chain batch. + * Gateway unavailability never blocks the on-chain path; an explicit + * "valid: false" verdict does. + * + * @param token - Token symbol for the batch. + * @param entries - Batch entries as (recipient, amount) pairs. + * @returns Whether to proceed, plus a report to surface in the action result. + */ + private async runPreflight(token: string, entries: BatchEntry[]): Promise { + try { + const response = await fetch( + `${this.config.gatewayBaseUrl}${SPRAAY_FREE_VALIDATE_BATCH_PATH}`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(this.buildValidateBody("base", token, entries)), + }, + ); + + if (!response.ok) { + return { + proceed: true, + report: `Pre-flight validation skipped: gateway responded with status ${response.status}.`, + }; + } + + const data = (await this.parseResponseData(response)) as Record; + const report = `Pre-flight validation: ${JSON.stringify(data)}`; + + if (data && typeof data === "object" && data.valid === false) { + return { proceed: false, report }; + } + + return { proceed: true, report }; + } catch (error) { + return { + proceed: true, + report: `Pre-flight validation skipped: gateway unreachable (${ + error instanceof Error ? error.message : String(error) + }).`, + }; + } + } + + /** + * Formats a successful spray result into the human-readable multi-line shape + * shared by all on-chain actions. + * + * @param params - The result fields. + * @param params.headline - First line describing the outcome. + * @param params.allowanceResult - Optional allowance message (permit/approve). + * @param params.subtotal - Total amount sent, excluding fee. + * @param params.fee - Protocol fee amount. + * @param params.feeBps - Protocol fee in basis points. + * @param params.decimals - Token decimals for formatting. + * @param params.symbol - Token symbol for formatting. + * @param params.txHash - The spray transaction hash. + * @param params.blockNumber - The block the transaction was mined in. + * @param params.preflightReport - Optional pre-flight report to surface. + * @returns The formatted result string. + */ + private formatSprayResult(params: { + headline: string; + allowanceResult?: string | null; + subtotal: bigint; + fee: bigint; + feeBps: number; + decimals: number; + symbol: string; + txHash: string; + blockNumber: unknown; + preflightReport?: string | null; + }): string { + const lines: string[] = []; + if (params.preflightReport) { + lines.push(params.preflightReport); + } + if (params.allowanceResult) { + lines.push(params.allowanceResult); + } + lines.push( + params.headline, + `Total sent: ${formatUnits(params.subtotal, params.decimals)} ${params.symbol}`, + `Protocol fee (${params.feeBps / 100}%): ${formatUnits(params.fee, params.decimals)} ${params.symbol}`, + `Transaction hash: ${params.txHash}`, + `Block: ${params.blockNumber}`, + `View on BaseScan: https://basescan.org/tx/${params.txHash}`, + ); + return lines.join("\n"); + } + + /** + * Reads the live protocol fee from the contract, falling back to the + * default constant if the read fails. + * + * @param walletProvider - The wallet provider to read with. + * @returns The protocol fee in basis points. + */ + private async getFeeBps(walletProvider: EvmWalletProvider): Promise { + try { + const result = await walletProvider.readContract({ + address: SPRAAY_CONTRACT_ADDRESS, + abi: SPRAAY_ABI, + functionName: "feeBps", + }); + return Number(result); + } catch { + return SPRAAY_PROTOCOL_FEE_BPS; + } + } + + /** + * Ensures the Spraay contract has a sufficient token allowance, preferring + * an EIP-2612 permit (exact value, deadline-bounded) when the token + * supports it, and falling back to a standard approve otherwise. + * + * @param walletProvider - The wallet provider to sign and send with. + * @param tokenAddress - The ERC-20 token contract address. + * @param requiredAmount - The allowance required, in atomic units. + * @returns A message describing what was done, or null if no action was needed. + */ + private async ensureTokenAllowance( + walletProvider: EvmWalletProvider, + tokenAddress: string, + requiredAmount: bigint, + ): Promise { + const walletAddress = walletProvider.getAddress() as `0x${string}`; + + const currentAllowance = (await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "allowance", + args: [walletAddress, SPRAAY_CONTRACT_ADDRESS], + })) as bigint; + + if (BigInt(currentAllowance) >= requiredAmount) { + return null; + } + + const permitResult = await this.tryPermit(walletProvider, tokenAddress, requiredAmount); + if (permitResult) { + return permitResult; + } + + const approveData = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [SPRAAY_CONTRACT_ADDRESS, requiredAmount], + }); + + const approveTxHash = await walletProvider.sendTransaction({ + to: tokenAddress as `0x${string}`, + data: approveData, + }); + + await walletProvider.waitForTransactionReceipt(approveTxHash); + + return `Token approval granted to Spraay contract (token does not support EIP-2612 permit). Approval tx: ${approveTxHash}`; + } + + /** + * Attempts to grant the allowance via an EIP-2612 permit. Support is + * detected at runtime (nonces/name reads) rather than from a hardcoded + * token list. Returns null when the token does not support permit or any + * step fails, so the caller can fall back to approve. + * + * @param walletProvider - The wallet provider to sign and send with. + * @param tokenAddress - The ERC-20 token contract address. + * @param requiredAmount - The allowance value to permit, in atomic units. + * @returns A message describing the permit, or null to signal fallback. + */ + private async tryPermit( + walletProvider: EvmWalletProvider, + tokenAddress: string, + requiredAmount: bigint, + ): Promise { + try { + const owner = walletProvider.getAddress() as `0x${string}`; + + // Detect permit support: EIP-2612 tokens expose nonces(owner). + const nonce = (await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "nonces", + args: [owner], + })) as bigint; + + const name = (await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "name", + })) as string; + + // EIP-2612 domain version: USDC on Base reports "2"; default to "1". + let version = "1"; + try { + version = (await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "version", + })) as string; + } catch { + // Tokens without version() use "1" per the EIP-2612 reference implementation. + } + + const chainId = Number(walletProvider.getNetwork().chainId ?? 8453); + const deadline = BigInt(Math.floor(Date.now() / 1000) + PERMIT_DEADLINE_SECONDS); + + const signature = await walletProvider.signTypedData({ + domain: { + name, + version, + chainId, + verifyingContract: tokenAddress as `0x${string}`, + }, + types: EIP2612_PERMIT_TYPES, + primaryType: "Permit", + message: { + owner, + spender: SPRAAY_CONTRACT_ADDRESS, + value: requiredAmount, + nonce: BigInt(nonce), + deadline, + }, + }); + + const { r, s, v } = this.splitSignature(signature); + + const permitData = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "permit", + args: [owner, SPRAAY_CONTRACT_ADDRESS, requiredAmount, deadline, v, r, s], + }); + + const permitTxHash = await walletProvider.sendTransaction({ + to: tokenAddress as `0x${string}`, + data: permitData, + }); + + await walletProvider.waitForTransactionReceipt(permitTxHash); + + // Verify the permit took effect (e.g. smart-wallet ERC-1271 signatures + // do not pass permit's ecrecover); otherwise fall back to approve. + const allowanceAfter = (await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "allowance", + args: [owner, SPRAAY_CONTRACT_ADDRESS], + })) as bigint; + if (BigInt(allowanceAfter) < requiredAmount) { + return null; + } + + return `Token allowance granted via EIP-2612 permit (exact value, ${PERMIT_DEADLINE_SECONDS / 60}-minute deadline, no standing unlimited approval). Permit tx: ${permitTxHash}`; + } catch { + // Token does not support EIP-2612 permit (or signing failed) — fall back to approve. + return null; + } + } + + /** + * Splits a 65-byte hex signature into its r, s, v components. + * + * @param signature - The 0x-prefixed 65-byte signature. + * @returns The r, s, and v components. + */ + private splitSignature(signature: `0x${string}`): { + r: `0x${string}`; + s: `0x${string}`; + v: number; + } { + const r = signature.slice(0, 66) as `0x${string}`; + const s = `0x${signature.slice(66, 130)}` as `0x${string}`; + let v = parseInt(signature.slice(130, 132), 16); + if (v < 27) { + v += 27; + } + return { r, s, v }; + } + + /** + * Makes a request to an x402-metered Spraay gateway endpoint. The first + * request is unpaid; on a 402 challenge the payment is validated against + * the configured limit and settled either with a pre-funded payment header + * or by signing with the wallet provider via the x402 client. Payment is + * never faked or stubbed. + * + * @param walletProvider - The wallet provider used to sign the x402 payment. + * @param path - The gateway endpoint path. + * @param body - The JSON request body. + * @returns A JSON string with the result and payment details. + */ + private async requestWithX402( + walletProvider: EvmWalletProvider, + path: string, + body: Record, + ): Promise { + const url = `${this.config.gatewayBaseUrl}${path}`; + + try { + const initialResponse = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (initialResponse.status !== 402) { + const data = await this.parseResponseData(initialResponse); + return JSON.stringify( + { + success: initialResponse.ok, + url, + status: initialResponse.status, + data, + }, + null, + 2, + ); + } + + // Parse the 402 challenge: v2 sends requirements in the PAYMENT-REQUIRED + // header; v1 sends them in the body. + let acceptsArray: Array<{ + network?: string; + asset?: string; + maxAmountRequired?: string; + amount?: string; + price?: string; + }> = []; + + const paymentRequiredHeader = initialResponse.headers.get("payment-required"); + if (paymentRequiredHeader) { + try { + const decoded = JSON.parse(atob(paymentRequiredHeader)); + acceptsArray = decoded.accepts ?? []; + } catch { + // Header parsing failed, fall back to body + } + } + if (acceptsArray.length === 0) { + try { + const challengeBody = (await initialResponse.json()) as { accepts?: typeof acceptsArray }; + acceptsArray = challengeBody.accepts ?? []; + } catch { + // No parseable challenge body + } + } + + // Enforce the configured payment limit against the cheapest quoted option. + const quotedAmounts = acceptsArray + .map(option => option.maxAmountRequired ?? option.amount ?? option.price) + .filter((amount): amount is string => Boolean(amount)); + + if (quotedAmounts.length > 0) { + const cheapest = quotedAmounts.reduce((min, amount) => + BigInt(amount) < BigInt(min) ? amount : min, + ); + const paymentValidation = validatePaymentLimit(cheapest, this.config.maxGatewayPaymentUsdc); + if (!paymentValidation.isValid) { + return JSON.stringify( + { + error: true, + message: "Gateway payment exceeds limit", + details: `The Spraay gateway quoted ${paymentValidation.requestedAmount} USDC, which exceeds the maximum gateway payment limit of ${paymentValidation.maxAmount} USDC. No payment was made.`, + maxGatewayPaymentUsdc: this.config.maxGatewayPaymentUsdc, + acceptablePaymentOptions: acceptsArray, + }, + null, + 2, + ); + } + } + + // Settle the payment: pre-funded header if configured, otherwise sign + // with the wallet provider via the x402 client. + let paidResponse: Response; + if (this.config.x402PaymentHeader) { + // The gateway's x402 v2 middleware reads Payment-Signature, with + // X-PAYMENT kept as the v1 fallback. + paidResponse = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Payment-Signature": this.config.x402PaymentHeader, + "X-PAYMENT": this.config.x402PaymentHeader, + }, + body: JSON.stringify(body), + }); + } else { + const client = new x402Client(); + const account = walletProvider.toSigner(); + const signer = { + ...account, + readContract: (readArgs: { + address: `0x${string}`; + abi: readonly unknown[]; + functionName: string; + args?: readonly unknown[]; + }) => + walletProvider.readContract({ + address: readArgs.address, + abi: readArgs.abi as never, + functionName: readArgs.functionName as never, + args: readArgs.args as never, + }), + }; + registerExactEvmScheme(client, { signer }); + const fetchWithPayment = wrapFetchWithPayment(fetch, client); + + paidResponse = await fetchWithPayment(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } + + const data = await this.parseResponseData(paidResponse); + + const paymentResponseHeader = + paidResponse.headers.get("payment-response") ?? + paidResponse.headers.get("x-payment-response"); + + let paymentProof: Record | null = null; + if (paymentResponseHeader) { + try { + paymentProof = JSON.parse(atob(paymentResponseHeader)); + } catch { + paymentProof = { raw: paymentResponseHeader }; + } + } + + if (paidResponse.status !== 200) { + return JSON.stringify( + { + error: true, + message: `Gateway request failed with status ${paidResponse.status}. Payment was not settled.`, + url, + status: paidResponse.status, + data, + }, + null, + 2, + ); + } + + return JSON.stringify( + { + success: true, + message: "Gateway request completed with x402 payment", + url, + status: paidResponse.status, + data, + paymentProof, + }, + null, + 2, + ); + } catch (error) { + return JSON.stringify( + { + error: true, + message: `Error calling the Spraay gateway at ${url}`, + details: error instanceof Error ? error.message : String(error), + }, + null, + 2, + ); + } + } + + /** + * Parses response data based on content type. + * + * @param response - The fetch Response object. + * @returns Parsed response data. + */ + private async parseResponseData(response: Response): Promise { + const contentType = response.headers.get("content-type") ?? ""; + if (contentType.includes("application/json")) { + try { + return await response.json(); + } catch { + return null; + } + } + return response.text(); + } + + /** + * Gets the number of decimals for an ERC-20 token. + * + * @param walletProvider - The wallet provider to read with. + * @param tokenAddress - The ERC-20 token contract address. + * @returns The token decimals, defaulting to 18 on failure. + */ + private async getTokenDecimals( + walletProvider: EvmWalletProvider, + tokenAddress: string, + ): Promise { + try { + const result = await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "decimals", + }); + return Number(result); + } catch { + return 18; + } + } + + /** + * Gets the symbol for an ERC-20 token. + * + * @param walletProvider - The wallet provider to read with. + * @param tokenAddress - The ERC-20 token contract address. + * @returns The token symbol, defaulting to "TOKEN" on failure. + */ + private async getTokenSymbol( + walletProvider: EvmWalletProvider, + tokenAddress: string, + ): Promise { + try { + const result = await walletProvider.readContract({ + address: tokenAddress as `0x${string}`, + abi: ERC20_ABI, + functionName: "symbol", + }); + return result as string; + } catch { + return "TOKEN"; + } + } +} + +/** + * Factory function to create a new SpraayActionProvider instance. + * + * @param config - Optional configuration for gateway payment limits and endpoints. + * @returns A new SpraayActionProvider. + * + * @example + * ```typescript + * import { spraayActionProvider } from "@coinbase/agentkit"; + * + * const agentKit = await AgentKit.from({ + * walletProvider, + * actionProviders: [spraayActionProvider({ maxGatewayPaymentUsdc: 0.5 })], + * }); + * ``` + */ +export const spraayActionProvider = (config?: SpraayActionProviderConfig) => + new SpraayActionProvider(config);