From 0544f69be1537fa427857b8e495f52a076f4e590 Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 21 Jul 2026 11:57:23 -0700 Subject: [PATCH 1/8] feat: add Spraay batch payment, escrow, and balance tools --- .../crewai_tools/tools/spraay_tool/README.md | 127 ++++++++++++ .../tools/spraay_tool/__init__.py | 9 + .../tools/spraay_tool/spraay_balance_tool.py | 92 +++++++++ .../spraay_tool/spraay_batch_payment_tool.py | 184 ++++++++++++++++++ .../tools/spraay_tool/spraay_escrow_tool.py | 123 ++++++++++++ 5 files changed, 535 insertions(+) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_balance_tool.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_batch_payment_tool.py create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_escrow_tool.py 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..a98f6fac2c --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md @@ -0,0 +1,127 @@ +# 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. + +Supported chains include Base, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BNB Chain, and more. + +## Installation + +To incorporate these tools into your project, follow the installation instructions below: + +```bash +pip install crewai[tools] requests +``` + +## 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..d47b71d34d --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py @@ -0,0 +1,9 @@ +from .spraay_batch_payment_tool import SpraayBatchPaymentTool +from .spraay_escrow_tool import SpraayEscrowTool +from .spraay_balance_tool import SpraayBalanceTool + +__all__ = [ + "SpraayBatchPaymentTool", + "SpraayEscrowTool", + "SpraayBalanceTool", +] 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..4ffb277924 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_balance_tool.py @@ -0,0 +1,92 @@ +"""Spraay Balance Tool for CrewAI. + +Enables AI agents to check token balances across supported chains +via the Spraay x402 payment gateway's free endpoints. +""" + +import json +from typing import Any + +import requests +from crewai.tools import BaseTool +from pydantic import BaseModel, Field + + +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. This is a free + endpoint that requires no API key, making it useful for pre-flight + checks before batch payments or escrow creation. + + 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. " + "Free endpoint — no API key required. Useful for verifying sufficient " + "funds before executing batch payments or creating escrow contracts." + ) + 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}/free/balance", + 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..bf1a3123c2 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_batch_payment_tool.py @@ -0,0 +1,184 @@ +"""Spraay Batch Payment Tool for CrewAI. + +Enables AI agents to validate, estimate, and execute batch cryptocurrency +payments on Base and 15+ supported chains via the Spraay x402 payment gateway. +""" + +import json +from typing import Any, ClassVar, Optional + +import requests +from crewai.tools import BaseTool +from pydantic import BaseModel, Field + + +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[dict[str, str]] = 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: Optional[str] = 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 ETH on Base, Ethereum, Solana, and 13+ other chains. + + Use cases include payroll, grant distributions, DAO disbursements, + airdrops, and bounty payouts. + + The gateway uses the x402 payment protocol. Validation and estimation + endpoints are free. The execute endpoint is paid per request via x402 + micropayment — no API key or signup required. + + 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 ETH on Base, Ethereum, Solana, and 13+ chains. " + "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", "") + recipients = kwargs.get("recipients", []) + chain_id = kwargs.get("chain_id", 8453) + sender_address = kwargs.get("sender_address") + + if not recipients: + return "Error: 'recipients' list is required and cannot be empty." + + if action in ("estimate", "execute") and not sender_address: + return f"Error: 'sender_address' is required for '{action}' action." + + payload = { + "tokenAddress": token_address, + "recipients": [ + {"address": r["address"], "amount": r["amount"]} for r in recipients + ], + "chainId": chain_id, + } + if sender_address: + payload["senderAddress"] = sender_address + + if action == "validate": + return self._validate_batch(payload) + elif action == "estimate": + return self._estimate_batch(payload) + else: + return self._execute_batch(payload) + + def _validate_batch(self, payload: dict) -> str: + """Validate a batch payment recipient list (free endpoint).""" + try: + response = requests.post( + f"{self.gateway_url}/free/validate-batch", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "valid", + "recipientCount": len(payload["recipients"]), + "details": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error validating batch: {e}" + + def _estimate_batch(self, payload: dict) -> str: + """Estimate gas and fees for a batch payment (free endpoint).""" + try: + response = requests.post( + f"{self.gateway_url}/free/estimate-batch", + json=payload, + timeout=30, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "estimated", + "recipientCount": len(payload["recipients"]), + "estimate": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error estimating batch: {e}" + + def _execute_batch(self, payload: dict) -> str: + """Execute a batch payment (x402 paid endpoint).""" + try: + response = requests.post( + f"{self.gateway_url}/api/v1/batch/execute", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=60, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "executed", + "recipientCount": len(payload["recipients"]), + "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..dfd2ce6204 --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_escrow_tool.py @@ -0,0 +1,123 @@ +"""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, Optional + +import requests +from crewai.tools import BaseTool +from pydantic import BaseModel, Field + + +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: Optional[str] = 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. + + 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 ETH " + "on Base, Ethereum, and 14+ other chains. 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." + ) + + payload = { + "tokenAddress": token_address, + "amount": amount, + "depositor": depositor, + "beneficiary": beneficiary, + "chainId": chain_id, + } + if conditions: + payload["conditions"] = conditions + + try: + response = requests.post( + f"{self.gateway_url}/api/v1/escrow/create", + json=payload, + headers={"Content-Type": "application/json"}, + timeout=60, + ) + response.raise_for_status() + data = response.json() + return json.dumps( + { + "status": "escrow_created", + "depositor": depositor, + "beneficiary": beneficiary, + "amount": amount, + "result": data, + }, + indent=2, + ) + except requests.RequestException as e: + return f"Error creating escrow: {e}" From f0624c8a07b1e009f6bac4a7351ca8bb3dd7c125 Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 21 Jul 2026 12:13:35 -0700 Subject: [PATCH 2/8] fix: correct estimate-batch to GET, balance endpoint to /api/v1/balances --- .../tools/spraay_tool/spraay_balance_tool.py | 19 +++++++++++-------- .../spraay_tool/spraay_batch_payment_tool.py | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) 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 index 4ffb277924..706f204634 100644 --- 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 @@ -1,7 +1,7 @@ -"""Spraay Balance Tool for CrewAI. +"""Spraay Balance Tool for CrewAI. Enables AI agents to check token balances across supported chains -via the Spraay x402 payment gateway's free endpoints. +via the Spraay x402 payment gateway. """ import json @@ -36,9 +36,11 @@ class SpraayBalanceInput(BaseModel): class SpraayBalanceTool(BaseTool): """Tool for checking token balances via the Spraay x402 gateway. - Query wallet balances across 16 supported chains. This is a free - endpoint that requires no API key, making it useful for pre-flight - checks before batch payments or escrow creation. + 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. @@ -47,8 +49,9 @@ class SpraayBalanceTool(BaseTool): name: str = "Spraay Balance Check" description: str = ( "Check the token balance of a wallet address on any supported chain. " - "Free endpoint — no API key required. Useful for verifying sufficient " - "funds before executing batch payments or creating escrow contracts." + "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 @@ -73,7 +76,7 @@ def _run(self, **kwargs: Any) -> str: try: response = requests.get( - f"{self.gateway_url}/free/balance", + f"{self.gateway_url}/api/v1/balances", params=params, timeout=30, ) 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 index bf1a3123c2..3deaf158c0 100644 --- 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 @@ -61,9 +61,9 @@ class SpraayBatchPaymentTool(BaseTool): Use cases include payroll, grant distributions, DAO disbursements, airdrops, and bounty payouts. - The gateway uses the x402 payment protocol. Validation and estimation - endpoints are free. The execute endpoint is paid per request via x402 - micropayment — no API key or signup required. + The gateway uses the x402 payment protocol. Validation is free. + Estimation and execution are paid per request via x402 micropayment + — no API key or signup required. Attributes: gateway_url: Base URL of the Spraay gateway. @@ -143,9 +143,16 @@ def _validate_batch(self, payload: dict) -> str: def _estimate_batch(self, payload: dict) -> str: """Estimate gas and fees for a batch payment (free endpoint).""" try: - response = requests.post( + params = { + "tokenAddress": payload["tokenAddress"], + "chainId": payload["chainId"], + "recipientCount": len(payload["recipients"]), + } + if "senderAddress" in payload: + params["senderAddress"] = payload["senderAddress"] + response = requests.get( f"{self.gateway_url}/free/estimate-batch", - json=payload, + params=params, timeout=30, ) response.raise_for_status() From 0c65ec3a69839124ae66dc53f3d2976beb53ae02 Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 21 Jul 2026 15:39:20 -0700 Subject: [PATCH 3/8] fix: address CodeRabbit review on Spraay tools - Replace recipients list[dict] with nested SpraayRecipient Pydantic model (required address and amount fields) in the batch payment tool - Add x402 payment challenge handling to paid endpoints (batch execute, escrow create) via the official x402 package: on HTTP 402, sign the payment requirements with SPRAAY_WALLET_PRIVATE_KEY and retry with the payment header attached; without the key, return the parsed payment requirements as structured JSON instead of raising - Document wallet requirement for paid endpoints in the README - Verified endpoints against the live gateway discovery doc (/.well-known/x402.json): batch execute $0.02, escrow create $0.10, x402 v2 on Base/USDC --- .../crewai_tools/tools/spraay_tool/README.md | 22 ++++- .../tools/spraay_tool/__init__.py | 13 ++- .../tools/spraay_tool/spraay_balance_tool.py | 2 +- .../spraay_tool/spraay_batch_payment_tool.py | 60 +++++++++----- .../tools/spraay_tool/spraay_escrow_tool.py | 26 +++--- .../tools/spraay_tool/spraay_x402.py | 81 +++++++++++++++++++ 6 files changed, 167 insertions(+), 37 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_x402.py 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 index a98f6fac2c..f66d4671d7 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md @@ -8,7 +8,7 @@ A suite of CrewAI tools for batch cryptocurrency payments, escrow, and balance q - **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. +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)). Supported chains include Base, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BNB Chain, and more. @@ -20,6 +20,26 @@ To incorporate these tools into your project, follow the installation instructio 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 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 index d47b71d34d..59db9c45d7 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/__init__.py @@ -1,9 +1,14 @@ -from .spraay_batch_payment_tool import SpraayBatchPaymentTool -from .spraay_escrow_tool import SpraayEscrowTool -from .spraay_balance_tool import SpraayBalanceTool +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", - "SpraayBalanceTool", + "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 index 706f204634..9c8083cdb6 100644 --- 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 @@ -7,9 +7,9 @@ import json from typing import Any -import requests from crewai.tools import BaseTool from pydantic import BaseModel, Field +import requests class SpraayBalanceInput(BaseModel): 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 index 3deaf158c0..edbb35a874 100644 --- 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 @@ -5,11 +5,23 @@ """ import json -from typing import Any, ClassVar, Optional +from typing import Any, ClassVar -import requests from crewai.tools import BaseTool -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError +import requests + +from crewai_tools.tools.spraay_tool.spraay_x402 import post_with_x402 + + +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): @@ -31,7 +43,7 @@ class SpraayBatchPaymentInput(BaseModel): "or '0x0000000000000000000000000000000000000000' for native ETH." ), ) - recipients: list[dict[str, str]] = Field( + recipients: list[SpraayRecipient] = Field( ..., description=( "List of payment recipients. Each entry must have " @@ -43,7 +55,7 @@ class SpraayBatchPaymentInput(BaseModel): default=8453, description="Chain ID for the payment. Default: 8453 (Base).", ) - sender_address: Optional[str] = Field( + sender_address: str | None = Field( default=None, description=( "Sender wallet address. Required for 'estimate' and 'execute' actions." @@ -61,9 +73,12 @@ class SpraayBatchPaymentTool(BaseTool): Use cases include payroll, grant distributions, DAO disbursements, airdrops, and bounty payouts. - The gateway uses the x402 payment protocol. Validation is free. - Estimation and execution are paid per request via x402 micropayment - — no API key or signup required. + 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. @@ -92,10 +107,19 @@ def _run(self, **kwargs: Any) -> str: ) token_address = kwargs.get("token_address", "") - recipients = kwargs.get("recipients", []) chain_id = kwargs.get("chain_id", 8453) sender_address = kwargs.get("sender_address") + 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." @@ -104,9 +128,7 @@ def _run(self, **kwargs: Any) -> str: payload = { "tokenAddress": token_address, - "recipients": [ - {"address": r["address"], "amount": r["amount"]} for r in recipients - ], + "recipients": [r.model_dump() for r in recipients], "chainId": chain_id, } if sender_address: @@ -114,10 +136,9 @@ def _run(self, **kwargs: Any) -> str: if action == "validate": return self._validate_batch(payload) - elif action == "estimate": + if action == "estimate": return self._estimate_batch(payload) - else: - return self._execute_batch(payload) + return self._execute_batch(payload) def _validate_batch(self, payload: dict) -> str: """Validate a batch payment recipient list (free endpoint).""" @@ -171,14 +192,13 @@ def _estimate_batch(self, payload: dict) -> str: def _execute_batch(self, payload: dict) -> str: """Execute a batch payment (x402 paid endpoint).""" try: - response = requests.post( + paid, data = post_with_x402( f"{self.gateway_url}/api/v1/batch/execute", - json=payload, - headers={"Content-Type": "application/json"}, + payload, timeout=60, ) - response.raise_for_status() - data = response.json() + if not paid: + return json.dumps(data, indent=2) return json.dumps( { "status": "executed", 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 index dfd2ce6204..2c316713bb 100644 --- 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 @@ -5,11 +5,13 @@ """ import json -from typing import Any, Optional +from typing import Any -import requests from crewai.tools import BaseTool from pydantic import BaseModel, Field +import requests + +from crewai_tools.tools.spraay_tool.spraay_x402 import post_with_x402 class SpraayEscrowInput(BaseModel): @@ -38,11 +40,10 @@ class SpraayEscrowInput(BaseModel): default=8453, description="Chain ID for the escrow. Default: 8453 (Base).", ) - conditions: Optional[str] = Field( + conditions: str | None = Field( default=None, description=( - "Optional human-readable description of release conditions " - "for the escrow." + "Optional human-readable description of release conditions for the escrow." ), ) @@ -58,7 +59,11 @@ class SpraayEscrowTool(BaseTool): milestone-based disbursements, and dispute-protected purchases. The gateway uses the x402 payment protocol — no API key or signup - required. + 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. @@ -101,14 +106,13 @@ def _run(self, **kwargs: Any) -> str: payload["conditions"] = conditions try: - response = requests.post( + paid, data = post_with_x402( f"{self.gateway_url}/api/v1/escrow/create", - json=payload, - headers={"Content-Type": "application/json"}, + payload, timeout=60, ) - response.raise_for_status() - data = response.json() + if not paid: + return json.dumps(data, indent=2) return json.dumps( { "status": "escrow_created", 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..1532aef44a --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_x402.py @@ -0,0 +1,81 @@ +"""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 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 + from x402 import x402ClientSync + from x402.http.clients import x402_requests + from x402.mechanisms.evm import EthAccountSigner + from x402.mechanisms.evm.exact.register import 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, + } + + client = x402ClientSync() + register_exact_evm_client(client, EthAccountSigner(Account.from_key(private_key))) + 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() From c989a18cfceaf36fd85594974919a69b56501c2f Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 21 Jul 2026 16:14:06 -0700 Subject: [PATCH 4/8] fix: register Spraay tools and align payloads with live gateway spec - Register SpraayBatchPaymentTool, SpraayEscrowTool, SpraayBalanceTool, and SpraayRecipient in crewai_tools __init__ so top-level imports work - Align request bodies with the gateway (verified against openapi.json, the BPA 1.0 spec/schema, and live probes of the free endpoints): - POST /api/v1/batch/execute: {token, recipients[], amounts[], sender} with flat parallel arrays and base-unit amount strings - POST /free/validate-batch: {chain, token, recipients: [{to, amount}]} with chain slugs (base, ethereum, ...) not chain IDs - GET /free/estimate-batch: ?recipients=&chain= - POST /api/v1/escrow/create: {depositor, beneficiary, token, amount} - Add spraay_payload module: chain-ID-to-slug map, known token decimals, and Decimal-based decimal-to-base-unit conversion at the request boundary; public input schemas (SpraayRecipient address/amount decimal strings) unchanged - Free endpoints verified live: validate-batch returns valid:true with the new shape; estimate-batch returns per-chain estimates --- lib/crewai-tools/src/crewai_tools/__init__.py | 10 ++ .../crewai_tools/tools/spraay_tool/README.md | 2 + .../spraay_tool/spraay_batch_payment_tool.py | 106 +++++++++++++----- .../tools/spraay_tool/spraay_escrow_tool.py | 21 +++- .../tools/spraay_tool/spraay_payload.py | 99 ++++++++++++++++ 5 files changed, 204 insertions(+), 34 deletions(-) create mode 100644 lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_payload.py 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 index f66d4671d7..e564ecbf8b 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md @@ -12,6 +12,8 @@ The gateway uses the [x402 payment protocol](https://www.x402.org/) — no API k Supported chains include Base, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BNB Chain, and more. +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 using known token decimals (USDC/USDT/EURC use 6, other tokens default to the ERC-20 standard 18). + ## Installation To incorporate these tools into your project, follow the installation instructions below: 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 index edbb35a874..f40f2ecf47 100644 --- 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 @@ -11,6 +11,11 @@ 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 @@ -126,34 +131,53 @@ def _run(self, **kwargs: Any) -> str: if action in ("estimate", "execute") and not sender_address: return f"Error: 'sender_address' is required for '{action}' action." - payload = { - "tokenAddress": token_address, - "recipients": [r.model_dump() for r in recipients], - "chainId": chain_id, - } - if sender_address: - payload["senderAddress"] = sender_address + try: + chain = chain_slug(chain_id) + decimals = token_decimals(token_address) + 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(payload) + return self._validate_batch(chain, token_address, recipients, base_amounts) if action == "estimate": - return self._estimate_batch(payload) - return self._execute_batch(payload) - - def _validate_batch(self, payload: dict) -> str: - """Validate a batch payment recipient list (free endpoint).""" + return self._estimate_batch(chain, len(recipients)) + 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=payload, + json=body, timeout=30, ) response.raise_for_status() data = response.json() return json.dumps( { - "status": "valid", - "recipientCount": len(payload["recipients"]), + "status": "valid" if data.get("valid") else "invalid", + "recipientCount": len(recipients), "details": data, }, indent=2, @@ -161,19 +185,15 @@ def _validate_batch(self, payload: dict) -> str: except requests.RequestException as e: return f"Error validating batch: {e}" - def _estimate_batch(self, payload: dict) -> str: - """Estimate gas and fees for a batch payment (free endpoint).""" + 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. + """ try: - params = { - "tokenAddress": payload["tokenAddress"], - "chainId": payload["chainId"], - "recipientCount": len(payload["recipients"]), - } - if "senderAddress" in payload: - params["senderAddress"] = payload["senderAddress"] response = requests.get( f"{self.gateway_url}/free/estimate-batch", - params=params, + params={"recipients": recipient_count, "chain": chain}, timeout=30, ) response.raise_for_status() @@ -181,7 +201,7 @@ def _estimate_batch(self, payload: dict) -> str: return json.dumps( { "status": "estimated", - "recipientCount": len(payload["recipients"]), + "recipientCount": recipient_count, "estimate": data, }, indent=2, @@ -189,12 +209,34 @@ def _estimate_batch(self, payload: dict) -> str: except requests.RequestException as e: return f"Error estimating batch: {e}" - def _execute_batch(self, payload: dict) -> str: - """Execute a batch payment (x402 paid endpoint).""" + 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", - payload, + body, timeout=60, ) if not paid: @@ -202,7 +244,9 @@ def _execute_batch(self, payload: dict) -> str: return json.dumps( { "status": "executed", - "recipientCount": len(payload["recipients"]), + "recipientCount": len(recipients), + "chain": chain, + "tokenDecimals": decimals, "result": data, }, indent=2, 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 index 2c316713bb..15eec1f523 100644 --- 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 @@ -11,6 +11,11 @@ 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 @@ -95,12 +100,21 @@ def _run(self, **kwargs: Any) -> str: "'beneficiary' are all required." ) + try: + chain = chain_slug(chain_id) + base_amount = to_base_units(amount, token_decimals(token_address)) + 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 = { - "tokenAddress": token_address, - "amount": amount, "depositor": depositor, "beneficiary": beneficiary, - "chainId": chain_id, + "token": token_address, + "amount": base_amount, + "chain": chain, } if conditions: payload["conditions"] = conditions @@ -119,6 +133,7 @@ def _run(self, **kwargs: Any) -> str: "depositor": depositor, "beneficiary": beneficiary, "amount": amount, + "amountBaseUnits": base_amount, "result": data, }, indent=2, 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..8c4a9fa85c --- /dev/null +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/spraay_payload.py @@ -0,0 +1,99 @@ +"""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. +""" + +from decimal import Decimal, InvalidOperation + + +NATIVE_ADDRESS = "0x0000000000000000000000000000000000000000" + +# 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", +} + +# Decimals for well-known tokens, keyed by lowercase contract address or +# uppercase native symbol. Amounts sent to the gateway must be integer +# strings in the token's base units (e.g. 0.01 USDC with 6 decimals -> +# "10000"). Unknown ERC-20 tokens fall back to the common default of 18. +DEFAULT_DECIMALS = 18 +TOKEN_DECIMALS: dict[str, int] = { + NATIVE_ADDRESS: 18, + "ETH": 18, + "WETH": 18, + "DAI": 18, + "USDC": 6, + "USDT": 6, + "EURC": 6, + # USDC (Base / Ethereum) + "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": 6, + "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": 6, + # USDT (Base / Ethereum) + "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2": 6, + "0xdac17f958d2ee523a2206206994597c13d831ec7": 6, + # EURC (Base) + "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42": 6, + # DAI (Ethereum), WETH (Base) + "0x6b175474e89094c44da98b954eedeac495271d0f": 18, + "0x4200000000000000000000000000000000000006": 18, +} + + +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 token_decimals(token: str) -> int: + """Return the decimals for a token address or native symbol.""" + return TOKEN_DECIMALS.get( + token.lower(), TOKEN_DECIMALS.get(token.upper(), DEFAULT_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 valid number, is negative, or has + more fractional digits than the token's decimals. + """ + try: + scaled = Decimal(str(amount)).scaleb(decimals) + except InvalidOperation as e: + raise ValueError(f"Invalid amount {amount!r}: not a decimal number.") 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)) From bc4e6d34d0eef4751f1ee3fbde55447ac84ddc32 Mon Sep 17 00:00:00 2001 From: plagtech Date: Tue, 21 Jul 2026 16:58:18 -0700 Subject: [PATCH 5/8] fix: address second CodeRabbit review round on Spraay tools - Resolve token decimals dynamically per (chain, token) instead of silently defaulting unknown tokens to 18: static known-token table -> gateway token directory (/api/v1/tokens, Base) -> the token contract's decimals() via public JSON-RPC eth_call, with results cached. If none resolve, return a clear error rather than guessing (e.g. USDC on Polygon is 6 decimals and now resolves correctly via RPC; previously it was mis-scaled by 10^12). Symbol shortcuts are now Base-only since the same symbol can use different decimals on other chains. - Reject non-finite amounts in to_base_units: Decimal('NaN')/'Infinity' previously escaped as InvalidOperation/OverflowError past the ValueError handlers; now guarded with is_finite() (and scaleb overflow is mapped to ValueError too). - Wrap x402 signer initialization (Account.from_key + register_exact_evm_client) in try/except so a malformed SPRAAY_WALLET_PRIVATE_KEY returns a structured payment_required response instead of crashing, matching the sibling failure branches. - Correct chain claims in docstrings/descriptions: the gateway supports exactly 9 EVM chains (Base, Ethereum, BNB Chain, Unichain, Polygon, Plasma, Arbitrum, Avalanche, BOB); removed inaccurate Solana and 13+/14+/15+ chain references in the batch, escrow, and README docs. Smoke-tested against the live gateway and public RPCs: unknown token on Polygon errors instead of guessing, USDC on Polygon resolves to 6 via RPC, NaN/Infinity amounts raise ValueError, malformed wallet key returns structured JSON, and the existing validate/estimate/execute/escrow payload shapes are unchanged. --- .../crewai_tools/tools/spraay_tool/README.md | 4 +- .../spraay_tool/spraay_batch_payment_tool.py | 11 +- .../tools/spraay_tool/spraay_escrow_tool.py | 7 +- .../tools/spraay_tool/spraay_payload.py | 200 +++++++++++++++--- .../tools/spraay_tool/spraay_x402.py | 20 +- 5 files changed, 204 insertions(+), 38 deletions(-) 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 index e564ecbf8b..3e85943446 100644 --- a/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md +++ b/lib/crewai-tools/src/crewai_tools/tools/spraay_tool/README.md @@ -10,9 +10,9 @@ A suite of CrewAI tools for batch cryptocurrency payments, escrow, and balance q 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)). -Supported chains include Base, Ethereum, Solana, Polygon, Arbitrum, Optimism, Avalanche, BNB Chain, and more. +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 using known token decimals (USDC/USDT/EURC use 6, other tokens default to the ERC-20 standard 18). +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 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 index f40f2ecf47..569a4a4fe0 100644 --- 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 @@ -1,7 +1,8 @@ """Spraay Batch Payment Tool for CrewAI. Enables AI agents to validate, estimate, and execute batch cryptocurrency -payments on Base and 15+ supported chains via the Spraay x402 payment gateway. +payments on nine EVM chains (Base, Ethereum, BNB Chain, Unichain, Polygon, +Plasma, Arbitrum, Avalanche, BOB) via the Spraay x402 payment gateway. """ import json @@ -73,7 +74,8 @@ class SpraayBatchPaymentTool(BaseTool): 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 ETH on Base, Ethereum, Solana, and 13+ other chains. + 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. @@ -93,7 +95,8 @@ class SpraayBatchPaymentTool(BaseTool): 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 ETH on Base, Ethereum, Solana, and 13+ chains. " + "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." @@ -133,7 +136,7 @@ def _run(self, **kwargs: Any) -> str: try: chain = chain_slug(chain_id) - decimals = token_decimals(token_address) + 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}" 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 index 15eec1f523..54c32662f4 100644 --- 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 @@ -78,8 +78,9 @@ class SpraayEscrowTool(BaseTool): 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 ETH " - "on Base, Ethereum, and 14+ other chains. No API key required " + "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 @@ -102,7 +103,7 @@ def _run(self, **kwargs: Any) -> str: try: chain = chain_slug(chain_id) - base_amount = to_base_units(amount, token_decimals(token_address)) + base_amount = to_base_units(amount, token_decimals(token_address, chain_id)) except ValueError as e: return f"Error: {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 index 8c4a9fa85c..31e2790980 100644 --- 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 @@ -6,13 +6,27 @@ 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 +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] = { @@ -27,32 +41,62 @@ 60808: "bob", } -# Decimals for well-known tokens, keyed by lowercase contract address or -# uppercase native symbol. Amounts sent to the gateway must be integer -# strings in the token's base units (e.g. 0.01 USDC with 6 decimals -> -# "10000"). Unknown ERC-20 tokens fall back to the common default of 18. -DEFAULT_DECIMALS = 18 -TOKEN_DECIMALS: dict[str, int] = { - NATIVE_ADDRESS: 18, +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, - # USDC (Base / Ethereum) - "0x833589fcd6edb6e08f4c7c32d4f71b54bda02913": 6, - "0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48": 6, - # USDT (Base / Ethereum) - "0xfde4c96c8593536e31f229ea8f37b2ada2699bb2": 6, - "0xdac17f958d2ee523a2206206994597c13d831ec7": 6, - # EURC (Base) - "0x60a3e35cc302bfa44cb288bc5a4f316fdb1adb42": 6, - # DAI (Ethereum), WETH (Base) - "0x6b175474e89094c44da98b954eedeac495271d0f": 18, - "0x4200000000000000000000000000000000000006": 18, } +_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. @@ -71,24 +115,128 @@ def chain_slug(chain_id: int) -> str: return slug -def token_decimals(token: str) -> int: - """Return the decimals for a token address or native symbol.""" - return TOKEN_DECIMALS.get( - token.lower(), TOKEN_DECIMALS.get(token.upper(), DEFAULT_DECIMALS) - ) +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 or token.upper() == "ETH": + 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 valid number, is negative, or has - more fractional digits than the token's decimals. + ValueError: If the amount is not a finite number, is negative, or + has more fractional digits than the token's decimals. """ try: - scaled = Decimal(str(amount)).scaleb(decimals) + 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: + scaled = value.scaleb(decimals) + except InvalidOperation 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(): 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 index 1532aef44a..fca4b3c2bc 100644 --- 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 @@ -29,7 +29,8 @@ def post_with_x402( Returns: A ``(paid, data)`` tuple. When ``paid`` is True, ``data`` is the endpoint's success response. When False, payment could not be - completed (missing wallet key or missing x402 dependency) and + completed (missing or malformed wallet key, or missing x402 + dependency) and ``data`` explains why, including the parsed payment requirements. Raises: @@ -73,8 +74,21 @@ def post_with_x402( "paymentRequirements": requirements, } - client = x402ClientSync() - register_exact_evm_client(client, EthAccountSigner(Account.from_key(private_key))) + 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() From 217e3473d8b86eb4db15091b05bc1a28e681aca8 Mon Sep 17 00:00:00 2001 From: plagtech Date: Wed, 16 Sep 2026 09:38:33 -0700 Subject: [PATCH 6/8] fix(spraay): restrict ETH symbol shortcut to Base, map decimal.Overflow to ValueError --- .../tools/spraay_tool/spraay_payload.py | 6 +-- .../tests/tools/test_spraay_payload.py | 47 +++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) create mode 100644 lib/crewai-tools/tests/tools/test_spraay_payload.py 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 index 31e2790980..4a60a3d8f6 100644 --- 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 @@ -15,7 +15,7 @@ of these fail, a ValueError is raised instead of falling back to a default. """ -from decimal import Decimal, InvalidOperation +from decimal import Decimal, InvalidOperation, Overflow import re import requests @@ -181,7 +181,7 @@ def token_decimals(token: str, chain_id: int) -> int: are never defaulted for unknown tokens — a wrong value would silently mis-scale every amount by a power of ten. """ - if token == NATIVE_ADDRESS or token.upper() == "ETH": + if token == NATIVE_ADDRESS: return NATIVE_DECIMALS if not _ADDRESS_RE.fullmatch(token): @@ -235,7 +235,7 @@ def to_base_units(amount: str, decimals: int) -> str: raise ValueError(f"Invalid amount {amount!r}: must be a finite number.") try: scaled = value.scaleb(decimals) - except InvalidOperation as e: + 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.") 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..2fc7780ce4 --- /dev/null +++ b/lib/crewai-tools/tests/tools/test_spraay_payload.py @@ -0,0 +1,47 @@ +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", ["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) From 7b9b0fd903e301db4f73d8c9aaf17e76158f0f53 Mon Sep 17 00:00:00 2001 From: plagtech Date: Wed, 16 Sep 2026 09:48:04 -0700 Subject: [PATCH 7/8] fix(spraay): resolve mypy errors in x402 client and batch payment tool --- .../spraay_tool/spraay_batch_payment_tool.py | 8 ++++++-- .../tools/spraay_tool/spraay_x402.py | 16 +++++++++++----- 2 files changed, 17 insertions(+), 7 deletions(-) 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 index 569a4a4fe0..81be92a9cb 100644 --- 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 @@ -116,7 +116,7 @@ def _run(self, **kwargs: Any) -> str: token_address = kwargs.get("token_address", "") chain_id = kwargs.get("chain_id", 8453) - sender_address = kwargs.get("sender_address") + sender_address: str = kwargs.get("sender_address") or "" try: recipients = [ @@ -193,10 +193,14 @@ def _estimate_batch(self, chain: str, recipient_count: int) -> str: 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={"recipients": recipient_count, "chain": chain}, + params=params, timeout=30, ) response.raise_for_status() 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 index fca4b3c2bc..224233a693 100644 --- 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 @@ -59,11 +59,17 @@ def post_with_x402( } try: - from eth_account import Account - from x402 import x402ClientSync - from x402.http.clients import x402_requests - from x402.mechanisms.evm import EthAccountSigner - from x402.mechanisms.evm.exact.register import register_exact_evm_client + 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", From 81eed1731289c0e2d4d005cd4c62476a54f8cf9f Mon Sep 17 00:00:00 2001 From: plagtech Date: Fri, 18 Sep 2026 20:48:47 -0700 Subject: [PATCH 8/8] fix(spraay): cap batch at 200 recipients, skip conversion for estimate, preserve Decimal precision --- .../spraay_tool/spraay_batch_payment_tool.py | 18 ++++- .../tools/spraay_tool/spraay_payload.py | 8 ++- .../tools/test_spraay_batch_payment_tool.py | 65 +++++++++++++++++++ .../tests/tools/test_spraay_payload.py | 26 ++++++++ 4 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 lib/crewai-tools/tests/tools/test_spraay_batch_payment_tool.py 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 index 81be92a9cb..a177f12976 100644 --- 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 @@ -20,6 +20,9 @@ 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.""" @@ -131,11 +134,24 @@ def _run(self, **kwargs: Any) -> str: 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: @@ -143,8 +159,6 @@ def _run(self, **kwargs: Any) -> str: if action == "validate": return self._validate_batch(chain, token_address, recipients, base_amounts) - if action == "estimate": - return self._estimate_batch(chain, len(recipients)) return self._execute_batch( chain, token_address, recipients, base_amounts, sender_address, decimals ) 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 index 4a60a3d8f6..6bcf3de6b8 100644 --- 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 @@ -15,7 +15,7 @@ of these fail, a ValueError is raised instead of falling back to a default. """ -from decimal import Decimal, InvalidOperation, Overflow +from decimal import Decimal, InvalidOperation, Overflow, localcontext import re import requests @@ -234,7 +234,11 @@ def to_base_units(amount: str, decimals: int) -> str: if not value.is_finite(): raise ValueError(f"Invalid amount {amount!r}: must be a finite number.") try: - scaled = value.scaleb(decimals) + # 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: 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 index 2fc7780ce4..17ed0d452c 100644 --- a/lib/crewai-tools/tests/tools/test_spraay_payload.py +++ b/lib/crewai-tools/tests/tools/test_spraay_payload.py @@ -34,6 +34,32 @@ 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: