@solana-developers/helpers
Version: 
Solana helper functions
372 lines • 17 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.DEFAULT_SEND_OPTIONS = exports.MAX_RETRIES = exports.RETRY_INTERVAL_INCREASE = exports.RETRY_INTERVAL_MS = exports.getSimulationComputeUnits = exports.confirmTransaction = void 0;
exports.hasSetComputeLimitInstruction = hasSetComputeLimitInstruction;
exports.hasSetComputeUnitPriceInstruction = hasSetComputeUnitPriceInstruction;
exports.sendTransaction = sendTransaction;
exports.sendVersionedTransaction = sendVersionedTransaction;
exports.addComputeInstructions = addComputeInstructions;
exports.createLookupTable = createLookupTable;
const web3_js_1 = require("@solana/web3.js");
const logs_1 = require("./logs");
const web3_js_2 = require("@solana/web3.js");
const confirmTransaction = async (connection, signature, commitment = "confirmed") => {
    const block = await connection.getLatestBlockhash(commitment);
    const rpcResponse = await connection.confirmTransaction({
        signature,
        ...block,
    }, commitment);
    (0, logs_1.getErrorFromRPCResponse)(rpcResponse);
    return signature;
};
exports.confirmTransaction = confirmTransaction;
/**
 * Check if a given instruction is a SetComputeUnitLimit instruction
 * See https://github.com/solana-program/compute-budget/blob/main/clients/js/src/generated/programs/computeBudget.ts#L29
 */
function isSetComputeLimitInstruction(ix) {
    return (ix.programId.equals(web3_js_1.ComputeBudgetProgram.programId) && ix.data[0] === 2 // opcode for setComputeUnitLimit is 2
    );
}
/**
 * Check if a given instruction is a SetComputeUnitLimit instruction
 * See https://github.com/solana-program/compute-budget/blob/main/clients/js/src/generated/programs/computeBudget.ts#L30
 */
function isSetComputeUnitPriceInstruction(ix) {
    return (ix.programId.equals(web3_js_1.ComputeBudgetProgram.programId) && ix.data[0] === 3 // opcode for setComputeUnitPrice is 3
    );
}
/**
 * Check if a given transaction contains a SetComputeUnitLimit instruction
 */
function hasSetComputeLimitInstruction(instructions) {
    return instructions.filter(isSetComputeLimitInstruction).length === 1;
}
/**
 * Check if a given transaction contains a SetComputeUnitLimit instruction
 */
function hasSetComputeUnitPriceInstruction(instructions) {
    return instructions.filter(isSetComputeUnitPriceInstruction).length === 1;
}
// Was getSimulationUnits
// Credit https://twitter.com/stegabob, originally from
// https://x.com/stegaBOB/status/1766662289392889920
const getSimulationComputeUnits = async (connection, instructions, payer, lookupTables, commitment = "confirmed") => {
    const simulationInstructions = [...instructions];
    // Replace or add compute limit instruction
    const computeLimitIndex = simulationInstructions.findIndex(isSetComputeLimitInstruction);
    const simulationLimitIx = web3_js_1.ComputeBudgetProgram.setComputeUnitLimit({
        units: 1_400_000,
    });
    if (computeLimitIndex >= 0) {
        simulationInstructions[computeLimitIndex] = simulationLimitIx;
    }
    else {
        simulationInstructions.unshift(simulationLimitIx);
    }
    const testTransaction = new web3_js_1.VersionedTransaction(new web3_js_1.TransactionMessage({
        instructions: simulationInstructions,
        payerKey: payer,
        // RecentBlockhash can by any public key during simulation
        // since 'replaceRecentBlockhash' is set to 'true' below
        recentBlockhash: web3_js_1.PublicKey.default.toString(),
    }).compileToV0Message(lookupTables));
    const rpcResponse = await connection.simulateTransaction(testTransaction, {
        replaceRecentBlockhash: true,
        sigVerify: false,
        commitment,
    });
    if (rpcResponse?.value?.err) {
        const logs = rpcResponse.value.logs?.join("\n  • ") || "No logs available";
        throw new Error(`Transaction simulation failed:\n  •${logs}` + JSON.stringify(rpcResponse?.value?.err));
    }
    return rpcResponse.value.unitsConsumed || null;
};
exports.getSimulationComputeUnits = getSimulationComputeUnits;
/**
 * Constants for transaction retry configuration
 */
