diff --git a/src/google/adk_community/tools/spraay/constants.py b/src/google/adk_community/tools/spraay/constants.py index b9630f2..4cfbc9f 100644 --- a/src/google/adk_community/tools/spraay/constants.py +++ b/src/google/adk_community/tools/spraay/constants.py @@ -17,63 +17,142 @@ # Spraay contract on Base Mainnet SPRAAY_CONTRACT_ADDRESS = "0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC" +# Sentinel address: sprayEqual uses address(0) as the token to send native ETH. +ZERO_ADDRESS = "0x0000000000000000000000000000000000000000" + # 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 +# Protocol fee fallback in basis points (30 = 0.3%). The contract's fee is +# mutable by its owner (capped on-chain at 5%), so the live value is read +# from feeBps() at call time; this constant is only used if that read fails. +SPRAAY_FEE_BPS = 30 -# Maximum recipients per transaction +# Maximum recipients per transaction (mirrors the contract's MAX_RECIPIENTS) MAX_RECIPIENTS = 200 +# On-chain fee cap in basis points (mirrors the contract's MAX_FEE_BPS = 5%) +MAX_FEE_BPS = 500 + # ERC-20 max approval MAX_UINT256 = 2**256 - 1 -# Spraay contract ABI (relevant functions only) +# SprayContract ABI (relevant functions only). +# Copied verbatim from the verified source of +# 0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC on Base (chain 8453), as +# published on Sourcify. Do not hand-edit; regenerate from the verified ABI. SPRAAY_ABI = [ { + "name": "sprayETH", + "type": "function", "inputs": [ - {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, - {"internalType": "uint256", "name": "_amount", "type": "uint256"}, + { + "name": "recipients", + "type": "tuple[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address payable" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "internalType": "struct SprayContract.Recipient[]" + } ], - "name": "spraayETH", "outputs": [], - "stateMutability": "payable", - "type": "function", + "stateMutability": "payable" }, { + "name": "sprayToken", + "type": "function", "inputs": [ - {"internalType": "address", "name": "_token", "type": "address"}, - {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, - {"internalType": "uint256", "name": "_amount", "type": "uint256"}, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "recipients", + "type": "tuple[]", + "components": [ + { + "name": "recipient", + "type": "address", + "internalType": "address payable" + }, + { + "name": "amount", + "type": "uint256", + "internalType": "uint256" + } + ], + "internalType": "struct SprayContract.Recipient[]" + } ], - "name": "spraayToken", "outputs": [], - "stateMutability": "nonpayable", - "type": "function", + "stateMutability": "nonpayable" }, { + "name": "sprayEqual", + "type": "function", "inputs": [ - {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, - {"internalType": "uint256[]", "name": "_amounts", "type": "uint256[]"}, + { + "name": "token", + "type": "address", + "internalType": "address" + }, + { + "name": "recipients", + "type": "address[]", + "internalType": "address payable[]" + }, + { + "name": "amountPerRecipient", + "type": "uint256", + "internalType": "uint256" + } ], - "name": "spraayETHVariable", "outputs": [], - "stateMutability": "payable", + "stateMutability": "payable" + }, + { + "name": "feeBps", "type": "function", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" }, { + "name": "calculateTotalCost", + "type": "function", "inputs": [ - {"internalType": "address", "name": "_token", "type": "address"}, - {"internalType": "address[]", "name": "_recipients", "type": "address[]"}, - {"internalType": "uint256[]", "name": "_amounts", "type": "uint256[]"}, + { + "name": "totalAmount", + "type": "uint256", + "internalType": "uint256" + } ], - "name": "spraayTokenVariable", - "outputs": [], - "stateMutability": "nonpayable", - "type": "function", - }, + "outputs": [ + { + "name": "", + "type": "uint256", + "internalType": "uint256" + } + ], + "stateMutability": "view" + } ] # ERC-20 approve ABI diff --git a/src/google/adk_community/tools/spraay/spraay_tools.py b/src/google/adk_community/tools/spraay/spraay_tools.py index ba6b94c..bbe6fa4 100644 --- a/src/google/adk_community/tools/spraay/spraay_tools.py +++ b/src/google/adk_community/tools/spraay/spraay_tools.py @@ -36,11 +36,13 @@ BASE_CHAIN_ID, BASE_RPC_URL, ERC20_APPROVE_ABI, + MAX_FEE_BPS, MAX_RECIPIENTS, MAX_UINT256, SPRAAY_ABI, SPRAAY_CONTRACT_ADDRESS, SPRAAY_FEE_BPS, + ZERO_ADDRESS, ) logger = logging.getLogger(__name__) @@ -110,9 +112,28 @@ def _validate_recipients(recipients: list[str]) -> list[str]: return checksummed -def _calculate_fee(total_wei: int) -> int: - """Calculate the Spraay protocol fee (0.3%).""" - return (total_wei * SPRAAY_FEE_BPS) // 10000 +def _get_fee_bps(spraay_contract) -> int: + """Read the current protocol fee (basis points) from the contract. + + The fee is owner-adjustable on-chain (capped at MAX_FEE_BPS), so it is + read live rather than hardcoded. Falls back to SPRAAY_FEE_BPS if the + read fails or returns an implausible value. + """ + try: + fee_bps = spraay_contract.functions.feeBps().call() + if isinstance(fee_bps, int) and 0 <= fee_bps <= MAX_FEE_BPS: + return fee_bps + except Exception: # pragma: no cover - network dependent + logger.warning( + "Could not read feeBps() from contract; falling back to %s bps.", + SPRAAY_FEE_BPS, + ) + return SPRAAY_FEE_BPS + + +def _calculate_fee(total_wei: int, fee_bps: int = SPRAAY_FEE_BPS) -> int: + """Calculate the Spraay protocol fee for a total amount.""" + return (total_wei * fee_bps) // 10000 def _verify_chain_id(w3) -> None: @@ -168,29 +189,32 @@ def spraay_batch_eth( 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 + total_wei = amount_wei * len(checksummed) + fee_wei = _calculate_fee(total_wei, _get_fee_bps(contract)) + total_with_fee = total_wei + fee_wei + + # sprayEqual with token=address(0) is the contract's native-ETH + # equal-amount path. msg.value must cover total + fee. + tx = contract.functions.sprayEqual( + ZERO_ADDRESS, checksummed, amount_wei ).build_transaction( { "from": account.address, "value": total_with_fee, - "nonce": w3.eth.get_transaction_count(account.address), + "nonce": w3.eth.get_transaction_count(account.address, "pending"), "chainId": BASE_CHAIN_ID, - "gas": 0, # Will be estimated } ) - # Add 10% gas buffer to prevent 'out of gas' errors - tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.1) + # build_transaction estimates gas (no "gas" placeholder: some RPCs, + # including the default Base RPC, treat an explicit 0 as a hard cap + # and reject estimation). Add a 10% buffer to the estimate. + tx["gas"] = int(tx["gas"] * 1.1) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) @@ -256,8 +280,10 @@ def spraay_batch_token( if amount_units <= 0: return {"status": "error", "error": "Amount must be greater than 0."} + spraay_contract = w3.eth.contract(address=spraay_addr, abi=SPRAAY_ABI) + total_units = amount_units * len(checksummed) - fee_units = (total_units * SPRAAY_FEE_BPS) // 10000 + fee_units = _calculate_fee(total_units, _get_fee_bps(spraay_contract)) total_with_fee = total_units + fee_units result = {"approval_tx_hash": None} @@ -274,13 +300,12 @@ def spraay_batch_token( ).build_transaction( { "from": account.address, - "nonce": w3.eth.get_transaction_count(account.address), + "nonce": w3.eth.get_transaction_count(account.address, "pending"), "chainId": BASE_CHAIN_ID, - "gas": 0, } ) - # Add 10% gas buffer - approve_tx["gas"] = int(w3.eth.estimate_gas(approve_tx) * 1.1) + # build_transaction estimated gas; add a 10% buffer. + approve_tx["gas"] = int(approve_tx["gas"] * 1.1) signed_approve = account.sign_transaction(approve_tx) approve_hash = w3.eth.send_raw_transaction( signed_approve.raw_transaction @@ -288,22 +313,22 @@ def spraay_batch_token( 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) + # Execute batch transfer. sprayEqual with a token address is the + # contract's ERC-20 equal-amount path; the contract pulls + # total + fee via transferFrom, so the allowance above covers it. + nonce = w3.eth.get_transaction_count(account.address, "pending") - tx = spraay_contract.functions.spraayToken( + tx = spraay_contract.functions.sprayEqual( token_addr, checksummed, amount_units ).build_transaction( { "from": account.address, "nonce": nonce, "chainId": BASE_CHAIN_ID, - "gas": 0, } ) - # Add 10% gas buffer - tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.1) + # build_transaction estimated gas; add a 10% buffer. + tx["gas"] = int(tx["gas"] * 1.1) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) @@ -370,28 +395,30 @@ def spraay_batch_eth_variable( 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 + total_wei = sum(amounts_wei) + fee_wei = _calculate_fee(total_wei, _get_fee_bps(contract)) + total_with_fee = total_wei + fee_wei + + # sprayETH takes an array of Recipient structs: (address, amount). + recipient_structs = list(zip(checksummed, amounts_wei)) + + tx = contract.functions.sprayETH( + recipient_structs ).build_transaction( { "from": account.address, "value": total_with_fee, - "nonce": w3.eth.get_transaction_count(account.address), + "nonce": w3.eth.get_transaction_count(account.address, "pending"), "chainId": BASE_CHAIN_ID, - "gas": 0, } ) - # Add 10% gas buffer - tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.1) + # build_transaction estimated gas; add a 10% buffer. + tx["gas"] = int(tx["gas"] * 1.1) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) @@ -464,8 +491,10 @@ def spraay_batch_token_variable( if any(a <= 0 for a in amounts_units): return {"status": "error", "error": "All amounts must be greater than 0."} + spraay_contract = w3.eth.contract(address=spraay_addr, abi=SPRAAY_ABI) + total_units = sum(amounts_units) - fee_units = (total_units * SPRAAY_FEE_BPS) // 10000 + fee_units = _calculate_fee(total_units, _get_fee_bps(spraay_contract)) total_with_fee = total_units + fee_units result = {"approval_tx_hash": None} @@ -482,13 +511,12 @@ def spraay_batch_token_variable( ).build_transaction( { "from": account.address, - "nonce": w3.eth.get_transaction_count(account.address), + "nonce": w3.eth.get_transaction_count(account.address, "pending"), "chainId": BASE_CHAIN_ID, - "gas": 0, } ) - # Add 10% gas buffer - approve_tx["gas"] = int(w3.eth.estimate_gas(approve_tx) * 1.1) + # build_transaction estimated gas; add a 10% buffer. + approve_tx["gas"] = int(approve_tx["gas"] * 1.1) signed_approve = account.sign_transaction(approve_tx) approve_hash = w3.eth.send_raw_transaction( signed_approve.raw_transaction @@ -496,22 +524,24 @@ def spraay_batch_token_variable( 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) + # Execute batch transfer. sprayToken takes an array of Recipient + # structs: (address, amount). The contract pulls total + fee via + # transferFrom, so the allowance above covers it. + nonce = w3.eth.get_transaction_count(account.address, "pending") + + recipient_structs = list(zip(checksummed, amounts_units)) - tx = spraay_contract.functions.spraayTokenVariable( - token_addr, checksummed, amounts_units + tx = spraay_contract.functions.sprayToken( + token_addr, recipient_structs ).build_transaction( { "from": account.address, "nonce": nonce, "chainId": BASE_CHAIN_ID, - "gas": 0, } ) - # Add 10% gas buffer - tx["gas"] = int(w3.eth.estimate_gas(tx) * 1.1) + # build_transaction estimated gas; add a 10% buffer. + tx["gas"] = int(tx["gas"] * 1.1) signed = account.sign_transaction(tx) tx_hash = w3.eth.send_raw_transaction(signed.raw_transaction) diff --git a/tests/unittests/tools/spraay/test_spraay_tools.py b/tests/unittests/tools/spraay/test_spraay_tools.py index fab5ddb..92db221 100644 --- a/tests/unittests/tools/spraay/test_spraay_tools.py +++ b/tests/unittests/tools/spraay/test_spraay_tools.py @@ -21,12 +21,16 @@ from google.adk_community.tools.spraay import spraay_tools as spraay_module from google.adk_community.tools.spraay.constants import ( BASE_CHAIN_ID, + MAX_FEE_BPS, MAX_RECIPIENTS, + SPRAAY_ABI, SPRAAY_CONTRACT_ADDRESS, SPRAAY_FEE_BPS, + ZERO_ADDRESS, ) from google.adk_community.tools.spraay.spraay_tools import ( _calculate_fee, + _get_fee_bps, _validate_recipients, spraay_batch_eth, spraay_batch_eth_variable, @@ -202,6 +206,177 @@ def test_missing_private_key(self, mock_web3, mock_account): self.assertIn("SPRAAY_PRIVATE_KEY", result["error"]) +class TestAbiMatchesDeployedContract(unittest.TestCase): + """Regression tests pinning the ABI to the verified deployed contract. + + The deployed SprayContract (0x1646452F98E36A3c9Cfc3eDD8868221E207B5eEC + on Base) exposes sprayETH/sprayToken (Recipient[] structs) and + sprayEqual. These tests fail if the ABI drifts from the verified + on-chain interface again. + """ + + def _fn(self, name): + matches = [e for e in SPRAAY_ABI if e.get("name") == name] + self.assertEqual(len(matches), 1, f"expected exactly one {name} in ABI") + return matches[0] + + def test_abi_function_names(self): + """ABI must contain exactly the deployed payment + fee functions.""" + names = {e["name"] for e in SPRAAY_ABI if e.get("type") == "function"} + self.assertEqual( + names, + {"sprayETH", "sprayToken", "sprayEqual", "feeBps", + "calculateTotalCost"}, + ) + + def test_spray_eth_takes_recipient_structs(self): + """sprayETH takes a single Recipient[] (address, uint256) argument.""" + fn = self._fn("sprayETH") + self.assertEqual(len(fn["inputs"]), 1) + arg = fn["inputs"][0] + self.assertEqual(arg["type"], "tuple[]") + self.assertEqual( + [(c["name"], c["type"]) for c in arg["components"]], + [("recipient", "address"), ("amount", "uint256")], + ) + self.assertEqual(fn["stateMutability"], "payable") + + def test_spray_token_takes_recipient_structs(self): + """sprayToken takes (address token, Recipient[] recipients).""" + fn = self._fn("sprayToken") + self.assertEqual( + [i["type"] for i in fn["inputs"]], ["address", "tuple[]"] + ) + + def test_spray_equal_signature(self): + """sprayEqual takes (address, address[], uint256) and is payable.""" + fn = self._fn("sprayEqual") + self.assertEqual( + [i["type"] for i in fn["inputs"]], + ["address", "address[]", "uint256"], + ) + self.assertEqual(fn["stateMutability"], "payable") + + +class TestGetFeeBps(unittest.TestCase): + """Tests for the live fee read with fallback.""" + + def test_uses_onchain_value(self): + """A plausible on-chain feeBps value should be used.""" + contract = MagicMock() + contract.functions.feeBps.return_value.call.return_value = 25 + self.assertEqual(_get_fee_bps(contract), 25) + + def test_fallback_on_error(self): + """RPC failure should fall back to SPRAAY_FEE_BPS.""" + contract = MagicMock() + contract.functions.feeBps.return_value.call.side_effect = Exception( + "rpc down" + ) + self.assertEqual(_get_fee_bps(contract), SPRAAY_FEE_BPS) + + def test_fallback_on_implausible_value(self): + """Values above the on-chain MAX_FEE_BPS cap should be rejected.""" + contract = MagicMock() + contract.functions.feeBps.return_value.call.return_value = ( + MAX_FEE_BPS + 1 + ) + self.assertEqual(_get_fee_bps(contract), SPRAAY_FEE_BPS) + + +class TestCallConstruction(unittest.TestCase): + """Tests that tools build calls against the deployed function names.""" + + def _mock_w3(self): + mock_w3 = _make_mock_w3() + mock_w3.to_wei.side_effect = lambda x, _: int(float(str(x)) * 10**18) + mock_w3.from_wei.side_effect = lambda x, _: x / 10**18 + mock_w3.to_checksum_address.side_effect = lambda x: x + mock_w3.eth.get_transaction_count.return_value = 1 + mock_w3.eth.estimate_gas.return_value = 100_000 + tx_hash = MagicMock() + tx_hash.hex.return_value = "0xabc" + mock_w3.eth.send_raw_transaction.return_value = tx_hash + return mock_w3 + + def _contract(self, mock_w3): + contract = MagicMock() + contract.functions.feeBps.return_value.call.return_value = 30 + for fn in ("sprayEqual", "sprayETH", "sprayToken"): + getattr( + contract.functions, fn + ).return_value.build_transaction.return_value = {"gas": 100_000} + mock_w3.eth.contract.return_value = contract + return contract + + @patch.object(spraay_module, "_validate_recipients") + @patch.object(spraay_module, "_get_account") + @patch.object(spraay_module, "_get_web3") + def test_equal_eth_uses_spray_equal_with_zero_address( + self, mock_web3, mock_account, mock_validate + ): + """Equal ETH sends must call sprayEqual(address(0), ...).""" + mock_w3 = self._mock_w3() + contract = self._contract(mock_w3) + mock_web3.return_value = mock_w3 + mock_account.return_value = MagicMock() + mock_validate.return_value = [ADDR_1, ADDR_2] + + result = spraay_batch_eth([ADDR_1, ADDR_2], "0.01") + + self.assertEqual(result["status"], "success") + args = contract.functions.sprayEqual.call_args[0] + self.assertEqual(args[0], ZERO_ADDRESS) + self.assertEqual(args[1], [ADDR_1, ADDR_2]) + self.assertEqual(args[2], 10**16) + + @patch.object(spraay_module, "_validate_recipients") + @patch.object(spraay_module, "_get_account") + @patch.object(spraay_module, "_get_web3") + def test_variable_eth_uses_spray_eth_structs( + self, mock_web3, mock_account, mock_validate + ): + """Variable ETH sends must call sprayETH with (address, amount) structs.""" + mock_w3 = self._mock_w3() + contract = self._contract(mock_w3) + mock_web3.return_value = mock_w3 + mock_account.return_value = MagicMock() + mock_validate.return_value = [ADDR_1, ADDR_2] + + result = spraay_batch_eth_variable([ADDR_1, ADDR_2], ["0.1", "0.25"]) + + self.assertEqual(result["status"], "success") + (structs,) = contract.functions.sprayETH.call_args[0] + self.assertEqual( + structs, + [(ADDR_1, 10**17), (ADDR_2, 25 * 10**16)], + ) + + @patch.object(spraay_module, "_validate_recipients") + @patch.object(spraay_module, "_get_account") + @patch.object(spraay_module, "_get_web3") + def test_variable_token_uses_spray_token_structs( + self, mock_web3, mock_account, mock_validate + ): + """Variable token sends must call sprayToken with structs.""" + mock_w3 = self._mock_w3() + contract = self._contract(mock_w3) + # allowance already sufficient -> no approval tx + contract.functions.allowance.return_value.call.return_value = 2**255 + mock_web3.return_value = mock_w3 + mock_account.return_value = MagicMock() + mock_validate.return_value = [ADDR_1] + + result = spraay_batch_token_variable( + TOKEN_ADDR, [ADDR_1], ["10"], token_decimals=6 + ) + + self.assertEqual(result["status"], "success") + token_arg, structs = contract.functions.sprayToken.call_args[0] + self.assertEqual(token_arg, TOKEN_ADDR) + self.assertEqual(structs, [(ADDR_1, 10_000_000)]) + + class TestConstants(unittest.TestCase): """Tests for Spraay constants."""