diff --git a/contributing/tools/spraay/README.md b/contributing/tools/spraay/README.md new file mode 100644 index 0000000..5a77859 --- /dev/null +++ b/contributing/tools/spraay/README.md @@ -0,0 +1,133 @@ +# Spraay Batch Payment Tools for Google ADK + +[Spraay](https://spraay.app) enables AI agents to batch-send ETH or ERC-20 tokens to up to 200 recipients in a single transaction on [Base](https://base.org), with ~80% gas savings compared to individual transfers. + +## Overview + +These tools allow any Google ADK agent to execute batch cryptocurrency payments on Base. Common use cases include: + +- **Payroll**: Pay team members in ETH or stablecoins in one transaction +- **Airdrops**: Distribute tokens to community members efficiently +- **Bounties**: Send rewards to multiple contributors at once +- **Revenue sharing**: Split payments across stakeholders + +## Installation + +```bash +pip install google-adk-community web3 +``` + +## Quick Start + +```python +from google.adk.agents import Agent +from google.adk_community.tools.spraay import ( + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, +) + +agent = Agent( + name="payment_agent", + model="gemini-2.5-flash", + instruction="""You are a payment assistant that helps users send + batch cryptocurrency payments on Base using Spraay. Always confirm + recipient addresses and amounts before executing transactions.""", + tools=[ + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, + ], +) +``` + +## Configuration + +Set the following environment variables: + +| Variable | Required | Description | +|---|---|---| +| `SPRAAY_PRIVATE_KEY` | Yes | Private key of the sending wallet | +| `SPRAAY_RPC_URL` | No | Base RPC endpoint (default: `https://mainnet.base.org`) | +| `SPRAAY_CONTRACT_ADDRESS` | No | Override Spraay contract address | + +```bash +export SPRAAY_PRIVATE_KEY="0x..." +``` + +## Tools + +### `spraay_batch_eth` + +Send equal amounts of ETH to multiple recipients. + +```python +# Example: Send 0.01 ETH to 3 recipients +result = spraay_batch_eth( + recipients=["0xAddr1...", "0xAddr2...", "0xAddr3..."], + amount_per_recipient_eth="0.01", +) +``` + +### `spraay_batch_token` + +Send equal amounts of an ERC-20 token to multiple recipients. Handles token approval automatically. + +```python +# Example: Send 100 USDC to 3 recipients +USDC_BASE = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" +result = spraay_batch_token( + token_address=USDC_BASE, + recipients=["0xAddr1...", "0xAddr2...", "0xAddr3..."], + amount_per_recipient="100", + token_decimals=6, # USDC uses 6 decimals +) +``` + +### `spraay_batch_eth_variable` + +Send different ETH amounts to each recipient. + +```python +# Example: Send variable amounts to 3 recipients +result = spraay_batch_eth_variable( + recipients=["0xAddr1...", "0xAddr2...", "0xAddr3..."], + amounts_eth=["0.1", "0.25", "0.05"], +) +``` + +### `spraay_batch_token_variable` + +Send different token amounts to each recipient. + +```python +# Example: Send variable USDC amounts to 3 recipients +result = spraay_batch_token_variable( + token_address=USDC_BASE, + recipients=["0xAddr1...", "0xAddr2...", "0xAddr3..."], + amounts=["100", "250.5", "75"], + token_decimals=6, +) +``` + +## Protocol Details + +- **Contract**: `0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC` on Base Mainnet +- **Max recipients**: 200 per transaction +- **Fee**: 0.3% protocol fee +- **Gas savings**: ~80% compared to individual transfers +- **Token support**: Any ERC-20 token on Base +- **Website**: [spraay.app](https://spraay.app) +- **Source**: [github.com/plagtech](https://github.com/plagtech) + +## Running Tests + +```bash +pytest tests/unittests/tools/spraay/ -v +``` + +## License + +Apache 2.0 - See [LICENSE](../../LICENSE) for details. diff --git a/src/google/adk_community/tools/spraay/__init__.py b/src/google/adk_community/tools/spraay/__init__.py new file mode 100644 index 0000000..dbf2a89 --- /dev/null +++ b/src/google/adk_community/tools/spraay/__init__.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Spraay batch payment tools for Google ADK agents. + +Spraay (https://spraay.app) enables AI agents to batch-send ETH or ERC-20 +tokens to up to 200 recipients in a single transaction on Base, with ~80% +gas savings compared to individual transfers. + +Tools: + spraay_batch_eth: Send equal ETH to multiple recipients. + spraay_batch_token: Send equal ERC-20 tokens to multiple recipients. + spraay_batch_eth_variable: Send variable ETH amounts per recipient. + spraay_batch_token_variable: Send variable token amounts per recipient. + +Usage: + from google.adk_community.tools.spraay import ( + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, + ) + from google.adk.agents import Agent + + agent = Agent( + name="payment_agent", + model="gemini-2.5-flash", + tools=[ + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, + ], + ) +""" + +from google.adk_community.tools.spraay.spraay_tools import ( + spraay_batch_eth, + spraay_batch_eth_variable, + spraay_batch_token, + spraay_batch_token_variable, +) + +__all__ = [ + "spraay_batch_eth", + "spraay_batch_token", + "spraay_batch_eth_variable", + "spraay_batch_token_variable", +] diff --git a/src/google/adk_community/tools/spraay/constants.py b/src/google/adk_community/tools/spraay/constants.py new file mode 100644 index 0000000..b9630f2 --- /dev/null +++ b/src/google/adk_community/tools/spraay/constants.py @@ -0,0 +1,101 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Constants for Spraay batch payment tools.""" + +# Spraay contract on Base Mainnet +SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" + +# Base Mainnet chain configuration +BASE_CHAIN_ID = 8453 +BASE_RPC_URL = "https://mainnet.base.org" + +# Protocol fee: 0.3% +SPRAAY_FEE_BPS = 30 # basis points + +# Maximum recipients per transaction +MAX_RECIPIENTS = 200 + +# ERC-20 max approval +MAX_UINT256 = 2**256 - 1 + +# Spraay contract ABI (relevant functions only) +SPRAAY_ABI = [ + { + "inputs": [ + {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, + {"internalType": "uint256", "name": "_amount", "type": "uint256"}, + ], + "name": "spraayETH", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "_token", "type": "address"}, + {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, + {"internalType": "uint256", "name": "_amount", "type": "uint256"}, + ], + "name": "spraayToken", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, + {"internalType": "uint256[]", "name": "_amounts", "type": "uint256[]"}, + ], + "name": "spraayETHVariable", + "outputs": [], + "stateMutability": "payable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "_token", "type": "address"}, + {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, + {"internalType": "uint256[]", "name": "_amounts", "type": "uint256[]"}, + ], + "name": "spraayTokenVariable", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function", + }, +] + +# ERC-20 approve ABI +ERC20_APPROVE_ABI = [ + { + "inputs": [ + {"internalType": "address", "name": "spender", "type": "address"}, + {"internalType": "uint256", "name": "amount", "type": "uint256"}, + ], + "name": "approve", + "outputs": [{"internalType": "bool", "name": "", "type": "bool"}], + "stateMutability": "nonpayable", + "type": "function", + }, + { + "inputs": [ + {"internalType": "address", "name": "owner", "type": "address"}, + {"internalType": "address", "name": "spender", "type": "address"}, + ], + "name": "allowance", + "outputs": [{"internalType": "uint256", "name": "", "type": "uint256"}], + "stateMutability": "view", + "type": "function", + }, +] diff --git a/src/google/adk_community/tools/spraay/spraay_tools.py b/src/google/adk_community/tools/spraay/spraay_tools.py new file mode 100644 index 0000000..7cad446 --- /dev/null +++ b/src/google/adk_community/tools/spraay/spraay_tools.py @@ -0,0 +1,487 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Spraay batch payment tool functions for Google ADK. + +These functions are designed to be used as ADK FunctionTools. When assigned +to an agent's tools list, the ADK framework automatically wraps them and +generates schemas from the function signatures and docstrings. + +Environment Variables: + SPRAAY_RPC_URL: Base RPC endpoint (default: https://mainnet.base.org) + SPRAAY_PRIVATE_KEY: Private key for signing transactions (required) + SPRAAY_CONTRACT_ADDRESS: Override default Spraay contract address + +Dependencies: + pip install web3 +""" + +import logging +import os +from typing import Optional + +from google.adk_community.tools.spraay.constants import ( + BASE_CHAIN_ID, + BASE_RPC_URL, + ERC20_APPROVE_ABI, + MAX_RECIPIENTS, + MAX_UINT256, + SPRAAY_ABI, + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_FEE_BPS, +) + +logger = logging.getLogger(__name__) + + +def _get_web3(): + """Initialize Web3 connection to Base.""" + try: + from web3 import Web3 + except ImportError: + raise ImportError( + "web3 is required for Spraay tools. Install with: pip install web3" + ) + + rpc_url = os.environ.get("SPRAAY_RPC_URL", BASE_RPC_URL) + w3 = Web3(Web3.HTTPProvider(rpc_url)) + if not w3.is_connected(): + raise ConnectionError(f"Failed to connect to Base RPC at {rpc_url}") + return w3 + + +def _get_account(): + """Get the signing account from environment.""" + from web3 import Account + + private_key = os.environ.get("SPRAAY_PRIVATE_KEY") + if not private_key: + raise ValueError( + "SPRAAY_PRIVATE_KEY environment variable is required. " + "Set it to the private key of the wallet that will send payments." + ) + return Account.from_key(private_key) + + +def _get_contract_address() -> str: + """Get the Spraay contract address (allows override via env).""" + return os.environ.get("SPRAAY_CONTRACT_ADDRESS", SPRAAY_CONTRACT_ADDRESS) + + +def _validate_recipients(recipients: list[str]) -> list[str]: + """Validate and checksum recipient addresses.""" + from web3 import Web3 + + if not recipients: + raise ValueError("Recipients list cannot be empty.") + if len(recipients) > MAX_RECIPIENTS: + raise ValueError( + f"Maximum {MAX_RECIPIENTS} recipients per transaction. " + f"Got {len(recipients)}." + ) + + checksummed = [] + for addr in recipients: + if not Web3.is_address(addr): + raise ValueError(f"Invalid Ethereum address: {addr}") + checksummed.append(Web3.to_checksum_address(addr)) + return checksummed + + +def _calculate_fee(total_wei: int) -> int: + """Calculate the Spraay protocol fee (0.3%).""" + return (total_wei * SPRAAY_FEE_BPS) // 10000 + + +def spraay_batch_eth( + recipients: list[str], + amount_per_recipient_eth: str, +) -> dict: + """Send equal amounts of ETH to multiple recipients in a single transaction on Base. + + Uses the Spraay protocol to batch-send ETH, saving ~80% on gas compared + to individual transfers. Supports up to 200 recipients per transaction. + A 0.3% protocol fee is applied. + + Args: + recipients: List of Ethereum addresses to receive ETH. + Maximum 200 addresses per transaction. + amount_per_recipient_eth: Amount of ETH each recipient will receive, + as a decimal string (e.g. "0.01" for 0.01 ETH per recipient). + + Returns: + dict with keys: + - status: "success" or "error" + - tx_hash: Transaction hash (on success) + - recipients_count: Number of recipients + - amount_per_recipient: ETH amount per recipient + - total_eth: Total ETH sent (including fee) + - error: Error message (on failure) + """ + try: + w3 = _get_web3() + account = _get_account() + contract_address = _get_contract_address() + + checksummed = _validate_recipients(recipients) + amount_wei = w3.to_wei(amount_per_recipient_eth, "ether") + + if amount_wei <= 0: + return {"status": "error", "error": "Amount must be greater than 0."} + + total_wei = amount_wei * len(checksummed) + fee_wei = _calculate_fee(total_wei) + total_with_fee = total_wei + fee_wei + + contract = w3.eth.contract( + address=w3.to_checksum_address(contract_address), + abi=SPRAAY_ABI, + ) + + tx = contract.functions.spraayETH( + checksummed, amount_wei + ).build_transaction( + { + "from": account.address, + "value": total_with_fee, + "nonce": w3.eth.get_transaction_count(account.address), + "chainId": BASE_CHAIN_ID, + "gas": 0, # Will be estimated + } + ) + + tx["gas"] = w3.eth.estimate_gas(tx) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + + return { + "status": "success", + "tx_hash": tx_hash.hex(), + "recipients_count": len(checksummed), + "amount_per_recipient": amount_per_recipient_eth, + "total_eth": str(w3.from_wei(total_with_fee, "ether")), + } + + except Exception as e: + logger.error("spraay_batch_eth failed: %s", str(e)) + return {"status": "error", "error": str(e)} + + +def spraay_batch_token( + token_address: str, + recipients: list[str], + amount_per_recipient: str, + token_decimals: int = 18, +) -> dict: + """Send equal amounts of an ERC-20 token to multiple recipients on Base. + + Uses the Spraay protocol to batch-send tokens, saving ~80% on gas. + Automatically handles token approval if needed. Supports up to 200 + recipients per transaction. A 0.3% protocol fee is applied. + + Args: + token_address: The ERC-20 token contract address (e.g. USDC on Base). + recipients: List of Ethereum addresses to receive tokens. + Maximum 200 addresses per transaction. + amount_per_recipient: Amount of tokens each recipient receives, + as a decimal string (e.g. "10.5" for 10.5 tokens each). + token_decimals: Number of decimals for the token (default 18). + USDC uses 6 decimals. Check the token contract if unsure. + + Returns: + dict with keys: + - status: "success" or "error" + - tx_hash: Transaction hash (on success) + - approval_tx_hash: Approval tx hash (if approval was needed) + - recipients_count: Number of recipients + - amount_per_recipient: Token amount per recipient + - token_address: Token contract address + - error: Error message (on failure) + """ + try: + w3 = _get_web3() + account = _get_account() + contract_address = _get_contract_address() + + checksummed = _validate_recipients(recipients) + token_addr = w3.to_checksum_address(token_address) + spraay_addr = w3.to_checksum_address(contract_address) + + # Convert human-readable amount to token units + amount_units = int(float(amount_per_recipient) * (10**token_decimals)) + if amount_units <= 0: + return {"status": "error", "error": "Amount must be greater than 0."} + + total_units = amount_units * len(checksummed) + fee_units = (total_units * SPRAAY_FEE_BPS) // 10000 + total_with_fee = total_units + fee_units + + result = {"approval_tx_hash": None} + + # Check and handle token approval + token_contract = w3.eth.contract(address=token_addr, abi=ERC20_APPROVE_ABI) + allowance = token_contract.functions.allowance( + account.address, spraay_addr + ).call() + + if allowance < total_with_fee: + approve_tx = token_contract.functions.approve( + spraay_addr, MAX_UINT256 + ).build_transaction( + { + "from": account.address, + "nonce": w3.eth.get_transaction_count(account.address), + "chainId": BASE_CHAIN_ID, + "gas": 0, + } + ) + approve_tx["gas"] = w3.eth.estimate_gas(approve_tx) + signed_approve = account.sign_transaction(approve_tx) + approve_hash = w3.eth.send_raw_transaction( + signed_approve.raw_transaction + ) + w3.eth.wait_for_transaction_receipt(approve_hash, timeout=120) + result["approval_tx_hash"] = approve_hash.hex() + + # Execute batch transfer + spraay_contract = w3.eth.contract(address=spraay_addr, abi=SPRAAY_ABI) + nonce = w3.eth.get_transaction_count(account.address) + + tx = spraay_contract.functions.spraayToken( + token_addr, checksummed, amount_units + ).build_transaction( + { + "from": account.address, + "nonce": nonce, + "chainId": BASE_CHAIN_ID, + "gas": 0, + } + ) + tx["gas"] = w3.eth.estimate_gas(tx) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + + result.update( + { + "status": "success", + "tx_hash": tx_hash.hex(), + "recipients_count": len(checksummed), + "amount_per_recipient": amount_per_recipient, + "token_address": token_address, + } + ) + return result + + except Exception as e: + logger.error("spraay_batch_token failed: %s", str(e)) + return {"status": "error", "error": str(e)} + + +def spraay_batch_eth_variable( + recipients: list[str], + amounts_eth: list[str], +) -> dict: + """Send variable amounts of ETH to multiple recipients on Base. + + Each recipient receives a different amount of ETH, useful for payroll, + bounties, or reward distributions where amounts differ per person. + Supports up to 200 recipients. A 0.3% protocol fee is applied. + + Args: + recipients: List of Ethereum addresses to receive ETH. + Maximum 200 addresses per transaction. + amounts_eth: List of ETH amounts as decimal strings, one per recipient. + Must be the same length as recipients. + Example: ["0.1", "0.25", "0.05"] for three recipients. + + Returns: + dict with keys: + - status: "success" or "error" + - tx_hash: Transaction hash (on success) + - recipients_count: Number of recipients + - total_eth: Total ETH sent (including fee) + - error: Error message (on failure) + """ + try: + w3 = _get_web3() + account = _get_account() + contract_address = _get_contract_address() + + checksummed = _validate_recipients(recipients) + + if len(amounts_eth) != len(checksummed): + return { + "status": "error", + "error": ( + f"Recipients count ({len(checksummed)}) must match " + f"amounts count ({len(amounts_eth)})." + ), + } + + amounts_wei = [w3.to_wei(a, "ether") for a in amounts_eth] + if any(a <= 0 for a in amounts_wei): + return {"status": "error", "error": "All amounts must be greater than 0."} + + total_wei = sum(amounts_wei) + fee_wei = _calculate_fee(total_wei) + total_with_fee = total_wei + fee_wei + + contract = w3.eth.contract( + address=w3.to_checksum_address(contract_address), + abi=SPRAAY_ABI, + ) + + tx = contract.functions.spraayETHVariable( + checksummed, amounts_wei + ).build_transaction( + { + "from": account.address, + "value": total_with_fee, + "nonce": w3.eth.get_transaction_count(account.address), + "chainId": BASE_CHAIN_ID, + "gas": 0, + } + ) + tx["gas"] = w3.eth.estimate_gas(tx) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + + return { + "status": "success", + "tx_hash": tx_hash.hex(), + "recipients_count": len(checksummed), + "total_eth": str(w3.from_wei(total_with_fee, "ether")), + } + + except Exception as e: + logger.error("spraay_batch_eth_variable failed: %s", str(e)) + return {"status": "error", "error": str(e)} + + +def spraay_batch_token_variable( + token_address: str, + recipients: list[str], + amounts: list[str], + token_decimals: int = 18, +) -> dict: + """Send variable amounts of an ERC-20 token to multiple recipients on Base. + + Each recipient receives a different token amount. Automatically handles + token approval. Supports up to 200 recipients. A 0.3% protocol fee + is applied. + + Args: + token_address: The ERC-20 token contract address. + recipients: List of Ethereum addresses to receive tokens. + Maximum 200 addresses per transaction. + amounts: List of token amounts as decimal strings, one per recipient. + Must be the same length as recipients. + Example: ["100", "250.5", "75"] for three recipients. + token_decimals: Number of decimals for the token (default 18). + + Returns: + dict with keys: + - status: "success" or "error" + - tx_hash: Transaction hash (on success) + - approval_tx_hash: Approval tx hash (if approval was needed) + - recipients_count: Number of recipients + - token_address: Token contract address + - error: Error message (on failure) + """ + try: + w3 = _get_web3() + account = _get_account() + contract_address = _get_contract_address() + + checksummed = _validate_recipients(recipients) + token_addr = w3.to_checksum_address(token_address) + spraay_addr = w3.to_checksum_address(contract_address) + + if len(amounts) != len(checksummed): + return { + "status": "error", + "error": ( + f"Recipients count ({len(checksummed)}) must match " + f"amounts count ({len(amounts)})." + ), + } + + amounts_units = [ + int(float(a) * (10**token_decimals)) for a in amounts + ] + if any(a <= 0 for a in amounts_units): + return {"status": "error", "error": "All amounts must be greater than 0."} + + total_units = sum(amounts_units) + fee_units = (total_units * SPRAAY_FEE_BPS) // 10000 + total_with_fee = total_units + fee_units + + result = {"approval_tx_hash": None} + + # Check and handle token approval + token_contract = w3.eth.contract(address=token_addr, abi=ERC20_APPROVE_ABI) + allowance = token_contract.functions.allowance( + account.address, spraay_addr + ).call() + + if allowance < total_with_fee: + approve_tx = token_contract.functions.approve( + spraay_addr, MAX_UINT256 + ).build_transaction( + { + "from": account.address, + "nonce": w3.eth.get_transaction_count(account.address), + "chainId": BASE_CHAIN_ID, + "gas": 0, + } + ) + approve_tx["gas"] = w3.eth.estimate_gas(approve_tx) + signed_approve = account.sign_transaction(approve_tx) + approve_hash = w3.eth.send_raw_transaction( + signed_approve.raw_transaction + ) + w3.eth.wait_for_transaction_receipt(approve_hash, timeout=120) + result["approval_tx_hash"] = approve_hash.hex() + + # Execute batch transfer + spraay_contract = w3.eth.contract(address=spraay_addr, abi=SPRAAY_ABI) + nonce = w3.eth.get_transaction_count(account.address) + + tx = spraay_contract.functions.spraayTokenVariable( + token_addr, checksummed, amounts_units + ).build_transaction( + { + "from": account.address, + "nonce": nonce, + "chainId": BASE_CHAIN_ID, + "gas": 0, + } + ) + tx["gas"] = w3.eth.estimate_gas(tx) + signed = account.sign_transaction(tx) + tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) + + result.update( + { + "status": "success", + "tx_hash": tx_hash.hex(), + "recipients_count": len(checksummed), + "token_address": token_address, + } + ) + return result + + except Exception as e: + logger.error("spraay_batch_token_variable failed: %s", str(e)) + return {"status": "error", "error": str(e)} diff --git a/tests/unittests/tools/spraay/__init__.py b/tests/unittests/tools/spraay/__init__.py new file mode 100644 index 0000000..dbf2a89 --- /dev/null +++ b/tests/unittests/tools/spraay/__init__.py @@ -0,0 +1,60 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Spraay batch payment tools for Google ADK agents. + +Spraay (https://spraay.app) enables AI agents to batch-send ETH or ERC-20 +tokens to up to 200 recipients in a single transaction on Base, with ~80% +gas savings compared to individual transfers. + +Tools: + spraay_batch_eth: Send equal ETH to multiple recipients. + spraay_batch_token: Send equal ERC-20 tokens to multiple recipients. + spraay_batch_eth_variable: Send variable ETH amounts per recipient. + spraay_batch_token_variable: Send variable token amounts per recipient. + +Usage: + from google.adk_community.tools.spraay import ( + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, + ) + from google.adk.agents import Agent + + agent = Agent( + name="payment_agent", + model="gemini-2.5-flash", + tools=[ + spraay_batch_eth, + spraay_batch_token, + spraay_batch_eth_variable, + spraay_batch_token_variable, + ], + ) +""" + +from google.adk_community.tools.spraay.spraay_tools import ( + spraay_batch_eth, + spraay_batch_eth_variable, + spraay_batch_token, + spraay_batch_token_variable, +) + +__all__ = [ + "spraay_batch_eth", + "spraay_batch_token", + "spraay_batch_eth_variable", + "spraay_batch_token_variable", +] diff --git a/tests/unittests/tools/spraay/test_spraay_tools.py b/tests/unittests/tools/spraay/test_spraay_tools.py new file mode 100644 index 0000000..85eec84 --- /dev/null +++ b/tests/unittests/tools/spraay/test_spraay_tools.py @@ -0,0 +1,185 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for Spraay batch payment tools.""" + +import os +import unittest +from unittest.mock import MagicMock, patch + +from google.adk_community.tools.spraay.constants import ( + MAX_RECIPIENTS, + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_FEE_BPS, +) +from google.adk_community.tools.spraay.spraay_tools import ( + _calculate_fee, + _validate_recipients, + spraay_batch_eth, + spraay_batch_eth_variable, + spraay_batch_token, + spraay_batch_token_variable, +) + +# Valid test addresses (checksummed) +ADDR_1 = "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD1e" +ADDR_2 = "0xAb5801a7D398351b8bE11C439e05C5b3259aeC9B" +ADDR_3 = "0xCA35b7d915458EF540aDe6068dFe2F44E8fa733c" +TOKEN_ADDR = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913" # USDC on Base + + +class TestValidateRecipients(unittest.TestCase): + """Tests for recipient address validation.""" + + @patch("google.adk_community.tools.spraay.spraay_tools.Web3") + def test_valid_addresses(self, mock_web3_class): + """Valid addresses should be checksummed and returned.""" + mock_web3_class.is_address.return_value = True + mock_web3_class.to_checksum_address.side_effect = lambda x: x + result = _validate_recipients([ADDR_1, ADDR_2]) + self.assertEqual(len(result), 2) + + def test_empty_list(self): + """Empty recipient list should raise ValueError.""" + with self.assertRaises(ValueError): + _validate_recipients([]) + + @patch("google.adk_community.tools.spraay.spraay_tools.Web3") + def test_too_many_recipients(self, mock_web3_class): + """More than MAX_RECIPIENTS should raise ValueError.""" + mock_web3_class.is_address.return_value = True + addresses = [f"0x{'0' * 39}{i:01x}" for i in range(MAX_RECIPIENTS + 1)] + with self.assertRaises(ValueError): + _validate_recipients(addresses) + + @patch("google.adk_community.tools.spraay.spraay_tools.Web3") + def test_invalid_address(self, mock_web3_class): + """Invalid address should raise ValueError.""" + mock_web3_class.is_address.return_value = False + with self.assertRaises(ValueError): + _validate_recipients(["not_an_address"]) + + +class TestCalculateFee(unittest.TestCase): + """Tests for fee calculation.""" + + def test_fee_calculation(self): + """Fee should be 0.3% (30 basis points).""" + total = 10000 + fee = _calculate_fee(total) + self.assertEqual(fee, (total * SPRAAY_FEE_BPS) // 10000) + + def test_zero_amount(self): + """Zero amount should produce zero fee.""" + self.assertEqual(_calculate_fee(0), 0) + + def test_small_amount(self): + """Small amounts should still produce valid fee.""" + fee = _calculate_fee(100) + self.assertIsInstance(fee, int) + self.assertGreaterEqual(fee, 0) + + +class TestSpraayBatchEth(unittest.TestCase): + """Tests for spraay_batch_eth function.""" + + def test_missing_private_key(self): + """Should return error if SPRAAY_PRIVATE_KEY is not set.""" + with patch.dict(os.environ, {}, clear=True): + result = spraay_batch_eth([ADDR_1], "0.01") + self.assertEqual(result["status"], "error") + self.assertIn("SPRAAY_PRIVATE_KEY", result["error"]) + + @patch("google.adk_community.tools.spraay.spraay_tools._get_web3") + @patch("google.adk_community.tools.spraay.spraay_tools._get_account") + def test_zero_amount_returns_error(self, mock_account, mock_web3): + """Zero ETH amount should return error.""" + mock_w3 = MagicMock() + mock_w3.to_wei.return_value = 0 + mock_web3.return_value = mock_w3 + mock_account.return_value = MagicMock() + + result = spraay_batch_eth([ADDR_1], "0") + self.assertEqual(result["status"], "error") + self.assertIn("greater than 0", result["error"]) + + +class TestSpraayBatchEthVariable(unittest.TestCase): + """Tests for spraay_batch_eth_variable function.""" + + def test_mismatched_lengths(self): + """Recipients and amounts must have same length.""" + with patch.dict(os.environ, {"SPRAAY_PRIVATE_KEY": "0x" + "a" * 64}): + with patch( + "google.adk_community.tools.spraay.spraay_tools._get_web3" + ) as mock_web3: + mock_w3 = MagicMock() + mock_w3.to_wei.side_effect = lambda x, _: int(float(x) * 10**18) + mock_web3.return_value = mock_w3 + + with patch( + "google.adk_community.tools.spraay.spraay_tools._validate_recipients" + ) as mock_validate: + mock_validate.return_value = [ADDR_1, ADDR_2] + + result = spraay_batch_eth_variable( + [ADDR_1, ADDR_2], ["0.1"] + ) + self.assertEqual(result["status"], "error") + self.assertIn("must match", result["error"]) + + +class TestSpraayBatchToken(unittest.TestCase): + """Tests for spraay_batch_token function.""" + + def test_missing_private_key(self): + """Should return error if SPRAAY_PRIVATE_KEY is not set.""" + with patch.dict(os.environ, {}, clear=True): + result = spraay_batch_token(TOKEN_ADDR, [ADDR_1], "10") + self.assertEqual(result["status"], "error") + self.assertIn("SPRAAY_PRIVATE_KEY", result["error"]) + + +class TestSpraayBatchTokenVariable(unittest.TestCase): + """Tests for spraay_batch_token_variable function.""" + + def test_missing_private_key(self): + """Should return error if SPRAAY_PRIVATE_KEY is not set.""" + with patch.dict(os.environ, {}, clear=True): + result = spraay_batch_token_variable( + TOKEN_ADDR, [ADDR_1], ["10"] + ) + self.assertEqual(result["status"], "error") + self.assertIn("SPRAAY_PRIVATE_KEY", result["error"]) + + +class TestConstants(unittest.TestCase): + """Tests for Spraay constants.""" + + def test_contract_address_format(self): + """Contract address should be valid checksum format.""" + self.assertTrue(SPRAAY_CONTRACT_ADDRESS.startswith("0x")) + self.assertEqual(len(SPRAAY_CONTRACT_ADDRESS), 42) + + def test_max_recipients(self): + """Max recipients should be 200.""" + self.assertEqual(MAX_RECIPIENTS, 200) + + def test_fee_bps(self): + """Fee should be 30 basis points (0.3%).""" + self.assertEqual(SPRAAY_FEE_BPS, 30) + + +if __name__ == "__main__": + unittest.main()