From 339092cb0688bc243b2fded9c0acdce05acfb8d3 Mon Sep 17 00:00:00 2001 From: Jonas Hahn Date: Thu, 9 Jan 2025 17:44:10 +0100 Subject: [PATCH] add transaction send helpers (#69) --- README.md | 74 +++++++++ package-lock.json | 21 ++- package.json | 2 +- src/lib/transaction.ts | 277 +++++++++++++++++++++++++++++++++- tests/src/transaction.test.ts | 122 +++++++++++++-- 5 files changed, 470 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index c1a5aef..630552e 100644 --- a/README.md +++ b/README.md @@ -482,3 +482,77 @@ esrun --node-test-name-pattern="getCustomErrorMessage" src/index.test.ts ``` To just run tests matching the name `getCustomErrorMessage`. + +### Transaction Utilities + +#### `sendTransactionWithRetry` + +Sends a transaction with automatic retries and status updates. This function implements a robust retry mechanism that: + +1. Signs the transaction (if signers are provided) +2. Sends the transaction only once +3. Monitors the transaction status until confirmation +4. Retries on failure with a fixed delay +5. Provides detailed status updates through a callback + +```typescript +const signature = await sendTransactionWithRetry( + connection, + transaction, + signers, + { + commitment: "confirmed", + onStatusUpdate: (status) => console.log(status), + maxRetries: 30, + initialDelayMs: 2000, + }, +); +``` + +Best combined with `prepareTransactionWithCompute` to ensure the transaction requests the minimum compute units and sets priority fees. + +````typescript +// This could be really nice if RPC providers would all have the same API... + // Please fall back to the fee api of your favourite RPC provider to get a good value. + const priorityFee = 1000; + + await prepareTransactionWithCompute( + connection, + tx, + keyPair.publicKey, + priorityFee + ); + + // can either sign the transaction here, or in the sendTransactionWithRetry function + tx.sign(keyPair); + + var signature = await sendTransactionWithRetry(connection, tx, [], { + onStatusUpdate: (status) => { + console.log("Transaction status:", status); + }, + }); + +``` + +#### `prepareTransactionWithCompute` + +Prepares a transaction with compute unit calculations and limits. This function: + +1. Simulates the transaction to determine required compute units +2. Adds compute budget instructions for both price and unit limit +3. Supports buffer settings to add safety margins (This is useful when inteacting with defi for examples where the price or route may change during the transaction) + +```typescript +await prepareTransactionWithCompute( + connection, + transaction, + payer.publicKey, + 1000, // priority fee in microLamports + { + multiplier: 1.1, // add 10% buffer + fixed: 100, // add fixed amount of CUs + }, +); +```` + +Both functions help with common transaction handling tasks in Solana, making it easier to send reliable transactions with appropriate compute unit settings. diff --git a/package-lock.json b/package-lock.json index f038844..ebbced4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@solana/spl-token": "^0.4.8", "@solana/spl-token-metadata": "^0.1.4", - "@solana/web3.js": "^1.95.2", + "@solana/web3.js": "^1.98.0", "bs58": "^6.0.0", "dotenv": "^16.4.5" }, @@ -24,10 +24,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.8.tgz", - "integrity": "sha512-5F7SDGs1T72ZczbRwbGO9lQi0NLjQxzl6i4lJxLxfW9U5UluCSyEJeniWvnhl3/euNiqQVbo8zruhsDfid0esA==", - "license": "MIT", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", "dependencies": { "regenerator-runtime": "^0.14.0" }, @@ -757,12 +756,11 @@ } }, "node_modules/@solana/web3.js": { - "version": "1.95.2", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.95.2.tgz", - "integrity": "sha512-SjlHp0G4qhuhkQQc+YXdGkI8EerCqwxvgytMgBpzMUQTafrkNant3e7pgilBGgjy/iM40ICvWBLgASTPMrQU7w==", - "license": "MIT", + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.0.tgz", + "integrity": "sha512-nz3Q5OeyGFpFCR+erX2f6JPt3sKhzhYcSycBCSPkWjzSVDh/Rr1FqTVMRe58FKO16/ivTUcuJjeS5MyBvpkbzA==", "dependencies": { - "@babel/runtime": "^7.24.8", + "@babel/runtime": "^7.25.0", "@noble/curves": "^1.4.2", "@noble/hashes": "^1.4.0", "@solana/buffer-layout": "^4.0.1", @@ -1296,8 +1294,7 @@ "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "license": "MIT" + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/rpc-websockets": { "version": "9.0.2", diff --git a/package.json b/package.json index e7d896b..1d52d05 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "dependencies": { "@solana/spl-token": "^0.4.8", "@solana/spl-token-metadata": "^0.1.4", - "@solana/web3.js": "^1.95.2", + "@solana/web3.js": "^1.98.0", "bs58": "^6.0.0", "dotenv": "^16.4.5" }, diff --git a/src/lib/transaction.ts b/src/lib/transaction.ts index 3fdd01c..fc96f3a 100644 --- a/src/lib/transaction.ts +++ b/src/lib/transaction.ts @@ -1,4 +1,16 @@ -import { AddressLookupTableAccount, Commitment, ComputeBudgetProgram, Connection, PublicKey, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js"; +import { + AddressLookupTableAccount, + Commitment, + ComputeBudgetProgram, + Connection, + Keypair, + PublicKey, + SignatureStatus, + Transaction, + TransactionInstruction, + TransactionMessage, + VersionedTransaction, +} from "@solana/web3.js"; import { getErrorFromRPCResponse } from "./logs"; export const confirmTransaction = async ( @@ -52,6 +64,265 @@ export const getSimulationComputeUnits = async ( sigVerify: false, }); - getErrorFromRPCResponse(rpcResponse); + if (rpcResponse?.value?.err) { + const logs = rpcResponse.value.logs?.join("\n • ") || "No logs available"; + throw new Error(`Transaction simulation failed:\n •${logs}`); + } + return rpcResponse.value.unitsConsumed || null; -}; \ No newline at end of file +}; + +/** + * Constants for transaction retry configuration + */ +export const RETRY_INTERVAL_MS = 2000; +export const MAX_RETRIES = 30; + +/** + * Represents the different states of a transaction during its lifecycle + * @property status - The current status of the transaction + * @property signature - The transaction signature (only present when status is "sent") + * @property result - The signature status (only present when status is "confirmed") + */ +export type TxStatusUpdate = + | { status: "created" } + | { status: "signed" } + | { status: "sent"; signature: string } + | { status: "confirmed"; result: SignatureStatus }; + +/** + * Configuration options for transaction retry mechanism + * @property maxRetries - Maximum number of retry attempts + * @property initialDelayMs - Delay between retries in milliseconds + * @property commitment - Desired commitment level for the transaction + * @property skipPreflight - Whether to skip transaction simulation + * @property onStatusUpdate - Callback function to receive transaction status updates + */ +export type SendTransactionOptions = Partial<{ + maxRetries: number; + initialDelayMs: number; + commitment: Commitment; + onStatusUpdate: (status: TxStatusUpdate) => void; + skipPreflight: boolean; +}>; + +/** + * Configuration for compute unit buffer calculation + * @property multiplier - Multiply simulated units by this value (e.g., 1.1 adds 10%) + * @property fixed - Add this fixed amount of compute units + */ +export type ComputeUnitBuffer = { + multiplier?: number; + fixed?: number; +}; + +/** + * Default configuration values for transaction sending + */ +export const DEFAULT_SEND_OPTIONS: Required< + Omit +> = { + maxRetries: MAX_RETRIES, + initialDelayMs: RETRY_INTERVAL_MS, + commitment: "confirmed", + skipPreflight: true, +}; + +/** + * Sends a transaction with automatic retries and status updates + * + * @param connection - The Solana connection object + * @param transaction - The transaction to send + * @param signers - Array of signers needed for the transaction + * @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. Signs the transaction (if signers are provided) + * 2. Sends the transaction only once + * 3. Monitors the transaction status until confirmation + * 4. Retries on failure with a fixed delay + * 5. 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. + * + * Status updates include: + * - "created": Initial transaction state + * - "signed": Transaction has been signed + * - "sent": Transaction has been sent (includes signature) + * - "confirmed": Transaction is confirmed or finalized + * + * @throws Error if the transaction fails after all retry attempts + * + * @example + * ```typescript + * const signature = await sendTransactionWithRetry( + * connection, + * transaction, + * signers, + * { + * onStatusUpdate: (status) => console.log(status), + * commitment: "confirmed" + * } + * ); + * ``` + */ +export async function sendTransactionWithRetry( + connection: Connection, + transaction: Transaction, + signers: Keypair[], + { + maxRetries = DEFAULT_SEND_OPTIONS.maxRetries, + initialDelayMs = DEFAULT_SEND_OPTIONS.initialDelayMs, + commitment = DEFAULT_SEND_OPTIONS.commitment, + skipPreflight = DEFAULT_SEND_OPTIONS.skipPreflight, + onStatusUpdate = () => {}, + }: SendTransactionOptions = {}, +): Promise { + onStatusUpdate?.({ status: "created" }); + + // Sign the transaction + if (signers.length > 0) { + transaction.sign(...signers); + } + + onStatusUpdate?.({ status: "signed" }); + + let signature: string | null = null; + let status: SignatureStatus | null = null; + let retries = 0; + + while (retries < maxRetries) { + try { + // Send transaction if not sent yet + if (!signature) { + signature = await connection.sendRawTransaction( + transaction.serialize(), + { + skipPreflight, + preflightCommitment: commitment, + maxRetries: 0, + }, + ); + onStatusUpdate?.({ status: "sent", signature }); + } + + // Check status + 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; + } + } + } catch (error: unknown) { + if (error instanceof Error) { + console.log(`Attempt ${retries + 1} failed:`, error.message); + } else { + console.log(`Attempt ${retries + 1} failed:`, error); + } + } + + retries++; + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, initialDelayMs)); + } + } + + throw new Error(`Transaction failed after ${maxRetries} attempts`); +} + +/** + * Prepares a transaction by adding compute budget instructions + * + * @param connection - The Solana connection object + * @param tx - The transaction to prepare + * @param payer - The public key of the transaction payer + * @param priorityFee - Priority fee in microLamports (default: 1000) + * @param computeUnitBuffer - Optional buffer to add to simulated compute units + * + * @remarks + * This function: + * 1. Adds a compute unit price instruction with the specified priority fee + * 2. Simulates the transaction to determine required compute units + * 3. Applies any specified compute unit buffers + * 4. Adds a compute unit limit instruction based on the simulation + * + * The compute unit buffer can be specified as: + * - A multiplier (e.g., 1.1 adds 10% to simulated units) + * - A fixed value (e.g., 1000 adds 1000 compute units) + * - Both (multiplier is applied first, then fixed value is added) + * + * Priority Fees: + * To find an appropriate priority fee, refer to your RPC provider's documentation: + * - Helius: https://docs.helius.dev/solana-apis/priority-fee-api + * - Triton: https://docs.triton.one/chains/solana/improved-priority-fees-api + * - Quicknode: https://www.quicknode.com/docs/solana/qn_estimatePriorityFees + * + * @throws If the transaction simulation fails + * + * @example + * ```typescript + * // Add 10% buffer plus 1000 fixed compute units + * await prepareTransactionWithCompute( + * connection, + * transaction, + * payer.publicKey, + * 1000, + * { multiplier: 1.1, fixed: 1000 } + * ); + * ``` + */ +export async function prepareTransactionWithCompute( + connection: Connection, + tx: Transaction, + payer: PublicKey, + priorityFee: number = 1000, + computeUnitBuffer: ComputeUnitBuffer = {}, +): Promise { + tx.add( + ComputeBudgetProgram.setComputeUnitPrice({ + microLamports: priorityFee, + }), + ); + + const simulatedCompute = await getSimulationComputeUnits( + connection, + tx.instructions, + payer, + [], + ); + + 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); + + tx.add( + ComputeBudgetProgram.setComputeUnitLimit({ + units: finalComputeUnits, + }), + ); +} diff --git a/tests/src/transaction.test.ts b/tests/src/transaction.test.ts index 3bf0481..ab292c4 100644 --- a/tests/src/transaction.test.ts +++ b/tests/src/transaction.test.ts @@ -1,14 +1,22 @@ import { describe, test } from "node:test"; -import { Keypair } from "@solana/web3.js"; -import { LAMPORTS_PER_SOL } from "@solana/web3.js"; -import { Connection } from "@solana/web3.js"; -import { airdropIfRequired, confirmTransaction, getSimulationComputeUnits } from "../../src"; +import { + Keypair, + LAMPORTS_PER_SOL, + Connection, + Transaction, + SystemProgram, + TransactionInstruction, + PublicKey, +} from "@solana/web3.js"; +import { + airdropIfRequired, + confirmTransaction, + getSimulationComputeUnits, + prepareTransactionWithCompute, + sendTransactionWithRetry, +} from "../../src"; import { sendAndConfirmTransaction } from "@solana/web3.js"; -import { Transaction } from "@solana/web3.js"; -import { SystemProgram } from "@solana/web3.js"; import assert from "node:assert"; -import { TransactionInstruction } from "@solana/web3.js"; -import { PublicKey } from "@solana/web3.js"; const LOCALHOST = "http://127.0.0.1:8899"; const MEMO_PROGRAM_ID = new PublicKey( @@ -27,7 +35,8 @@ describe("confirmTransaction", () => { 1 * LAMPORTS_PER_SOL, ); - const signature = await sendAndConfirmTransaction(connection, + const signature = await sendAndConfirmTransaction( + connection, new Transaction().add( SystemProgram.transfer({ fromPubkey: sender.publicKey, @@ -87,4 +96,97 @@ describe("getSimulationComputeUnits", () => { // also worth reviewing why memo program seems to use so many CUs. assert.equal(computeUnitsSendSolAndSayThanks, 3888); }); -}); \ No newline at end of file +}); + +describe("Transaction utilities", () => { + test.only("sendTransactionWithRetry should send and confirm a transaction", async () => { + const connection = new Connection(LOCALHOST); + const sender = Keypair.generate(); + await airdropIfRequired( + connection, + sender.publicKey, + 2 * LAMPORTS_PER_SOL, + 1 * LAMPORTS_PER_SOL, + ); + const recipient = Keypair.generate(); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sender.publicKey, + toPubkey: recipient.publicKey, + lamports: LAMPORTS_PER_SOL * 0.1, + }), + ); + + // Add recent blockhash + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = sender.publicKey; + + const statusUpdates: any[] = []; + const signature = await sendTransactionWithRetry( + connection, + transaction, + [sender], + { + commitment: "confirmed", + onStatusUpdate: (status) => statusUpdates.push(status), + }, + ); + + assert.ok(signature); + assert.deepEqual( + statusUpdates.map((s) => s.status), + ["created", "signed", "sent", "confirmed"], + ); + }); + + test.only("prepareTransactionWithCompute should add compute budget instructions", async () => { + const connection = new Connection(LOCALHOST); + const sender = Keypair.generate(); + await airdropIfRequired( + connection, + sender.publicKey, + 2 * LAMPORTS_PER_SOL, + 1 * LAMPORTS_PER_SOL, + ); + const recipient = Keypair.generate(); + + const transaction = new Transaction().add( + SystemProgram.transfer({ + fromPubkey: sender.publicKey, + toPubkey: recipient.publicKey, + lamports: LAMPORTS_PER_SOL * 0.1, + }), + ); + + // Add recent blockhash and feePayer + const { blockhash } = await connection.getLatestBlockhash(); + transaction.recentBlockhash = blockhash; + transaction.feePayer = sender.publicKey; + + const initialInstructionCount = transaction.instructions.length; + + await prepareTransactionWithCompute( + connection, + transaction, + sender.publicKey, + 1000, + { multiplier: 1.1 }, + ); + + // Should add 2 instructions: setComputeUnitPrice and setComputeUnitLimit + assert.equal(transaction.instructions.length, initialInstructionCount + 2); + + // Verify the instructions are ComputeBudget instructions + const newInstructions = transaction.instructions.slice( + initialInstructionCount, + ); + newInstructions.forEach((instruction) => { + assert.equal( + instruction.programId.toString(), + "ComputeBudget111111111111111111111111111111", + ); + }); + }); +});