diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py index c24574ea4..f172f67dc 100644 --- a/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/__init__.py @@ -85,3 +85,5 @@ "x402_action_provider", "X402Config", ] + +from .spraay import SpraayActionProvider, spraay_action_provider diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py new file mode 100644 index 000000000..266e2704f --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/__init__.py @@ -0,0 +1,5 @@ +"""Spraay Action Provider for Coinbase AgentKit.""" + +from .spraay_action_provider import SpraayActionProvider, spraay_action_provider + +__all__ = ["SpraayActionProvider", "spraay_action_provider"] diff --git a/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py new file mode 100644 index 000000000..ebe8d732c --- /dev/null +++ b/python/coinbase-agentkit/coinbase_agentkit/action_providers/spraay/spraay_action_provider.py @@ -0,0 +1,447 @@ +"""Spraay Action Provider for Coinbase AgentKit. + +Enables AI agents to batch-send ETH or ERC-20 tokens to multiple recipients +in a single transaction via the Spraay protocol on Base. + +Contract: 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC (Base Mainnet) +Website: https://spraay.app +""" + +from typing import Any + +from pydantic import BaseModel, Field + +from coinbase_agentkit import ActionProvider, WalletProvider, create_action +from coinbase_agentkit.network import Network + +# ── Constants ────────────────────────────────────────────────────────────────── + +SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" +SPRAAY_PROTOCOL_FEE_BPS = 30 # 0.3% +SPRAAY_MAX_RECIPIENTS = 200 + +SPRAAY_ABI = [ + { + "name": "sprayETH", + "type": "function", + "stateMutability": "payable", + "inputs": [ + {"name": "recipients", "type": "address[]"}, + {"name": "amounts", "type": "uint256[]"}, + ], + "outputs": [], + }, + { + "name": "sprayToken", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "token", "type": "address"}, + {"name": "recipients", "type": "address[]"}, + {"name": "amounts", "type": "uint256[]"}, + ], + "outputs": [], + }, +] + +ERC20_ABI = [ + { + "name": "approve", + "type": "function", + "stateMutability": "nonpayable", + "inputs": [ + {"name": "spender", "type": "address"}, + {"name": "amount", "type": "uint256"}, + ], + "outputs": [{"name": "", "type": "bool"}], + }, + { + "name": "allowance", + "type": "function", + "stateMutability": "view", + "inputs": [ + {"name": "owner", "type": "address"}, + {"name": "spender", "type": "address"}, + ], + "outputs": [{"name": "", "type": "uint256"}], + }, + { + "name": "decimals", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "uint8"}], + }, + { + "name": "symbol", + "type": "function", + "stateMutability": "view", + "inputs": [], + "outputs": [{"name": "", "type": "string"}], + }, +] + + +# ── Schemas ──────────────────────────────────────────────────────────────────── + + +class SprayEthInput(BaseModel): + """Input schema for spraying ETH to multiple recipients.""" + + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses (e.g. ['0xABC...', '0xDEF...'])", + min_length=1, + max_length=200, + ) + amount_per_recipient: str = Field( + ..., + description="Amount of ETH to send to each recipient in whole units (e.g. '0.01')", + ) + + +class SprayTokenInput(BaseModel): + """Input schema for spraying ERC-20 tokens to multiple recipients.""" + + token_address: str = Field(..., description="The ERC-20 token contract address") + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses", + min_length=1, + max_length=200, + ) + amount_per_recipient: str = Field( + ..., + description="Amount of tokens to send to each recipient in whole units (e.g. '100')", + ) + + +class SprayEthVariableInput(BaseModel): + """Input schema for spraying variable ETH amounts to multiple recipients.""" + + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses", + min_length=1, + max_length=200, + ) + amounts: list[str] = Field( + ..., + description="Array of ETH amounts corresponding to each recipient (e.g. ['0.01', '0.05'])", + min_length=1, + ) + + +class SprayTokenVariableInput(BaseModel): + """Input schema for spraying variable token amounts to multiple recipients.""" + + token_address: str = Field(..., description="The ERC-20 token contract address") + recipients: list[str] = Field( + ..., + description="Array of recipient wallet addresses", + min_length=1, + max_length=200, + ) + amounts: list[str] = Field( + ..., + description="Array of token amounts corresponding to each recipient (e.g. ['100', '50'])", + min_length=1, + ) + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + + +def _parse_units(value: str, decimals: int) -> int: + """Convert a human-readable number string to an integer with the given decimals.""" + parts = value.split(".") + if len(parts) == 1: + return int(parts[0]) * (10**decimals) + integer_part = parts[0] + decimal_part = parts[1][:decimals].ljust(decimals, "0") + return int(integer_part) * (10**decimals) + int(decimal_part) + + +def _format_units(value: int, decimals: int) -> str: + """Convert a wei-like integer back to human-readable format.""" + whole = value // (10**decimals) + fraction = value % (10**decimals) + if fraction == 0: + return str(whole) + frac_str = str(fraction).zfill(decimals).rstrip("0") + return f"{whole}.{frac_str}" + + +# ── Action Provider ──────────────────────────────────────────────────────────── + + +class SpraayActionProvider(ActionProvider[WalletProvider]): + """Spraay Action Provider — batch crypto payments on Base via the Spraay protocol.""" + + def __init__(self) -> None: + super().__init__("spraay", []) + + def supports_network(self, network: Network) -> bool: + """Spraay is currently deployed only on Base mainnet.""" + return network.protocol_family == "evm" and network.network_id == "base-mainnet" + + # ── ETH (equal amounts) ──────────────────────────────────────────────── + + @create_action( + name="spraay_eth", + description=( + "Send equal amounts of ETH to multiple recipients in a single transaction " + "via the Spraay protocol. Up to 200 recipients, ~80% gas savings. " + "0.3% protocol fee. Deployed on Base mainnet." + ), + schema=SprayEthInput, + ) + def spraay_eth( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + try: + recipients = args["recipients"] + amount_wei = _parse_units(args["amount_per_recipient"], 18) + amounts = [amount_wei] * len(recipients) + + subtotal = amount_wei * len(recipients) + fee = (subtotal * SPRAAY_PROTOCOL_FEE_BPS) // 10000 + total_value = subtotal + fee + + tx_hash = wallet_provider.send_transaction( + to=SPRAAY_CONTRACT_ADDRESS, + abi=SPRAAY_ABI, + function_name="sprayETH", + args=[recipients, amounts], + value=total_value, + ) + + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return "\n".join([ + f"Successfully sprayed {args['amount_per_recipient']} ETH to {len(recipients)} recipients via Spraay.", + f"Total sent: {_format_units(subtotal, 18)} ETH", + f"Protocol fee (0.3%): {_format_units(fee, 18)} ETH", + f"Transaction hash: {tx_hash}", + f"Block: {receipt.get('blockNumber', 'pending')}", + f"View on BaseScan: https://basescan.org/tx/{tx_hash}", + ]) + except Exception as e: + return f"Error spraying ETH via Spraay: {e}" + + # ── Token (equal amounts) ────────────────────────────────────────────── + + @create_action( + name="spraay_token", + description=( + "Send equal amounts of an ERC-20 token to multiple recipients in a single " + "transaction via the Spraay protocol. Requires token approval before first use. " + "Up to 200 recipients. 0.3% protocol fee. Deployed on Base mainnet." + ), + schema=SprayTokenInput, + ) + def spraay_token( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + try: + token_address = args["token_address"] + recipients = args["recipients"] + + decimals = self._get_token_decimals(wallet_provider, token_address) + symbol = self._get_token_symbol(wallet_provider, token_address) + + amount_wei = _parse_units(args["amount_per_recipient"], decimals) + amounts = [amount_wei] * len(recipients) + + subtotal = amount_wei * len(recipients) + fee = (subtotal * SPRAAY_PROTOCOL_FEE_BPS) // 10000 + total_amount = subtotal + fee + + approval_msg = self._ensure_token_approval( + wallet_provider, token_address, total_amount + ) + + tx_hash = wallet_provider.send_transaction( + to=SPRAAY_CONTRACT_ADDRESS, + abi=SPRAAY_ABI, + function_name="sprayToken", + args=[token_address, recipients, amounts], + ) + + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + lines = [] + if approval_msg: + lines.append(approval_msg) + lines.extend([ + f"Successfully sprayed {args['amount_per_recipient']} {symbol} to {len(recipients)} recipients via Spraay.", + f"Total sent: {_format_units(subtotal, decimals)} {symbol}", + f"Protocol fee (0.3%): {_format_units(fee, decimals)} {symbol}", + f"Transaction hash: {tx_hash}", + f"Block: {receipt.get('blockNumber', 'pending')}", + f"View on BaseScan: https://basescan.org/tx/{tx_hash}", + ]) + return "\n".join(lines) + except Exception as e: + return f"Error spraying tokens via Spraay: {e}" + + # ── ETH (variable amounts) ───────────────────────────────────────────── + + @create_action( + name="spraay_eth_variable", + description=( + "Send different amounts of ETH to multiple recipients in a single transaction " + "via the Spraay protocol. Each recipient gets a different specified amount. " + "Up to 200 recipients. 0.3% protocol fee. Deployed on Base mainnet." + ), + schema=SprayEthVariableInput, + ) + def spraay_eth_variable( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + recipients = args["recipients"] + amount_strs = args["amounts"] + + if len(recipients) != len(amount_strs): + return ( + f"Error: recipients length ({len(recipients)}) must match " + f"amounts length ({len(amount_strs)})." + ) + + try: + amounts = [_parse_units(a, 18) for a in amount_strs] + subtotal = sum(amounts) + fee = (subtotal * SPRAAY_PROTOCOL_FEE_BPS) // 10000 + total_value = subtotal + fee + + tx_hash = wallet_provider.send_transaction( + to=SPRAAY_CONTRACT_ADDRESS, + abi=SPRAAY_ABI, + function_name="sprayETH", + args=[recipients, amounts], + value=total_value, + ) + + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + return "\n".join([ + f"Successfully sprayed variable ETH amounts to {len(recipients)} recipients via Spraay.", + f"Total sent: {_format_units(subtotal, 18)} ETH", + f"Protocol fee (0.3%): {_format_units(fee, 18)} ETH", + f"Transaction hash: {tx_hash}", + f"Block: {receipt.get('blockNumber', 'pending')}", + f"View on BaseScan: https://basescan.org/tx/{tx_hash}", + ]) + except Exception as e: + return f"Error spraying variable ETH via Spraay: {e}" + + # ── Token (variable amounts) ─────────────────────────────────────────── + + @create_action( + name="spraay_token_variable", + description=( + "Send different amounts of an ERC-20 token to multiple recipients in a single " + "transaction via the Spraay protocol. Requires token approval. " + "Up to 200 recipients. 0.3% protocol fee. Deployed on Base mainnet." + ), + schema=SprayTokenVariableInput, + ) + def spraay_token_variable( + self, wallet_provider: WalletProvider, args: dict[str, Any] + ) -> str: + recipients = args["recipients"] + amount_strs = args["amounts"] + token_address = args["token_address"] + + if len(recipients) != len(amount_strs): + return ( + f"Error: recipients length ({len(recipients)}) must match " + f"amounts length ({len(amount_strs)})." + ) + + try: + decimals = self._get_token_decimals(wallet_provider, token_address) + symbol = self._get_token_symbol(wallet_provider, token_address) + + amounts = [_parse_units(a, decimals) for a in amount_strs] + subtotal = sum(amounts) + fee = (subtotal * SPRAAY_PROTOCOL_FEE_BPS) // 10000 + total_amount = subtotal + fee + + approval_msg = self._ensure_token_approval( + wallet_provider, token_address, total_amount + ) + + tx_hash = wallet_provider.send_transaction( + to=SPRAAY_CONTRACT_ADDRESS, + abi=SPRAAY_ABI, + function_name="sprayToken", + args=[token_address, recipients, amounts], + ) + + receipt = wallet_provider.wait_for_transaction_receipt(tx_hash) + + lines = [] + if approval_msg: + lines.append(approval_msg) + lines.extend([ + f"Successfully sprayed variable {symbol} amounts to {len(recipients)} recipients via Spraay.", + f"Total sent: {_format_units(subtotal, decimals)} {symbol}", + f"Protocol fee (0.3%): {_format_units(fee, decimals)} {symbol}", + f"Transaction hash: {tx_hash}", + f"Block: {receipt.get('blockNumber', 'pending')}", + f"View on BaseScan: https://basescan.org/tx/{tx_hash}", + ]) + return "\n".join(lines) + except Exception as e: + return f"Error spraying variable tokens via Spraay: {e}" + + # ── Private helpers ──────────────────────────────────────────────────── + + def _ensure_token_approval( + self, wallet_provider: WalletProvider, token_address: str, required: int + ) -> str | None: + wallet_address = wallet_provider.get_address() + current_allowance = int( + wallet_provider.read_contract( + token_address, ERC20_ABI, "allowance", [wallet_address, SPRAAY_CONTRACT_ADDRESS] + ) + ) + if current_allowance >= required: + return None + + tx_hash = wallet_provider.send_transaction( + to=token_address, + abi=ERC20_ABI, + function_name="approve", + args=[SPRAAY_CONTRACT_ADDRESS, required], + ) + wallet_provider.wait_for_transaction_receipt(tx_hash) + return f"Token approval granted to Spraay contract. Approval tx: {tx_hash}" + + def _get_token_decimals(self, wallet_provider: WalletProvider, token_address: str) -> int: + try: + return int(wallet_provider.read_contract(token_address, ERC20_ABI, "decimals", [])) + except Exception: + return 18 + + def _get_token_symbol(self, wallet_provider: WalletProvider, token_address: str) -> str: + try: + return str(wallet_provider.read_contract(token_address, ERC20_ABI, "symbol", [])) + except Exception: + return "TOKEN" + + +def spraay_action_provider() -> SpraayActionProvider: + """Factory function to create a new SpraayActionProvider instance. + + Example:: + + from spraay_action_provider import spraay_action_provider + + agent_kit = AgentKit(AgentKitConfig( + wallet_provider=wallet_provider, + action_providers=[spraay_action_provider()], + )) + """ + return SpraayActionProvider() diff --git a/typescript/agentkit/src/action-providers/index.ts b/typescript/agentkit/src/action-providers/index.ts index 7f2b0233a..3b6ee19f2 100644 --- a/typescript/agentkit/src/action-providers/index.ts +++ b/typescript/agentkit/src/action-providers/index.ts @@ -40,3 +40,5 @@ export * from "./zerion"; export * from "./zerodev"; export * from "./zeroX"; export * from "./zora"; + +export { spraayActionProvider, SpraayActionProvider } from "./spraay"; diff --git a/typescript/agentkit/src/action-providers/spraay/__tests__/spraayActionProvider.test.ts b/typescript/agentkit/src/action-providers/spraay/__tests__/spraayActionProvider.test.ts new file mode 100644 index 000000000..f4f6de779 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/__tests__/spraayActionProvider.test.ts @@ -0,0 +1,199 @@ +import { SpraayActionProvider } from "../spraayActionProvider"; +import { EvmWalletProvider } from "@coinbase/agentkit"; +import { SPRAAY_CONTRACT_ADDRESS } from "../constants"; + +// Mock the wallet provider +const mockSendTransaction = jest.fn(); +const mockWaitForTransactionReceipt = jest.fn(); +const mockGetAddress = jest.fn(); +const mockReadContract = jest.fn(); + +const mockWalletProvider = { + sendTransaction: mockSendTransaction, + waitForTransactionReceipt: mockWaitForTransactionReceipt, + getAddress: mockGetAddress, + readContract: mockReadContract, + getNetwork: jest.fn().mockReturnValue({ + protocolFamily: "evm", + networkId: "base-mainnet", + }), +} as unknown as EvmWalletProvider; + +describe("SpraayActionProvider", () => { + let provider: SpraayActionProvider; + + beforeEach(() => { + provider = new SpraayActionProvider(); + jest.clearAllMocks(); + + mockGetAddress.mockResolvedValue("0x1234567890123456789012345678901234567890"); + mockSendTransaction.mockResolvedValue("0xmocktxhash123"); + mockWaitForTransactionReceipt.mockResolvedValue({ blockNumber: 12345n }); + }); + + describe("supportsNetwork", () => { + it("should support Base mainnet", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-mainnet" }) + ).toBe(true); + }); + + it("should not support other networks", () => { + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "ethereum-mainnet" }) + ).toBe(false); + expect( + provider.supportsNetwork({ protocolFamily: "evm", networkId: "base-sepolia" }) + ).toBe(false); + expect( + provider.supportsNetwork({ protocolFamily: "svm", networkId: "solana-mainnet" }) + ).toBe(false); + }); + }); + + describe("sprayEth", () => { + it("should spray ETH to multiple recipients", async () => { + const result = await provider.sprayEth(mockWalletProvider, { + recipients: [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + amountPerRecipient: "0.01", + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(mockWaitForTransactionReceipt).toHaveBeenCalledWith("0xmocktxhash123"); + expect(result).toContain("Successfully sprayed"); + expect(result).toContain("2 recipients"); + expect(result).toContain("0xmocktxhash123"); + expect(result).toContain("basescan.org"); + }); + + it("should include protocol fee in the total value", async () => { + await provider.sprayEth(mockWalletProvider, { + recipients: ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + amountPerRecipient: "1", + }); + + const callArgs = mockSendTransaction.mock.calls[0][0]; + expect(callArgs.to).toBe(SPRAAY_CONTRACT_ADDRESS); + // Value should be 1 ETH + 0.3% fee = 1.003 ETH in wei + expect(callArgs.value).toBeDefined(); + }); + + it("should return error message on failure", async () => { + mockSendTransaction.mockRejectedValue(new Error("Insufficient funds")); + + const result = await provider.sprayEth(mockWalletProvider, { + recipients: ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + amountPerRecipient: "100", + }); + + expect(result).toContain("Error spraying ETH"); + expect(result).toContain("Insufficient funds"); + }); + }); + + describe("sprayToken", () => { + beforeEach(() => { + mockReadContract.mockImplementation((_addr: string, _abi: any, method: string) => { + if (method === "decimals") return 6; // USDC-like + if (method === "symbol") return "USDC"; + if (method === "allowance") return BigInt(0); + return null; + }); + }); + + it("should spray tokens and handle approval", async () => { + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: "0xcccccccccccccccccccccccccccccccccccccccc", + recipients: [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + amountPerRecipient: "100", + }); + + // Should have sent 2 transactions: approve + spray + expect(mockSendTransaction).toHaveBeenCalledTimes(2); + expect(result).toContain("Successfully sprayed"); + expect(result).toContain("USDC"); + expect(result).toContain("2 recipients"); + expect(result).toContain("Token approval granted"); + }); + + it("should skip approval if allowance is sufficient", async () => { + mockReadContract.mockImplementation((_addr: string, _abi: any, method: string) => { + if (method === "decimals") return 6; + if (method === "symbol") return "USDC"; + if (method === "allowance") return BigInt("1000000000000"); // Large allowance + return null; + }); + + const result = await provider.sprayToken(mockWalletProvider, { + tokenAddress: "0xcccccccccccccccccccccccccccccccccccccccc", + recipients: ["0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], + amountPerRecipient: "10", + }); + + // Should only send 1 transaction (spray, no approve needed) + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(result).not.toContain("Token approval granted"); + }); + }); + + describe("sprayEthVariable", () => { + it("should spray variable ETH amounts", async () => { + const result = await provider.sprayEthVariable(mockWalletProvider, { + recipients: [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + amounts: ["0.01", "0.05"], + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(1); + expect(result).toContain("Successfully sprayed variable ETH"); + expect(result).toContain("2 recipients"); + }); + + it("should reject mismatched arrays", async () => { + const result = await provider.sprayEthVariable(mockWalletProvider, { + recipients: [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + amounts: ["0.01"], + }); + + expect(result).toContain("Error: recipients array length"); + expect(mockSendTransaction).not.toHaveBeenCalled(); + }); + }); + + describe("sprayTokenVariable", () => { + beforeEach(() => { + mockReadContract.mockImplementation((_addr: string, _abi: any, method: string) => { + if (method === "decimals") return 18; + if (method === "symbol") return "DAI"; + if (method === "allowance") return BigInt(0); + return null; + }); + }); + + it("should spray variable token amounts", async () => { + const result = await provider.sprayTokenVariable(mockWalletProvider, { + tokenAddress: "0xcccccccccccccccccccccccccccccccccccccccc", + recipients: [ + "0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + ], + amounts: ["100", "200"], + }); + + expect(mockSendTransaction).toHaveBeenCalledTimes(2); // approve + spray + expect(result).toContain("Successfully sprayed variable DAI"); + expect(result).toContain("2 recipients"); + }); + }); +}); diff --git a/typescript/agentkit/src/action-providers/spraay/constants.ts b/typescript/agentkit/src/action-providers/spraay/constants.ts new file mode 100644 index 000000000..bbe66ef6f --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/constants.ts @@ -0,0 +1,96 @@ +/** + * Spraay contract address on Base Mainnet. + */ +export const SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" as const; + +/** + * Spraay protocol fee in basis points (0.3% = 30 bps). + */ +export const SPRAAY_PROTOCOL_FEE_BPS = 30; + +/** + * Maximum number of recipients per transaction. + */ +export const SPRAAY_MAX_RECIPIENTS = 200; + +/** + * Spraay contract ABI — only the functions needed for the action provider. + */ +export const SPRAAY_ABI = [ + { + name: "sprayETH", + type: "function", + stateMutability: "payable", + inputs: [ + { + name: "recipients", + type: "address[]", + }, + { + name: "amounts", + type: "uint256[]", + }, + ], + outputs: [], + }, + { + name: "sprayToken", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { + name: "token", + type: "address", + }, + { + name: "recipients", + type: "address[]", + }, + { + name: "amounts", + type: "uint256[]", + }, + ], + outputs: [], + }, +] as const; + +/** + * Standard ERC-20 ABI fragments needed for token approval and decimals lookup. + */ +export const ERC20_ABI = [ + { + name: "approve", + type: "function", + stateMutability: "nonpayable", + inputs: [ + { name: "spender", type: "address" }, + { name: "amount", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, + { + name: "allowance", + type: "function", + stateMutability: "view", + inputs: [ + { name: "owner", type: "address" }, + { name: "spender", type: "address" }, + ], + outputs: [{ name: "", type: "uint256" }], + }, + { + name: "decimals", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "uint8" }], + }, + { + name: "symbol", + type: "function", + stateMutability: "view", + inputs: [], + outputs: [{ name: "", type: "string" }], + }, +] as const; diff --git a/typescript/agentkit/src/action-providers/spraay/index.ts b/typescript/agentkit/src/action-providers/spraay/index.ts new file mode 100644 index 000000000..e30d95483 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/index.ts @@ -0,0 +1,13 @@ +export { SpraayActionProvider, spraayActionProvider } from "./spraayActionProvider"; +export { + SprayEthSchema, + SprayTokenSchema, + SprayEthVariableSchema, + SprayTokenVariableSchema, +} from "./schemas"; +export { + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_ABI, + SPRAAY_PROTOCOL_FEE_BPS, + SPRAAY_MAX_RECIPIENTS, +} from "./constants"; diff --git a/typescript/agentkit/src/action-providers/spraay/schemas.ts b/typescript/agentkit/src/action-providers/spraay/schemas.ts new file mode 100644 index 000000000..332d305e4 --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/schemas.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +/** + * Schema for spraying ETH to multiple recipients. + */ +export const SprayEthSchema = z + .object({ + recipients: z + .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address")) + .min(1, "At least one recipient is required") + .max(200, "Maximum 200 recipients per transaction") + .describe("Array of recipient wallet addresses (e.g. ['0xABC...', '0xDEF...'])"), + amountPerRecipient: z + .string() + .describe( + "Amount of ETH to send to each recipient, in whole units (e.g. '0.01' for 0.01 ETH)" + ), + }) + .strip() + .describe("Input schema for spraying ETH to multiple recipients in a single transaction"); + +/** + * Schema for spraying ERC-20 tokens to multiple recipients. + */ +export const SprayTokenSchema = z + .object({ + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token contract address") + .describe("The ERC-20 token contract address"), + recipients: z + .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address")) + .min(1, "At least one recipient is required") + .max(200, "Maximum 200 recipients per transaction") + .describe("Array of recipient wallet addresses"), + amountPerRecipient: z + .string() + .describe( + "Amount of tokens to send to each recipient, in whole units (e.g. '100' for 100 USDC)" + ), + }) + .strip() + .describe("Input schema for spraying ERC-20 tokens to multiple recipients in a single transaction"); + +/** + * Schema for spraying ETH with variable amounts per recipient. + */ +export const SprayEthVariableSchema = z + .object({ + recipients: z + .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address")) + .min(1, "At least one recipient is required") + .max(200, "Maximum 200 recipients per transaction") + .describe("Array of recipient wallet addresses"), + amounts: z + .array(z.string()) + .min(1, "At least one amount is required") + .describe( + "Array of ETH amounts corresponding to each recipient, in whole units (e.g. ['0.01', '0.05'])" + ), + }) + .strip() + .describe( + "Input schema for spraying variable amounts of ETH to multiple recipients in a single transaction" + ); + +/** + * Schema for spraying ERC-20 tokens with variable amounts per recipient. + */ +export const SprayTokenVariableSchema = z + .object({ + tokenAddress: z + .string() + .regex(/^0x[a-fA-F0-9]{40}$/, "Invalid token contract address") + .describe("The ERC-20 token contract address"), + recipients: z + .array(z.string().regex(/^0x[a-fA-F0-9]{40}$/, "Invalid Ethereum address")) + .min(1, "At least one recipient is required") + .max(200, "Maximum 200 recipients per transaction") + .describe("Array of recipient wallet addresses"), + amounts: z + .array(z.string()) + .min(1, "At least one amount is required") + .describe( + "Array of token amounts corresponding to each recipient, in whole units (e.g. ['100', '50'])" + ), + }) + .strip() + .describe( + "Input schema for spraying variable amounts of ERC-20 tokens to multiple recipients in a single transaction" + ); diff --git a/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts new file mode 100644 index 000000000..ea73307be --- /dev/null +++ b/typescript/agentkit/src/action-providers/spraay/spraayActionProvider.ts @@ -0,0 +1,419 @@ +import { z } from "zod"; +import { encodeFunctionData, parseUnits, formatUnits } from "viem"; +import { ActionProvider, CreateAction, EvmWalletProvider, Network } from "@coinbase/agentkit"; +import { + SprayEthSchema, + SprayTokenSchema, + SprayEthVariableSchema, + SprayTokenVariableSchema, +} from "./schemas"; +import { + SPRAAY_CONTRACT_ADDRESS, + SPRAAY_ABI, + ERC20_ABI, + SPRAAY_PROTOCOL_FEE_BPS, + SPRAAY_MAX_RECIPIENTS, +} from "./constants"; + +/** + * SpraayActionProvider enables AI agents to batch-send ETH or ERC-20 tokens + * to multiple recipients in a single transaction via the Spraay protocol on Base. + * + * Key features: + * - Batch ETH sends (equal or variable amounts) + * - Batch ERC-20 token sends (equal or variable amounts) + * - Up to 200 recipients per transaction + * - ~80% gas savings vs individual transfers + * - 0.3% protocol fee + * + * Contract: 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC (Base Mainnet) + * Website: https://spraay.app + */ +export class SpraayActionProvider extends ActionProvider { + constructor() { + super("spraay", []); + } + + /** + * Spray equal amounts of ETH to multiple recipients in one transaction. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (recipients, amountPerRecipient). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_eth", + description: ` +Send equal amounts of ETH to multiple recipients in a single transaction via the Spraay protocol. +This is ideal for team payments, airdrops, or distributing rewards. +Up to 200 recipients per transaction with ~80% gas savings vs individual transfers. +A 0.3% protocol fee is applied. The contract is deployed on Base mainnet. + `.trim(), + schema: SprayEthSchema, + }) + async sprayEth( + walletProvider: EvmWalletProvider, + args: z.infer + ): Promise { + const { recipients, amountPerRecipient } = args; + + try { + // Parse amount per recipient in wei + const amountWei = parseUnits(amountPerRecipient, 18); + const amounts = recipients.map(() => amountWei); + + // Calculate total value including 0.3% protocol fee + const subtotal = amountWei * BigInt(recipients.length); + const fee = (subtotal * BigInt(SPRAAY_PROTOCOL_FEE_BPS)) / BigInt(10000); + const totalValue = subtotal + fee; + + // Encode the contract call + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayETH", + args: [recipients as `0x${string}`[], amounts], + }); + + // Send the transaction + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS as `0x${string}`, + data, + value: totalValue, + }); + + // Wait for confirmation + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return [ + `Successfully sprayed ${amountPerRecipient} ETH to ${recipients.length} recipients via Spraay.`, + `Total sent: ${formatUnits(subtotal, 18)} ETH`, + `Protocol fee (0.3%): ${formatUnits(fee, 18)} ETH`, + `Transaction hash: ${txHash}`, + `Block: ${receipt.blockNumber}`, + `View on BaseScan: https://basescan.org/tx/${txHash}`, + ].join("\n"); + } catch (error) { + return `Error spraying ETH via Spraay: ${error}`; + } + } + + /** + * Spray equal amounts of an ERC-20 token to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (tokenAddress, recipients, amountPerRecipient). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_token", + description: ` +Send equal amounts of an ERC-20 token (like USDC, DAI, or any token) to multiple recipients in a single transaction via the Spraay protocol. +Requires token approval before first use. Up to 200 recipients per transaction. +A 0.3% protocol fee is applied. The contract is deployed on Base mainnet. + `.trim(), + schema: SprayTokenSchema, + }) + async sprayToken( + walletProvider: EvmWalletProvider, + args: z.infer + ): Promise { + const { tokenAddress, recipients, amountPerRecipient } = args; + + try { + // Get token decimals and symbol + const decimals = await this.getTokenDecimals(walletProvider, tokenAddress); + const symbol = await this.getTokenSymbol(walletProvider, tokenAddress); + + // Parse amount per recipient + const amountPerRecipientWei = parseUnits(amountPerRecipient, decimals); + const amounts = recipients.map(() => amountPerRecipientWei); + + // Calculate total amount needed (including 0.3% fee) + const subtotal = amountPerRecipientWei * BigInt(recipients.length); + const fee = (subtotal * BigInt(SPRAAY_PROTOCOL_FEE_BPS)) / BigInt(10000); + const totalAmount = subtotal + fee; + + // Check and set approval if needed + const approvalResult = await this.ensureTokenApproval( + walletProvider, + tokenAddress, + totalAmount + ); + + // Encode the contract call + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayToken", + args: [tokenAddress as `0x${string}`, recipients as `0x${string}`[], amounts], + }); + + // Send the transaction + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS as `0x${string}`, + data, + }); + + // Wait for confirmation + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + const resultLines = []; + if (approvalResult) { + resultLines.push(approvalResult); + } + resultLines.push( + `Successfully sprayed ${amountPerRecipient} ${symbol} to ${recipients.length} recipients via Spraay.`, + `Total sent: ${formatUnits(subtotal, decimals)} ${symbol}`, + `Protocol fee (0.3%): ${formatUnits(fee, decimals)} ${symbol}`, + `Transaction hash: ${txHash}`, + `Block: ${receipt.blockNumber}`, + `View on BaseScan: https://basescan.org/tx/${txHash}` + ); + + return resultLines.join("\n"); + } catch (error) { + return `Error spraying tokens via Spraay: ${error}`; + } + } + + /** + * Spray variable amounts of ETH to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (recipients, amounts). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_eth_variable", + description: ` +Send different amounts of ETH to multiple recipients in a single transaction via the Spraay protocol. +Each recipient gets a different specified amount. Ideal for bounty payouts or tiered distributions. +Up to 200 recipients per transaction. A 0.3% protocol fee is applied. Deployed on Base mainnet. + `.trim(), + schema: SprayEthVariableSchema, + }) + async sprayEthVariable( + walletProvider: EvmWalletProvider, + args: z.infer + ): Promise { + const { recipients, amounts: amountStrings } = args; + + if (recipients.length !== amountStrings.length) { + return `Error: recipients array length (${recipients.length}) must match amounts array length (${amountStrings.length}).`; + } + + try { + const amounts = amountStrings.map((a) => parseUnits(a, 18)); + const subtotal = amounts.reduce((sum, a) => sum + a, BigInt(0)); + const fee = (subtotal * BigInt(SPRAAY_PROTOCOL_FEE_BPS)) / BigInt(10000); + const totalValue = subtotal + fee; + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayETH", + args: [recipients as `0x${string}`[], amounts], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS as `0x${string}`, + data, + value: totalValue, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + return [ + `Successfully sprayed variable ETH amounts to ${recipients.length} recipients via Spraay.`, + `Total sent: ${formatUnits(subtotal, 18)} ETH`, + `Protocol fee (0.3%): ${formatUnits(fee, 18)} ETH`, + `Transaction hash: ${txHash}`, + `Block: ${receipt.blockNumber}`, + `View on BaseScan: https://basescan.org/tx/${txHash}`, + ].join("\n"); + } catch (error) { + return `Error spraying variable ETH via Spraay: ${error}`; + } + } + + /** + * Spray variable amounts of an ERC-20 token to multiple recipients. + * + * @param walletProvider - The wallet provider to send the transaction. + * @param args - The input arguments (tokenAddress, recipients, amounts). + * @returns A string describing the result of the transaction. + */ + @CreateAction({ + name: "spraay_token_variable", + description: ` +Send different amounts of an ERC-20 token to multiple recipients in a single transaction via the Spraay protocol. +Each recipient gets a different specified amount. Requires token approval before first use. +Up to 200 recipients per transaction. A 0.3% protocol fee is applied. Deployed on Base mainnet. + `.trim(), + schema: SprayTokenVariableSchema, + }) + async sprayTokenVariable( + walletProvider: EvmWalletProvider, + args: z.infer + ): Promise { + const { tokenAddress, recipients, amounts: amountStrings } = args; + + if (recipients.length !== amountStrings.length) { + return `Error: recipients array length (${recipients.length}) must match amounts array length (${amountStrings.length}).`; + } + + try { + const decimals = await this.getTokenDecimals(walletProvider, tokenAddress); + const symbol = await this.getTokenSymbol(walletProvider, tokenAddress); + + const amounts = amountStrings.map((a) => parseUnits(a, decimals)); + const subtotal = amounts.reduce((sum, a) => sum + a, BigInt(0)); + const fee = (subtotal * BigInt(SPRAAY_PROTOCOL_FEE_BPS)) / BigInt(10000); + const totalAmount = subtotal + fee; + + const approvalResult = await this.ensureTokenApproval( + walletProvider, + tokenAddress, + totalAmount + ); + + const data = encodeFunctionData({ + abi: SPRAAY_ABI, + functionName: "sprayToken", + args: [tokenAddress as `0x${string}`, recipients as `0x${string}`[], amounts], + }); + + const txHash = await walletProvider.sendTransaction({ + to: SPRAAY_CONTRACT_ADDRESS as `0x${string}`, + data, + }); + + const receipt = await walletProvider.waitForTransactionReceipt(txHash); + + const resultLines = []; + if (approvalResult) { + resultLines.push(approvalResult); + } + resultLines.push( + `Successfully sprayed variable ${symbol} amounts to ${recipients.length} recipients via Spraay.`, + `Total sent: ${formatUnits(subtotal, decimals)} ${symbol}`, + `Protocol fee (0.3%): ${formatUnits(fee, decimals)} ${symbol}`, + `Transaction hash: ${txHash}`, + `Block: ${receipt.blockNumber}`, + `View on BaseScan: https://basescan.org/tx/${txHash}` + ); + + return resultLines.join("\n"); + } catch (error) { + return `Error spraying variable tokens via Spraay: ${error}`; + } + } + + /** + * Check if the Spraay contract has sufficient token allowance, and approve if not. + */ + private async ensureTokenApproval( + walletProvider: EvmWalletProvider, + tokenAddress: string, + requiredAmount: bigint + ): Promise { + const walletAddress = await walletProvider.getAddress(); + + // Check current allowance + const allowanceData = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "allowance", + args: [walletAddress as `0x${string}`, SPRAAY_CONTRACT_ADDRESS as `0x${string}`], + }); + + const allowanceResult = await walletProvider.readContract( + tokenAddress as `0x${string}`, + ERC20_ABI, + "allowance", + [walletAddress, SPRAAY_CONTRACT_ADDRESS] + ); + + const currentAllowance = BigInt(allowanceResult as string); + + if (currentAllowance >= requiredAmount) { + return null; // No approval needed + } + + // Approve the Spraay contract to spend tokens + const approveData = encodeFunctionData({ + abi: ERC20_ABI, + functionName: "approve", + args: [SPRAAY_CONTRACT_ADDRESS as `0x${string}`, requiredAmount], + }); + + const approveTxHash = await walletProvider.sendTransaction({ + to: tokenAddress as `0x${string}`, + data: approveData, + }); + + await walletProvider.waitForTransactionReceipt(approveTxHash); + + return `Token approval granted to Spraay contract. Approval tx: ${approveTxHash}`; + } + + /** + * Get the number of decimals for an ERC-20 token. + */ + private async getTokenDecimals( + walletProvider: EvmWalletProvider, + tokenAddress: string + ): Promise { + try { + const result = await walletProvider.readContract( + tokenAddress as `0x${string}`, + ERC20_ABI, + "decimals", + [] + ); + return Number(result); + } catch { + return 18; // Default to 18 decimals + } + } + + /** + * Get the symbol for an ERC-20 token. + */ + private async getTokenSymbol( + walletProvider: EvmWalletProvider, + tokenAddress: string + ): Promise { + try { + const result = await walletProvider.readContract( + tokenAddress as `0x${string}`, + ERC20_ABI, + "symbol", + [] + ); + return result as string; + } catch { + return "TOKEN"; + } + } + + /** + * Spraay is currently deployed only on Base mainnet. + */ + supportsNetwork = (network: Network) => + network.protocolFamily === "evm" && network.networkId === "base-mainnet"; +} + +/** + * Factory function to create a new SpraayActionProvider instance. + * + * @returns A new SpraayActionProvider. + * + * @example + * ```typescript + * import { spraayActionProvider } from "./spraay"; + * + * const agentKit = await AgentKit.from({ + * walletProvider, + * actionProviders: [spraayActionProvider()], + * }); + * ``` + */ +export const spraayActionProvider = () => new SpraayActionProvider();