UNPKG

@nosana/kit

Version:

Nosana KIT

203 lines 10.4 kB
import { address } from '@solana/kit'; import { NosanaError, ErrorCodes } from '../../errors/NosanaError.js'; import { TOKEN_PROGRAM_ADDRESS, getTransferInstruction, findAssociatedTokenPda, } from '@solana-program/token'; // Standard SPL token account size const TOKEN_ACCOUNT_SIZE = 165; // Offset of mint address in token account data structure const MINT_OFFSET = 0; /** * Creates a TokenService instance. */ export function createTokenService(deps, config) { return { /** * Retrieve all token accounts for all token holders * Uses a single RPC call to fetch all accounts holding the token * * @param options - Optional configuration * @param options.includeZeroBalance - Whether to include accounts with zero balance (default: false) * @param options.excludePdaAccounts - Whether to exclude PDA (Program Derived Address) accounts owned by smart contracts (default: false) * @returns Array of token accounts with their balances */ async getAllTokenHolders(options) { try { const tokenMint = config.tokenAddress; deps.logger.debug(`Fetching all token holders for mint: ${tokenMint}`); // Use getProgramAccounts to fetch all token accounts for the token mint const accounts = await deps.solana.rpc .getProgramAccounts(TOKEN_PROGRAM_ADDRESS, { encoding: 'jsonParsed', filters: [ { dataSize: BigInt(TOKEN_ACCOUNT_SIZE), }, { memcmp: { offset: BigInt(MINT_OFFSET), bytes: tokenMint.toString(), encoding: 'base58', }, }, ], }) .send(); deps.logger.info(`Found ${accounts.length} token accounts`); // Parse the response const allAccounts = accounts.map((accountInfo) => { const parsed = accountInfo.account.data.parsed.info; return { pubkey: accountInfo.pubkey, owner: parsed.owner, mint: parsed.mint, amount: BigInt(parsed.tokenAmount.amount), decimals: parsed.tokenAmount.decimals, uiAmount: parsed.tokenAmount.uiAmount ?? 0, }; }); // Apply filters const includeZeroBalance = options?.includeZeroBalance ?? false; const excludePdaAccounts = options?.excludePdaAccounts ?? false; let filteredAccounts = allAccounts; // Filter out zero balance accounts unless explicitly included if (!includeZeroBalance) { filteredAccounts = filteredAccounts.filter((account) => account.uiAmount > 0); } // Filter out PDA accounts (where token account equals owner, indicating smart contract ownership) if (excludePdaAccounts) { const beforePdaFilter = filteredAccounts.length; filteredAccounts = filteredAccounts.filter((account) => account.pubkey !== account.owner); const pdaCount = beforePdaFilter - filteredAccounts.length; deps.logger.debug(`Filtered out ${pdaCount} PDA accounts`); } const filterInfo = []; if (!includeZeroBalance) filterInfo.push('excluding zero balances'); if (excludePdaAccounts) filterInfo.push('excluding PDA accounts'); const filterText = filterInfo.length > 0 ? ` (${filterInfo.join(', ')})` : ''; deps.logger.info(`Returning ${filteredAccounts.length} token holders${filterText}`); return filteredAccounts; } catch (error) { deps.logger.error(`Failed to fetch token holders: ${error}`); throw new NosanaError('Failed to fetch token holders', ErrorCodes.RPC_ERROR, error); } }, /** * Retrieve the token account for a specific owner address * * @param owner - The owner address to query * @returns The token account with balance, or null if no account exists */ async getTokenAccountForAddress(owner) { try { const ownerAddr = typeof owner === 'string' ? address(owner) : owner; const tokenMint = config.tokenAddress; deps.logger.debug(`Fetching token account for owner: ${ownerAddr}`); // Use getTokenAccountsByOwner to fetch token accounts for this owner filtered by token mint const response = await deps.solana.rpc .getTokenAccountsByOwner(ownerAddr, { mint: tokenMint }, { encoding: 'jsonParsed' }) .send(); if (response.value.length === 0) { deps.logger.debug(`No token account found for owner: ${ownerAddr}`); return null; } // Typically there should only be one token account per owner per mint const accountInfo = response.value[0]; const parsed = accountInfo.account.data.parsed.info; deps.logger.info(`Found token account for owner ${ownerAddr}: balance = ${parsed.tokenAmount.uiAmount}`); return { pubkey: accountInfo.pubkey, owner: parsed.owner, mint: parsed.mint, amount: BigInt(parsed.tokenAmount.amount), decimals: parsed.tokenAmount.decimals, uiAmount: parsed.tokenAmount.uiAmount ?? 0, }; } catch (error) { deps.logger.error(`Failed to fetch token account for owner: ${error}`); throw new NosanaError('Failed to fetch token account', ErrorCodes.RPC_ERROR, error); } }, /** * Get the token balance for a specific owner address * Convenience method that returns just the balance * * @param owner - The owner address to query * @returns The token balance as a UI amount (with decimals), or 0 if no account exists */ async getBalance(owner) { const account = await this.getTokenAccountForAddress(owner); return account ? account.uiAmount : 0; }, /** * Get the associated token account address for a given owner. * * @param owner The owner address * @returns The associated token account address */ async getATA(owner) { const ownerAddr = typeof owner === 'string' ? address(owner) : owner; const tokenMint = config.tokenAddress; const [ata] = await findAssociatedTokenPda({ mint: tokenMint, owner: ownerAddr, tokenProgram: TOKEN_PROGRAM_ADDRESS, }); return ata; }, /** * Get instruction(s) to transfer SPL tokens from one address to another. * May return 1 or 2 instructions depending on whether the recipient's associated token account needs to be created. * * @param params Transfer parameters * @param params.to Recipient address * @param params.amount Amount in token base units (number or bigint) * @param params.from Optional sender TransactionSigner. If not provided, uses wallet from client. * @returns Array of instructions (create ATA instruction if needed, then transfer instruction) */ async transfer(params) { try { // Determine sender: use params.from if provided, otherwise use feePayer from solana service // Note: feePayer is typically set to the wallet when the client wallet is set const sender = params.from ?? deps.solana.feePayer; if (!sender) { throw new NosanaError('No wallet found and no from parameter provided', ErrorCodes.NO_WALLET); } // Convert amount to bigint if it's a number const amountBigInt = typeof params.amount === 'bigint' ? params.amount : BigInt(params.amount); // Convert recipient to Address const recipient = typeof params.to === 'string' ? address(params.to) : params.to; const tokenMint = config.tokenAddress; deps.logger.debug(`Creating SPL token transfer instruction: ${amountBigInt} tokens from ${sender.address} to ${recipient}`); // Find sender's ATA const senderAta = await this.getATA(sender.address); // Find recipient's ATA const recipientAta = await this.getATA(recipient); // Check if recipient ATA exists and get create instruction if needed const createAtaInstruction = await deps.solana.getCreateATAInstructionIfNeeded(recipientAta, tokenMint, recipient, sender); // Create transfer instruction const transferIx = getTransferInstruction({ source: senderAta, destination: recipientAta, authority: sender.address, amount: amountBigInt, }); // Return array of instructions - either 1 or 2 instructions if (createAtaInstruction) { return [createAtaInstruction, transferIx]; } return [transferIx]; } catch (error) { if (error instanceof NosanaError) { throw error; } deps.logger.error(`Failed to get transfer instruction: ${error}`); throw new NosanaError('Failed to get transfer instruction', ErrorCodes.TRANSACTION_ERROR, error); } }, }; } //# sourceMappingURL=TokenService.js.map