diff --git a/typescript/.changeset/case-insensitive-address-comparisons.md b/typescript/.changeset/case-insensitive-address-comparisons.md new file mode 100644 index 000000000..001906ee0 --- /dev/null +++ b/typescript/.changeset/case-insensitive-address-comparisons.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Fixed three EVM address comparisons that were case-sensitive: the erc20 transfer guardrail that refuses to send tokens to the token's own contract, the sushi router's native-asset check, and the yelay vault lookup. Each compared addresses as raw strings, so the same address written in a different case did not match. They now compare lowercased, as the rest of the codebase and the Python SDK already do. diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts index 2089d2036..691bc3c80 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts @@ -139,6 +139,25 @@ describe("Transfer Action", () => { expect(response).toContain(`Transaction hash for the transfer: ${TRANSACTION_HASH}`); }); + it("should refuse a transfer whose destination is the token contract in a different case", async () => { + mockMulticall.mockResolvedValueOnce([ + { result: "MockToken" }, // name + { result: MOCK_DECIMALS }, // decimals + { result: BigInt(100000 * 10 ** MOCK_DECIMALS) }, // balance + ]); + + const args = { + amount: MOCK_AMOUNT.toString(), + tokenAddress: "0xABCDEF1234567890123456789012345678901234", + destinationAddress: "0xabcdef1234567890123456789012345678901234", + }; + + const response = await actionProvider.transfer(mockWallet, args); + + expect(mockWallet.sendTransaction).not.toHaveBeenCalled(); + expect(response).toContain("Transfer destination is the token contract address"); + }); + it("should fail with an error", async () => { mockMulticall.mockRejectedValue(new Error("Failed to get token details")); diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index db03f8db7..16b101319 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -97,7 +97,7 @@ Important notes: } // Guardrails to prevent loss of funds - if (args.tokenAddress === args.destinationAddress) { + if (args.tokenAddress.toLowerCase() === args.destinationAddress.toLowerCase()) { return "Error: Transfer destination is the token contract address. Refusing transfer to prevent loss of funds."; } if ( diff --git a/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.test.ts b/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.test.ts index 2f3dc1798..266e0e3a7 100644 --- a/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.test.ts @@ -356,6 +356,31 @@ describe("Sushi Action Provider", () => { expect(result).toContain(`on ${getEvmChainById(chainId).shortName}`); }); + it("should treat a mixed-case native address as native", async () => { + const args: Parameters<(typeof actionProvider)["swap"]>[1] = { + amount: formatUnits(amountIn, tokenIn.decimals), + fromAssetAddress: nativeAddress.toUpperCase().replace("0X", "0x") as Address, + toAssetAddress: tokenOut.address, + maxSlippage: 0.005, + }; + + mockWallet.getBalance.mockResolvedValue(amountIn); + mockWallet.sendTransaction.mockResolvedValue(txHash); + mockWallet.waitForTransactionReceipt.mockResolvedValueOnce({ + status: "success", + logs: getRouteLog({ tokenIn: nativeToken, tokenOut, amountIn, amountOut }), + }); + mockedGetSwap.mockReturnValue( + getSuccessfullSwapResponse({ tokenIn: nativeToken, amountIn, tokenOut, amountOut }), + ); + + const result = await actionProvider.swap(mockWallet, args); + + // No decimals lookup: the native asset has no ERC20 contract to read it from + expect(mockWallet.readContract).toHaveBeenCalledTimes(0); + expect(result).toContain("Swapped"); + }); + it("should fail if there isn't enough balance (native)", async () => { const args: Parameters<(typeof actionProvider)["swap"]>[1] = { amount: formatUnits(amountIn, tokenIn.decimals), diff --git a/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.ts b/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.ts index 0eab9575b..c5a904849 100644 --- a/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.ts +++ b/typescript/agentkit/src/action-providers/sushi/sushiRouterActionProvider.ts @@ -270,7 +270,7 @@ async function fetchDecimals({ }; } - if (token === nativeAddress) { + if (token.toLowerCase() === nativeAddress) { return { success: true, decimals: EvmNative.fromChainId(chainId).decimals }; } diff --git a/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.test.ts b/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.test.ts index 3b20e906a..4ea78e22e 100644 --- a/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.test.ts @@ -211,6 +211,23 @@ APY: 5.2% const response = await provider.deposit(mockWallet, args); expect(response).toContain("Deposit failed"); }); + + it("should find the vault when its address case differs from the API response", async () => { + const checksummedVault = "0xAbCdEf1234567890123456789012345678901234"; + mockedFetch.mockResolvedValueOnce( + mockFetchResult(200, [{ ...mockVaults[0], address: checksummedVault }]), + ); + + const args = { + assets: MOCK_WHOLE_ASSETS, + vaultAddress: checksummedVault.toLowerCase(), + }; + + const response = await provider.deposit(mockWallet, args); + + expect(response).not.toContain("Vault not found"); + expect(response).toContain(`Deposited ${MOCK_WHOLE_ASSETS}`); + }); }); describe("redeem action", () => { diff --git a/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.ts b/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.ts index d06c840ca..af46e987c 100644 --- a/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.ts +++ b/typescript/agentkit/src/action-providers/yelay/yelayActionProvider.ts @@ -124,7 +124,9 @@ APY: ${vault.apy}% const chainId = wallet.getNetwork().chainId! as ChainId; const vaultsResponse = await fetch(`${YELAY_BACKEND_URL}/vaults?chainId=${chainId}`); const vaults = (await vaultsResponse.json()) as VaultsDetailsResponse[]; - const vault = vaults.find(vault => vault.address === args.vaultAddress); + const vault = vaults.find( + vault => vault.address.toLowerCase() === args.vaultAddress.toLowerCase(), + ); if (!vault) { return "Error: Vault not found"; @@ -181,7 +183,9 @@ Important notes: const chainId = wallet.getNetwork().chainId! as ChainId; const vaultsResponse = await fetch(`${YELAY_BACKEND_URL}/vaults?chainId=${chainId}`); const vaults = (await vaultsResponse.json()) as VaultsDetailsResponse[]; - const vault = vaults.find(vault => vault.address === args.vaultAddress); + const vault = vaults.find( + vault => vault.address.toLowerCase() === args.vaultAddress.toLowerCase(), + ); if (!vault) { return "Error: Vault not found"; @@ -287,7 +291,9 @@ It takes: const chainId = wallet.getNetwork().chainId! as ChainId; const vaultsResponse = await fetch(`${YELAY_BACKEND_URL}/vaults?chainId=${chainId}`); const vaults = (await vaultsResponse.json()) as VaultsDetailsResponse[]; - const vault = vaults.find(vault => vault.address === args.vaultAddress); + const vault = vaults.find( + vault => vault.address.toLowerCase() === args.vaultAddress.toLowerCase(), + ); if (!vault) { return "Error: Vault not found";