exports.RETRY_INTERVAL_MS = 2000;
exports.RETRY_INTERVAL_INCREASE = 200;
exports.MAX_RETRIES = 15;
/**
 * Default configuration values for transaction sending
 */
exports.DEFAULT_SEND_OPTIONS = {
    maxRetries: exports.MAX_RETRIES,
    initialDelayMs: exports.RETRY_INTERVAL_MS,
    commitment: "confirmed",
    skipPreflight: true,
};
/**
 * Sends a transaction with compute unit optimization and automatic retries
 *
 * @param connection - The Solana connection object
 * @param transaction - The transaction to send
 * @param signers - Array of signers needed for the transaction
 * @param priorityFee - Priority fee in microLamports (default: 10000)
 * @param options - Optional configuration for retry mechanism and compute units
 * @returns Promise that resolves to the transaction signature
 *
 * @example
 * ```typescript
 * const signature = await sendTransaction(
 *   connection,
 *   transaction,
 *   [payer],
 *   10000,
 *   {
 *     computeUnitBuffer: { multiplier: 1.1 },
 *     onStatusUpdate: (status) => console.log(status),
 *   }
 * );
 * ```
 */
async function sendTransaction(connection, transaction, signers, priorityFee = 10000, options = {}) {
    const { computeUnitBuffer: userComputeBuffer, commitment = "confirmed", ...sendOptions } = options;
    // Use user provided buffer or default to 1.1 multiplier
    const computeUnitBuffer = userComputeBuffer ?? { multiplier: 1.1 };
    if (transaction.recentBlockhash === undefined) {
        console.log("No blockhash provided. Setting recent blockhash");
        const { blockhash } = await connection.getLatestBlockhash(commitment);
        transaction.recentBlockhash = blockhash;
    }
    if (transaction.feePayer === undefined) {
        if (signers.length === 0) {
            throw new Error("No signers or fee payer provided");
        }
        transaction.feePayer = signers[0].publicKey;
    }
    // Skip compute preparation if transaction is already signed or has compute instructions
    if (transaction.signatures.length > 0) {
        console.log("Transaction already signed, skipping compute preparation");
        return sendRawTransactionWithRetry(connection, transaction.serialize(), {
            commitment,
            ...sendOptions,
        });
    }
    transaction.instructions = await addComputeInstructions(connection, transaction.instructions, [], transaction.feePayer, priorityFee, computeUnitBuffer, commitment);
    transaction.sign(...signers);
    return sendRawTransactionWithRetry(connection, transaction.serialize(), {
        commitment,
        ...sendOptions,
    });
}
/**
 * Sends a versioned transaction with compute unit optimization and automatic retries
 *
 * @param connection - The Solana connection object
 * @param instructions - Array of instructions to include in the transaction
 * @param signers - Array of signers needed for the transaction
 * @param priorityFee - Priority fee in microLamports (default: 10000)
 * @param lookupTables - Optional array of address lookup tables for account compression
 * @param options - Optional configuration for retry mechanism and compute units
 * @returns Promise that resolves to the transaction signature
 *
 * @remarks
 * This function:
 * 1. Automatically calculates and adds compute unit instructions if not present
 * 2. Creates a v0 transaction message with the provided instructions
 * 3. Signs and sends the transaction with automatic retries
 * 4. Provides status updates through the callback
 *
 * Status updates include:
 * - "computeUnitBufferAdded": Compute unit instructions were added
 * - "created": Transaction was created
 * - "signed": Transaction was signed
 * - "sent": Transaction was sent (includes signature)
 * - "confirmed": Transaction was confirmed
 *
 * @example
 * ```typescript
 * const signature = await sendVersionedTransaction(
 *   connection,
 *   instructions,
 *   [payer],
 *   10000,
 *   lookupTables,
 *   {
 *     computeUnitBuffer: { multiplier: 1.1 },
 *     onStatusUpdate: (status) => console.log(status),
 *   }
 * );
 * ```
 */
