UNPKG

@drift-labs/sdk

Version:
835 lines (715 loc) • 23.1 kB
import { AddressLookupTableAccount, BlockhashWithExpiryBlockHeight, Commitment, ComputeBudgetProgram, ConfirmOptions, Connection, Message, MessageV0, Signer, SimulatedTransactionResponse, Transaction, TransactionInstruction, TransactionMessage, TransactionVersion, VersionedTransaction, } from '@solana/web3.js'; import { TransactionParamProcessor } from './txParamProcessor'; import bs58 from 'bs58'; import { BaseTxParams, DriftClientMetricsEvents, IWallet, MappedRecord, SignedTxData, TxParams, } from '../types'; import { containsComputeUnitIxs } from '../util/computeUnits'; import { CachedBlockhashFetcher } from './blockhashFetcher/cachedBlockhashFetcher'; import { BaseBlockhashFetcher } from './blockhashFetcher/baseBlockhashFetcher'; import { BlockhashFetcher } from './blockhashFetcher/types'; import { getSizeOfTransaction, isVersionedTransaction, MAX_TX_BYTE_SIZE, } from './utils'; import { DEFAULT_CONFIRMATION_OPTS } from '../config'; /** * Explanation for SIGNATURE_BLOCK_AND_EXPIRY: * * When the whileValidTxSender waits for confirmation of a given transaction, it needs the last available blockheight and blockhash used in the signature to do so. For pre-signed transactions, these values aren't attached to the transaction object by default. For a "scrappy" workaround which doesn't break backwards compatibility, the SIGNATURE_BLOCK_AND_EXPIRY property is simply attached to the transaction objects as they are created or signed in this handler despite a mismatch in the typescript types. If the values are attached to the transaction when they reach the whileValidTxSender, it can opt-in to use these values. */ const DEV_TRY_FORCE_TX_TIMEOUTS = process.env.DEV_TRY_FORCE_TX_TIMEOUTS === 'true' || false; export const COMPUTE_UNITS_DEFAULT = 200_000; const BLOCKHASH_FETCH_RETRY_COUNT = 3; const BLOCKHASH_FETCH_RETRY_SLEEP = 200; const RECENT_BLOCKHASH_STALE_TIME_MS = 2_000; // Reuse blockhashes within this timeframe during bursts of tx contruction export type TxBuildingProps = { instructions: TransactionInstruction | TransactionInstruction[]; txVersion: TransactionVersion; connection: Connection; preFlightCommitment: Commitment; fetchAllMarketLookupTableAccounts: () => Promise<AddressLookupTableAccount[]>; lookupTables?: AddressLookupTableAccount[]; forceVersionedTransaction?: boolean; txParams?: TxParams; recentBlockhash?: BlockhashWithExpiryBlockHeight; wallet?: IWallet; optionalIxs?: TransactionInstruction[]; // additional instructions to add to the front of ixs if there's enough room, such as oracle cranks simulatedTx?: SimulatedTransactionResponse; // we could have pre-simulated the tx and we can use this later to get compute units }; export type TxHandlerConfig = { blockhashCachingEnabled?: boolean; blockhashCachingConfig?: { retryCount: number; retrySleepTimeMs: number; staleCacheTimeMs: number; }; }; /** * This class is responsible for creating and signing transactions. */ export class TxHandler { private blockHashToLastValidBlockHeightLookup: Record<string, number> = {}; private returnBlockHeightsWithSignedTxCallbackData = false; private connection: Connection; private wallet: IWallet; private confirmationOptions: ConfirmOptions; private preSignedCb?: () => void; private onSignedCb?: (txSigs: DriftClientMetricsEvents['txSigned']) => void; private blockhashCommitment: Commitment = DEFAULT_CONFIRMATION_OPTS.commitment; private blockHashFetcher: BlockhashFetcher; constructor(props: { connection: Connection; wallet: IWallet; confirmationOptions: ConfirmOptions; opts?: { returnBlockHeightsWithSignedTxCallbackData?: boolean; onSignedCb?: (txSigs: DriftClientMetricsEvents['txSigned']) => void; preSignedCb?: () => void; }; config?: TxHandlerConfig; }) { this.connection = props.connection; this.wallet = props.wallet; this.confirmationOptions = props.confirmationOptions; this.blockhashCommitment = props.confirmationOptions?.preflightCommitment ?? props?.connection?.commitment ?? this.blockhashCommitment ?? 'confirmed'; this.blockHashFetcher = props?.config?.blockhashCachingEnabled ? new CachedBlockhashFetcher( this.connection, this.blockhashCommitment, props?.config?.blockhashCachingConfig?.retryCount ?? BLOCKHASH_FETCH_RETRY_COUNT, props?.config?.blockhashCachingConfig?.retrySleepTimeMs ?? BLOCKHASH_FETCH_RETRY_SLEEP, props?.config?.blockhashCachingConfig?.staleCacheTimeMs ?? RECENT_BLOCKHASH_STALE_TIME_MS ) : new BaseBlockhashFetcher(this.connection, this.blockhashCommitment); // #Optionals this.returnBlockHeightsWithSignedTxCallbackData = props.opts?.returnBlockHeightsWithSignedTxCallbackData ?? false; this.onSignedCb = props.opts?.onSignedCb; this.preSignedCb = props.opts?.preSignedCb; } public getWallet() { return this.wallet; } private addHashAndExpiryToLookup( hashAndExpiry: BlockhashWithExpiryBlockHeight ) { if (!this.returnBlockHeightsWithSignedTxCallbackData) return; this.blockHashToLastValidBlockHeightLookup[hashAndExpiry.blockhash] = hashAndExpiry.lastValidBlockHeight; } private getProps = (wallet?: IWallet, confirmationOpts?: ConfirmOptions) => [wallet ?? this.wallet, confirmationOpts ?? this.confirmationOptions] as [ IWallet, ConfirmOptions, ]; public updateWallet(wallet: IWallet) { this.wallet = wallet; } /** * Created this to prevent non-finalized blockhashes being used when building transactions. We want to always use finalized because otherwise it's easy to get the BlockHashNotFound error (RPC uses finalized to validate a transaction). Using an older blockhash when building transactions should never really be a problem right now. * * https://www.helius.dev/blog/how-to-deal-with-blockhash-errors-on-solana#why-do-blockhash-errors-occur * * @returns */ public async getLatestBlockhashForTransaction() { return this.blockHashFetcher.getLatestBlockhash(); } /** * Applies recent blockhash and signs a given transaction * @param tx * @param additionalSigners * @param wallet * @param confirmationOpts * @param preSigned * @param recentBlockhash * @returns */ public async prepareTx( tx: Transaction, additionalSigners: Array<Signer>, wallet?: IWallet, confirmationOpts?: ConfirmOptions, preSigned?: boolean, recentBlockhash?: BlockhashWithExpiryBlockHeight ): Promise<Transaction> { if (preSigned) { return tx; } [wallet, confirmationOpts] = this.getProps(wallet, confirmationOpts); tx.feePayer = wallet.publicKey; recentBlockhash = recentBlockhash ? recentBlockhash : await this.getLatestBlockhashForTransaction(); tx.recentBlockhash = recentBlockhash.blockhash; this.addHashAndExpiryToLookup(recentBlockhash); const signedTx = await this.signTx(tx, additionalSigners); // @ts-ignore signedTx.SIGNATURE_BLOCK_AND_EXPIRY = recentBlockhash; return signedTx; } private isVersionedTransaction( tx: Transaction | VersionedTransaction ): boolean { return isVersionedTransaction(tx); } private isLegacyTransaction(tx: Transaction | VersionedTransaction) { return !this.isVersionedTransaction(tx); } private getTxSigFromSignedTx(signedTx: Transaction | VersionedTransaction) { if (this.isVersionedTransaction(signedTx)) { return bs58.encode( Buffer.from((signedTx as VersionedTransaction).signatures[0]) ) as string; } else { return bs58.encode( Buffer.from((signedTx as Transaction).signature) ) as string; } } private getBlockhashFromSignedTx( signedTx: Transaction | VersionedTransaction ) { if (this.isVersionedTransaction(signedTx)) { return (signedTx as VersionedTransaction).message.recentBlockhash; } else { return (signedTx as Transaction).recentBlockhash; } } private async signTx( tx: Transaction, additionalSigners: Array<Signer>, wallet?: IWallet ): Promise<Transaction> { [wallet] = this.getProps(wallet); additionalSigners .filter((s): s is Signer => s !== undefined) .forEach((kp) => { tx.partialSign(kp); }); this.preSignedCb?.(); const signedTx = await wallet.signTransaction(tx); // Turn txSig Buffer into base58 string const txSig = this.getTxSigFromSignedTx(signedTx); this.handleSignedTxData([ { txSig, signedTx, blockHash: this.getBlockhashFromSignedTx(signedTx), }, ]); return signedTx; } public async signVersionedTx( tx: VersionedTransaction, additionalSigners: Array<Signer>, recentBlockhash?: BlockhashWithExpiryBlockHeight, wallet?: IWallet ): Promise<VersionedTransaction> { [wallet] = this.getProps(wallet); if (recentBlockhash) { tx.message.recentBlockhash = recentBlockhash.blockhash; this.addHashAndExpiryToLookup(recentBlockhash); // @ts-ignore tx.SIGNATURE_BLOCK_AND_EXPIRY = recentBlockhash; } additionalSigners ?.filter((s): s is Signer => s !== undefined) .forEach((kp) => { tx.sign([kp]); }); this.preSignedCb?.(); //@ts-ignore const signedTx = (await wallet.signTransaction(tx)) as VersionedTransaction; // Turn txSig Buffer into base58 string const txSig = this.getTxSigFromSignedTx(signedTx); this.handleSignedTxData([ { txSig, signedTx, blockHash: this.getBlockhashFromSignedTx(signedTx), }, ]); return signedTx; } private handleSignedTxData( txData: Omit<SignedTxData, 'lastValidBlockHeight'>[] ) { if (!this.returnBlockHeightsWithSignedTxCallbackData) { if (this.onSignedCb) { this.onSignedCb(txData); } return; } const signedTxData = txData.map((tx) => { const lastValidBlockHeight = this.blockHashToLastValidBlockHeightLookup[tx.blockHash]; return { ...tx, lastValidBlockHeight, }; }); if (this.onSignedCb) { this.onSignedCb(signedTxData); } return signedTxData; } /** * Gets transaction params with extra processing applied, like using the simulated compute units or using a dynamically calculated compute unit price. * @param txBuildingProps * @returns */ private async getProcessedTransactionParams( txBuildingProps: TxBuildingProps ): Promise<BaseTxParams> { const baseTxParams: BaseTxParams = { computeUnits: txBuildingProps?.txParams?.computeUnits, computeUnitsPrice: txBuildingProps?.txParams?.computeUnitsPrice, }; const processedTxParams = await TransactionParamProcessor.process({ baseTxParams, txBuilder: (updatedTxParams) => this.buildTransaction({ ...txBuildingProps, txParams: updatedTxParams.txParams ?? baseTxParams, forceVersionedTransaction: true, }) as Promise<VersionedTransaction>, processConfig: { useSimulatedComputeUnits: txBuildingProps.txParams.useSimulatedComputeUnits, computeUnitsBufferMultiplier: txBuildingProps.txParams.computeUnitsBufferMultiplier, useSimulatedComputeUnitsForCUPriceCalculation: txBuildingProps.txParams .useSimulatedComputeUnitsForCUPriceCalculation, getCUPriceFromComputeUnits: txBuildingProps.txParams.getCUPriceFromComputeUnits, }, processParams: { connection: this.connection, simulatedTx: txBuildingProps.simulatedTx, }, }); return processedTxParams; } private _generateVersionedTransaction( recentBlockhash: BlockhashWithExpiryBlockHeight, message: Message | MessageV0 ) { this.addHashAndExpiryToLookup(recentBlockhash); return new VersionedTransaction(message); } public generateLegacyVersionedTransaction( recentBlockhash: BlockhashWithExpiryBlockHeight, ixs: TransactionInstruction[], wallet?: IWallet ) { [wallet] = this.getProps(wallet); const message = new TransactionMessage({ payerKey: wallet.publicKey, recentBlockhash: recentBlockhash.blockhash, instructions: ixs, }).compileToLegacyMessage(); const tx = this._generateVersionedTransaction(recentBlockhash, message); // @ts-ignore tx.SIGNATURE_BLOCK_AND_EXPIRY = recentBlockhash; return tx; } public generateVersionedTransaction( recentBlockhash: BlockhashWithExpiryBlockHeight, ixs: TransactionInstruction[], lookupTableAccounts: AddressLookupTableAccount[], wallet?: IWallet ) { [wallet] = this.getProps(wallet); const message = new TransactionMessage({ payerKey: wallet.publicKey, recentBlockhash: recentBlockhash.blockhash, instructions: ixs, }).compileToV0Message(lookupTableAccounts); const tx = this._generateVersionedTransaction(recentBlockhash, message); // @ts-ignore tx.SIGNATURE_BLOCK_AND_EXPIRY = recentBlockhash; return tx; } public generateLegacyTransaction( ixs: TransactionInstruction[], recentBlockhash?: BlockhashWithExpiryBlockHeight ) { const tx = new Transaction().add(...ixs); if (recentBlockhash) { tx.recentBlockhash = recentBlockhash.blockhash; } return tx; } /** * Accepts multiple instructions and builds a transaction for each. Prevents needing to spam RPC with requests for the same blockhash. * @param props * @returns */ public async buildBulkTransactions( props: Omit<TxBuildingProps, 'instructions'> & { instructions: (TransactionInstruction | TransactionInstruction[])[]; } ) { const recentBlockhash = props?.recentBlockhash ?? (await this.getLatestBlockhashForTransaction()); return await Promise.all( props.instructions.map((ix) => { if (!ix) return undefined; return this.buildTransaction({ ...props, instructions: ix, recentBlockhash, }); }) ); } /** * * @param instructions * @param txParams * @param txVersion * @param lookupTables * @param forceVersionedTransaction Return a VersionedTransaction instance even if the version of the transaction is Legacy * @returns */ public async buildTransaction( props: TxBuildingProps ): Promise<Transaction | VersionedTransaction> { const { txVersion, txParams, connection: _connection, preFlightCommitment: _preFlightCommitment, fetchAllMarketLookupTableAccounts, forceVersionedTransaction, instructions, } = props; let { lookupTables } = props; const marketLookupTables = await fetchAllMarketLookupTableAccounts(); lookupTables = lookupTables ? [...lookupTables, ...marketLookupTables] : marketLookupTables; // # Collect and process Tx Params let baseTxParams: BaseTxParams = { computeUnits: txParams?.computeUnits, computeUnitsPrice: txParams?.computeUnitsPrice, }; const instructionsArray = Array.isArray(instructions) ? instructions : [instructions]; let instructionsToUse: TransactionInstruction[]; let simulatedTx: SimulatedTransactionResponse | undefined; // add optional ixs if there's room and it doesn't fail simulation (usually oracle cranks) if (props.optionalIxs && txVersion === 0) { [instructionsToUse, simulatedTx] = await this.simulateAndCalculateInstructions( { ...props, instructions: instructionsArray, txVersion, lookupTables, }, props.optionalIxs, txVersion === 0, lookupTables ); } else { instructionsToUse = instructionsArray; } if (txParams?.useSimulatedComputeUnits) { const processedTxParams = await this.getProcessedTransactionParams({ ...props, instructions: instructionsToUse, simulatedTx: simulatedTx, }); baseTxParams = { ...baseTxParams, ...processedTxParams, }; } const { hasSetComputeUnitLimitIx, hasSetComputeUnitPriceIx } = containsComputeUnitIxs(instructionsToUse); // # Create Tx Instructions const allIx = []; const computeUnits = baseTxParams?.computeUnits; if (computeUnits > 0 && !hasSetComputeUnitLimitIx) { allIx.push( ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits, }) ); } const computeUnitsPrice = baseTxParams?.computeUnitsPrice; if (DEV_TRY_FORCE_TX_TIMEOUTS) { allIx.push( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0, }) ); } else if (computeUnitsPrice > 0 && !hasSetComputeUnitPriceIx) { allIx.push( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: computeUnitsPrice, }) ); } allIx.push(...instructionsToUse); const recentBlockhash = props?.recentBlockhash ?? (await this.getLatestBlockhashForTransaction()); // # Create and return Transaction if (txVersion === 'legacy') { if (forceVersionedTransaction) { return this.generateLegacyVersionedTransaction(recentBlockhash, allIx); } else { return this.generateLegacyTransaction(allIx, recentBlockhash); } } else { return this.generateVersionedTransaction( recentBlockhash, allIx, lookupTables ); } } public wrapInTx( instruction: TransactionInstruction, computeUnits = 600_000, computeUnitsPrice = 0 ): Transaction { const tx = new Transaction(); if (computeUnits != COMPUTE_UNITS_DEFAULT) { tx.add( ComputeBudgetProgram.setComputeUnitLimit({ units: computeUnits, }) ); } if (DEV_TRY_FORCE_TX_TIMEOUTS) { tx.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 0, }) ); } else if (computeUnitsPrice != 0) { tx.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: computeUnitsPrice, }) ); } return tx.add(instruction); } /** * Get a map of signed and prepared transactions from an array of legacy transactions * @param txsToSign * @param keys * @param wallet * @param commitment * @returns */ public async getPreparedAndSignedLegacyTransactionMap< T extends Record<string, Transaction | undefined>, >( txsMap: T, wallet?: IWallet, commitment?: Commitment, recentBlockhash?: BlockhashWithExpiryBlockHeight ) { recentBlockhash = recentBlockhash ? recentBlockhash : await this.getLatestBlockhashForTransaction(); this.addHashAndExpiryToLookup(recentBlockhash); for (const tx of Object.values(txsMap)) { if (!tx) continue; tx.recentBlockhash = recentBlockhash.blockhash; tx.feePayer = wallet?.publicKey ?? this.wallet?.publicKey; // @ts-ignore tx.SIGNATURE_BLOCK_AND_EXPIRY = recentBlockhash; } return this.getSignedTransactionMap(txsMap, wallet); } /** * Get a map of signed transactions from an array of transactions to sign. * @param txsToSign * @param keys * @param wallet * @returns */ public async getSignedTransactionMap< T extends Record<string, Transaction | VersionedTransaction | undefined>, >( txsToSignMap: T, wallet?: IWallet ): Promise<{ signedTxMap: T; signedTxData: SignedTxData[]; }> { [wallet] = this.getProps(wallet); const txsToSignEntries = Object.entries(txsToSignMap); // Create a map of the same keys as the input map, but with the values set to undefined. We'll populate the filtered (non-undefined) values with signed transactions. const signedTxMap = txsToSignEntries.reduce((acc, [key]) => { acc[key] = undefined; return acc; }, {}) as T; const filteredTxEntries = txsToSignEntries.filter(([_, tx]) => !!tx); // Extra handling for legacy transactions for (const [_key, tx] of filteredTxEntries) { if (this.isLegacyTransaction(tx)) { (tx as Transaction).feePayer = wallet.publicKey; } } this.preSignedCb?.(); const signedFilteredTxs = await wallet.signAllTransactions( filteredTxEntries.map(([_, tx]) => tx as Transaction) ); signedFilteredTxs.forEach((signedTx, index) => { // @ts-ignore signedTx.SIGNATURE_BLOCK_AND_EXPIRY = // @ts-ignore filteredTxEntries[index][1]?.SIGNATURE_BLOCK_AND_EXPIRY; }); const signedTxData = this.handleSignedTxData( signedFilteredTxs.map((signedTx) => { return { txSig: this.getTxSigFromSignedTx(signedTx), signedTx, blockHash: this.getBlockhashFromSignedTx(signedTx), }; }) ); filteredTxEntries.forEach(([key], index) => { const signedTx = signedFilteredTxs[index]; // @ts-ignore signedTxMap[key] = signedTx; }); return { signedTxMap, signedTxData }; } /** * Accepts multiple instructions and builds a transaction for each. Prevents needing to spam RPC with requests for the same blockhash. * @param props * @returns */ public async buildTransactionsMap< T extends Record<string, TransactionInstruction | TransactionInstruction[]>, >( props: Omit<TxBuildingProps, 'instructions'> & { instructionsMap: T; } ): Promise<MappedRecord<T, Transaction | VersionedTransaction>> { const builtTxs = await this.buildBulkTransactions({ ...props, instructions: Object.values(props.instructionsMap), }); return Object.keys(props.instructionsMap).reduce((acc, key, index) => { acc[key] = builtTxs[index]; return acc; }, {}) as MappedRecord<T, Transaction | VersionedTransaction>; } /** * Builds and signs transactions from a given array of instructions for multiple transactions. * @param props * @returns */ public async buildAndSignTransactionMap< T extends Record<string, TransactionInstruction | TransactionInstruction[]>, >( props: Omit<TxBuildingProps, 'instructions'> & { instructionsMap: T; } ) { const builtTxs = await this.buildTransactionsMap(props); const preppedTransactions = await (props.txVersion === 'legacy' ? this.getPreparedAndSignedLegacyTransactionMap( builtTxs as Record<string, Transaction>, props.wallet, props.preFlightCommitment ) : this.getSignedTransactionMap(builtTxs, props.wallet)); return preppedTransactions; } public async simulateAndCalculateInstructions( txBuildingProps: TxBuildingProps, optionalInstructions: TransactionInstruction[] = [], versionedTransaction = true, addressLookupTables: AddressLookupTableAccount[] = [] ): Promise< [TransactionInstruction[], SimulatedTransactionResponse | undefined] > { const baseInstructions = Array.isArray(txBuildingProps.instructions) ? txBuildingProps.instructions : [txBuildingProps.instructions]; if (optionalInstructions.length === 0) { return [baseInstructions, undefined]; } let allInstructions = [...optionalInstructions, ...baseInstructions]; let txSize = getSizeOfTransaction( allInstructions, versionedTransaction, addressLookupTables ); while ( txSize > MAX_TX_BYTE_SIZE && allInstructions.length > baseInstructions.length ) { allInstructions = allInstructions.slice(1); txSize = getSizeOfTransaction( allInstructions, versionedTransaction, addressLookupTables ); } const tx = await this.buildTransaction({ ...txBuildingProps, optionalIxs: undefined, instructions: allInstructions, }); const simulatedTx = await this.connection.simulateTransaction( tx as VersionedTransaction ); if (simulatedTx.value?.err) { const tx = await this.buildTransaction({ ...txBuildingProps, optionalIxs: undefined, instructions: baseInstructions, }); const simulationWithoutOptionalIxs = await this.connection.simulateTransaction(tx as VersionedTransaction); return [baseInstructions, simulationWithoutOptionalIxs.value]; } return [allInstructions, simulatedTx.value]; } }