diff --git a/typescript/.changeset/fuzzy-birds-beg.md b/typescript/.changeset/fuzzy-birds-beg.md new file mode 100644 index 000000000..e3044b4c1 --- /dev/null +++ b/typescript/.changeset/fuzzy-birds-beg.md @@ -0,0 +1,5 @@ +--- +"@coinbase/agentkit": minor +--- + +Added gasless transfers for USDC/CBBTC on Base/Base-Sepolia diff --git a/typescript/agentkit/src/action-providers/erc20/constants.ts b/typescript/agentkit/src/action-providers/erc20/constants.ts index 2167286d4..29c059c29 100644 --- a/typescript/agentkit/src/action-providers/erc20/constants.ts +++ b/typescript/agentkit/src/action-providers/erc20/constants.ts @@ -1,3 +1,5 @@ +import { Coinbase } from "@coinbase/coinbase-sdk"; + export const abi = [ { type: "event", @@ -186,3 +188,15 @@ export const abi = [ ], }, ] as const; + +export const BaseTokenToAssetId = new Map([ + ["0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", Coinbase.assets.Cbbtc], + ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Coinbase.assets.Usdc], + ["0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", Coinbase.assets.Eurc], +]); + +export const BaseSepoliaTokenToAssetId = new Map([ + ["0xcbB7C0006F23900c38EB856149F799620fcb8A4a", Coinbase.assets.Cbbtc], + ["0x036CbD53842c5426634e7929541eC2318f3dCF7e", Coinbase.assets.Usdc], + ["0x808456652fdb597867f38412077A9182bf77359F", Coinbase.assets.Eurc], +]); diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts index 7b10011b4..5ec32ff32 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.test.ts @@ -96,6 +96,10 @@ describe("Transfer Action", () => { mockWallet = { sendTransaction: jest.fn(), waitForTransactionReceipt: jest.fn(), + getName: jest.fn().mockReturnValue("evm_wallet_provider"), + getNetwork: jest.fn().mockReturnValue({ + networkId: "base-mainnet", + }), } as unknown as jest.Mocked; mockWallet.sendTransaction.mockResolvedValue(TRANSACTION_HASH); diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 83638e501..2c2f27b4d 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -3,9 +3,9 @@ import { ActionProvider } from "../actionProvider"; import { Network } from "../../network"; import { CreateAction } from "../actionDecorator"; import { GetBalanceSchema, TransferSchema } from "./schemas"; -import { abi } from "./constants"; -import { encodeFunctionData, formatUnits, Hex } from "viem"; -import { EvmWalletProvider } from "../../wallet-providers"; +import { abi, BaseTokenToAssetId, BaseSepoliaTokenToAssetId } from "./constants"; +import { encodeFunctionData, formatUnits, Hex, getAddress } from "viem"; +import { EvmWalletProvider, CdpWalletProvider } from "../../wallet-providers"; /** * ERC20ActionProvider is an action provider for ERC20 tokens. @@ -85,6 +85,37 @@ Important notes: args: z.infer, ): Promise { try { + // Check if we can do gasless transfer + const isCdpWallet = walletProvider.getName() === "cdp_wallet_provider"; + const network = walletProvider.getNetwork(); + const tokenAddress = getAddress(args.contractAddress); + + const canDoGasless = + isCdpWallet && + ((network.networkId === "base-mainnet" && BaseTokenToAssetId.has(tokenAddress)) || + (network.networkId === "base-sepolia" && BaseSepoliaTokenToAssetId.has(tokenAddress))); + + if (canDoGasless) { + // Cast to CdpWalletProvider to access erc20Transfer + const cdpWallet = walletProvider as CdpWalletProvider; + const assetId = + network.networkId === "base-mainnet" + ? BaseTokenToAssetId.get(tokenAddress)! + : BaseSepoliaTokenToAssetId.get(tokenAddress)!; + const hash = await cdpWallet.gaslessERC20Transfer( + assetId, + args.destination as Hex, + args.amount, + ); + + await walletProvider.waitForTransactionReceipt(hash); + + return `Transferred ${args.amount} of ${args.contractAddress} to ${ + args.destination + } using gasless transfer.\nTransaction hash: ${hash}`; + } + + // Fallback to regular transfer const hash = await walletProvider.sendTransaction({ to: args.contractAddress as Hex, data: encodeFunctionData({ diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index bd704a13a..56cdee86f 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -14,6 +14,8 @@ import { Abi, ContractFunctionName, ContractFunctionArgs, + Address, + Hex, } from "viem"; import { EvmWalletProvider } from "./evmWalletProvider"; import { Network } from "../network"; @@ -567,4 +569,40 @@ export class CdpWalletProvider extends EvmWalletProvider { } return this.#cdpWallet; } + + /** + * ERC20 transfer method + * + * @param assetId - The asset ID to transfer. Either USDC, CBBTC or EURC + * @param destination - The destination address + * @param amount - The amount to transfer + * @returns The transaction hash + */ + async gaslessERC20Transfer( + assetId: + | typeof Coinbase.assets.Usdc + | typeof Coinbase.assets.Cbbtc + | typeof Coinbase.assets.Eurc, + destination: Address, + amount: bigint, + ): Promise { + if (!this.#cdpWallet) { + throw new Error("Wallet not initialized"); + } + + const transferResult = await this.#cdpWallet.createTransfer({ + amount, + assetId, + destination, + gasless: true, + }); + + const result = await transferResult.wait(); + + if (!result.getTransactionHash()) { + throw new Error("Transaction hash not found"); + } + + return result.getTransactionHash() as Hex; + } }