@nosana/kit
Version:
Nosana KIT
283 lines • 15.2 kB
JavaScript
import { createSolanaRpc, createSolanaRpcSubscriptions, address, getProgramDerivedAddress, getAddressEncoder, createTransactionMessage, signTransactionMessageWithSigners, getSignatureFromTransaction, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, sendAndConfirmTransactionFactory, appendTransactionMessageInstructions, pipe, assertIsSendableTransaction, } from '@solana/kit';
import { estimateComputeUnitLimitFactory, getSetComputeUnitLimitInstruction, } from '@solana-program/compute-budget';
import { getCreateAssociatedTokenIdempotentInstructionAsync, TOKEN_PROGRAM_ADDRESS, } from '@solana-program/token';
import { SYSTEM_PROGRAM_ADDRESS, getTransferSolInstruction } from '@solana-program/system';
import { NosanaError, ErrorCodes } from '../../errors/NosanaError.js';
import { convertHttpToWebSocketUrl } from '../../utils/convertHttpToWebSocketUrl.js';
/**
* Factory function to create an estimateAndSetComputeUnitLimit function
* that estimates compute units and adds the set compute unit limit instruction
*/
function estimateAndSetComputeUnitLimitFactory(...params) {
const estimateComputeUnitLimit = estimateComputeUnitLimitFactory(...params);
return async (transactionMessage) => {
const computeUnitsEstimate = await estimateComputeUnitLimit(transactionMessage);
return appendTransactionMessageInstructions([getSetComputeUnitLimitInstruction({ units: computeUnitsEstimate })], transactionMessage);
};
}
/**
* Creates a Solana service instance.
*/
export function createSolanaService(deps, config) {
if (!config.rpcEndpoint) {
throw new NosanaError('RPC URL is required', ErrorCodes.INVALID_CONFIG);
}
const rpc = createSolanaRpc(config.rpcEndpoint);
// Use wsEndpoint if provided, otherwise convert rpcEndpoint from http(s) to ws(s)
const wsUrl = config.wsEndpoint ?? convertHttpToWebSocketUrl(config.rpcEndpoint);
const rpcSubscriptions = createSolanaRpcSubscriptions(wsUrl);
const sendAndConfirmTransaction = sendAndConfirmTransactionFactory({
rpc,
rpcSubscriptions,
});
// Create a function to estimate and set the compute unit limit
const estimateAndSetComputeUnitLimit = estimateAndSetComputeUnitLimitFactory({ rpc });
// Store feePayer in a mutable variable, initialized from config
let feePayer = config.feePayer;
return {
config,
rpc,
rpcSubscriptions,
sendAndConfirmTransaction,
estimateAndSetComputeUnitLimit,
get feePayer() {
return feePayer;
},
set feePayer(value) {
feePayer = value;
},
async pda(seeds, programId) {
const addressEncoder = getAddressEncoder();
const [pda] = await getProgramDerivedAddress({
programAddress: programId,
seeds: seeds.map((seed) => {
// Address is a branded string type, so typeof will return 'string'
// We need to encode Address types to bytes for PDA seeds
// Try to encode if it looks like an address (base58, 32-44 chars), otherwise pass as-is
if (typeof seed === 'string') {
// Check if it's likely an address (base58 encoded addresses are 32-44 chars)
// Short strings like 'ClaimStatus' should be passed as-is
if (seed.length >= 32 && seed.length <= 44) {
try {
// Try to encode as Address - if it's a valid address, this will work
return addressEncoder.encode(seed);
}
catch {
// If encoding fails, it's not a valid address, pass as-is (e.g., 'ClaimStatus')
return seed;
}
}
// Short strings pass as-is
return seed;
}
// Non-string types should be encoded
return addressEncoder.encode(seed);
}),
});
return pda;
},
async getBalance(addressStr) {
try {
// Use wallet address if no address is provided
let addr;
if (addressStr) {
addr = address(addressStr);
}
else {
const wallet = deps.getWallet();
if (!wallet) {
throw new NosanaError('No wallet found and no address provided', ErrorCodes.NO_WALLET);
}
addr = wallet.address;
}
deps.logger.debug(`Getting balance for address: ${addr}`);
const balance = await rpc.getBalance(addr).send();
return balance.value;
}
catch (error) {
if (error instanceof NosanaError) {
throw error;
}
deps.logger.error(`Failed to get balance: ${error}`);
throw new NosanaError('Failed to get balance', ErrorCodes.RPC_ERROR, error);
}
},
/**
* Build a transaction message from instructions
* This function creates a transaction message with:
* - Fee payer set to the provided feePayer, service feePayer, or wallet (in that order)
* - Latest blockhash for lifetime
* - Provided instructions
* - Estimated compute unit limit
*
* @param instructions Single instruction or array of instructions
* @param options Optional configuration
* @param options.feePayer Optional custom fee payer. Takes precedence over service feePayer and wallet.
* @returns An unsigned transaction message ready to be signed
*/
async buildTransaction(instructions, options) {
// Priority: options.feePayer > service.feePayer > wallet
const transactionFeePayer = options?.feePayer ?? feePayer ?? deps.getWallet();
if (!transactionFeePayer) {
throw new NosanaError('No wallet found and no feePayer provided', ErrorCodes.NO_WALLET);
}
try {
// Get latest blockhash
const { value: latestBlockhash } = await rpc.getLatestBlockhash().send();
// Normalize instructions to array
const instructionsArray = Array.isArray(instructions) ? instructions : [instructions];
// Build transaction message using pipe
const transactionMessage = await pipe(createTransactionMessage({ version: 0 }), (tx) => setTransactionMessageFeePayerSigner(transactionFeePayer, tx), (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstructions(instructionsArray, tx), (tx) => estimateAndSetComputeUnitLimit(tx));
return transactionMessage;
}
catch (error) {
deps.logger.error(`Failed to build transaction: ${error}`);
throw new NosanaError('Failed to build transaction', ErrorCodes.RPC_ERROR, error);
}
},
/**
* Sign a transaction message
* This function signs the transaction message using the signers embedded in the message.
* The transaction message should already contain the signers (e.g., from buildTransaction).
*
* @param transactionMessage The transaction message to sign (must contain signers)
* @returns A signed transaction
*/
async signTransaction(transactionMessage) {
try {
// Sign the transaction message using signers embedded in the message
const transaction = await signTransactionMessageWithSigners(transactionMessage);
return transaction;
}
catch (error) {
deps.logger.error(`Failed to sign transaction: ${error}`);
throw new NosanaError('Failed to sign transaction', ErrorCodes.TRANSACTION_ERROR, error);
}
},
/**
* Send and confirm a signed transaction
* This function validates that the transaction is sendable, then sends it and waits for confirmation.
*
* @param transaction The signed transaction to send
* @param options Optional configuration (same as sendAndConfirmTransaction)
* @param options.commitment Commitment level for confirmation (takes precedence over config, then falls back to config.commitment, then 'confirmed')
* @returns The transaction signature
*/
async sendTransaction(transaction, options) {
try {
// Ensure the transaction is sendable before attempting to send
assertIsSendableTransaction(transaction);
// Get the transaction signature for logging
const signature = getSignatureFromTransaction(transaction);
deps.logger.info(`Sending transaction: ${signature}`);
// Send and confirm the transaction with optional commitment level
// Priority: options.commitment > config.commitment > 'confirmed'
const commitment = options?.commitment ?? config.commitment ?? 'confirmed';
await sendAndConfirmTransaction(transaction, { commitment });
deps.logger.info(`Transaction ${signature} confirmed!`);
return signature;
}
catch (error) {
const errorMessage = `Failed to send transaction: ${error instanceof Error ? error.message : String(error)}`;
deps.logger.error(errorMessage);
throw new NosanaError(errorMessage, ErrorCodes.RPC_ERROR, error);
}
},
/**
* Build, sign, and send a transaction in one call
* This is a convenience function that combines buildTransaction, signTransaction, and sendTransaction.
* Use this when you want to build, sign, and send in a single operation.
*
* @param instructions Single instruction or array of instructions
* @param options Optional configuration
* @param options.feePayer Optional custom fee payer. If not provided, uses the wallet.
* @param options.commitment Commitment level for confirmation (takes precedence over config, then falls back to config.commitment, then 'confirmed')
* @returns The transaction signature
*/
async buildSignAndSend(instructions, options) {
const transactionMessage = await this.buildTransaction(instructions, {
feePayer: options?.feePayer,
});
const signedTransaction = await this.signTransaction(transactionMessage);
return await this.sendTransaction(signedTransaction, { commitment: options?.commitment });
},
/**
* Get an instruction to transfer SOL from one address to another.
*
* @param params Transfer parameters
* @param params.to Recipient address
* @param params.amount Amount in lamports (number or bigint)
* @param params.from Optional sender TransactionSigner. If not provided, uses wallet from client.
* @returns An instruction to transfer SOL
*/
async transfer(params) {
try {
// Determine sender: use params.from if provided, otherwise get from wallet
const sender = params.from ?? deps.getWallet();
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;
deps.logger.debug(`Creating SOL transfer instruction: ${amountBigInt} lamports from ${sender.address} to ${recipient}`);
// Create and return transfer instruction
const instruction = getTransferSolInstruction({
source: sender,
destination: recipient,
amount: amountBigInt,
});
return instruction;
}
catch (error) {
if (error instanceof NosanaError) {
throw error;
}
deps.logger.error(`Failed to get transfer SOL instruction: ${error}`);
throw new NosanaError('Failed to get transfer SOL instruction', ErrorCodes.TRANSACTION_ERROR, error);
}
},
/**
* Get an instruction to create an associated token account if it doesn't exist.
* Checks if the ATA exists, and if not, returns an instruction to create it.
* Uses the idempotent version so it's safe to call even if the account already exists.
*
* @param ata The associated token account address
* @param mint The token mint address
* @param owner The owner of the associated token account
* @param payer Optional payer for the account creation. If not provided, uses the wallet or service feePayer.
* @returns An instruction to create the ATA if it doesn't exist, or null if it already exists
*/
async getCreateATAInstructionIfNeeded(ata, mint, owner, payer) {
try {
// Check if the account exists
const accountInfo = await rpc.getAccountInfo(ata).send();
if (accountInfo.value !== null) {
// Account already exists
return null;
}
// Account doesn't exist, create instruction
// Priority: provided payer > service feePayer > wallet
const instructionPayer = payer ?? feePayer ?? deps.getWallet();
if (!instructionPayer) {
throw new NosanaError('No payer found for creating associated token account', ErrorCodes.NO_WALLET);
}
const instruction = await getCreateAssociatedTokenIdempotentInstructionAsync({
payer: instructionPayer,
ata,
owner,
mint,
systemProgram: SYSTEM_PROGRAM_ADDRESS,
tokenProgram: TOKEN_PROGRAM_ADDRESS,
});
return instruction;
}
catch (error) {
deps.logger.error(`Failed to get create ATA instruction: ${error}`);
throw new NosanaError('Failed to get create ATA instruction', ErrorCodes.RPC_ERROR, error);
}
},
};
}
//# sourceMappingURL=SolanaService.js.map