-
Notifications
You must be signed in to change notification settings - Fork 145
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(typescript): add nft transfer and balance actions (#118)
- Loading branch information
Showing
7 changed files
with
441 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
70 changes: 70 additions & 0 deletions
70
cdp-agentkit-core/typescript/src/actions/cdp/get_balance_nft.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
87 changes: 87 additions & 0 deletions
87
cdp-agentkit-core/typescript/src/actions/cdp/transfer_nft.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
129
cdp-agentkit-core/typescript/src/tests/get_balance_nft_test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`, | ||
); | ||
}); | ||
}); |
Oops, something went wrong.