UNPKG

@nosana/kit

Version:

Nosana KIT

486 lines 27.5 kB
import { createSolanaRpc, createSolanaRpcSubscriptions, address, airdropFactory, lamports, getProgramDerivedAddress, getAddressEncoder, createTransactionMessage, signTransactionMessageWithSigners, partiallySignTransactionMessageWithSigners, getSignatureFromTransaction, setTransactionMessageFeePayer, setTransactionMessageFeePayerSigner, setTransactionMessageLifetimeUsingBlockhash, sendAndConfirmTransactionFactory, appendTransactionMessageInstructions, pipe, assertIsSendableTransaction, getTransactionDecoder, getBase64Encoder, getBase64EncodedWireTransaction, isTransactionSigner, decompileTransactionMessage, getCompiledTransactionMessageDecoder, assertIsTransactionWithinSizeLimit, } from '@solana/kit'; import { estimateComputeUnitLimitFactory, getSetComputeUnitLimitInstruction, getSetComputeUnitPriceInstruction, } 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'; import { resolvePriorityFeeMicroLamports } from './priorityFees.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. * @group @nosana/kit */ 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, }); const airdrop = airdropFactory({ 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, async airdrop(params) { try { const recipient = typeof params.recipient === 'string' ? address(params.recipient) : params.recipient; const amount = typeof params.amount === 'bigint' ? params.amount : BigInt(params.amount); return await airdrop({ recipientAddress: recipient, lamports: lamports(amount), commitment: config.commitment ?? 'confirmed', }); } catch (error) { deps.logger.error(`Failed to airdrop SOL: ${error}`); throw new NosanaError('Failed to airdrop SOL', ErrorCodes.RPC_ERROR, error); } }, 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 Number(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 * - Optionally: estimated compute unit limit * * @param instructions Single instruction or array of instructions * @param options Optional configuration * @param options.feePayer Optional custom fee payer. Can be a TransactionSigner (for full signing) * or an Address/string (for partial signing where feepayer signs later). * Takes precedence over service feePayer and wallet. * @param options.estimateComputeUnits If true, estimates and sets the compute unit limit. Default: false. * @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 { // Normalize instructions to array let instructionsArray = Array.isArray(instructions) ? instructions : [instructions]; // Prepend priority fee instruction when config.priorityFees is set if (config.priorityFees) { const microLamports = await resolvePriorityFeeMicroLamports(config.priorityFees, rpc, deps.logger); instructionsArray = [ getSetComputeUnitPriceInstruction({ microLamports }), ...instructionsArray, ]; } // Helper to check if the feePayer is a TransactionSigner const isSigner = (value) => typeof value === 'object' && isTransactionSigner(value); // Get latest blockhash as late as possible to minimize staleness const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); // Build transaction message using pipe // Use setTransactionMessageFeePayerSigner for TransactionSigner, setTransactionMessageFeePayer for Address const transactionMessage = await pipe(createTransactionMessage({ version: 0 }), (tx) => { if (isSigner(transactionFeePayer)) { return setTransactionMessageFeePayerSigner(transactionFeePayer, tx); } else { // It's a string address, convert to Address type const feePayerAddress = address(transactionFeePayer); return setTransactionMessageFeePayer(feePayerAddress, tx); } }, (tx) => setTransactionMessageLifetimeUsingBlockhash(latestBlockhash, tx), (tx) => appendTransactionMessageInstructions(instructionsArray, tx), // Optionally estimate and set compute unit limit (tx) => (options?.estimateComputeUnits ? estimateAndSetComputeUnitLimit(tx) : 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) { let signature = undefined; try { // Ensure the transaction is sendable before attempting to send assertIsSendableTransaction(transaction); // Get the transaction signature for logging 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 ${signature ? `${signature}` : ''}: ${error instanceof Error ? error.message : String(error)}`; deps.logger.error(errorMessage); throw new NosanaError(errorMessage, ErrorCodes.RPC_ERROR, error, signature); } }, /** * 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') * @param options.estimateComputeUnits If true, estimates and sets the compute unit limit. Default: false. * @returns The transaction signature */ async buildSignAndSend(instructions, options) { const transactionMessage = await this.buildTransaction(instructions, { feePayer: options?.feePayer, estimateComputeUnits: options?.estimateComputeUnits, }); 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 // Use base64 encoding to avoid base58 128-byte limit error const accountInfo = await rpc.getAccountInfo(ata, { encoding: 'base64' }).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); } // If payer is an Address (string), wrap it so the instruction builder accepts it. // The signature will be collected later when the transaction is signed by the payer. const payerSigner = typeof instructionPayer === 'string' ? { address: instructionPayer } : instructionPayer; const instruction = await getCreateAssociatedTokenIdempotentInstructionAsync({ payer: payerSigner, 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); } }, /** * Partially sign a transaction message with the signers embedded in the transaction. * The transaction message must already have a fee payer address set (via buildTransaction with an address). * Signers are extracted from instructions in the message (e.g., transfer source signer). * Use this when building transactions where the fee payer will sign later. * * @param transactionMessage The transaction message to sign (must have fee payer address set and signers embedded in instructions) * @returns A partially signed transaction */ async partiallySignTransaction(transactionMessage) { try { deps.logger.debug('Partially signing transaction with embedded signers'); // Use partiallySignTransactionMessageWithSigners to sign with signers embedded in the message const partiallySignedTransaction = await partiallySignTransactionMessageWithSigners(transactionMessage); return partiallySignedTransaction; } catch (error) { deps.logger.error(`Failed to partially sign transaction: ${error}`); throw new NosanaError('Failed to partially sign transaction', ErrorCodes.TRANSACTION_ERROR, error); } }, /** * Serialize a transaction to a base64 string. * Works with both partially signed and fully signed transactions. * Use this to transmit transactions to other parties (e.g., for fee payer signing). * * @param transaction The transaction to serialize * @returns Base64 encoded wire transaction string */ serializeTransaction(transaction) { try { deps.logger.debug('Serializing transaction to base64'); // Use getBase64EncodedWireTransaction to encode the transaction const base64String = getBase64EncodedWireTransaction(transaction); return base64String; } catch (error) { deps.logger.error(`Failed to serialize transaction: ${error}`); throw new NosanaError('Failed to serialize transaction', ErrorCodes.TRANSACTION_ERROR, error); } }, /** * Deserialize a base64 string back to a transaction. * Use this to receive transactions from other parties. * * Note: This method automatically restores the `lastValidBlockHeight` metadata * that is lost during serialization by fetching the latest blockhash from the RPC. * * @param base64 The base64 encoded transaction string * @returns The deserialized transaction with restored lifetime metadata */ async deserializeTransaction(base64) { try { deps.logger.debug('Deserializing transaction from base64'); // Decode the base64 string to bytes, then to transaction const transactionBytes = getBase64Encoder().encode(base64); let transaction = getTransactionDecoder().decode(transactionBytes); // Get latest blockhash info to restore lastValidBlockHeight const { value: latestBlockhash } = await rpc.getLatestBlockhash().send(); // Use decompileTransaction to extract the blockhash from the transaction const decompiled = this.decompileTransaction(transaction); const transactionBlockhash = 'lifetimeConstraint' in decompiled && decompiled.lifetimeConstraint && 'blockhash' in decompiled.lifetimeConstraint ? decompiled.lifetimeConstraint.blockhash : null; if (transactionBlockhash) { // Restore lastValidBlockHeight in the transaction's lifetime constraint // If blockhash matches latest, use latest's lastValidBlockHeight // Otherwise, use latest as fallback (transaction may still be valid) deps.logger.debug(`Restored lastValidBlockHeight: ${latestBlockhash.lastValidBlockHeight} for blockhash: ${transactionBlockhash}`); return { ...transaction, lifetimeConstraint: { blockhash: transactionBlockhash, lastValidBlockHeight: latestBlockhash.lastValidBlockHeight, }, }; } else { throw new Error('Could not determine transaction blockhash - lastValidBlockHeight not restored'); } } catch (error) { deps.logger.error(`Failed to deserialize transaction: ${error}`); throw new NosanaError('Failed to deserialize transaction', ErrorCodes.TRANSACTION_ERROR, error); } }, /** * Sign a transaction with the provided signers. * Use this when receiving a partially signed transaction that needs additional signatures. * This adds signatures from the provided signers to the transaction. * * @param transaction The transaction to sign (typically partially signed, received from another party) * @param signers Array of TransactionPartialSigners to sign with * @returns The signed transaction with additional signatures */ async signTransactionWithSigners(transaction, signers) { try { deps.logger.debug(`Signing transaction with ${signers.length} signer(s): ${signers.map((s) => s.address).join(', ')}`); assertIsTransactionWithinSizeLimit(transaction); // Sign with each signer and merge signatures into the transaction // TransactionPartialSigner.signTransactions returns SignatureDictionary[] (not transactions) // We need to merge these signatures into the transaction's signatures map let updatedSignatures = { ...transaction.signatures }; for (const signer of signers) { // signTransactions returns an array of SignatureDictionary (one per transaction) const [signatureDict] = await signer.signTransactions([transaction]); // Merge the new signatures into our accumulated signatures updatedSignatures = { ...updatedSignatures, ...signatureDict }; } // Create a new transaction with the merged signatures const signedTransaction = { ...transaction, signatures: updatedSignatures, }; return signedTransaction; } catch (error) { if (error instanceof NosanaError) { throw error; } deps.logger.error(`Failed to sign transaction: ${error}`); throw new NosanaError('Failed to sign transaction', ErrorCodes.TRANSACTION_ERROR, error); } }, /** * Decompile a transaction back to a transaction message. * Use this to inspect/verify the content of a deserialized transaction before signing. * * Note: Decompilation is lossy - some information like lastValidBlockHeight may not be fully * reconstructed. The returned message is suitable for inspection but may not be suitable * for re-signing without additional context. * * @param transaction The compiled transaction to decompile * @returns The decompiled transaction message (with either blockhash or durable nonce lifetime) */ decompileTransaction(transaction) { try { deps.logger.debug('Decompiling transaction to transaction message'); // First decode the compiled transaction message from the transaction bytes const compiledMessage = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes); // Then decompile to get the transaction message const transactionMessage = decompileTransactionMessage(compiledMessage); return transactionMessage; } catch (error) { deps.logger.error(`Failed to decompile transaction: ${error}`); throw new NosanaError('Failed to decompile transaction', ErrorCodes.TRANSACTION_ERROR, error); } }, }; } //# sourceMappingURL=SolanaService.js.map