diff --git a/cdp-agentkit-core/typescript/CHANGELOG.md b/cdp-agentkit-core/typescript/CHANGELOG.md index 4664d5822..a0dd9f160 100644 --- a/cdp-agentkit-core/typescript/CHANGELOG.md +++ b/cdp-agentkit-core/typescript/CHANGELOG.md @@ -2,6 +2,11 @@ ## Unreleased +### Added + +- Added `get_balance_nft` action. +- Added `transfer_nft` action. + ## [0.0.11] - 2025-01-13 ### Added diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/get_balance_nft.ts b/cdp-agentkit-core/typescript/src/actions/cdp/get_balance_nft.ts new file mode 100644 index 000000000..3c81743af --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/get_balance_nft.ts @@ -0,0 +1,70 @@ +import { CdpAction } from "./cdp_action"; +import { readContract, Wallet } from "@coinbase/coinbase-sdk"; +import { Hex } from "viem"; +import { z } from "zod"; + +const GET_BALANCE_NFT_PROMPT = ` +This tool will get the NFTs (ERC721 tokens) owned by the wallet for a specific NFT contract. + +It takes the following inputs: +- contractAddress: The NFT contract address to check +- address: (Optional) The address to check NFT balance for. If not provided, uses the wallet's default address +`; + +/** + * Input schema for get NFT balance action. + */ +export const GetBalanceNftInput = z + .object({ + contractAddress: z.string().describe("The NFT contract address to check balance for"), + address: z + .string() + .optional() + .describe( + "The address to check NFT balance for. If not provided, uses the wallet's default address", + ), + }) + .strip() + .describe("Instructions for getting NFT balance"); + +/** + * Gets NFT balance for a specific contract. + * + * @param wallet - The wallet to check balance from. + * @param args - The input arguments for the action. + * @returns A message containing the NFT balance details. + */ +export async function getBalanceNft( + wallet: Wallet, + args: z.infer, +): Promise { + try { + const checkAddress = args.address || (await wallet.getDefaultAddress()).getId(); + + const ownedTokens = await readContract({ + contractAddress: args.contractAddress as Hex, + networkId: wallet.getNetworkId(), + method: "tokensOfOwner", + args: { owner: checkAddress }, + }); + + if (!ownedTokens || ownedTokens.length === 0) { + return `Address ${checkAddress} owns no NFTs in contract ${args.contractAddress}`; + } + + const tokenList = ownedTokens.map(String).join(", "); + return `Address ${checkAddress} owns ${ownedTokens.length} NFTs in contract ${args.contractAddress}.\nToken IDs: ${tokenList}`; + } catch (error) { + return `Error getting NFT balance for address ${args.address} in contract ${args.contractAddress}: ${error}`; + } +} + +/** + * Get NFT balance action. + */ +export class GetBalanceNftAction implements CdpAction { + name = "get_balance_nft"; + description = GET_BALANCE_NFT_PROMPT; + argsSchema = GetBalanceNftInput; + func = getBalanceNft; +} diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/index.ts b/cdp-agentkit-core/typescript/src/actions/cdp/index.ts index 837652e02..43f79a24b 100644 --- a/cdp-agentkit-core/typescript/src/actions/cdp/index.ts +++ b/cdp-agentkit-core/typescript/src/actions/cdp/index.ts @@ -2,12 +2,14 @@ import { CdpAction, CdpActionSchemaAny } from "./cdp_action"; import { DeployNftAction } from "./deploy_nft"; import { DeployTokenAction } from "./deploy_token"; import { GetBalanceAction } from "./get_balance"; +import { GetBalanceNftAction } from "./get_balance_nft"; import { GetWalletDetailsAction } from "./get_wallet_details"; import { MintNftAction } from "./mint_nft"; import { RegisterBasenameAction } from "./register_basename"; import { RequestFaucetFundsAction } from "./request_faucet_funds"; import { TradeAction } from "./trade"; import { TransferAction } from "./transfer"; +import { TransferNftAction } from "./transfer_nft"; import { WrapEthAction } from "./wrap_eth"; import { WOW_ACTIONS } from "./defi/wow"; @@ -23,11 +25,13 @@ export function getAllCdpActions(): CdpAction[] { new DeployNftAction(), new DeployTokenAction(), new GetBalanceAction(), + new GetBalanceNftAction(), new MintNftAction(), new RegisterBasenameAction(), new RequestFaucetFundsAction(), new TradeAction(), new TransferAction(), + new TransferNftAction(), new WrapEthAction(), ]; } @@ -41,10 +45,12 @@ export { DeployNftAction, DeployTokenAction, GetBalanceAction, + GetBalanceNftAction, MintNftAction, RegisterBasenameAction, RequestFaucetFundsAction, TradeAction, TransferAction, + TransferNftAction, WrapEthAction, }; diff --git a/cdp-agentkit-core/typescript/src/actions/cdp/transfer_nft.ts b/cdp-agentkit-core/typescript/src/actions/cdp/transfer_nft.ts new file mode 100644 index 000000000..7f1c34bc2 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/actions/cdp/transfer_nft.ts @@ -0,0 +1,87 @@ +import { CdpAction } from "./cdp_action"; +import { Wallet } from "@coinbase/coinbase-sdk"; +import { z } from "zod"; + +const TRANSFER_NFT_PROMPT = ` +This tool will transfer an NFT (ERC721 token) from the wallet to another onchain address. + +It takes the following inputs: +- contractAddress: The NFT contract address +- tokenId: The ID of the specific NFT to transfer +- destination: Where to send the NFT (can be an onchain address, ENS 'example.eth', or Basename 'example.base.eth') + +Important notes: +- Ensure you have ownership of the NFT before attempting transfer +- Ensure there is sufficient native token balance for gas fees +- The wallet must either own the NFT or have approval to transfer it +`; + +/** + * Input schema for NFT transfer action. + */ +export const TransferNftInput = z + .object({ + contractAddress: z.string().describe("The NFT contract address to interact with"), + tokenId: z.string().describe("The ID of the NFT to transfer"), + destination: z + .string() + .describe( + "The destination to transfer the NFT, e.g. `0x58dBecc0894Ab4C24F98a0e684c989eD07e4e027`, `example.eth`, `example.base.eth`", + ), + fromAddress: z + .string() + .optional() + .describe( + "The address to transfer from. If not provided, defaults to the wallet's default address", + ), + }) + .strip() + .describe("Input schema for transferring an NFT"); + +/** + * Transfers an NFT (ERC721 token) to a destination address. + * + * @param wallet - The wallet to transfer the NFT from. + * @param args - The input arguments for the action. + * @returns A message containing the transfer details. + */ +export async function transferNft( + wallet: Wallet, + args: z.infer, +): Promise { + const from = args.fromAddress || (await wallet.getDefaultAddress()).getId(); + + try { + const transferResult = await wallet.invokeContract({ + contractAddress: args.contractAddress, + method: "transferFrom", + args: { + from, + to: args.destination, + tokenId: args.tokenId, + }, + }); + + const result = await transferResult.wait(); + + const transaction = result.getTransaction(); + + return `Transferred NFT (ID: ${args.tokenId}) from contract ${args.contractAddress} to ${ + args.destination + }.\nTransaction hash: ${transaction.getTransactionHash()}\nTransaction link: ${transaction.getTransactionLink()}`; + } catch (error) { + return `Error transferring the NFT (contract: ${args.contractAddress}, ID: ${ + args.tokenId + }) from ${from} to ${args.destination}): ${error}`; + } +} + +/** + * Transfer NFT action. + */ +export class TransferNftAction implements CdpAction { + name = "transfer_nft"; + description = TRANSFER_NFT_PROMPT; + argsSchema = TransferNftInput; + func = transferNft; +} diff --git a/cdp-agentkit-core/typescript/src/tests/get_balance_nft_test.ts b/cdp-agentkit-core/typescript/src/tests/get_balance_nft_test.ts new file mode 100644 index 000000000..29412050f --- /dev/null +++ b/cdp-agentkit-core/typescript/src/tests/get_balance_nft_test.ts @@ -0,0 +1,129 @@ +import { GetBalanceNftInput, getBalanceNft } from "../actions/cdp/get_balance_nft"; +import { Wallet } from "@coinbase/coinbase-sdk"; +import { readContract } from "@coinbase/coinbase-sdk"; + +const MOCK_CONTRACT_ADDRESS = "0xvalidContractAddress"; +const MOCK_ADDRESS = "0xvalidAddress"; +const MOCK_TOKEN_IDS = ["1", "2", "3"]; + +jest.mock("@coinbase/coinbase-sdk", () => ({ + ...jest.requireActual("@coinbase/coinbase-sdk"), + readContract: jest.fn(), +})); + +describe("GetBalanceNft", () => { + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockWallet = { + getDefaultAddress: jest.fn().mockResolvedValue({ getId: () => MOCK_ADDRESS }), + getNetworkId: jest.fn().mockReturnValue("base-sepolia"), + } as unknown as jest.Mocked; + + (readContract as jest.Mock).mockClear(); + }); + + it("should validate input schema with all parameters", () => { + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + address: MOCK_ADDRESS, + }; + + const result = GetBalanceNftInput.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should validate input schema with required parameters only", () => { + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + }; + + const result = GetBalanceNftInput.safeParse(input); + expect(result.success).toBe(true); + }); + + it("should fail validation with missing required parameters", () => { + const input = {}; + + const result = GetBalanceNftInput.safeParse(input); + expect(result.success).toBe(false); + }); + + it("should successfully get NFT balance using default address", async () => { + (readContract as jest.Mock).mockResolvedValueOnce(MOCK_TOKEN_IDS); + + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + }; + + const response = await getBalanceNft(mockWallet, input); + + expect(mockWallet.getDefaultAddress).toHaveBeenCalled(); + expect(readContract).toHaveBeenCalledWith({ + contractAddress: MOCK_CONTRACT_ADDRESS, + networkId: "base-sepolia", + method: "tokensOfOwner", + args: { owner: MOCK_ADDRESS }, + }); + + expect(response).toBe( + `Address ${MOCK_ADDRESS} owns ${MOCK_TOKEN_IDS.length} NFTs in contract ${MOCK_CONTRACT_ADDRESS}.\n` + + `Token IDs: ${MOCK_TOKEN_IDS.join(", ")}`, + ); + }); + + it("should handle case when no tokens are owned", async () => { + (readContract as jest.Mock).mockResolvedValueOnce([]); + + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + address: MOCK_ADDRESS, + }; + + const response = await getBalanceNft(mockWallet, input); + + expect(response).toBe( + `Address ${MOCK_ADDRESS} owns no NFTs in contract ${MOCK_CONTRACT_ADDRESS}`, + ); + }); + + it("should get NFT balance with specific address", async () => { + const customAddress = "0xcustomAddress"; + (readContract as jest.Mock).mockResolvedValueOnce(MOCK_TOKEN_IDS); + + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + address: customAddress, + }; + + const response = await getBalanceNft(mockWallet, input); + + expect(readContract).toHaveBeenCalledWith({ + contractAddress: MOCK_CONTRACT_ADDRESS, + networkId: "base-sepolia", + method: "tokensOfOwner", + args: { owner: customAddress }, + }); + + expect(response).toBe( + `Address ${customAddress} owns ${MOCK_TOKEN_IDS.length} NFTs in contract ${MOCK_CONTRACT_ADDRESS}.\n` + + `Token IDs: ${MOCK_TOKEN_IDS.join(", ")}`, + ); + }); + + it("should handle API errors gracefully", async () => { + const errorMessage = "API error"; + (readContract as jest.Mock).mockRejectedValueOnce(new Error(errorMessage)); + + const input = { + contractAddress: MOCK_CONTRACT_ADDRESS, + address: MOCK_ADDRESS, + }; + + const response = await getBalanceNft(mockWallet, input); + + expect(response).toBe( + `Error getting NFT balance for address ${MOCK_ADDRESS} in contract ${MOCK_CONTRACT_ADDRESS}: Error: ${errorMessage}`, + ); + }); +}); diff --git a/cdp-agentkit-core/typescript/src/tests/transfer_nft_test.ts b/cdp-agentkit-core/typescript/src/tests/transfer_nft_test.ts new file mode 100644 index 000000000..2c1a0dc16 --- /dev/null +++ b/cdp-agentkit-core/typescript/src/tests/transfer_nft_test.ts @@ -0,0 +1,142 @@ +import { ContractInvocation, Wallet } from "@coinbase/coinbase-sdk"; + +import { transferNft, TransferNftInput } from "../actions/cdp/transfer_nft"; + +const MOCK_CONTRACT_ADDRESS = "0x123456789abcdef"; +const MOCK_TOKEN_ID = "1000"; +const MOCK_DESTINATION = "0xabcdef123456789"; +const MOCK_FROM_ADDRESS = "0xdefault123456789"; + +describe("Transfer NFT Input", () => { + it("should successfully parse valid input", () => { + const validInput = { + contractAddress: MOCK_CONTRACT_ADDRESS, + tokenId: MOCK_TOKEN_ID, + destination: MOCK_DESTINATION, + fromAddress: MOCK_FROM_ADDRESS, + }; + + const result = TransferNftInput.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should successfully parse input without optional fromAddress", () => { + const validInput = { + contractAddress: MOCK_CONTRACT_ADDRESS, + tokenId: MOCK_TOKEN_ID, + destination: MOCK_DESTINATION, + }; + + const result = TransferNftInput.safeParse(validInput); + + expect(result.success).toBe(true); + expect(result.data).toEqual(validInput); + }); + + it("should fail parsing empty input", () => { + const emptyInput = {}; + const result = TransferNftInput.safeParse(emptyInput); + + expect(result.success).toBe(false); + }); +}); + +describe("Transfer NFT Action", () => { + const TRANSACTION_HASH = "0xghijkl987654321"; + const TRANSACTION_LINK = `https://etherscan.io/tx/${TRANSACTION_HASH}`; + + let mockContractInvocation: jest.Mocked; + let mockWallet: jest.Mocked; + + beforeEach(() => { + mockContractInvocation = { + wait: jest.fn().mockResolvedValue({ + getTransaction: jest.fn().mockReturnValue({ + getTransactionHash: jest.fn().mockReturnValue(TRANSACTION_HASH), + getTransactionLink: jest.fn().mockReturnValue(TRANSACTION_LINK), + }), + }), + } as unknown as jest.Mocked; + + mockWallet = { + invokeContract: jest.fn(), + getDefaultAddress: jest.fn().mockResolvedValue({ + getId: jest.fn().mockReturnValue(MOCK_FROM_ADDRESS), + }), + } as unknown as jest.Mocked; + + mockWallet.invokeContract.mockResolvedValue(mockContractInvocation); + }); + + it("should successfully transfer NFT with provided fromAddress", async () => { + const args = { + contractAddress: MOCK_CONTRACT_ADDRESS, + tokenId: MOCK_TOKEN_ID, + destination: MOCK_DESTINATION, + fromAddress: MOCK_FROM_ADDRESS, + }; + + const response = await transferNft(mockWallet, args); + + expect(mockWallet.invokeContract).toHaveBeenCalledWith({ + contractAddress: MOCK_CONTRACT_ADDRESS, + method: "transferFrom", + args: { + from: MOCK_FROM_ADDRESS, + to: MOCK_DESTINATION, + tokenId: MOCK_TOKEN_ID, + }, + }); + expect(mockContractInvocation.wait).toHaveBeenCalled(); + expect(response).toContain( + `Transferred NFT (ID: ${MOCK_TOKEN_ID}) from contract ${MOCK_CONTRACT_ADDRESS} to ${MOCK_DESTINATION}`, + ); + expect(response).toContain(`Transaction hash: ${TRANSACTION_HASH}`); + expect(response).toContain(`Transaction link: ${TRANSACTION_LINK}`); + }); + + it("should successfully transfer NFT with default address", async () => { + const args = { + contractAddress: MOCK_CONTRACT_ADDRESS, + tokenId: MOCK_TOKEN_ID, + destination: MOCK_DESTINATION, + }; + + const response = await transferNft(mockWallet, args); + + expect(mockWallet.getDefaultAddress).toHaveBeenCalled(); + expect(mockWallet.invokeContract).toHaveBeenCalledWith({ + contractAddress: MOCK_CONTRACT_ADDRESS, + method: "transferFrom", + args: { + from: MOCK_FROM_ADDRESS, + to: MOCK_DESTINATION, + tokenId: MOCK_TOKEN_ID, + }, + }); + expect(mockContractInvocation.wait).toHaveBeenCalled(); + expect(response).toContain( + `Transferred NFT (ID: ${MOCK_TOKEN_ID}) from contract ${MOCK_CONTRACT_ADDRESS} to ${MOCK_DESTINATION}`, + ); + }); + + it("should fail with an error", async () => { + const args = { + contractAddress: MOCK_CONTRACT_ADDRESS, + tokenId: MOCK_TOKEN_ID, + destination: MOCK_DESTINATION, + }; + + const error = new Error("Failed to transfer NFT"); + mockWallet.invokeContract.mockRejectedValue(error); + + const response = await transferNft(mockWallet, args); + + expect(mockWallet.invokeContract).toHaveBeenCalled(); + expect(response).toContain( + `Error transferring the NFT (contract: ${MOCK_CONTRACT_ADDRESS}, ID: ${MOCK_TOKEN_ID}) from ${MOCK_FROM_ADDRESS} to ${MOCK_DESTINATION}): ${error}`, + ); + }); +}); diff --git a/cdp-langchain/typescript/src/toolkits/cdp_toolkit.ts b/cdp-langchain/typescript/src/toolkits/cdp_toolkit.ts index 126b146fc..fa8fdf097 100644 --- a/cdp-langchain/typescript/src/toolkits/cdp_toolkit.ts +++ b/cdp-langchain/typescript/src/toolkits/cdp_toolkit.ts @@ -29,8 +29,10 @@ import { CdpTool } from "../tools/cdp_tool"; * // Available tools include: * // - get_wallet_details * // - get_balance + * // - get_balance_nft * // - request_faucet_funds * // - transfer + * // - transfer_nft * // - trade * // - deploy_token * // - mint_nft