@nosana/kit
Version:
Nosana KIT
203 lines • 10.4 kB
JavaScript
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