diff --git a/packages/plugin-token/package.json b/packages/plugin-token/package.json index e4d5b2739..04eb344d0 100644 --- a/packages/plugin-token/package.json +++ b/packages/plugin-token/package.json @@ -1,6 +1,6 @@ { "name": "@wardenprotocol/plugin-token", - "version": "2.0.15", + "version": "2.0.18", "main": "dist/index.cjs", "module": "dist/index.js", "types": "dist/index.d.ts", diff --git a/packages/plugin-token/src/solana/tools/get_token_balances.ts b/packages/plugin-token/src/solana/tools/get_token_balances.ts index 1da088ccf..6f99a783e 100644 --- a/packages/plugin-token/src/solana/tools/get_token_balances.ts +++ b/packages/plugin-token/src/solana/tools/get_token_balances.ts @@ -1,5 +1,5 @@ -import { TOKEN_PROGRAM_ID } from "@solana/spl-token"; -import { LAMPORTS_PER_SOL, type PublicKey } from "@solana/web3.js"; +import { TOKEN_2022_PROGRAM_ID, TOKEN_PROGRAM_ID } from "@solana/spl-token"; +import { LAMPORTS_PER_SOL, PublicKey } from "@solana/web3.js"; import { SolanaAgentKit } from "solana-agent-kit"; import { getTokenMetadata } from "./utils/tokenMetadata"; @@ -22,44 +22,60 @@ export async function get_token_balance( decimals: number; }>; }> { - const [lamportsBalance, tokenAccountData] = await Promise.all([ - agent.connection.getBalance(walletAddress ?? agent.wallet.publicKey), - agent.connection.getParsedTokenAccountsByOwner( - walletAddress ?? agent.wallet.publicKey, - { + const owner = walletAddress ?? agent.wallet.publicKey; + const [lamportsBalance, tokenAccountData, token2022AccountData] = + await Promise.all([ + agent.connection.getBalance(owner), + agent.connection.getParsedTokenAccountsByOwner(owner, { programId: TOKEN_PROGRAM_ID, - }, - ), - ]); + }), + agent.connection.getParsedTokenAccountsByOwner(owner, { + programId: TOKEN_2022_PROGRAM_ID, + }), + ]); - const removedZeroBalance = tokenAccountData.value.filter( - (v: any) => v.account.data.parsed.info.tokenAmount.uiAmount !== 0, - ); + // Process tokens from both programs separately since we know which program each belongs to + const processTokens = async (tokens: any[], isToken2022: boolean) => { + const nonZeroTokens = tokens.filter( + (v: any) => v.account.data.parsed.info.tokenAmount.uiAmount !== 0, + ); - const tokenBalances = await Promise.all( - removedZeroBalance.map(async (v: any) => { - const mint = v.account.data.parsed.info.mint; - const mintInfo = await getTokenMetadata(agent.connection, mint); - // If metadata is not found, return Null values to not let the tool fail - if (!mintInfo) { + return Promise.all( + nonZeroTokens.map(async (v: any) => { + const mint = v.account.data.parsed.info.mint; + const mintInfo = await getTokenMetadata( + agent.connection, + mint, + isToken2022, + ); + // If metadata is not found, return empty string values to not let the tool fail + if (!mintInfo) { + return { + tokenAddress: mint, + name: "", + symbol: "", + balance: v.account.data.parsed.info.tokenAmount.uiAmount as number, + decimals: v.account.data.parsed.info.tokenAmount.decimals as number, + }; + } + // Normal return return { tokenAddress: mint, - name: "NULL", - symbol: "NULL", + name: mintInfo.name ?? "", + symbol: mintInfo.symbol ?? "", balance: v.account.data.parsed.info.tokenAmount.uiAmount as number, decimals: v.account.data.parsed.info.tokenAmount.decimals as number, }; - } - // Normal return - return { - tokenAddress: mint, - name: mintInfo.name ?? "", - symbol: mintInfo.symbol ?? "", - balance: v.account.data.parsed.info.tokenAmount.uiAmount as number, - decimals: v.account.data.parsed.info.tokenAmount.decimals as number, - }; - }), - ); + }), + ); + }; + + const [legacyTokenBalances, token2022Balances] = await Promise.all([ + processTokens(tokenAccountData.value, false), + processTokens(token2022AccountData.value, true), + ]); + + const tokenBalances = [...legacyTokenBalances, ...token2022Balances]; const solBalance = lamportsBalance / LAMPORTS_PER_SOL; diff --git a/packages/plugin-token/src/solana/tools/transfer.ts b/packages/plugin-token/src/solana/tools/transfer.ts index 82232a5d6..91c77c65c 100644 --- a/packages/plugin-token/src/solana/tools/transfer.ts +++ b/packages/plugin-token/src/solana/tools/transfer.ts @@ -1,6 +1,6 @@ import { createAssociatedTokenAccountInstruction, - createTransferInstruction, + createTransferCheckedInstruction, getAccount, getAssociatedTokenAddress, getMint, @@ -42,15 +42,29 @@ export async function transfer( tx = await signOrSendTX(agent, transaction.instructions); } else { const transaction = new Transaction(); + + const mintAccountInfo = await agent.connection.getAccountInfo(mint); + if (!mintAccountInfo) { + throw new Error("Mint account not found"); + } + const tokenProgramId = mintAccountInfo.owner; + // Transfer SPL token const fromAta = await getAssociatedTokenAddress( mint, agent.wallet.publicKey, + false, + tokenProgramId, + ); + const toAta = await getAssociatedTokenAddress( + mint, + to, + false, + tokenProgramId, ); - const toAta = await getAssociatedTokenAddress(mint, to); try { - await getAccount(agent.connection, toAta); + await getAccount(agent.connection, toAta, undefined, tokenProgramId); } catch { // Error is thrown if the tokenAccount doesn't exist transaction.add( @@ -59,20 +73,30 @@ export async function transfer( toAta, to, mint, + tokenProgramId, ), ); } // Get mint info to determine decimals - const mintInfo = await getMint(agent.connection, mint); - const adjustedAmount = amount * Math.pow(10, mintInfo.decimals); + const mintInfo = await getMint( + agent.connection, + mint, + undefined, + tokenProgramId, + ); + const adjustedAmount = BigInt(Math.round(amount * Math.pow(10, mintInfo.decimals))); transaction.add( - createTransferInstruction( + createTransferCheckedInstruction( fromAta, + mint, toAta, agent.wallet.publicKey, adjustedAmount, + mintInfo.decimals, + [], + tokenProgramId, ), ); diff --git a/packages/plugin-token/src/solana/tools/utils/tokenMetadata.ts b/packages/plugin-token/src/solana/tools/utils/tokenMetadata.ts index 0d87980fa..3d4016586 100644 --- a/packages/plugin-token/src/solana/tools/utils/tokenMetadata.ts +++ b/packages/plugin-token/src/solana/tools/utils/tokenMetadata.ts @@ -1,9 +1,154 @@ +import { + getMint, + TOKEN_2022_PROGRAM_ID, + getExtensionTypes, + ExtensionType, + getMetadataPointerState, + getTokenMetadata as getSplTokenMetadata, +} from "@solana/spl-token"; import { Connection, PublicKey } from "@solana/web3.js"; +/** + * Get Token-2022 metadata from extensions + */ +async function getToken2022Metadata( + connection: Connection, + mintPubkey: PublicKey, +): Promise<{ name: string; symbol: string; uri?: string } | null> { + try { + const mint2022 = await getMint( + connection, + mintPubkey, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + + const exts = getExtensionTypes(mint2022.tlvData); + const hasEmbedded = exts.includes(ExtensionType.TokenMetadata); + const hasPointer = exts.includes(ExtensionType.MetadataPointer); + + // 1. Try embedded TokenMetadata extension first (standard for Token-2022) + if (hasEmbedded) { + const embeddedMetadata = await getSplTokenMetadata( + connection, + mintPubkey, + "confirmed", + TOKEN_2022_PROGRAM_ID, + ); + + if (embeddedMetadata) { + return { + name: embeddedMetadata.name, + symbol: embeddedMetadata.symbol, + uri: embeddedMetadata.uri, + }; + } + } + + // 2. If no embedded metadata found (or failed), check MetadataPointer + if (hasPointer) { + const pointerState = getMetadataPointerState(mint2022); + if (pointerState?.metadataAddress) { + const metadataAddr = new PublicKey(pointerState.metadataAddress); + + // Only check external accounts if it doesn't point to self + // (Self-pointing would have been handled by hasEmbedded check above) + if (!metadataAddr.equals(mintPubkey)) { + const metadataAccount = await connection.getAccountInfo( + metadataAddr, + "confirmed", + ); + + if (metadataAccount?.data) { + // Try parsing as Metaplex (most common external format) + const parsed = parseMetaplexMetadata(metadataAccount.data); + if (parsed) { + return parsed; + } + } + } + } + } + } catch (error) { + // Not a Token-2022 mint or error fetching, will try legacy + } + + return null; +} + +/** + * Parse Metaplex metadata from account data + */ +function parseMetaplexMetadata( + data: Buffer, +): { name: string; symbol: string; uri?: string } | null { + try { + // Bounds check: minimum 65 bytes (1 key + 32 update auth + 32 mint) + if (data.length < 65) { + return null; + } + + let offset = 1 + 32 + 32; // key + update auth + mint + const decoder = new TextDecoder(); + + const readString = () => { + // Skip padding/zero bytes to find the actual length byte + while (offset < data.length && data[offset] === 0) { + offset++; + } + + if (offset >= data.length) { + return null; + } + + const nameLength = data[offset]; + offset++; + + if (offset + nameLength > data.length) { + return null; + } + + const str = decoder + .decode(data.slice(offset, offset + nameLength)) + .replace(new RegExp(String.fromCharCode(0), "g"), ""); + offset += nameLength; + return str; + }; + + const name = readString(); + const symbol = readString(); + const uri = readString(); + + // Ensure name and symbol are non-null and non-empty + if (name && symbol && name.length > 0 && symbol.length > 0) { + return { name, symbol, uri: uri || undefined }; + } + } catch (error) { + // Parsing failed + } + return null; +} + export async function getTokenMetadata( connection: Connection, tokenMint: string, + isToken2022?: boolean, ) { + const mintPubkey = new PublicKey(tokenMint); + + // First, try Token-2022 metadata if it's a Token-2022 token + // Even if it's Token-2022, it might not have metadata extensions, + // so we'll fall back to legacy metadata if Token-2022 metadata is not found + if (isToken2022 === true) { + const token2022Metadata = await getToken2022Metadata(connection, mintPubkey); + if (token2022Metadata) { + return token2022Metadata; + } + // If Token-2022 metadata not found, continue to try legacy metadata + // (Token-2022 tokens can still use Metaplex metadata) + } + + // Try legacy Metaplex metadata (works for both legacy tokens and Token-2022 tokens without extensions) const METADATA_PROGRAM_ID = new PublicKey( "metaqbxxUerdq28cj1RbAWkYQm3ybzjb6a8bt518x1s", ); @@ -12,74 +157,22 @@ export async function getTokenMetadata( [ Buffer.from("metadata"), METADATA_PROGRAM_ID.toBuffer(), - new PublicKey(tokenMint).toBuffer(), + mintPubkey.toBuffer(), ], METADATA_PROGRAM_ID, ); const metadata = await connection.getAccountInfo(metadataPDA); if (!metadata?.data) { - //throw new Error("Metadata not found"); - console.log("Metadata not found!"); + // Metadata not found - this is normal for tokens without metadata return null; } - let offset = 1 + 32 + 32; // key + update auth + mint - const data = metadata.data; - const decoder = new TextDecoder(); - - // Read variable length strings - const readString = () => { - let nameLength = data[offset]; - - while (nameLength === 0) { - offset++; - nameLength = data[offset]; - if (offset >= data.length) { - return null; - } - } - - offset++; - const name = decoder - .decode(data.slice(offset, offset + nameLength)) - // @eslint-disable-next-line no-control-regex - .replace(new RegExp(String.fromCharCode(0), "g"), ""); - offset += nameLength; - return name; - }; - - const name = readString(); - const symbol = readString(); - const uri = readString(); - - // Read remaining data - const sellerFeeBasisPoints = data.readUInt16LE(offset); - offset += 2; - - let creators: - | { address: PublicKey; verified: boolean; share: number }[] - | null = null; - if (data[offset] === 1) { - offset++; - const numCreators = data[offset]; - offset++; - creators = [...Array(numCreators)].map(() => { - const creator = { - address: new PublicKey(data.slice(offset, offset + 32)), - verified: data[offset + 32] === 1, - share: data[offset + 33], - }; - offset += 34; - return creator; - }); + // Parse Metaplex metadata + const parsedMetadata = parseMetaplexMetadata(metadata.data); + if (parsedMetadata) { + return parsedMetadata; } - return { - name, - symbol, - uri, - sellerFeeBasisPoints, - creators, - }; + return null; }