From 31e136cdb87517c47bfa9fe84a9453b466ce64ee Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 16:59:35 -0700 Subject: [PATCH 1/6] feat: added gasless transfers for USDC/CBBTC on Base/Base-Sepolia --- typescript/.changeset/fuzzy-birds-beg.md | 5 +++ .../src/action-providers/erc20/constants.ts | 14 +++++++ .../erc20/erc20ActionProvider.ts | 33 +++++++++++++++-- .../src/wallet-providers/cdpWalletProvider.ts | 37 +++++++++++++++++++ 4 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 typescript/.changeset/fuzzy-birds-beg.md 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..a5cc7fbf9 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 BASE_MAINNET_GASLESS_TOKENS = new Map([ + ["0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", Coinbase.assets.Cbbtc], + ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Coinbase.assets.Usdc], + ["0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", Coinbase.assets.Eurc], +]); + +export const BASE_SEPOLIA_GASLESS_TOKENS = new Map([ + ["0xcbB7C0006F23900c38EB856149F799620fcb8A4a", Coinbase.assets.Cbbtc], + ["0x036CbD53842c5426634e7929541eC2318f3dCF7e", Coinbase.assets.Usdc], + ["0x808456652fdb597867f38412077A9182bf77359F", Coinbase.assets.Eurc], +]); diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 83638e501..606d8cb23 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, BASE_MAINNET_GASLESS_TOKENS, BASE_SEPOLIA_GASLESS_TOKENS } 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,33 @@ 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" && BASE_MAINNET_GASLESS_TOKENS.has(tokenAddress)) || + (network.networkId === "base-sepolia" && BASE_SEPOLIA_GASLESS_TOKENS.has(tokenAddress))); + + if (canDoGasless) { + // Cast to CdpWalletProvider to access erc20Transfer + const cdpWallet = walletProvider as CdpWalletProvider; + const assetId = network.networkId === "base-mainnet" ? BASE_MAINNET_GASLESS_TOKENS.get(tokenAddress)! : BASE_SEPOLIA_GASLESS_TOKENS.get(tokenAddress)!; + const hash = await cdpWallet.erc20Transfer( + 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..4744ec149 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -567,4 +567,41 @@ export class CdpWalletProvider extends EvmWalletProvider { } return this.#cdpWallet; } + + /** + * ERC20 transfer method + * + * @param options - The transfer options + * @returns The transaction hash + */ + async erc20Transfer( + assetId: typeof Coinbase.assets.Usdc | typeof Coinbase.assets.Cbbtc | typeof Coinbase.assets.Eurc, + destination: `0x${string}`, + amount: bigint + ): Promise<`0x${string}`> { + if (!this.#cdpWallet) { + throw new Error("Wallet not initialized"); + } + + console.info({ + amount, + assetId, + destination, + gasless: true, + }); + 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 `0x${string}`; + } } From b4bad04c3e67cfe6e8f1c231651c3b4be0be8c12 Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 17:02:46 -0700 Subject: [PATCH 2/6] chore: lint/format --- .../erc20/erc20ActionProvider.ts | 18 +++++++++--------- .../src/wallet-providers/cdpWalletProvider.ts | 17 ++++++++--------- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 606d8cb23..0dfc92729 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -89,20 +89,20 @@ Important notes: const isCdpWallet = walletProvider.getName() === "cdp_wallet_provider"; const network = walletProvider.getNetwork(); const tokenAddress = getAddress(args.contractAddress); - - const canDoGasless = isCdpWallet && + + const canDoGasless = + isCdpWallet && ((network.networkId === "base-mainnet" && BASE_MAINNET_GASLESS_TOKENS.has(tokenAddress)) || - (network.networkId === "base-sepolia" && BASE_SEPOLIA_GASLESS_TOKENS.has(tokenAddress))); + (network.networkId === "base-sepolia" && BASE_SEPOLIA_GASLESS_TOKENS.has(tokenAddress))); if (canDoGasless) { // Cast to CdpWalletProvider to access erc20Transfer const cdpWallet = walletProvider as CdpWalletProvider; - const assetId = network.networkId === "base-mainnet" ? BASE_MAINNET_GASLESS_TOKENS.get(tokenAddress)! : BASE_SEPOLIA_GASLESS_TOKENS.get(tokenAddress)!; - const hash = await cdpWallet.erc20Transfer( - assetId, - args.destination as Hex, - args.amount, - ); + const assetId = + network.networkId === "base-mainnet" + ? BASE_MAINNET_GASLESS_TOKENS.get(tokenAddress)! + : BASE_SEPOLIA_GASLESS_TOKENS.get(tokenAddress)!; + const hash = await cdpWallet.erc20Transfer(assetId, args.destination as Hex, args.amount); await walletProvider.waitForTransactionReceipt(hash); diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index 4744ec149..7c60db08b 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -571,24 +571,23 @@ export class CdpWalletProvider extends EvmWalletProvider { /** * ERC20 transfer method * - * @param options - The transfer options + * @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 erc20Transfer( - assetId: typeof Coinbase.assets.Usdc | typeof Coinbase.assets.Cbbtc | typeof Coinbase.assets.Eurc, + assetId: + | typeof Coinbase.assets.Usdc + | typeof Coinbase.assets.Cbbtc + | typeof Coinbase.assets.Eurc, destination: `0x${string}`, - amount: bigint + amount: bigint, ): Promise<`0x${string}`> { if (!this.#cdpWallet) { throw new Error("Wallet not initialized"); } - console.info({ - amount, - assetId, - destination, - gasless: true, - }); const transferResult = await this.#cdpWallet.createTransfer({ amount, assetId, From b9f8beeb9c039ba011a20bbc0c68bd8fee0ce0b1 Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 17:04:43 -0700 Subject: [PATCH 3/6] chore: renamed erc20Transfer to gaslessERC20Transfer --- .../agentkit/src/action-providers/erc20/erc20ActionProvider.ts | 2 +- typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 0dfc92729..10e4308a9 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -102,7 +102,7 @@ Important notes: network.networkId === "base-mainnet" ? BASE_MAINNET_GASLESS_TOKENS.get(tokenAddress)! : BASE_SEPOLIA_GASLESS_TOKENS.get(tokenAddress)!; - const hash = await cdpWallet.erc20Transfer(assetId, args.destination as Hex, args.amount); + const hash = await cdpWallet.gaslessERC20Transfer(assetId, args.destination as Hex, args.amount); await walletProvider.waitForTransactionReceipt(hash); diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index 7c60db08b..54dd5f3ec 100644 --- a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts +++ b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts @@ -576,7 +576,7 @@ export class CdpWalletProvider extends EvmWalletProvider { * @param amount - The amount to transfer * @returns The transaction hash */ - async erc20Transfer( + async gaslessERC20Transfer( assetId: | typeof Coinbase.assets.Usdc | typeof Coinbase.assets.Cbbtc From 88e1c7f501bc39fc7b532a4601f6208d4482bcff Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 17:07:41 -0700 Subject: [PATCH 4/6] chore: renamed token to assetId mappings --- .../agentkit/src/action-providers/erc20/constants.ts | 4 ++-- .../src/action-providers/erc20/erc20ActionProvider.ts | 10 +++++----- .../agentkit/src/wallet-providers/cdpWalletProvider.ts | 8 +++++--- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/typescript/agentkit/src/action-providers/erc20/constants.ts b/typescript/agentkit/src/action-providers/erc20/constants.ts index a5cc7fbf9..29c059c29 100644 --- a/typescript/agentkit/src/action-providers/erc20/constants.ts +++ b/typescript/agentkit/src/action-providers/erc20/constants.ts @@ -189,13 +189,13 @@ export const abi = [ }, ] as const; -export const BASE_MAINNET_GASLESS_TOKENS = new Map([ +export const BaseTokenToAssetId = new Map([ ["0xcbB7C0000aB88B473b1f5aFd9ef808440eed33Bf", Coinbase.assets.Cbbtc], ["0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", Coinbase.assets.Usdc], ["0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42", Coinbase.assets.Eurc], ]); -export const BASE_SEPOLIA_GASLESS_TOKENS = new Map([ +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.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 10e4308a9..216726b5a 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -3,7 +3,7 @@ import { ActionProvider } from "../actionProvider"; import { Network } from "../../network"; import { CreateAction } from "../actionDecorator"; import { GetBalanceSchema, TransferSchema } from "./schemas"; -import { abi, BASE_MAINNET_GASLESS_TOKENS, BASE_SEPOLIA_GASLESS_TOKENS } from "./constants"; +import { abi, BaseTokenToAssetId, BaseSepoliaTokenToAssetId } from "./constants"; import { encodeFunctionData, formatUnits, Hex, getAddress } from "viem"; import { EvmWalletProvider, CdpWalletProvider } from "../../wallet-providers"; @@ -92,16 +92,16 @@ Important notes: const canDoGasless = isCdpWallet && - ((network.networkId === "base-mainnet" && BASE_MAINNET_GASLESS_TOKENS.has(tokenAddress)) || - (network.networkId === "base-sepolia" && BASE_SEPOLIA_GASLESS_TOKENS.has(tokenAddress))); + ((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" - ? BASE_MAINNET_GASLESS_TOKENS.get(tokenAddress)! - : BASE_SEPOLIA_GASLESS_TOKENS.get(tokenAddress)!; + ? BaseTokenToAssetId.get(tokenAddress)! + : BaseSepoliaTokenToAssetId.get(tokenAddress)!; const hash = await cdpWallet.gaslessERC20Transfer(assetId, args.destination as Hex, args.amount); await walletProvider.waitForTransactionReceipt(hash); diff --git a/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts b/typescript/agentkit/src/wallet-providers/cdpWalletProvider.ts index 54dd5f3ec..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"; @@ -581,9 +583,9 @@ export class CdpWalletProvider extends EvmWalletProvider { | typeof Coinbase.assets.Usdc | typeof Coinbase.assets.Cbbtc | typeof Coinbase.assets.Eurc, - destination: `0x${string}`, + destination: Address, amount: bigint, - ): Promise<`0x${string}`> { + ): Promise { if (!this.#cdpWallet) { throw new Error("Wallet not initialized"); } @@ -601,6 +603,6 @@ export class CdpWalletProvider extends EvmWalletProvider { throw new Error("Transaction hash not found"); } - return result.getTransactionHash() as `0x${string}`; + return result.getTransactionHash() as Hex; } } From 03607be53dbaa796232c7749cf0d75517f5815a7 Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 17:09:22 -0700 Subject: [PATCH 5/6] chore: ran format/lint --- .../src/action-providers/erc20/erc20ActionProvider.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts index 216726b5a..2c2f27b4d 100644 --- a/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts +++ b/typescript/agentkit/src/action-providers/erc20/erc20ActionProvider.ts @@ -102,7 +102,11 @@ Important notes: network.networkId === "base-mainnet" ? BaseTokenToAssetId.get(tokenAddress)! : BaseSepoliaTokenToAssetId.get(tokenAddress)!; - const hash = await cdpWallet.gaslessERC20Transfer(assetId, args.destination as Hex, args.amount); + const hash = await cdpWallet.gaslessERC20Transfer( + assetId, + args.destination as Hex, + args.amount, + ); await walletProvider.waitForTransactionReceipt(hash); From de87aac56a30bdf346f9a9531ff32c0676361b48 Mon Sep 17 00:00:00 2001 From: CarsonRoscoe Date: Mon, 17 Mar 2025 17:19:10 -0700 Subject: [PATCH 6/6] fix: unit test --- .../src/action-providers/erc20/erc20ActionProvider.test.ts | 4 ++++ 1 file changed, 4 insertions(+) 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);