async function sendVersionedTransaction(connection, instructions, signers, priorityFee = 10000, lookupTables, options = {}) {
    const { computeUnitBuffer: userComputeBuffer, // Rename to make clear it's user provided
    commitment = "confirmed", ...sendOptions } = options;
    const hasComputeLimitInstructions = hasSetComputeLimitInstruction(instructions);
    if (!hasComputeLimitInstructions) {
        const computeUnitBuffer = userComputeBuffer ?? { multiplier: 1.1 };
        instructions = await addComputeInstructions(connection, instructions, lookupTables ?? [], signers[0].publicKey, priorityFee, computeUnitBuffer, commitment);
    }
    const messageV0 = new web3_js_1.TransactionMessage({
        payerKey: signers[0].publicKey,
        recentBlockhash: (await connection.getLatestBlockhash(commitment)).blockhash,
        instructions,
    }).compileToV0Message(lookupTables);
    const transaction = new web3_js_1.VersionedTransaction(messageV0);
    transaction.sign(signers);
    return await sendRawTransactionWithRetry(connection, transaction.serialize(), sendOptions);
}
/**
 * Adds compute unit price and limit instructions and returns the updated instructions
 *
 * @param connection - The Solana connection object
 * @param instructions - Array of instructions to which compute unit instructions will be added
 * @param lookupTables - Optional array of address lookup tables for account compression
 * @param payer - The public key of the transaction payer
 * @param priorityFee - Priority fee in microLamports (default: 10000)
 * @param computeUnitBuffer - Optional buffer to add to simulated compute units
 * @param commitment - Desired commitment level for the transaction
 * @returns Array of instructions with compute unit instructions added
 */
async function addComputeInstructions(connection, instructions, lookupTables, payer, priorityFee = 10000, computeUnitBuffer = {}, commitment = "confirmed") {
    if (!hasSetComputeUnitPriceInstruction(instructions)) {
        instructions.push(web3_js_1.ComputeBudgetProgram.setComputeUnitPrice({
            microLamports: priorityFee,
        }));
    }
    if (!hasSetComputeLimitInstruction(instructions)) {
        const simulatedCompute = await (0, exports.getSimulationComputeUnits)(connection, instructions, payer, lookupTables, commitment);
        if (simulatedCompute === null) {
            throw new Error("Failed to simulate compute units");
        }
        console.log("Simulated compute units", simulatedCompute);
        // Apply buffer to compute units
        let finalComputeUnits = simulatedCompute;
        if (computeUnitBuffer.multiplier) {
            finalComputeUnits = Math.floor(finalComputeUnits * computeUnitBuffer.multiplier);
        }
        if (computeUnitBuffer.fixed) {
            finalComputeUnits += computeUnitBuffer.fixed;
        }
        console.log("Final compute units (with buffer)", finalComputeUnits);
        instructions.push(web3_js_1.ComputeBudgetProgram.setComputeUnitLimit({
            units: finalComputeUnits,
        }));
    }
    return instructions;
}
/**
 * Internal helper to send a versioned transaction with automatic retries and status updates
 *
 * @param connection - The Solana connection object
 * @param transaction - The versioned transaction to send
 * @param options - Optional configuration for the retry mechanism
 * @returns Promise that resolves to the transaction signature
 *
 * @remarks
 * This function implements a robust retry mechanism that:
 * 1. Sends the transaction only once
 * 2. Monitors the transaction status until confirmation
 * 3. Retries on failure with increasing delay
 * 4. Provides detailed status updates through the callback
 *
 * The function uses default values that can be partially overridden through the options parameter.
 * Default values are defined in DEFAULT_SEND_OPTIONS.
 *
 * Retry behavior:
 * - Initial delay between retries is max(500ms, options.initialDelayMs)
 * - Delay increases by RETRY_INTERVAL_INCREASE (200ms) after each retry
 * - Maximum retries defined by options.maxRetries (default: 15)
 *
 * Status updates include:
 * - "created": Initial transaction state
 * - "signed": Transaction has been signed
 * - "sent": Transaction has been sent (includes signature)
 * - "retry": Transaction is being retried (includes last signature if any)
 * - "confirmed": Transaction is confirmed or finalized (includes status)
 *
 * @throws Error if the transaction fails after all retry attempts
 *
 * @internal This is an internal helper function used by sendVersionedTransaction
 */
