From c08ae5bb04a57ed8cf240ea611b039b69a172bc2 Mon Sep 17 00:00:00 2001 From: thrishank Date: Thu, 2 Jan 2025 16:58:56 +0530 Subject: [PATCH 1/9] feat: add close accounts and reclaim rent --- src/agent/index.ts | 26 +++++-- src/langchain/index.ts | 29 ++++++++ src/tools/close_empty_token_accounts.ts | 92 +++++++++++++++++++++++++ src/tools/index.ts | 1 + 4 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 src/tools/close_empty_token_accounts.ts diff --git a/src/agent/index.ts b/src/agent/index.ts index 4acf694a..74aa052e 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -50,6 +50,7 @@ import { create_TipLink, listNFTForSale, cancelListing, + closeEmptyTokenAccounts, } from "../tools"; import { @@ -83,24 +84,30 @@ export class SolanaAgentKit { * @deprecated Using openai_api_key directly in constructor is deprecated. * Please use the new constructor with Config object instead: * @example - * const agent = new SolanaAgentKit(privateKey, rpcUrl, { + * const agent = new SolanaAgentKit(privateKey, rpcUrl, { * OPENAI_API_KEY: 'your-key' * }); */ - constructor(private_key: string, rpc_url: string, openai_api_key: string | null); + constructor( + private_key: string, + rpc_url: string, + openai_api_key: string | null, + ); constructor(private_key: string, rpc_url: string, config: Config); constructor( private_key: string, rpc_url: string, configOrKey: Config | string | null, ) { - this.connection = new Connection(rpc_url || "https://api.mainnet-beta.solana.com"); + this.connection = new Connection( + rpc_url || "https://api.mainnet-beta.solana.com", + ); this.wallet = Keypair.fromSecretKey(bs58.decode(private_key)); this.wallet_address = this.wallet.publicKey; // Handle both old and new patterns - if (typeof configOrKey === 'string' || configOrKey === null) { - this.config = { OPENAI_API_KEY: configOrKey || '' }; + if (typeof configOrKey === "string" || configOrKey === null) { + this.config = { OPENAI_API_KEY: configOrKey || "" }; } else { this.config = configOrKey; } @@ -463,8 +470,15 @@ export class SolanaAgentKit { async tensorListNFT(nftMint: PublicKey, price: number): Promise { return listNFTForSale(this, nftMint, price); } - + 1; async tensorCancelListing(nftMint: PublicKey): Promise { return cancelListing(this, nftMint); } + + async closeEmptyTokenAccounts(): Promise<{ + signature: string; + size: number; + }> { + return closeEmptyTokenAccounts(this); + } } diff --git a/src/langchain/index.ts b/src/langchain/index.ts index 4605fd3e..4d08e0a1 100644 --- a/src/langchain/index.ts +++ b/src/langchain/index.ts @@ -1825,6 +1825,34 @@ export class SolanaCancelNFTListingTool extends Tool { } } +export class CloseEmptyTokenAccounts extends Tool { + name = "close_empty_token_accounts"; + description = `Close all empty spl-token accounts and reclaim the rent`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(): Promise { + try { + const { signature, size } = + await this.solanaKit.closeEmptyTokenAccounts(); + + return JSON.stringify({ + status: "success", + message: `${size} accounts closed successfully. ${size === 48 ? "48 accounts can be closed in a single transaction try again to close more accounts" : ""}`, + signature, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + export function createSolanaTools(solanaKit: SolanaAgentKit) { return [ new SolanaBalanceTool(solanaKit), @@ -1873,5 +1901,6 @@ export function createSolanaTools(solanaKit: SolanaAgentKit) { new SolanaTipLinkTool(solanaKit), new SolanaListNFTForSaleTool(solanaKit), new SolanaCancelNFTListingTool(solanaKit), + new CloseEmptyTokenAccounts(solanaKit), ]; } diff --git a/src/tools/close_empty_token_accounts.ts b/src/tools/close_empty_token_accounts.ts new file mode 100644 index 00000000..a327261a --- /dev/null +++ b/src/tools/close_empty_token_accounts.ts @@ -0,0 +1,92 @@ +import { + PublicKey, + Transaction, + TransactionInstruction, +} from "@solana/web3.js"; +import { SolanaAgentKit } from "../agent"; +import { + AccountLayout, + createCloseAccountInstruction, + TOKEN_2022_PROGRAM_ID, + TOKEN_PROGRAM_ID, +} from "@solana/spl-token"; + +/** + * Close Empty SPL Token accounts of the agent + * @param agent SolanaAgentKit instance + * @returns transaction signature and total number of accounts closed + */ +export async function closeEmptyTokenAccounts( + agent: SolanaAgentKit, +): Promise<{ signature: string; size: number }> { + try { + const spl_token = await create_close_instruction(agent, TOKEN_PROGRAM_ID); + const token_2022 = await create_close_instruction( + agent, + TOKEN_2022_PROGRAM_ID, + ); + const transaction = new Transaction(); + + spl_token.forEach((instruction) => transaction.add(instruction)); + token_2022.forEach((instruction) => transaction.add(instruction)); + + const signature = await agent.connection.sendTransaction(transaction, [ + agent.wallet, + ]); + const size = spl_token.length + token_2022.length; + return { + signature, + size, + }; + } catch (error) { + throw new Error(`Error closing empty token accounts: ${error}`); + } +} + +/** + * creates the close instuctions of a spl token account + * @param agnet SolanaAgentKit instance + * @param token_program Token Program Id + * @returns close instuction array + */ + +async function create_close_instruction( + agent: SolanaAgentKit, + token_program: PublicKey, +): Promise { + const instructions = []; + + const ata_accounts = await agent.connection.getTokenAccountsByOwner( + agent.wallet_address, + { programId: token_program }, + "confirmed", + ); + + const tokens = ata_accounts.value; + const size = tokens.length > 25 ? 24 : tokens.length; // closing 24 accounts in a single transaction + + const accountExceptions = [ + "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC + "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", // USDT + ]; + + for (let i = 0; i < size; i++) { + const token_data = AccountLayout.decode(tokens[i].account.data); + if ( + token_data.amount === BigInt(0) && + !accountExceptions.includes(token_data.mint.toString()) + ) { + const closeInstruction = createCloseAccountInstruction( + ata_accounts.value[i].pubkey, + agent.wallet_address, + agent.wallet_address, + [], + token_program, + ); + + instructions.push(closeInstruction); + } + } + + return instructions; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index b9f75420..82734303 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -52,3 +52,4 @@ export * from "./rock_paper_scissor"; export * from "./create_tiplinks"; export * from "./tensor_trade"; +export * from "./close_empty_token_accounts"; From e8147da5fe145737406630de6e8df927f5a7643c Mon Sep 17 00:00:00 2001 From: thrishank Date: Sat, 4 Jan 2025 23:31:01 +0530 Subject: [PATCH 2/9] handle 0 empty accounts --- src/tools/close_empty_token_accounts.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/tools/close_empty_token_accounts.ts b/src/tools/close_empty_token_accounts.ts index a327261a..984e27d8 100644 --- a/src/tools/close_empty_token_accounts.ts +++ b/src/tools/close_empty_token_accounts.ts @@ -30,14 +30,20 @@ export async function closeEmptyTokenAccounts( spl_token.forEach((instruction) => transaction.add(instruction)); token_2022.forEach((instruction) => transaction.add(instruction)); + const size = spl_token.length + token_2022.length; + + if (size === 0) { + return { + signature: "", + size: 0, + }; + } + const signature = await agent.connection.sendTransaction(transaction, [ agent.wallet, ]); - const size = spl_token.length + token_2022.length; - return { - signature, - size, - }; + + return { signature, size }; } catch (error) { throw new Error(`Error closing empty token accounts: ${error}`); } From 8136bde20f701b62a731aae7df0a248fa9a5d423 Mon Sep 17 00:00:00 2001 From: thrishank Date: Sat, 4 Jan 2025 23:34:19 +0530 Subject: [PATCH 3/9] Add docs --- README.md | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index f4d55623..26838971 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,6 @@ ![Solana Agent Kit Cover 1 (3)](https://github.com/user-attachments/assets/cfa380f6-79d9-474d-9852-3e1976c6de70) - ![NPM Downloads](https://img.shields.io/npm/dm/solana-agent-kit?style=for-the-badge) ![GitHub forks](https://img.shields.io/github/forks/sendaifun/solana-agent-kit?style=for-the-badge) ![GitHub License](https://img.shields.io/github/license/sendaifun/solana-agent-kit?style=for-the-badge) @@ -23,7 +22,6 @@ An open-source toolkit for connecting AI agents to Solana protocols. Now, any ag Anyone - whether an SF-based AI researcher or a crypto-native builder - can bring their AI agents trained with any model and seamlessly integrate with Solana. - [![Run on Repl.it](https://replit.com/badge/github/sendaifun/solana-agent-kit)](https://replit.com/@sendaifun/Solana-Agent-Kit) > Replit template created by [Arpit Singh](https://github.com/The-x-35) @@ -56,9 +54,9 @@ Anyone - whether an SF-based AI researcher or a crypto-native builder - can brin - Register/resolve Alldomains - **Solana Blinks** - - Lending by Lulo (Best APR for USDC) - - Send Arcade Games - - JupSOL staking + - Lending by Lulo (Best APR for USDC) + - Send Arcade Games + - JupSOL staking - **Non-Financial Actions** - Gib Work for registering bounties @@ -205,6 +203,13 @@ const price = await agent.pythFetchPrice( console.log("Price in BTC/USD:", price); ``` +### Close Empty Token Accounts + +``` typescript + +const { signature } = await agent.closeEmptyTokenAccounts(); +``` + ## Examples ### LangGraph Multi-Agent System @@ -246,7 +251,6 @@ Refer to [CONTRIBUTING.md](CONTRIBUTING.md) for detailed guidelines on how to co - ## Star History [![Star History Chart](https://api.star-history.com/svg?repos=sendaifun/solana-agent-kit&type=Date)](https://star-history.com/#sendaifun/solana-agent-kit&Date) @@ -258,4 +262,3 @@ Apache-2 License ## Security This toolkit handles private keys and transactions. Always ensure you're using it in a secure environment and never share your private keys. - From 01341e584f698ac7191c303bba34cb40633c54c7 Mon Sep 17 00:00:00 2001 From: thrishank Date: Sun, 5 Jan 2025 12:41:04 +0530 Subject: [PATCH 4/9] fix request --- src/agent/index.ts | 2 +- src/tools/close_empty_token_accounts.ts | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 74aa052e..8dea1984 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -470,7 +470,7 @@ export class SolanaAgentKit { async tensorListNFT(nftMint: PublicKey, price: number): Promise { return listNFTForSale(this, nftMint, price); } - 1; + async tensorCancelListing(nftMint: PublicKey): Promise { return cancelListing(this, nftMint); } diff --git a/src/tools/close_empty_token_accounts.ts b/src/tools/close_empty_token_accounts.ts index 984e27d8..428a5ec3 100644 --- a/src/tools/close_empty_token_accounts.ts +++ b/src/tools/close_empty_token_accounts.ts @@ -73,7 +73,6 @@ async function create_close_instruction( const accountExceptions = [ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC - "Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB", // USDT ]; for (let i = 0; i < size; i++) { From fabcf636c46a3a93f83192ce7f3a95fe0adc9e1b Mon Sep 17 00:00:00 2001 From: thrishank Date: Sun, 5 Jan 2025 13:04:32 +0530 Subject: [PATCH 5/9] add action file --- src/actions/closeEmptyTokenAccounts.ts | 71 ++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 src/actions/closeEmptyTokenAccounts.ts diff --git a/src/actions/closeEmptyTokenAccounts.ts b/src/actions/closeEmptyTokenAccounts.ts new file mode 100644 index 00000000..867c25e3 --- /dev/null +++ b/src/actions/closeEmptyTokenAccounts.ts @@ -0,0 +1,71 @@ +import { Action } from "../types/action"; +import { SolanaAgentKit } from "../agent"; +import { z } from "zod"; +import { closeEmptyTokenAccounts } from "../tools"; + +const closeEmptyTokenAccountsAction: Action = { + name: "CLOSE_EMPTY_TOKEN_ACCOUNTS", + similes: [ + "close token accounts", + "remove empty accounts", + "clean up token accounts", + "close SPL token accounts", + "clean wallet", + ], + description: `Close empty SPL Token accounts associated with your wallet to reclaim rent. + This action will close both regular SPL Token accounts and Token-2022 accounts that have zero balance. `, + examples: [ + [ + { + input: {}, + output: { + status: "success", + signature: + "3KmPyiZvJQk8CfBVVaz8nf3c2crb6iqjQVDqNxknnusyb1FTFpXqD8zVSCBAd1X3rUcD8WiG1bdSjFbeHsmcYGXY", + accountsClosed: 10, + }, + explanation: "Closed 10 empty token accounts successfully.", + }, + ], + [ + { + input: {}, + output: { + status: "success", + signature: "", + accountsClosed: 0, + }, + explanation: "No empty token accounts were found to close.", + }, + ], + ], + schema: z.object({}), + handler: async (agent: SolanaAgentKit) => { + try { + const result = await closeEmptyTokenAccounts(agent); + + if (result.size === 0) { + return { + status: "success", + signature: "", + accountsClosed: 0, + message: "No empty token accounts found to close", + }; + } + + return { + status: "success", + signature: result.signature, + accountsClosed: result.size, + message: `Successfully closed ${result.size} empty token accounts`, + }; + } catch (error: any) { + return { + status: "error", + message: `Failed to close empty token accounts: ${error.message}`, + }; + } + }, +}; + +export default closeEmptyTokenAccountsAction; From 8d2d65904ab73c4a202abe0e105bd8845161dcbf Mon Sep 17 00:00:00 2001 From: thrishank Date: Sun, 5 Jan 2025 13:08:01 +0530 Subject: [PATCH 6/9] lint & format --- src/agent/index.ts | 3 ++- src/tools/index.ts | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/agent/index.ts b/src/agent/index.ts index 9925fb84..3a61d421 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -540,7 +540,8 @@ export class SolanaAgentKit { size: number; }> { return closeEmptyTokenAccounts(this); - + } + async fetchTokenReportSummary(mint: string): Promise { return fetchTokenReportSummary(mint); } diff --git a/src/tools/index.ts b/src/tools/index.ts index f3b132e0..f208c260 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -44,9 +44,9 @@ export * from "./send_compressed_airdrop"; export * from "./stake_with_jup"; export * from "./stake_with_solayer"; export * from "./tensor_trade"; - + export * from "./close_empty_token_accounts"; - + export * from "./trade"; export * from "./transfer"; export * from "./withdraw_all"; From 454cb3b8db0a30cd9ae955bcff48ba0fd9a7e6a3 Mon Sep 17 00:00:00 2001 From: thrishank Date: Sun, 5 Jan 2025 13:12:24 +0530 Subject: [PATCH 7/9] add the tool in langchain --- src/langchain/index.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/src/langchain/index.ts b/src/langchain/index.ts index 2e39f5b4..05324f2c 100644 --- a/src/langchain/index.ts +++ b/src/langchain/index.ts @@ -2125,6 +2125,34 @@ export class SolanaFetchTokenDetailedReportTool extends Tool { } } +export class SolanaCloseEmptyTokenAccounts extends Tool { + name = "close_empty_token_accounts"; + description = `Close all empty spl-token accounts and reclaim the rent`; + + constructor(private solanaKit: SolanaAgentKit) { + super(); + } + + protected async _call(): Promise { + try { + const { signature, size } = + await this.solanaKit.closeEmptyTokenAccounts(); + + return JSON.stringify({ + status: "success", + message: `${size} accounts closed successfully. ${size === 48 ? "48 accounts can be closed in a single transaction try again to close more accounts" : ""}`, + signature, + }); + } catch (error: any) { + return JSON.stringify({ + status: "error", + message: error.message, + code: error.code || "UNKNOWN_ERROR", + }); + } + } +} + export function createSolanaTools(solanaKit: SolanaAgentKit) { return [ new SolanaBalanceTool(solanaKit), From 46491bc2a4fc309dc6dc28a5433b3c5fadbdfaa5 Mon Sep 17 00:00:00 2001 From: thrishank Date: Sun, 5 Jan 2025 13:47:19 +0530 Subject: [PATCH 8/9] update instructions processing logic --- src/tools/close_empty_token_accounts.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/tools/close_empty_token_accounts.ts b/src/tools/close_empty_token_accounts.ts index 428a5ec3..ebc7f78a 100644 --- a/src/tools/close_empty_token_accounts.ts +++ b/src/tools/close_empty_token_accounts.ts @@ -27,8 +27,15 @@ export async function closeEmptyTokenAccounts( ); const transaction = new Transaction(); - spl_token.forEach((instruction) => transaction.add(instruction)); - token_2022.forEach((instruction) => transaction.add(instruction)); + const MAX_INSTRUCTIONS = 48; // 25 instructions can be processed in a single transaction without failing + + spl_token + .slice(0, Math.min(MAX_INSTRUCTIONS, spl_token.length)) + .forEach((instruction) => transaction.add(instruction)); + + token_2022 + .slice(0, Math.max(0, MAX_INSTRUCTIONS - spl_token.length)) + .forEach((instruction) => transaction.add(instruction)); const size = spl_token.length + token_2022.length; @@ -69,13 +76,12 @@ async function create_close_instruction( ); const tokens = ata_accounts.value; - const size = tokens.length > 25 ? 24 : tokens.length; // closing 24 accounts in a single transaction const accountExceptions = [ "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", // USDC ]; - for (let i = 0; i < size; i++) { + for (let i = 0; i < tokens.length; i++) { const token_data = AccountLayout.decode(tokens[i].account.data); if ( token_data.amount === BigInt(0) && From f827b3104c4eb6d1fd6210d2a168852dad1c85b9 Mon Sep 17 00:00:00 2001 From: thrishank Date: Mon, 6 Jan 2025 19:05:13 +0530 Subject: [PATCH 9/9] fix log --- src/tools/close_empty_token_accounts.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/close_empty_token_accounts.ts b/src/tools/close_empty_token_accounts.ts index ebc7f78a..9a102dbd 100644 --- a/src/tools/close_empty_token_accounts.ts +++ b/src/tools/close_empty_token_accounts.ts @@ -27,7 +27,7 @@ export async function closeEmptyTokenAccounts( ); const transaction = new Transaction(); - const MAX_INSTRUCTIONS = 48; // 25 instructions can be processed in a single transaction without failing + const MAX_INSTRUCTIONS = 40; // 40 instructions can be processed in a single transaction without failing spl_token .slice(0, Math.min(MAX_INSTRUCTIONS, spl_token.length))