Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -34,12 +36,15 @@ describe("Transfer Schema", () => {
describe("Get Balance Action", () => {
let mockWallet: jest.Mocked<EvmWalletProvider>;
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"),
};

Expand Down Expand Up @@ -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");
});
Expand Down
66 changes: 41 additions & 25 deletions typescript/agentkit/src/action-providers/erc20/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,32 +26,48 @@ export async function getTokenDetails(
address?: string,
): Promise<TokenDetails | null> {
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;
Expand Down
Loading