async function sendRawTransactionWithRetry(connection, transaction, { maxRetries = exports.DEFAULT_SEND_OPTIONS.maxRetries, initialDelayMs = exports.DEFAULT_SEND_OPTIONS.initialDelayMs, commitment = exports.DEFAULT_SEND_OPTIONS.commitment, skipPreflight = exports.DEFAULT_SEND_OPTIONS.skipPreflight, onStatusUpdate = (status) => console.log("Transaction status:", status), } = {}) {
    onStatusUpdate?.({ status: "created" });
    onStatusUpdate?.({ status: "signed" });
    let signature = null;
    let status = null;
    let retries = 0;
    // Setting a minimum to decrease spam and for the confirmation to work
    let delayBetweenRetries = Math.max(initialDelayMs, 500);
    while (retries < maxRetries) {
        try {
            const isFirstSend = signature === null;
            // Send transaction if not sent yet
            signature = await connection.sendRawTransaction(transaction, {
                skipPreflight,
                preflightCommitment: commitment,
                maxRetries: 0,
            });
            if (isFirstSend) {
                onStatusUpdate?.({ status: "sent", signature: signature ?? "" });
            }
            // Poll for confirmation
            let pollTimeout = delayBetweenRetries;
            const timeBetweenPolls = 500;
            while (pollTimeout > 0) {
                await new Promise((resolve) => setTimeout(resolve, timeBetweenPolls));
                const response = await connection.getSignatureStatus(signature);
                if (response?.value) {
                    status = response.value;
                    if (status.confirmationStatus === "confirmed" ||
                        status.confirmationStatus === "finalized") {
                        onStatusUpdate?.({ status: "confirmed", result: status });
                        return signature ?? "";
                    }
                }
                pollTimeout -= timeBetweenPolls;
            }
        }
        catch (error) {
            if (error instanceof Error) {
                console.log(`Attempt ${retries + 1} failed:`, error.message);
            }
            else {
                console.log(`Attempt ${retries + 1} failed:`, error);
            }
        }
        retries++;
        if (retries < maxRetries) {
            onStatusUpdate?.({ status: "retry", signature: signature ?? null });
            delayBetweenRetries += exports.RETRY_INTERVAL_INCREASE;
        }
    }
    throw new Error(`Transaction failed after ${maxRetries} attempts`);
}
/**
 * Creates a new address lookup table and extends it with additional addresses
 *
 * @param connection - The Solana connection object
 * @param sender - The keypair of the transaction sender
 * @param additionalAddresses - Array of additional addresses to include in the lookup table
 * @returns A tuple containing the lookup table address and the lookup table account
 */
async function createLookupTable(connection, sender, additionalAddresses, priorityFee = 10000) {
    const slot = await connection.getSlot();
    const [lookupTableInst, lookupTableAddress] = web3_js_2.AddressLookupTableProgram.createLookupTable({
        authority: sender.publicKey,
        payer: sender.publicKey,
        recentSlot: slot,
    });
    const extendInstruction = web3_js_2.AddressLookupTableProgram.extendLookupTable({
        payer: sender.publicKey,
        authority: sender.publicKey,
        lookupTable: lookupTableAddress,
        addresses: additionalAddresses,
    });
    const lookupTableInstructions = [lookupTableInst, extendInstruction];
    const lookupTableInstructionsSignature = await sendVersionedTransaction(connection, lookupTableInstructions, [sender], priorityFee);
    // Need to wait until the lookup table is active
    await (0, exports.confirmTransaction)(connection, lookupTableInstructionsSignature, "finalized");
    console.log("Lookup table instructions signature", lookupTableInstructionsSignature);
    const lookupTableAccount = (await connection.getAddressLookupTable(lookupTableAddress, {
        commitment: "confirmed",
    })).value;
    if (!lookupTableAccount) {
        throw new Error("Failed to get lookup table account");
    }
    return [lookupTableAddress, lookupTableAccount];
}
//# sourceMappingURL=transaction.js.map