Skip to content

Commit

Permalink
feat(typescript): add nft transfer and balance actions (#118)
Browse files Browse the repository at this point in the history
  • Loading branch information
0xRAG authored Jan 16, 2025
1 parent 9add653 commit b89c75d
Show file tree
Hide file tree
Showing 7 changed files with 441 additions and 0 deletions.
5 changes: 5 additions & 0 deletions cdp-agentkit-core/typescript/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

## Unreleased

### Added

- Added `get_balance_nft` action.
- Added `transfer_nft` action.

## [0.0.11] - 2025-01-13

### Added
Expand Down
70 changes: 70 additions & 0 deletions cdp-agentkit-core/typescript/src/actions/cdp/get_balance_nft.ts
Original file line number Diff line number Diff line change
@@ -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<typeof GetBalanceNftInput>,
): Promise<string> {
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<typeof GetBalanceNftInput> {
name = "get_balance_nft";
description = GET_BALANCE_NFT_PROMPT;
argsSchema = GetBalanceNftInput;
func = getBalanceNft;
}
6 changes: 6 additions & 0 deletions cdp-agentkit-core/typescript/src/actions/cdp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -23,11 +25,13 @@ export function getAllCdpActions(): CdpAction<CdpActionSchemaAny>[] {
new DeployNftAction(),
new DeployTokenAction(),
new GetBalanceAction(),
new GetBalanceNftAction(),
new MintNftAction(),
new RegisterBasenameAction(),
new RequestFaucetFundsAction(),
new TradeAction(),
new TransferAction(),
new TransferNftAction(),
new WrapEthAction(),
];
}
Expand All @@ -41,10 +45,12 @@ export {
DeployNftAction,
DeployTokenAction,
GetBalanceAction,
GetBalanceNftAction,
MintNftAction,
RegisterBasenameAction,
RequestFaucetFundsAction,
TradeAction,
TransferAction,
TransferNftAction,
WrapEthAction,
};
87 changes: 87 additions & 0 deletions cdp-agentkit-core/typescript/src/actions/cdp/transfer_nft.ts
Original file line number Diff line number Diff line change
@@ -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<typeof TransferNftInput>,
): Promise<string> {
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<typeof TransferNftInput> {
name = "transfer_nft";
description = TRANSFER_NFT_PROMPT;
argsSchema = TransferNftInput;
func = transferNft;
}
129 changes: 129 additions & 0 deletions cdp-agentkit-core/typescript/src/tests/get_balance_nft_test.ts
Original file line number Diff line number Diff line change
@@ -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<Wallet>;

beforeEach(() => {
mockWallet = {
getDefaultAddress: jest.fn().mockResolvedValue({ getId: () => MOCK_ADDRESS }),
getNetworkId: jest.fn().mockReturnValue("base-sepolia"),
} as unknown as jest.Mocked<Wallet>;

(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}`,
);
});
});
Loading

0 comments on commit b89c75d

Please sign in to comment.