From e085847fab93ede9c540c9129d9ceb8feb713b04 Mon Sep 17 00:00:00 2001 From: Selim Aytac <53125635+selimaytac@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:57:44 +0300 Subject: [PATCH] fix: read ERC-20 token details on chains without multicall3 --- .../erc20-token-details-without-multicall3.md | 5 ++ .../erc20/erc20ActionProvider.test.ts | 53 +++++++++++++++ .../src/action-providers/erc20/utils.ts | 66 ++++++++++++------- 3 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 typescript/.changeset/erc20-token-details-without-multicall3.md diff --git a/typescript/.changeset/erc20-token-details-without-multicall3.md b/typescript/.changeset/erc20-token-details-without-multicall3.md new file mode 100644 index 000000000..350e1f696 --- /dev/null +++ b/typescript/.changeset/erc20-token-details-without-multicall3.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": patch +--- + +Fixed ERC-20 actions (`get_balance`, `transfer`, `approve`, `get_allowance`) failing with "Could not fetch token details" on chains whose viem definition has no `multicall3` address, such as a local Anvil (`foundry`) or custom chains. Token details are now read with plain contract calls when multicall is not available. diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts index 2089d2036..39c77928c 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts @@ -1,6 +1,8 @@ import { erc20ActionProvider } from "./erc20ActionProvider"; import { TransferSchema, GetTokenAddressSchema, ApproveSchema, AllowanceSchema } from "./schemas"; import { EvmWalletProvider } from "../../wallet-providers"; +import { ChainDoesNotSupportContract } from "viem"; +import { foundry } from "viem/chains"; const MOCK_AMOUNT = 15; const MOCK_DECIMALS = 6; @@ -34,12 +36,15 @@ describe("Transfer Schema", () => { describe("Get Balance Action", () => { let mockWallet: jest.Mocked; let mockMulticall: jest.Mock; + let mockReadContract: jest.Mock; const actionProvider = erc20ActionProvider(); beforeEach(async () => { mockMulticall = jest.fn(); + mockReadContract = jest.fn(); const mockPublicClient = { multicall: mockMulticall, + readContract: mockReadContract, getCode: jest.fn().mockResolvedValue("0x"), }; @@ -80,6 +85,54 @@ describe("Get Balance Action", () => { const response = await actionProvider.getBalance(mockWallet, args); expect(mockMulticall).toHaveBeenCalled(); + expect(mockReadContract).not.toHaveBeenCalled(); + + expect(response).toContain("Error: Could not fetch token details"); + }); + + it("should read the token with plain calls on a chain without multicall3", async () => { + mockMulticall.mockRejectedValue( + new ChainDoesNotSupportContract({ chain: foundry, contract: { name: "multicall3" } }), + ); + mockReadContract.mockImplementation(async ({ functionName }) => { + switch (functionName) { + case "name": + return "MockToken"; + case "decimals": + return MOCK_DECIMALS; + case "balanceOf": + return BigInt(MOCK_AMOUNT * 10 ** MOCK_DECIMALS); + } + }); + + const args = { + tokenAddress: MOCK_CONTRACT_ADDRESS, + address: null, + }; + + const response = await actionProvider.getBalance(mockWallet, args); + + expect(mockReadContract).toHaveBeenCalledTimes(3); + expect(mockReadContract).toHaveBeenCalledWith( + expect.objectContaining({ functionName: "balanceOf", args: [MOCK_ADDRESS] }), + ); + expect(response).toContain( + `Balance of MockToken (${MOCK_CONTRACT_ADDRESS}) at address ${MOCK_ADDRESS} is ${MOCK_AMOUNT}`, + ); + }); + + it("should fail when a plain call fails on a chain without multicall3", async () => { + mockMulticall.mockRejectedValue( + new ChainDoesNotSupportContract({ chain: foundry, contract: { name: "multicall3" } }), + ); + mockReadContract.mockRejectedValue(new Error("execution reverted")); + + const args = { + tokenAddress: MOCK_CONTRACT_ADDRESS, + address: null, + }; + + const response = await actionProvider.getBalance(mockWallet, args); expect(response).toContain("Error: Could not fetch token details"); }); diff --git a/typescript/agentkit/src/action-providers/erc20/utils.ts b/typescript/agentkit/src/action-providers/erc20/utils.ts index c6b7ff287..0ab933474 100644 --- a/typescript/agentkit/src/action-providers/erc20/utils.ts +++ b/typescript/agentkit/src/action-providers/erc20/utils.ts @@ -26,32 +26,48 @@ export async function getTokenDetails( address?: string, ): Promise { try { - const results = await walletProvider.getPublicClient().multicall({ - contracts: [ - { - address: contractAddress as Hex, - abi: erc20Abi, - functionName: "name", - args: [], - }, - { - address: contractAddress as Hex, - abi: erc20Abi, - functionName: "decimals", - args: [], - }, - { - address: contractAddress as Hex, - abi: erc20Abi, - functionName: "balanceOf", - args: [(address || walletProvider.getAddress()) as Hex], - }, - ], - }); + const publicClient = walletProvider.getPublicClient(); + const contracts = [ + { + address: contractAddress as Hex, + abi: erc20Abi, + functionName: "name", + args: [], + }, + { + address: contractAddress as Hex, + abi: erc20Abi, + functionName: "decimals", + args: [], + }, + { + address: contractAddress as Hex, + abi: erc20Abi, + functionName: "balanceOf", + args: [(address || walletProvider.getAddress()) as Hex], + }, + ] as const; - const rawName = results[0].result; - const decimals = results[1]?.result; - const balance = results[2]?.result; + let rawName: string | undefined; + let decimals: number | undefined; + let balance: bigint | undefined; + try { + const results = await publicClient.multicall({ contracts }); + rawName = results[0].result; + decimals = results[1]?.result; + balance = results[2]?.result; + } catch (error) { + // Chains whose definition has no Multicall3 address (e.g. local Anvil or custom chains) + // cannot use multicall, so read each value with a plain call instead. + if ((error as Error)?.name !== "ChainDoesNotSupportContract") { + throw error; + } + [rawName, decimals, balance] = await Promise.all([ + publicClient.readContract(contracts[0]), + publicClient.readContract(contracts[1]), + publicClient.readContract(contracts[2]), + ]); + } if (balance === undefined || decimals === undefined || rawName === undefined) { return null;