diff --git a/lib/crewai-tools/src/crewai_tools/__init__.py b/lib/crewai-tools/src/crewai_tools/__init__.py index 089712a40c..ca5071fde6 100644 --- a/lib/crewai-tools/src/crewai_tools/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/__init__.py @@ -197,6 +197,12 @@ SnowflakeSearchTool, ) from crewai_tools.tools.spider_tool.spider_tool import SpiderTool +from crewai_tools.tools.spraay_tool.spraay_balance_tool import SpraayBalanceTool +from crewai_tools.tools.spraay_tool.spraay_batch_payment_tool import ( + SpraayBatchPaymentTool, + SpraayRecipient, +) +from crewai_tools.tools.spraay_tool.spraay_escrow_tool import SpraayEscrowTool from crewai_tools.tools.stagehand_tool.stagehand_tool import StagehandTool from crewai_tools.tools.tavily_extractor_tool.tavily_extractor_tool import ( TavilyExtractorTool, @@ -322,6 +328,10 @@ "SnowflakeConfig", "SnowflakeSearchTool", "SpiderTool", + "SpraayBalanceTool", + "SpraayBatchPaymentTool", + "SpraayEscrowTool", + "SpraayRecipient", "StagehandTool", "TXTSearchTool", "TavilyExtractorTool", diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md new file mode 100644 index 0000000000..3e85943446 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md @@ -0,0 +1,149 @@ +# Spraay Tools Documentation + +## Description + +A suite of CrewAI tools for batch cryptocurrency payments, escrow, and balance queries via the [Spraay x402 payment gateway](https://docs.spraay.app). These tools provide three capabilities: + +- **SpraayBatchPaymentTool**: Validate, estimate, and execute batch payments to up to 200 recipients in a single transaction with ~80% gas savings +- **SpraayEscrowTool**: Create on-chain escrow contracts between two parties with programmable release conditions +- **SpraayBalanceTool**: Check token balances across 16 supported chains + +The gateway uses the [x402 payment protocol](https://www.x402.org/) — no API key or signup required. Free endpoints (validate, estimate, balance) cost nothing. Paid endpoints (execute, escrow) are paid per request via x402 micropayment and require a funded wallet private key in the `SPRAAY_WALLET_PRIVATE_KEY` environment variable (see [Paid Endpoints and Wallet Setup](#paid-endpoints-and-wallet-setup)). + +Batch payments and escrow support nine EVM chains: Base (8453), Ethereum (1), BNB Chain (56), Unichain (130), Polygon (137), Plasma (9745), Arbitrum (42161), Avalanche (43114), and BOB (60808). + +Amounts are given as human-readable decimal strings (e.g. `"50.0"`); the tools convert them to the token's base units at the request boundary. Token decimals are resolved per chain and token — from a known-token table, the gateway's free token directory (`/api/v1/tokens`, Base), or the token contract's `decimals()` via public RPC — and the result is cached. If decimals cannot be resolved, the tool returns an error instead of guessing, since a wrong decimals value would mis-scale every amount (e.g. USDC uses 6 decimals, not 18). + +## Installation + +To incorporate these tools into your project, follow the installation instructions below: + +```bash +pip install crewai[tools] requests +``` + +To use the paid endpoints (batch `execute` and escrow creation), also install the official [x402](https://pypi.org/project/x402/) package with its `requests` and EVM extras: + +```bash +pip install 'x402[requests,evm]' +``` + +## Paid Endpoints and Wallet Setup + +Free endpoints (validate, estimate, balance) need no setup. Paid endpoints — batch `execute` ($0.02/request) and escrow creation ($0.10/request) — are paid via x402 micropayment in USDC on Base, and require a funded wallet: + +```bash +export SPRAAY_WALLET_PRIVATE_KEY="0xYourPrivateKey" +``` + +When a paid endpoint responds with an HTTP 402 payment challenge, the tool signs the payment requirements with this key via the official x402 client and retries the request with the payment header attached. The wallet must hold enough USDC on Base (chain ID 8453) to cover the per-request fee. + +If `SPRAAY_WALLET_PRIVATE_KEY` is not set, the tool does not fail — it returns the gateway's parsed payment requirements as structured JSON (`"status": "payment_required"`) so the agent can report what payment is needed. + +> **Security note:** The private key signs real on-chain payments. Use a dedicated wallet funded with only the amount you intend to spend, and never commit the key to source control. + +## Examples + +### Batch Payment - Validate Recipients + +```python +from crewai_tools import SpraayBatchPaymentTool + +tool = SpraayBatchPaymentTool() +result = tool.run( + action="validate", + token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", # USDC on Base + recipients=[ + {"address": "0xAbc123...", "amount": "50.0"}, + {"address": "0xDef456...", "amount": "25.0"}, + {"address": "0x789Ghi...", "amount": "75.0"}, + ], + chain_id=8453, +) +``` + +### Batch Payment - Estimate Gas Costs + +```python +from crewai_tools import SpraayBatchPaymentTool + +tool = SpraayBatchPaymentTool() +result = tool.run( + action="estimate", + token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + recipients=[ + {"address": "0xAbc123...", "amount": "50.0"}, + {"address": "0xDef456...", "amount": "25.0"}, + ], + sender_address="0xYourWallet...", + chain_id=8453, +) +``` + +### Batch Payment - Execute + +```python +from crewai_tools import SpraayBatchPaymentTool + +tool = SpraayBatchPaymentTool() +result = tool.run( + action="execute", + token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + recipients=[ + {"address": "0xAbc123...", "amount": "50.0"}, + {"address": "0xDef456...", "amount": "25.0"}, + ], + sender_address="0xYourWallet...", + chain_id=8453, +) +``` + +### Escrow - Create Contract + +```python +from crewai_tools import SpraayEscrowTool + +tool = SpraayEscrowTool() +result = tool.run( + token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + amount="500.0", + depositor="0xClientWallet...", + beneficiary="0xFreelancerWallet...", + chain_id=8453, + conditions="Release upon delivery of completed project files", +) +``` + +### Balance - Check Wallet + +```python +from crewai_tools import SpraayBalanceTool + +tool = SpraayBalanceTool() +result = tool.run( + wallet_address="0xYourWallet...", + token_address="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + chain_id=8453, +) +``` + +## Steps to Get Started + +To effectively use the Spraay Tools, follow these steps: + +1. **Package Installation**: Confirm that the `crewai[tools]` package is installed in your Python environment. + +2. **Tool Selection**: Choose the appropriate tool based on your needs: + - Use **SpraayBatchPaymentTool** for sending payments to multiple recipients + - Use **SpraayEscrowTool** for trustless escrow between two parties + - Use **SpraayBalanceTool** for checking wallet balances before transactions + +3. **Start with free endpoints**: The validate, estimate, and balance actions require no payment and no setup — call them immediately to test your integration. + +## Batch Contract + +The batch payment smart contract is deployed on Base at [`0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC`](https://basescan.org/address/0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC). + +## Conclusion + +By integrating Spraay Tools into your CrewAI agents, you give them the ability to handle real cryptocurrency payments — from payroll and grant distributions to escrow-protected freelance contracts. The free validation and estimation endpoints let agents plan transactions safely before committing funds, and the x402 protocol means there is no API key to manage — agents pay per request natively. diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py new file mode 100644 index 0000000000..59db9c45d7 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py @@ -0,0 +1,14 @@ +from crewai_tools.tools.spraay_tool.spraay_balance_tool import SpraayBalanceTool +from crewai_tools.tools.spraay_tool.spraay_batch_payment_tool import ( + SpraayBatchPaymentTool, + SpraayRecipient, +) +from crewai_tools.tools.spraay_tool.spraay_escrow_tool import SpraayEscrowTool + + +__all__ = [ + "SpraayBalanceTool", + "SpraayBatchPaymentTool", + "SpraayEscrowTool", + "SpraayRecipient", +] diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_balance_tool.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_balance_tool.py new file mode 100644 index 0000000000..9c8083cdb6 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_balance_tool.py @@ -0,0 +1,95 @@ +"""Spraay Balance Tool for CrewAI. + +Enables AI agents to check token balances across supported chains +via the Spraay x402 payment gateway. +""" + +import json +from typing import Any + +from crewai.tools import BaseTool +from pydantic import BaseModel, Field +import requests + + +class SpraayBalanceInput(BaseModel): + """Input schema for the SpraayBalanceTool.""" + + wallet_address: str = Field( + ..., + description="The wallet address to check the balance of.", + ) + token_address: str = Field( + default="0x0000000000000000000000000000000000000000", + description=( + "The ERC-20 token contract address to check, " + "or '0x0000000000000000000000000000000000000000' for native ETH. " + "Default: native ETH." + ), + ) + chain_id: int = Field( + default=8453, + description="Chain ID to check. Default: 8453 (Base).", + ) + + +class SpraayBalanceTool(BaseTool): + """Tool for checking token balances via the Spraay x402 gateway. + + Query wallet balances across 16 supported chains. Useful for + pre-flight checks before batch payments or escrow creation. + + The gateway uses the x402 payment protocol — no API key or + signup required. + + Attributes: + gateway_url: Base URL of the Spraay gateway. + """ + + name: str = "Spraay Balance Check" + description: str = ( + "Check the token balance of a wallet address on any supported chain. " + "Useful for verifying sufficient funds before executing batch payments " + "or creating escrow contracts. No API key required — the gateway uses " + "the x402 payment protocol." + ) + args_schema: type[BaseModel] = SpraayBalanceInput + + gateway_url: str = "https://gateway.spraay.app" + + def _run(self, **kwargs: Any) -> str: + wallet_address = kwargs.get("wallet_address", "") + token_address = kwargs.get( + "token_address", + "0x0000000000000000000000000000000000000000", + ) + chain_id = kwargs.get("chain_id", 8453) + + if not wallet_address: + return "Error: 'wallet_address' is required." + + params = { + "walletAddress": wallet_address, + "tokenAddress": token_address, + "chainId": chain_id, + } + + try: + response = requests.get( + f"{self.gateway_url}/api/v1/balances", + params=params, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "success", + "walletAddress": wallet_address, + "chainId": chain_id, + "balance": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error checking balance: {e}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_batch_payment_tool.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_batch_payment_tool.py new file mode 100644 index 0000000000..a177f12976 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_batch_payment_tool.py @@ -0,0 +1,276 @@ +"""Spraay Batch Payment Tool for CrewAI. + +Enables AI agents to validate, estimate, and execute batch cryptocurrency +payments on nine EVM chains (Base, Ethereum, BNB Chain, Unichain, Polygon, +Plasma, Arbitrum, Avalanche, BOB) via the Spraay x402 payment gateway. +""" + +import json +from typing import Any, ClassVar + +from crewai.tools import BaseTool +from pydantic import BaseModel, Field, ValidationError +import requests + +from crewai_tools.tools.spraay_tool.spraay_payload import ( + chain_slug, + to_base_units, + token_decimals, +) +from crewai_tools.tools.spraay_tool.spraay_x402 import post_with_x402 + + +MAX_RECIPIENTS = 200 + + +class SpraayRecipient(BaseModel): + """A single recipient in a batch payment.""" + + address: str = Field(..., description="Recipient wallet address.") + amount: str = Field( + ..., + description="Token amount to send, as a string (e.g. '10.0').", + ) + + +class SpraayBatchPaymentInput(BaseModel): + """Input schema for the SpraayBatchPaymentTool.""" + + action: str = Field( + ..., + description=( + "The batch payment action to perform. " + "Options: 'validate' (check recipient list), " + "'estimate' (get gas and fee estimates), " + "'execute' (send the batch payment)." + ), + ) + token_address: str = Field( + ..., + description=( + "The ERC-20 token contract address to send, " + "or '0x0000000000000000000000000000000000000000' for native ETH." + ), + ) + recipients: list[SpraayRecipient] = Field( + ..., + description=( + "List of payment recipients. Each entry must have " + "'address' (wallet address) and 'amount' (token amount as string). " + "Example: [{'address': '0xAbc...', 'amount': '10.0'}]" + ), + ) + chain_id: int = Field( + default=8453, + description="Chain ID for the payment. Default: 8453 (Base).", + ) + sender_address: str | None = Field( + default=None, + description=( + "Sender wallet address. Required for 'estimate' and 'execute' actions." + ), + ) + + +class SpraayBatchPaymentTool(BaseTool): + """Tool for batch cryptocurrency payments via the Spraay x402 gateway. + + Send payments to up to 200 recipients in a single transaction with + ~80% gas savings compared to individual transfers. Supports ERC-20 + tokens and native coins on nine EVM chains: Base, Ethereum, BNB Chain, + Unichain, Polygon, Plasma, Arbitrum, Avalanche, and BOB. + + Use cases include payroll, grant distributions, DAO disbursements, + airdrops, and bounty payouts. + + The gateway uses the x402 payment protocol. Validation and estimation + are free. Execution is paid per request via x402 micropayment — no API + key or signup required, but a funded wallet private key must be set in + the SPRAAY_WALLET_PRIVATE_KEY environment variable. Without it, the + 'execute' action returns the gateway's payment requirements instead of + executing. + + Attributes: + gateway_url: Base URL of the Spraay gateway. + """ + + name: str = "Spraay Batch Payment" + description: str = ( + "Validate, estimate gas costs for, and execute batch cryptocurrency " + "payments to multiple recipients in a single transaction. Supports " + "ERC-20 tokens and native coins on nine EVM chains (Base, Ethereum, " + "BNB Chain, Unichain, Polygon, Plasma, Arbitrum, Avalanche, BOB). " + "Use 'validate' to check a recipient list, 'estimate' to preview fees, " + "and 'execute' to send the payment. No API key required — the gateway " + "uses the x402 payment protocol." + ) + args_schema: type[BaseModel] = SpraayBatchPaymentInput + + gateway_url: str = "https://gateway.spraay.app" + _supported_actions: ClassVar[set[str]] = {"validate", "estimate", "execute"} + + def _run(self, **kwargs: Any) -> str: + action = kwargs.get("action", "").lower() + if action not in self._supported_actions: + return ( + f"Error: Invalid action '{action}'. " + f"Must be one of: {', '.join(sorted(self._supported_actions))}" + ) + + token_address = kwargs.get("token_address", "") + chain_id = kwargs.get("chain_id", 8453) + sender_address: str = kwargs.get("sender_address") or "" + + try: + recipients = [ + r + if isinstance(r, SpraayRecipient) + else SpraayRecipient.model_validate(r) + for r in kwargs.get("recipients", []) + ] + except ValidationError as e: + return f"Error: Invalid recipient entry: {e}" + + if not recipients: + return "Error: 'recipients' list is required and cannot be empty." + + if len(recipients) > MAX_RECIPIENTS: + return ( + f"Error: 'recipients' list must contain at most " + f"{MAX_RECIPIENTS} entries." + ) + + if action in ("estimate", "execute") and not sender_address: + return f"Error: 'sender_address' is required for '{action}' action." + + try: + chain = chain_slug(chain_id) + except ValueError as e: + return f"Error: {e}" + + if action == "estimate": + return self._estimate_batch(chain, len(recipients)) + + try: + decimals = token_decimals(token_address, chain_id) + base_amounts = [to_base_units(r.amount, decimals) for r in recipients] + except ValueError as e: + return f"Error: {e}" + + if action == "validate": + return self._validate_batch(chain, token_address, recipients, base_amounts) + return self._execute_batch( + chain, token_address, recipients, base_amounts, sender_address, decimals + ) + + def _validate_batch( + self, + chain: str, + token: str, + recipients: list[SpraayRecipient], + base_amounts: list[str], + ) -> str: + """Validate a batch payment recipient list (free endpoint). + + The gateway expects {chain, token, recipients: [{to, amount}]} with + amounts in base units (verified against /free/validate-batch). + """ + body = { + "chain": chain, + "token": token, + "recipients": [ + {"to": r.address, "amount": amount} + for r, amount in zip(recipients, base_amounts, strict=True) + ], + } + try: + response = requests.post( + f"{self.gateway_url}/free/validate-batch", + json=body, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "valid" if data.get("valid") else "invalid", + "recipientCount": len(recipients), + "details": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error validating batch: {e}" + + def _estimate_batch(self, chain: str, recipient_count: int) -> str: + """Estimate gas and fees for a batch payment (free endpoint). + + The gateway expects ?recipients=&chain= query params. + """ + params: dict[str, int | str] = { + "recipients": recipient_count, + "chain": chain, + } + try: + response = requests.get( + f"{self.gateway_url}/free/estimate-batch", + params=params, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "estimated", + "recipientCount": recipient_count, + "estimate": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error estimating batch: {e}" + + def _execute_batch( + self, + chain: str, + token: str, + recipients: list[SpraayRecipient], + base_amounts: list[str], + sender: str, + decimals: int, + ) -> str: + """Execute a batch payment (x402 paid endpoint). + + The gateway expects {token, recipients, amounts, sender} — token as + contract address or native symbol, recipients as a flat address + array, amounts as a parallel base-unit array (per the gateway + OpenAPI spec and BPA 1.0 §12). The chain slug is included for + non-Base batches; the gateway tolerates extra fields. + """ + body = { + "token": token, + "recipients": [r.address for r in recipients], + "amounts": base_amounts, + "sender": sender, + "chain": chain, + } + try: + paid, data = post_with_x402( + f"{self.gateway_url}/api/v1/batch/execute", + body, + timeout=60, + ) + if not paid: + return json.dumps(data, indent=2) + return json.dumps( + { + "status": "executed", + "recipientCount": len(recipients), + "chain": chain, + "tokenDecimals": decimals, + "result": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error executing batch payment: {e}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_escrow_tool.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_escrow_tool.py new file mode 100644 index 0000000000..54c32662f4 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_escrow_tool.py @@ -0,0 +1,143 @@ +"""Spraay Escrow Tool for CrewAI. + +Enables AI agents to create and manage on-chain escrow contracts +for trustless transactions via the Spraay x402 payment gateway. +""" + +import json +from typing import Any + +from crewai.tools import BaseTool +from pydantic import BaseModel, Field +import requests + +from crewai_tools.tools.spraay_tool.spraay_payload import ( + chain_slug, + to_base_units, + token_decimals, +) +from crewai_tools.tools.spraay_tool.spraay_x402 import post_with_x402 + + +class SpraayEscrowInput(BaseModel): + """Input schema for the SpraayEscrowTool.""" + + token_address: str = Field( + ..., + description=( + "The ERC-20 token contract address for the escrow, " + "or '0x0000000000000000000000000000000000000000' for native ETH." + ), + ) + amount: str = Field( + ..., + description="The amount to escrow (as a string, e.g. '100.0').", + ) + depositor: str = Field( + ..., + description="Wallet address of the party depositing funds.", + ) + beneficiary: str = Field( + ..., + description="Wallet address of the party who will receive funds on release.", + ) + chain_id: int = Field( + default=8453, + description="Chain ID for the escrow. Default: 8453 (Base).", + ) + conditions: str | None = Field( + default=None, + description=( + "Optional human-readable description of release conditions for the escrow." + ), + ) + + +class SpraayEscrowTool(BaseTool): + """Tool for creating on-chain escrow contracts via the Spraay x402 gateway. + + Create trustless escrow agreements between two parties with + programmable release conditions. Funds are held in a smart contract + until conditions are met, eliminating counterparty risk. + + Use cases include freelance payments, agent-to-agent service contracts, + milestone-based disbursements, and dispute-protected purchases. + + The gateway uses the x402 payment protocol — no API key or signup + required. Escrow creation is a paid endpoint: set a funded wallet + private key in the SPRAAY_WALLET_PRIVATE_KEY environment variable + to pay the x402 micropayment automatically. Without it, the tool + returns the gateway's payment requirements instead of creating + the escrow. + + Attributes: + gateway_url: Base URL of the Spraay gateway. + """ + + name: str = "Spraay Escrow" + description: str = ( + "Create an on-chain escrow contract between two parties. " + "Funds are locked in a smart contract and released when " + "conditions are met. Supports ERC-20 tokens and native coins " + "on nine EVM chains (Base, Ethereum, BNB Chain, Unichain, " + "Polygon, Plasma, Arbitrum, Avalanche, BOB). No API key required " + "— the gateway uses the x402 payment protocol." + ) + args_schema: type[BaseModel] = SpraayEscrowInput + + gateway_url: str = "https://gateway.spraay.app" + + def _run(self, **kwargs: Any) -> str: + token_address = kwargs.get("token_address", "") + amount = kwargs.get("amount", "") + depositor = kwargs.get("depositor", "") + beneficiary = kwargs.get("beneficiary", "") + chain_id = kwargs.get("chain_id", 8453) + conditions = kwargs.get("conditions") + + if not all([token_address, amount, depositor, beneficiary]): + return ( + "Error: 'token_address', 'amount', 'depositor', and " + "'beneficiary' are all required." + ) + + try: + chain = chain_slug(chain_id) + base_amount = to_base_units(amount, token_decimals(token_address, chain_id)) + except ValueError as e: + return f"Error: {e}" + + # The gateway expects {depositor, beneficiary, token, amount} with + # the amount in base units (per the gateway OpenAPI spec). The chain + # slug and conditions are extra hints the gateway tolerates. + payload = { + "depositor": depositor, + "beneficiary": beneficiary, + "token": token_address, + "amount": base_amount, + "chain": chain, + } + if conditions: + payload["conditions"] = conditions + + try: + paid, data = post_with_x402( + f"{self.gateway_url}/api/v1/escrow/create", + payload, + timeout=60, + ) + if not paid: + return json.dumps(data, indent=2) + return json.dumps( + { + "status": "escrow_created", + "depositor": depositor, + "beneficiary": beneficiary, + "amount": amount, + "amountBaseUnits": base_amount, + "result": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error creating escrow: {e}" diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_payload.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_payload.py new file mode 100644 index 0000000000..6bcf3de6b8 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_payload.py @@ -0,0 +1,251 @@ +"""Payload transformation for the Spraay gateway request boundary. + +The tools accept user-friendly input (chain IDs, token addresses, decimal +token amounts) while the gateway expects chain slugs and integer base-unit +amount strings (verified against https://gateway.spraay.app/openapi.json +and the live /free/validate-batch validator). This module converts between +the two at the request boundary only — the tools' public input schemas are +unchanged. + +Token decimals are resolved per (chain, token) — never guessed: a wrong +decimals value silently mis-scales every amount by a power of ten (e.g. +USDC on Polygon uses 6 decimals, not the ERC-20-common 18). Resolution +order: native token -> static known-token table -> cache -> gateway token +directory (Base) -> the token contract's decimals() via public RPC. If all +of these fail, a ValueError is raised instead of falling back to a default. +""" + +from decimal import Decimal, InvalidOperation, Overflow, localcontext +import re + +import requests + + +NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000" + +# The native coin on every supported chain (ETH, BNB, POL, AVAX, XPL) uses +# 18 decimals. +NATIVE_DECIMALS = 18 + +# EVM chain IDs mapped to the gateway's chain slugs (the slug set the +# /free/validate-batch endpoint reports as supported). +CHAIN_SLUGS: dict[int, str] = { + 1: "ethereum", + 56: "bnb", + 130: "unichain", + 137: "polygon", + 8453: "base", + 9745: "plasma", + 42161: "arbitrum", + 43114: "avalanche", + 60808: "bob", +} + +BASE_CHAIN_ID = 8453 + +# The gateway's free token directory (Base tokens with addresses, symbols, +# and decimals). The gateway publishes no per-chain decimals endpoint for +# the other chains, so those resolve on-chain via RPC below. +GATEWAY_TOKENS_URL = "https://gateway.spraay.app/api/v1/tokens" + +# Public JSON-RPC endpoints used to resolve an ERC-20 token's decimals() +# on-chain when the token is not in the static table or gateway directory. +RPC_URLS: dict[int, str] = { + 1: "https://ethereum-rpc.publicnode.com", + 56: "https://bsc-rpc.publicnode.com", + 130: "https://unichain-rpc.publicnode.com", + 137: "https://polygon-bor-rpc.publicnode.com", + 8453: "https://base-rpc.publicnode.com", + 9745: "https://rpc.plasma.to", + 42161: "https://arbitrum-one-rpc.publicnode.com", + 43114: "https://avalanche-c-chain-rpc.publicnode.com", + 60808: "https://rpc.gobob.xyz", +} + +# keccak256("decimals()")[:4] +_DECIMALS_SELECTOR = "0x313ce567" + +# Decimals for well-known tokens, keyed by (chain_id, lowercase contract +# address). The Base entries mirror the gateway token directory. +KNOWN_TOKEN_DECIMALS: dict[tuple[int, str], int] = { + # Base + (8453, "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913"): 6, # USDC + (8453, "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2"): 6, # USDT + (8453, "0x50c5725949a6f0c72e6c4a641f24049a917db0cb"): 18, # DAI + (8453, "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42"): 6, # EURC + (8453, "0x4200000000000000000000000000000000000006"): 18, # WETH + # Ethereum + (1, "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48"): 6, # USDC + (1, "0xdac17f958d2ee523a2206206994597c13d831ec7"): 6, # USDT + (1, "0x6b175474e89094c44da98b954eedeac495271d0f"): 18, # DAI +} + +# Symbol shortcuts from the gateway token directory — valid on Base only. +# The same symbol can use different decimals elsewhere (e.g. Binance-peg +# USDT on BNB Chain uses 18, not 6), so symbols never resolve cross-chain. +BASE_SYMBOL_DECIMALS: dict[str, int] = { + "ETH": 18, + "WETH": 18, + "DAI": 18, + "USDC": 6, + "USDT": 6, + "EURC": 6, +} + +_ADDRESS_RE = re.compile(r"0x[0-9a-fA-F]{40}\Z") + +_decimals_cache: dict[tuple[int, str], int] = {} +_gateway_directory_cache: dict[str, int] | None = None + + +def chain_slug(chain_id: int) -> str: + """Map an EVM chain ID to the gateway's chain slug. + + Raises: + ValueError: If the chain ID is not a supported gateway chain. + """ + slug = CHAIN_SLUGS.get(chain_id) + if slug is None: + supported = ", ".join( + f"{cid} ({name})" for cid, name in sorted(CHAIN_SLUGS.items()) + ) + raise ValueError( + f"Unsupported chain_id {chain_id}. Supported chain IDs: {supported}." + ) + return slug + + +def _gateway_token_directory() -> dict[str, int]: + """Fetch the gateway's Base token directory, mapping lowercase contract + addresses to decimals. Cached after the first successful fetch; a failed + fetch returns an empty mapping without poisoning the cache. + """ + global _gateway_directory_cache + if _gateway_directory_cache is None: + directory: dict[str, int] = {} + try: + response = requests.get(GATEWAY_TOKENS_URL, timeout=10) + response.raise_for_status() + tokens = response.json().get("popularTokens", {}) + for info in tokens.values(): + address = info.get("address") + decimals = info.get("decimals") + if isinstance(address, str) and isinstance(decimals, int): + directory[address.lower()] = decimals + except (requests.RequestException, ValueError, AttributeError): + return {} + _gateway_directory_cache = directory + return _gateway_directory_cache + + +def _rpc_decimals(chain_id: int, token: str) -> int | None: + """Read decimals() from the token contract via the chain's public RPC. + + Returns None when the value cannot be read (unreachable RPC, address is + not a contract, contract without decimals(), or an out-of-range result). + """ + url = RPC_URLS.get(chain_id) + if url is None: + return None + try: + response = requests.post( + url, + json={ + "jsonrpc": "2.0", + "id": 1, + "method": "eth_call", + "params": [{"to": token, "data": _DECIMALS_SELECTOR}, "latest"], + }, + timeout=10, + ) + response.raise_for_status() + result = response.json().get("result") + except (requests.RequestException, ValueError, AttributeError): + return None + if not isinstance(result, str) or not result.startswith("0x") or len(result) <= 2: + return None + try: + decimals = int(result, 16) + except ValueError: + return None + if not 0 <= decimals <= 255: + return None + return decimals + + +def token_decimals(token: str, chain_id: int) -> int: + """Resolve the decimals for a token on a specific chain. + + Raises: + ValueError: If the token's decimals cannot be resolved. Decimals + are never defaulted for unknown tokens — a wrong value would + silently mis-scale every amount by a power of ten. + """ + if token == NATIVE_ADDRESS: + return NATIVE_DECIMALS + + if not _ADDRESS_RE.fullmatch(token): + symbol = token.upper() + if chain_id == BASE_CHAIN_ID and symbol in BASE_SYMBOL_DECIMALS: + return BASE_SYMBOL_DECIMALS[symbol] + raise ValueError( + f"Unknown token symbol {token!r} on chain {chain_id}. Symbol " + "shortcuts are only supported on Base (8453); pass the token's " + "contract address (0x...) instead." + ) + + key = (chain_id, token.lower()) + known = KNOWN_TOKEN_DECIMALS.get(key) + if known is not None: + return known + cached = _decimals_cache.get(key) + if cached is not None: + return cached + + decimals = None + if chain_id == BASE_CHAIN_ID: + decimals = _gateway_token_directory().get(key[1]) + if decimals is None: + decimals = _rpc_decimals(chain_id, token) + if decimals is None: + raise ValueError( + f"Could not resolve decimals for token {token} on chain " + f"{chain_id}: the gateway token directory and the on-chain " + "decimals() lookup both failed. Refusing to guess, since a " + "wrong decimals value would mis-scale every amount. Verify the " + "token contract address, or retry when the chain's RPC " + "endpoint is reachable." + ) + _decimals_cache[key] = decimals + return decimals + + +def to_base_units(amount: str, decimals: int) -> str: + """Convert a decimal token amount string to an integer base-unit string. + + Raises: + ValueError: If the amount is not a finite number, is negative, or + has more fractional digits than the token's decimals. + """ + try: + value = Decimal(str(amount)) + except InvalidOperation as e: + raise ValueError(f"Invalid amount {amount!r}: not a decimal number.") from e + if not value.is_finite(): + raise ValueError(f"Invalid amount {amount!r}: must be a finite number.") + try: + # scaleb() rounds to the context precision (default 28 digits), so + # widen it to hold every significant digit of the input exactly. + with localcontext() as ctx: + ctx.prec = max(ctx.prec, len(value.as_tuple().digits)) + scaled = value.scaleb(decimals) + except (InvalidOperation, Overflow) as e: + raise ValueError(f"Invalid amount {amount!r}: out of range.") from e + if scaled < 0: + raise ValueError(f"Invalid amount {amount!r}: must not be negative.") + if scaled != scaled.to_integral_value(): + raise ValueError( + f"Invalid amount {amount!r}: has more than {decimals} decimal " + "places for this token." + ) + return str(int(scaled)) diff --git a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_x402.py b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_x402.py new file mode 100644 index 0000000000..224233a693 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_x402.py @@ -0,0 +1,101 @@ +"""Shared x402 payment-challenge handling for Spraay paid endpoints. + +Paid gateway endpoints respond with HTTP 402 and a payment-requirements +body. This module retries such requests through the official ``x402`` +client, which signs the requirements with the wallet key from the +SPRAAY_WALLET_PRIVATE_KEY environment variable and attaches the +protocol's payment header on the retry. +""" + +import os +from typing import Any + +import requests + + +WALLET_ENV_VAR = "SPRAAY_WALLET_PRIVATE_KEY" + + +def post_with_x402( + url: str, payload: dict[str, Any], timeout: int = 60 +) -> tuple[bool, dict[str, Any]]: + """POST to a Spraay paid endpoint, handling the x402 payment challenge. + + Args: + url: Full endpoint URL. + payload: JSON body for the request. + timeout: Request timeout in seconds. + + Returns: + A ``(paid, data)`` tuple. When ``paid`` is True, ``data`` is the + endpoint's success response. When False, payment could not be + completed (missing or malformed wallet key, or missing x402 + dependency) and + ``data`` explains why, including the parsed payment requirements. + + Raises: + requests.RequestException: On network errors or non-402 HTTP errors. + """ + response = requests.post(url, json=payload, timeout=timeout) + if response.status_code != 402: + response.raise_for_status() + return True, response.json() + + try: + requirements = response.json() + except ValueError: + requirements = {"raw": response.text} + + private_key = os.environ.get(WALLET_ENV_VAR) + if not private_key: + return False, { + "status": "payment_required", + "message": ( + "This endpoint requires an x402 micropayment. Set the " + f"{WALLET_ENV_VAR} environment variable to a funded wallet " + "private key to pay automatically." + ), + "paymentRequirements": requirements, + } + + try: + from eth_account import Account # type: ignore[import-not-found] + from x402 import x402ClientSync # type: ignore[import-not-found] + from x402.http.clients import ( # type: ignore[import-not-found] + x402_requests, + ) + from x402.mechanisms.evm import ( # type: ignore[import-not-found] + EthAccountSigner, + ) + from x402.mechanisms.evm.exact.register import ( # type: ignore[import-not-found] + register_exact_evm_client, + ) + except ImportError: + return False, { + "status": "payment_required", + "message": ( + "The 'x402' package is required to pay for this endpoint. " + "Install it with: pip install 'x402[requests,evm]'" + ), + "paymentRequirements": requirements, + } + + try: + client = x402ClientSync() + register_exact_evm_client( + client, EthAccountSigner(Account.from_key(private_key)) + ) + except Exception as e: + return False, { + "status": "payment_required", + "message": ( + f"Could not initialize the x402 payment signer from the " + f"{WALLET_ENV_VAR} environment variable: {e}. Set it to a " + "valid EVM wallet private key (0x-prefixed 32-byte hex)." + ), + "paymentRequirements": requirements, + } + with x402_requests(client) as session: + paid_response = session.post(url, json=payload, timeout=timeout) + paid_response.raise_for_status() + return True, paid_response.json() diff --git a/lib/crewai-tools/tests/tools/test_spraay_batch_payment_tool.py b/lib/crewai-tools/tests/tools/test_spraay_batch_payment_tool.py new file mode 100644 index 0000000000..3baba9fbc2 --- /dev/null +++ b/lib/crewai-tools/tests/tools/test_spraay_batch_payment_tool.py @@ -0,0 +1,65 @@ +import json +from unittest.mock import MagicMock, patch + +from crewai_tools.tools.spraay_tool.spraay_batch_payment_tool import ( + MAX_RECIPIENTS, + SpraayBatchPaymentTool, +) +import pytest + + +MODULE = "crewai_tools.tools.spraay_tool.spraay_batch_payment_tool" +SENDER = "0x1111111111111111111111111111111111111111" +TOKEN = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" + + +def _recipients(count: int, amount: str = "1.0") -> list[dict[str, str]]: + return [ + {"address": f"0x{i:040x}", "amount": amount} for i in range(1, count + 1) + ] + + +@pytest.mark.parametrize("action", ["validate", "estimate", "execute"]) +@patch(f"{MODULE}.post_with_x402") +@patch(f"{MODULE}.token_decimals") +@patch(f"{MODULE}.requests.get") +@patch(f"{MODULE}.requests.post") +def test_rejects_more_than_max_recipients_without_http( + mock_post, mock_get, mock_decimals, mock_x402, action +): + result = SpraayBatchPaymentTool()._run( + action=action, + token_address=TOKEN, + recipients=_recipients(MAX_RECIPIENTS + 1), + sender_address=SENDER, + ) + + assert result == "Error: 'recipients' list must contain at most 200 entries." + mock_post.assert_not_called() + mock_get.assert_not_called() + mock_decimals.assert_not_called() + mock_x402.assert_not_called() + + +@patch(f"{MODULE}.token_decimals") +@patch(f"{MODULE}.requests.get") +def test_estimate_skips_amount_conversion(mock_get, mock_decimals): + response = MagicMock() + response.json.return_value = {"gas": "123"} + mock_get.return_value = response + + result = SpraayBatchPaymentTool()._run( + action="estimate", + token_address=TOKEN, + recipients=_recipients(3, amount="not-a-number"), + sender_address=SENDER, + ) + + mock_decimals.assert_not_called() + mock_get.assert_called_once() + assert mock_get.call_args.kwargs["params"] == {"recipients": 3, "chain": "base"} + assert json.loads(result) == { + "status": "estimated", + "recipientCount": 3, + "estimate": {"gas": "123"}, + } diff --git a/lib/crewai-tools/tests/tools/test_spraay_payload.py b/lib/crewai-tools/tests/tools/test_spraay_payload.py new file mode 100644 index 0000000000..17ed0d452c --- /dev/null +++ b/lib/crewai-tools/tests/tools/test_spraay_payload.py @@ -0,0 +1,73 @@ +from decimal import Overflow + +from crewai_tools.tools.spraay_tool.spraay_payload import ( + BASE_CHAIN_ID, + NATIVE_ADDRESS, + NATIVE_DECIMALS, + to_base_units, + token_decimals, +) +import pytest + + +NON_BASE_CHAIN_IDS = [1, 56, 137, 43114] + + +@pytest.mark.parametrize("symbol", ["ETH", "eth"]) +def test_eth_symbol_resolves_on_base(symbol): + assert token_decimals(symbol, BASE_CHAIN_ID) == 18 + + +@pytest.mark.parametrize("chain_id", NON_BASE_CHAIN_IDS) +@pytest.mark.parametrize("symbol", ["ETH", "eth"]) +def test_eth_symbol_rejected_on_non_base_chains(symbol, chain_id): + with pytest.raises(ValueError, match="only supported on Base"): + token_decimals(symbol, chain_id) + + +@pytest.mark.parametrize("chain_id", [BASE_CHAIN_ID, *NON_BASE_CHAIN_IDS]) +def test_native_address_resolves_on_every_chain(chain_id): + assert token_decimals(NATIVE_ADDRESS, chain_id) == NATIVE_DECIMALS + + +def test_to_base_units_scales_amount(): + assert to_base_units("1.5", 6) == "1500000" + + +@pytest.mark.parametrize( + ("amount", "decimals", "expected"), + [ + ( + "123456789012345678901234567890.123456789012345678", + 18, + "123456789012345678901234567890123456789012345678", + ), + ( + "999999999999999999999999999999.999999", + 6, + "999999999999999999999999999999999999", + ), + ], +) +def test_to_base_units_preserves_precision_beyond_28_digits( + amount, decimals, expected +): + assert to_base_units(amount, decimals) == expected + + +def test_to_base_units_rejects_excess_decimals_beyond_28_digits(): + with pytest.raises(ValueError, match="more than 6 decimal places"): + to_base_units("123456789012345678901234567890.1234567", 6) + + +@pytest.mark.parametrize("amount", ["1E999999", "9.99E999990"]) +def test_to_base_units_maps_decimal_overflow_to_value_error(amount): + with pytest.raises(ValueError, match="out of range") as exc_info: + to_base_units(amount, 18) + assert isinstance(exc_info.value.__cause__, Overflow) + + +@pytest.mark.parametrize("amount", ["inf", "-Infinity", "NaN"]) +def test_to_base_units_rejects_non_finite(amount): + with pytest.raises(ValueError, match="finite"): + to_base_units(amount, 18)