Skip to content

Commit 339092c

Browse files
authoredJan 9, 2025··
add transaction send helpers (#69)
1 parent af00163 commit 339092c

File tree

5 files changed

+470
-26
lines changed

5 files changed

+470
-26
lines changed
 

‎README.md

+74
Original file line numberDiff line numberDiff line change
@@ -482,3 +482,77 @@ esrun --node-test-name-pattern="getCustomErrorMessage" src/index.test.ts
482482
```
483483

484484
To just run tests matching the name `getCustomErrorMessage`.
485+
486+
### Transaction Utilities
487+
488+
#### `sendTransactionWithRetry`
489+
490+
Sends a transaction with automatic retries and status updates. This function implements a robust retry mechanism that:
491+
492+
1. Signs the transaction (if signers are provided)
493+
2. Sends the transaction only once
494+
3. Monitors the transaction status until confirmation
495+
4. Retries on failure with a fixed delay
496+
5. Provides detailed status updates through a callback
497+
498+
```typescript
499+
const signature = await sendTransactionWithRetry(
500+
connection,
501+
transaction,
502+
signers,
503+
{
504+
commitment: "confirmed",
505+
onStatusUpdate: (status) => console.log(status),
506+
maxRetries: 30,
507+
initialDelayMs: 2000,
508+
},
509+
);
510+
```
511+
512+
Best combined with `prepareTransactionWithCompute` to ensure the transaction requests the minimum compute units and sets priority fees.
513+
514+
````typescript
515+
// This could be really nice if RPC providers would all have the same API...
516+
// Please fall back to the fee api of your favourite RPC provider to get a good value.
517+
const priorityFee = 1000;
518+
519+
await prepareTransactionWithCompute(
520+
connection,
521+
tx,
522+
keyPair.publicKey,
523+
priorityFee
524+
);
525+
526+
// can either sign the transaction here, or in the sendTransactionWithRetry function
527+
tx.sign(keyPair);
528+
529+
var signature = await sendTransactionWithRetry(connection, tx, [], {
530+
onStatusUpdate: (status) => {
531+
console.log("Transaction status:", status);
532+
},
533+
});
534+
535+
```
536+
537+
#### `prepareTransactionWithCompute`
538+
539+
Prepares a transaction with compute unit calculations and limits. This function:
540+
541+
1. Simulates the transaction to determine required compute units
542+
2. Adds compute budget instructions for both price and unit limit
543+
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)
544+
545+
```typescript
546+
await prepareTransactionWithCompute(
547+
connection,
548+
transaction,
549+
payer.publicKey,
550+
1000, // priority fee in microLamports
551+
{
552+
multiplier: 1.1, // add 10% buffer
553+
fixed: 100, // add fixed amount of CUs
554+
},
555+
);
556+
````
557+
558+
Both functions help with common transaction handling tasks in Solana, making it easier to send reliable transactions with appropriate compute unit settings.

‎package-lock.json

+9-12
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@
5454
"dependencies": {
5555
"@solana/spl-token": "^0.4.8",
5656
"@solana/spl-token-metadata": "^0.1.4",
57-
"@solana/web3.js": "^1.95.2",
57+
"@solana/web3.js": "^1.98.0",
5858
"bs58": "^6.0.0",
5959
"dotenv": "^16.4.5"
6060
},

‎src/lib/transaction.ts

+274-3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,16 @@
1-
import { AddressLookupTableAccount, Commitment, ComputeBudgetProgram, Connection, PublicKey, TransactionInstruction, TransactionMessage, VersionedTransaction } from "@solana/web3.js";
1+
import {
2+
AddressLookupTableAccount,
3+
Commitment,
4+
ComputeBudgetProgram,
5+
Connection,
6+
Keypair,
7+
PublicKey,
8+
SignatureStatus,
9+
Transaction,
10+
TransactionInstruction,
11+
TransactionMessage,
12+
VersionedTransaction,
13+
} from "@solana/web3.js";
214
import { getErrorFromRPCResponse } from "./logs";
315

416
export const confirmTransaction = async (
@@ -52,6 +64,265 @@ export const getSimulationComputeUnits = async (
5264
sigVerify: false,
5365
});
5466

55-
getErrorFromRPCResponse(rpcResponse);
67+
if (rpcResponse?.value?.err) {
68+
const logs = rpcResponse.value.logs?.join("\n • ") || "No logs available";
69+
throw new Error(`Transaction simulation failed:\n •${logs}`);
70+
}
71+
5672
return rpcResponse.value.unitsConsumed || null;
57-
};
73+
};
74+
75+
/**
76+
* Constants for transaction retry configuration
77+
*/
78+
export const RETRY_INTERVAL_MS = 2000;
79+
export const MAX_RETRIES = 30;
80+
81+
/**
82+
* Represents the different states of a transaction during its lifecycle
83+
* @property status - The current status of the transaction
84+
* @property signature - The transaction signature (only present when status is "sent")
85+
* @property result - The signature status (only present when status is "confirmed")
86+
*/
87+
export type TxStatusUpdate =
88+
| { status: "created" }
89+
| { status: "signed" }
90+
| { status: "sent"; signature: string }
91+
| { status: "confirmed"; result: SignatureStatus };
92+
93+
/**
94+
* Configuration options for transaction retry mechanism
95+
* @property maxRetries - Maximum number of retry attempts
96+
* @property initialDelayMs - Delay between retries in milliseconds
97+
* @property commitment - Desired commitment level for the transaction
98+
* @property skipPreflight - Whether to skip transaction simulation
99+
* @property onStatusUpdate - Callback function to receive transaction status updates
100+
*/
101+
export type SendTransactionOptions = Partial<{
102+
maxRetries: number;
103+
initialDelayMs: number;
104+
commitment: Commitment;
105+
onStatusUpdate: (status: TxStatusUpdate) => void;
106+
skipPreflight: boolean;
107+
}>;
108+
109+
/**
110+
* Configuration for compute unit buffer calculation
111+
* @property multiplier - Multiply simulated units by this value (e.g., 1.1 adds 10%)
112+
* @property fixed - Add this fixed amount of compute units
113+
*/
114+
export type ComputeUnitBuffer = {
115+
multiplier?: number;
116+
fixed?: number;
117+
};
118+
119+
/**
120+
* Default configuration values for transaction sending
121+
*/
122+
export const DEFAULT_SEND_OPTIONS: Required<
123+
Omit<SendTransactionOptions, "onStatusUpdate">
124+
> = {
125+
maxRetries: MAX_RETRIES,
126+
initialDelayMs: RETRY_INTERVAL_MS,
127+
commitment: "confirmed",
128+
skipPreflight: true,
129+
};
130+
131+
/**
132+
* Sends a transaction with automatic retries and status updates
133+
*
134+
* @param connection - The Solana connection object
135+
* @param transaction - The transaction to send
136+
* @param signers - Array of signers needed for the transaction
137+
* @param options - Optional configuration for the retry mechanism
138+
*
139+
* @returns Promise that resolves to the transaction signature
140+
*
141+
* @remarks
142+
* This function implements a robust retry mechanism that:
143+
* 1. Signs the transaction (if signers are provided)
144+
* 2. Sends the transaction only once
145+
* 3. Monitors the transaction status until confirmation
146+
* 4. Retries on failure with a fixed delay
147+
* 5. Provides detailed status updates through the callback
148+
*
149+
* The function uses default values that can be partially overridden through the options parameter.
150+
* Default values are defined in DEFAULT_SEND_OPTIONS.
151+
*
152+
* Status updates include:
153+
* - "created": Initial transaction state
154+
* - "signed": Transaction has been signed
155+
* - "sent": Transaction has been sent (includes signature)
156+
* - "confirmed": Transaction is confirmed or finalized
157+
*
158+
* @throws Error if the transaction fails after all retry attempts
159+
*
160+
* @example
161+
* ```typescript
162+
* const signature = await sendTransactionWithRetry(
163+
* connection,
164+
* transaction,
165+
* signers,
166+
* {
167+
* onStatusUpdate: (status) => console.log(status),
168+
* commitment: "confirmed"
169+
* }
170+
* );
171+
* ```
172+
*/
173+
export async function sendTransactionWithRetry(
174+
connection: Connection,
175+
transaction: Transaction,
176+
signers: Keypair[],
177+
{
178+
maxRetries = DEFAULT_SEND_OPTIONS.maxRetries,
179+
initialDelayMs = DEFAULT_SEND_OPTIONS.initialDelayMs,
180+
commitment = DEFAULT_SEND_OPTIONS.commitment,
181+
skipPreflight = DEFAULT_SEND_OPTIONS.skipPreflight,
182+
onStatusUpdate = () => {},
183+
}: SendTransactionOptions = {},
184+
): Promise<string> {
185+
onStatusUpdate?.({ status: "created" });
186+
187+
// Sign the transaction
188+
if (signers.length > 0) {
189+
transaction.sign(...signers);
190+
}
191+
192+
onStatusUpdate?.({ status: "signed" });
193+
194+
let signature: string | null = null;
195+
let status: SignatureStatus | null = null;
196+
let retries = 0;
197+
198+
while (retries < maxRetries) {
199+
try {
200+
// Send transaction if not sent yet
201+
if (!signature) {
202+
signature = await connection.sendRawTransaction(
203+
transaction.serialize(),
204+
{
205+
skipPreflight,
206+
preflightCommitment: commitment,
207+
maxRetries: 0,
208+
},
209+
);
210+
onStatusUpdate?.({ status: "sent", signature });
211+
}
212+
213+
// Check status
214+
const response = await connection.getSignatureStatus(signature);
215+
if (response?.value) {
216+
status = response.value;
217+
218+
if (
219+
status.confirmationStatus === "confirmed" ||
220+
status.confirmationStatus === "finalized"
221+
) {
222+
onStatusUpdate?.({ status: "confirmed", result: status });
223+
return signature;
224+
}
225+
}
226+
} catch (error: unknown) {
227+
if (error instanceof Error) {
228+
console.log(`Attempt ${retries + 1} failed:`, error.message);
229+
} else {
230+
console.log(`Attempt ${retries + 1} failed:`, error);
231+
}
232+
}
233+
234+
retries++;
235+
if (retries < maxRetries) {
236+
await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
237+
}
238+
}
239+
240+
throw new Error(`Transaction failed after ${maxRetries} attempts`);
241+
}
242+
243+
/**
244+
* Prepares a transaction by adding compute budget instructions
245+
*
246+
* @param connection - The Solana connection object
247+
* @param tx - The transaction to prepare
248+
* @param payer - The public key of the transaction payer
249+
* @param priorityFee - Priority fee in microLamports (default: 1000)
250+
* @param computeUnitBuffer - Optional buffer to add to simulated compute units
251+
*
252+
* @remarks
253+
* This function:
254+
* 1. Adds a compute unit price instruction with the specified priority fee
255+
* 2. Simulates the transaction to determine required compute units
256+
* 3. Applies any specified compute unit buffers
257+
* 4. Adds a compute unit limit instruction based on the simulation
258+
*
259+
* The compute unit buffer can be specified as:
260+
* - A multiplier (e.g., 1.1 adds 10% to simulated units)
261+
* - A fixed value (e.g., 1000 adds 1000 compute units)
262+
* - Both (multiplier is applied first, then fixed value is added)
263+
*
264+
* Priority Fees:
265+
* To find an appropriate priority fee, refer to your RPC provider's documentation:
266+
* - Helius: https://docs.helius.dev/solana-apis/priority-fee-api
267+
* - Triton: https://docs.triton.one/chains/solana/improved-priority-fees-api
268+
* - Quicknode: https://www.quicknode.com/docs/solana/qn_estimatePriorityFees
269+
*
270+
* @throws If the transaction simulation fails
271+
*
272+
* @example
273+
* ```typescript
274+
* // Add 10% buffer plus 1000 fixed compute units
275+
* await prepareTransactionWithCompute(
276+
* connection,
277+
* transaction,
278+
* payer.publicKey,
279+
* 1000,
280+
* { multiplier: 1.1, fixed: 1000 }
281+
* );
282+
* ```
283+
*/
284+
export async function prepareTransactionWithCompute(
285+
connection: Connection,
286+
tx: Transaction,
287+
payer: PublicKey,
288+
priorityFee: number = 1000,
289+
computeUnitBuffer: ComputeUnitBuffer = {},
290+
): Promise<void> {
291+
tx.add(
292+
ComputeBudgetProgram.setComputeUnitPrice({
293+
microLamports: priorityFee,
294+
}),
295+
);
296+
297+
const simulatedCompute = await getSimulationComputeUnits(
298+
connection,
299+
tx.instructions,
300+
payer,
301+
[],
302+
);
303+
304+
if (simulatedCompute === null) {
305+
throw new Error("Failed to simulate compute units");
306+
}
307+
308+
console.log("Simulated compute units", simulatedCompute);
309+
310+
// Apply buffer to compute units
311+
let finalComputeUnits = simulatedCompute;
312+
if (computeUnitBuffer.multiplier) {
313+
finalComputeUnits = Math.floor(
314+
finalComputeUnits * computeUnitBuffer.multiplier,
315+
);
316+
}
317+
if (computeUnitBuffer.fixed) {
318+
finalComputeUnits += computeUnitBuffer.fixed;
319+
}
320+
321+
console.log("Final compute units (with buffer)", finalComputeUnits);
322+
323+
tx.add(
324+
ComputeBudgetProgram.setComputeUnitLimit({
325+
units: finalComputeUnits,
326+
}),
327+
);
328+
}

‎tests/src/transaction.test.ts

+112-10
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,22 @@
11
import { describe, test } from "node:test";
2-
import { Keypair } from "@solana/web3.js";
3-
import { LAMPORTS_PER_SOL } from "@solana/web3.js";
4-
import { Connection } from "@solana/web3.js";
5-
import { airdropIfRequired, confirmTransaction, getSimulationComputeUnits } from "../../src";
2+
import {
3+
Keypair,
4+
LAMPORTS_PER_SOL,
5+
Connection,
6+
Transaction,
7+
SystemProgram,
8+
TransactionInstruction,
9+
PublicKey,
10+
} from "@solana/web3.js";
11+
import {
12+
airdropIfRequired,
13+
confirmTransaction,
14+
getSimulationComputeUnits,
15+
prepareTransactionWithCompute,
16+
sendTransactionWithRetry,
17+
} from "../../src";
618
import { sendAndConfirmTransaction } from "@solana/web3.js";
7-
import { Transaction } from "@solana/web3.js";
8-
import { SystemProgram } from "@solana/web3.js";
919
import assert from "node:assert";
10-
import { TransactionInstruction } from "@solana/web3.js";
11-
import { PublicKey } from "@solana/web3.js";
1220

1321
const LOCALHOST = "http://127.0.0.1:8899";
1422
const MEMO_PROGRAM_ID = new PublicKey(
@@ -27,7 +35,8 @@ describe("confirmTransaction", () => {
2735
1 * LAMPORTS_PER_SOL,
2836
);
2937

30-
const signature = await sendAndConfirmTransaction(connection,
38+
const signature = await sendAndConfirmTransaction(
39+
connection,
3140
new Transaction().add(
3241
SystemProgram.transfer({
3342
fromPubkey: sender.publicKey,
@@ -87,4 +96,97 @@ describe("getSimulationComputeUnits", () => {
8796
// also worth reviewing why memo program seems to use so many CUs.
8897
assert.equal(computeUnitsSendSolAndSayThanks, 3888);
8998
});
90-
});
99+
});
100+
101+
describe("Transaction utilities", () => {
102+
test.only("sendTransactionWithRetry should send and confirm a transaction", async () => {
103+
const connection = new Connection(LOCALHOST);
104+
const sender = Keypair.generate();
105+
await airdropIfRequired(
106+
connection,
107+
sender.publicKey,
108+
2 * LAMPORTS_PER_SOL,
109+
1 * LAMPORTS_PER_SOL,
110+
);
111+
const recipient = Keypair.generate();
112+
113+
const transaction = new Transaction().add(
114+
SystemProgram.transfer({
115+
fromPubkey: sender.publicKey,
116+
toPubkey: recipient.publicKey,
117+
lamports: LAMPORTS_PER_SOL * 0.1,
118+
}),
119+
);
120+
121+
// Add recent blockhash
122+
const { blockhash } = await connection.getLatestBlockhash();
123+
transaction.recentBlockhash = blockhash;
124+
transaction.feePayer = sender.publicKey;
125+
126+
const statusUpdates: any[] = [];
127+
const signature = await sendTransactionWithRetry(
128+
connection,
129+
transaction,
130+
[sender],
131+
{
132+
commitment: "confirmed",
133+
onStatusUpdate: (status) => statusUpdates.push(status),
134+
},
135+
);
136+
137+
assert.ok(signature);
138+
assert.deepEqual(
139+
statusUpdates.map((s) => s.status),
140+
["created", "signed", "sent", "confirmed"],
141+
);
142+
});
143+
144+
test.only("prepareTransactionWithCompute should add compute budget instructions", async () => {
145+
const connection = new Connection(LOCALHOST);
146+
const sender = Keypair.generate();
147+
await airdropIfRequired(
148+
connection,
149+
sender.publicKey,
150+
2 * LAMPORTS_PER_SOL,
151+
1 * LAMPORTS_PER_SOL,
152+
);
153+
const recipient = Keypair.generate();
154+
155+
const transaction = new Transaction().add(
156+
SystemProgram.transfer({
157+
fromPubkey: sender.publicKey,
158+
toPubkey: recipient.publicKey,
159+
lamports: LAMPORTS_PER_SOL * 0.1,
160+
}),
161+
);
162+
163+
// Add recent blockhash and feePayer
164+
const { blockhash } = await connection.getLatestBlockhash();
165+
transaction.recentBlockhash = blockhash;
166+
transaction.feePayer = sender.publicKey;
167+
168+
const initialInstructionCount = transaction.instructions.length;
169+
170+
await prepareTransactionWithCompute(
171+
connection,
172+
transaction,
173+
sender.publicKey,
174+
1000,
175+
{ multiplier: 1.1 },
176+
);
177+
178+
// Should add 2 instructions: setComputeUnitPrice and setComputeUnitLimit
179+
assert.equal(transaction.instructions.length, initialInstructionCount + 2);
180+
181+
// Verify the instructions are ComputeBudget instructions
182+
const newInstructions = transaction.instructions.slice(
183+
initialInstructionCount,
184+
);
185+
newInstructions.forEach((instruction) => {
186+
assert.equal(
187+
instruction.programId.toString(),
188+
"ComputeBudget111111111111111111111111111111",
189+
);
190+
});
191+
});
192+
});

0 commit comments

Comments
 (0)
Please sign in to comment.