From a6514ebc92529df907b15ed0e48a9da6a79abbf0 Mon Sep 17 00:00:00 2001 From: Gustavo <42685889+tavindev@users.noreply.github.com> Date: Tue, 13 Aug 2024 04:47:06 -0300 Subject: [PATCH] feat: refactor smart transaction sender & confirmation (#114) * feat: refactor smart transaction sender & confirmation * chore: update eslint config * chore: format * feat: retry send transaction until lastValidBlockHeightOffset on block height exceeded error * feat: add lastValidBlockHeightOffset to sendSmartTransactionWithTip * chore: update js doc * refactor: add lastValidBlockHeightOffset to lastValidBlockHeight in confirmTransaction params * fix: typo * refactor: return built transaction instead of serialized transaction * fix: consistent options * refactor: set defaults during method call --------- Co-authored-by: Evan <96965321+0xIchigo@users.noreply.github.com> --- src/RpcClient.ts | 216 +++++++++++++++++++++++++++------------------ src/types/types.ts | 15 +++- 2 files changed, 142 insertions(+), 89 deletions(-) diff --git a/src/RpcClient.ts b/src/RpcClient.ts index 861436f..85ef3ec 100644 --- a/src/RpcClient.ts +++ b/src/RpcClient.ts @@ -19,8 +19,10 @@ import { SendOptions, Signer, SystemProgram, + TransactionConfirmationStatus, + TransactionExpiredBlockheightExceededError, } from '@solana/web3.js'; -const bs58 = require('bs58'); +import bs58 from 'bs58'; import axios from 'axios'; import { DAS } from './types/das-types'; @@ -31,6 +33,7 @@ import { JITO_API_URLS, JITO_TIP_ACCOUNTS, JitoRegion, + SmartTransactionContext, } from './types'; export type SendAndConfirmTransactionResponse = { @@ -187,7 +190,7 @@ export class RpcClient { } ); - const result = response.data.result; + const { result } = response.data; return result as DAS.GetAssetResponse; } catch (error) { throw new Error(`Error in getAsset: ${error}`); @@ -220,7 +223,7 @@ export class RpcClient { } ); - const result = response.data.result; + const { result } = response.data; return result as DAS.GetRwaAssetResponse; } catch (error) { throw new Error(`Error in getRwaAsset: ${error}`); @@ -250,6 +253,7 @@ export class RpcClient { throw new Error(`Error in getAssetBatch: ${error}`); } } + /** * Get Asset proof. * @returns {Promise} @@ -264,10 +268,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getAssetProof', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetProofResponse; } catch (error) { throw new Error(`Error in getAssetProof: ${error}`); @@ -288,10 +292,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getAssetsByGroup', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetResponseList; } catch (error) { throw new Error(`Error in getAssetsByGroup: ${error}`); @@ -312,10 +316,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getAssetsByOwner', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetResponseList; } catch (error) { throw new Error(`Error in getAssetsByOwner: ${error}`); @@ -336,10 +340,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getAssetsByCreator', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetResponseList; } catch (error) { throw new Error(`Error in getAssetsByCreator: ${error}`); @@ -360,10 +364,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getAssetsByAuthority', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetResponseList; } catch (error) { throw new Error(`Error in getAssetsByAuthority: ${error}`); @@ -384,10 +388,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'searchAssets', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetAssetResponseList; } catch (error) { throw new Error(`Error in searchAssets: ${error}`); @@ -408,10 +412,10 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getSignaturesForAsset', - params: params, + params, }); - const data = response.data; + const { data } = response; return data.result as DAS.GetSignaturesForAssetResponse; } catch (error) { throw new Error(`Error in getSignaturesForAsset: ${error}`); @@ -529,17 +533,14 @@ export class RpcClient { * @param {Signer[]} signers - The transaction's signers. The first signer should be the fee payer * @param {AddressLookupTableAccount[]} lookupTables - The lookup tables to be included in a versioned transaction. Defaults to `[]` * @param {Signer} feePayer - Optional fee payer separate from the signers - * @returns {Promise<{ smartTransaction: Transaction | VersionedTransaction, lastValidBlockHeight: number }>} - The transaction and the last valid block height + * @returns {Promise} - The transaction with blockhash, blockheight and slot */ async createSmartTransaction( instructions: TransactionInstruction[], signers: Signer[], lookupTables: AddressLookupTableAccount[] = [], feePayer?: Signer - ): Promise<{ - smartTransaction: Transaction | VersionedTransaction; - lastValidBlockHeight: number; - }> { + ): Promise { if (!signers.length) { throw new Error('The transaction must have at least one signer'); } @@ -558,8 +559,11 @@ export class RpcClient { // For building the transaction const payerKey = feePayer ? feePayer.publicKey : signers[0].publicKey; - let { blockhash: recentBlockhash, lastValidBlockHeight } = - await this.connection.getLatestBlockhash(); + const { + context: { slot: minContextSlot }, + value: blockhash, + } = await this.connection.getLatestBlockhashAndContext(); + const recentBlockhash = blockhash.blockhash; // Determine if we need to use a versioned transaction const isVersioned = lookupTables.length > 0; @@ -569,9 +573,9 @@ export class RpcClient { // Build the initial transaction based on whether lookup tables are present if (isVersioned) { const v0Message = new TransactionMessage({ - instructions: instructions, - payerKey: payerKey, - recentBlockhash: recentBlockhash, + instructions, + payerKey, + recentBlockhash, }).compileToV0Message(lookupTables); versionedTransaction = new VersionedTransaction(v0Message); @@ -608,7 +612,7 @@ export class RpcClient { }, }); - const priorityFeeEstimate = priorityFeeEstimateResponse.priorityFeeEstimate; + const { priorityFeeEstimate } = priorityFeeEstimateResponse; if (!priorityFeeEstimate) { throw new Error('Priority fee estimate not available'); @@ -635,7 +639,7 @@ export class RpcClient { } // For very small transactions, such as simple transfers, default to 1k CUs - let customersCU = units < 1000 ? 1000 : Math.ceil(units * 1.1); + const customersCU = units < 1000 ? 1000 : Math.ceil(units * 1.1); const computeUnitsIx = ComputeBudgetProgram.setComputeUnitLimit({ units: customersCU, @@ -646,9 +650,9 @@ export class RpcClient { // Rebuild the transaction with the final instructions if (isVersioned) { const v0Message = new TransactionMessage({ - instructions: instructions, - payerKey: payerKey, - recentBlockhash: recentBlockhash, + instructions, + payerKey, + recentBlockhash, }).compileToV0Message(lookupTables); versionedTransaction = new VersionedTransaction(v0Message); @@ -656,22 +660,29 @@ export class RpcClient { const allSigners = feePayer ? [...signers, feePayer] : signers; versionedTransaction.sign(allSigners); - return { smartTransaction: versionedTransaction, lastValidBlockHeight }; - } else { - legacyTransaction = new Transaction().add(...instructions); - legacyTransaction.recentBlockhash = recentBlockhash; - legacyTransaction.feePayer = payerKey; - - for (const signer of signers) { - legacyTransaction.partialSign(signer); - } + return { + transaction: versionedTransaction, + blockhash, + minContextSlot, + }; + } + legacyTransaction = new Transaction().add(...instructions); + legacyTransaction.recentBlockhash = recentBlockhash; + legacyTransaction.feePayer = payerKey; - if (feePayer) { - legacyTransaction.partialSign(feePayer); - } + for (const signer of signers) { + legacyTransaction.partialSign(signer); + } - return { smartTransaction: legacyTransaction, lastValidBlockHeight }; + if (feePayer) { + legacyTransaction.partialSign(feePayer); } + + return { + transaction: legacyTransaction, + blockhash, + minContextSlot, + }; } /** @@ -679,18 +690,24 @@ export class RpcClient { * @param {TransactionInstruction[]} instructions - The transaction instructions * @param {Signer[]} signers - The transaction's signers. The first signer should be the fee payer * @param {AddressLookupTableAccount[]} lookupTables - The lookup tables to be included in a versioned transaction. Defaults to `[]` - * @param {SendOptions & { feePayer?: Signer }} sendOptions - Options for sending the transaction, including an optional feePayer. Defaults to `{ skipPreflight: false }` + * @param {SendOptions & { feePayer?: Signer; lastValidBlockHeightOffset?: number }} sendOptions - Options for sending the transaction, including an optional feePayer and lastValidBlockHeightOffset. Defaults to `{ skipPreflight: false; lastValidBlockheightOffset: 150 }` * @returns {Promise} - The transaction signature */ async sendSmartTransaction( instructions: TransactionInstruction[], signers: Signer[], lookupTables: AddressLookupTableAccount[] = [], - sendOptions: SendOptions & { feePayer?: Signer } = { skipPreflight: false } + sendOptions: SendOptions & { + feePayer?: Signer; + lastValidBlockHeightOffset: number; + } = { skipPreflight: false, lastValidBlockHeightOffset: 150 } ): Promise { + if (sendOptions.lastValidBlockHeightOffset < 0) + throw new Error('expiryBlockOffset must be a positive integer'); + try { // Create a smart transaction - const { smartTransaction: transaction, lastValidBlockHeight } = + const { transaction, blockhash, minContextSlot } = await this.createSmartTransaction( instructions, signers, @@ -698,29 +715,52 @@ export class RpcClient { sendOptions.feePayer ); - // Timeout of 60s. The transaction will be routed through our staked connections and should be confirmed by then - const timeout = 60000; - const startTime = Date.now(); - let txtSig; + const commitment = sendOptions?.preflightCommitment || 'confirmed'; - while ( - Date.now() - startTime < timeout || - (await this.connection.getBlockHeight()) <= lastValidBlockHeight - ) { + let error: Error; + + // We will retry the transaction on TransactionExpiredBlockheightExceededError + // until the lastValidBlockHeightOffset is reached in case the transaction is + // included after the lastValidBlockHeight due to network latency or + // to the leader not forwarding the transaction for an unknown reason + // Worst case scenario, it'll retry until the lastValidBlockHeightOffset is reached + // The tradeoff is better reliability at the cost of a possible longer confirmation time + do { try { - txtSig = await this.connection.sendRawTransaction( + // signature does not change when it resends the same one + const signature = await this.connection.sendRawTransaction( transaction.serialize(), { + maxRetries: 0, + preflightCommitment: 'confirmed', skipPreflight: sendOptions.skipPreflight, + minContextSlot, ...sendOptions, } ); - return await this.pollTransactionConfirmation(txtSig); - } catch (error) { - continue; + const abortSignal = AbortSignal.timeout(15000); + await this.connection.confirmTransaction( + { + abortSignal, + signature, + blockhash: blockhash.blockhash, + lastValidBlockHeight: + blockhash.lastValidBlockHeight + + sendOptions.lastValidBlockHeightOffset, + }, + commitment + ); + + abortSignal.removeEventListener('abort', () => {}); + + return signature; + } catch (_error: any) { + if (!(_error instanceof Error)) error = new Error(); + + error = _error; } - } + } while (!(error instanceof TransactionExpiredBlockheightExceededError)); } catch (error) { throw new Error(`Error sending smart transaction: ${error}`); } @@ -767,7 +807,7 @@ export class RpcClient { lookupTables: AddressLookupTableAccount[] = [], tipAmount: number = 1000, feePayer?: Signer - ): Promise<{ serializedTransaction: string; lastValidBlockHeight: number }> { + ): Promise { if (!signers.length) { throw new Error('The transaction must have at least one signer'); } @@ -780,19 +820,12 @@ export class RpcClient { const payerKey = feePayer ? feePayer.publicKey : signers[0].publicKey; this.addTipInstruction(instructions, payerKey, randomTipAccount, tipAmount); - const { smartTransaction, lastValidBlockHeight } = - await this.createSmartTransaction( - instructions, - signers, - lookupTables, - feePayer - ); - - // Return the serialized transaction - return { - serializedTransaction: bs58.encode(smartTransaction.serialize()), - lastValidBlockHeight, - }; + return this.createSmartTransaction( + instructions, + signers, + lookupTables, + feePayer + ); } /** @@ -867,6 +900,7 @@ export class RpcClient { * @param {number} tipAmount - The amount of lamports to tip. Defaults to 1000 * @param {JitoRegion} region - The Jito Block Engine region. Defaults to "Default" (i.e., https://mainnet.block-engine.jito.wtf) * @param {Signer} feePayer - Optional fee payer separate from the signers + * @param {number} lastValidBlockHeightOffset - The offset to add to lastValidBlockHeight. Defaults to 150 * @returns {Promise} - The bundle ID */ async sendSmartTransactionWithTip( @@ -875,24 +909,29 @@ export class RpcClient { lookupTables: AddressLookupTableAccount[] = [], tipAmount: number = 1000, region: JitoRegion = 'Default', - feePayer?: Signer + feePayer?: Signer, + lastValidBlockHeightOffset = 150 ): Promise { + if (lastValidBlockHeightOffset < 0) + throw new Error('lastValidBlockHeightOffset must be a positive integer'); + if (!signers.length) { throw new Error('The transaction must have at least one signer'); } // Create the smart transaction with tip based - let { serializedTransaction, lastValidBlockHeight } = - await this.createSmartTransactionWithTip( - instructions, - signers, - lookupTables, - tipAmount, - feePayer - ); + const { transaction, blockhash } = await this.createSmartTransactionWithTip( + instructions, + signers, + lookupTables, + tipAmount, + feePayer + ); + + const serializedTransaction = bs58.encode(transaction.serialize()); // Get the Jito API URL for the specified region - const jitoApiUrl = JITO_API_URLS[region] + '/api/v1/bundles'; + const jitoApiUrl = `${JITO_API_URLS[region]}/api/v1/bundles`; // Send the transaction as a Jito Bundle const bundleId = await this.sendJitoBundle( @@ -907,7 +946,8 @@ export class RpcClient { while ( Date.now() - startTime < timeout || - (await this.connection.getBlockHeight()) <= lastValidBlockHeight + (await this.connection.getBlockHeight()) <= + blockhash.lastValidBlockHeight + lastValidBlockHeightOffset ) { const bundleStatuses = await this.getBundleStatuses( [bundleId], @@ -948,7 +988,7 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getNftEditions', - params: params, + params, }, { headers: { 'Content-Type': 'application/json' }, @@ -977,7 +1017,7 @@ export class RpcClient { jsonrpc: '2.0', id: this.id, method: 'getTokenAccounts', - params: params, + params, }, { headers: { 'Content-Type': 'application/json' }, diff --git a/src/types/types.ts b/src/types/types.ts index fe04968..cf0061b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,4 +1,11 @@ -import type { Cluster, Keypair, TransactionError } from '@solana/web3.js'; +import type { + BlockhashWithExpiryBlockHeight, + Cluster, + Keypair, + Transaction, + TransactionError, + VersionedTransaction, +} from '@solana/web3.js'; import type { WebhookType, @@ -15,6 +22,12 @@ import type { export type HeliusCluster = Omit; +export type SmartTransactionContext = { + transaction: Transaction | VersionedTransaction; + blockhash: BlockhashWithExpiryBlockHeight; + minContextSlot: number; +}; + export interface HeliusEndpoints { api: string; rpc: string;