From 0d5290063767f46970e315506953d3b910daf21f Mon Sep 17 00:00:00 2001 From: Eugene Chybisov <18644653+chybisov@users.noreply.github.com> Date: Fri, 19 Jul 2024 11:52:31 +0200 Subject: [PATCH] fix: add solana tx simulation and expiration handling (#201) --- src/core/Solana/SolanaStepExecutor.ts | 72 +++++++++++++++++++-------- src/core/Solana/parseSolanaErrors.ts | 18 +++++++ src/errors/SDKError.ts | 6 +-- src/errors/SDKError.unit.spec.ts | 12 ++--- src/errors/constants.ts | 1 + 5 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/core/Solana/SolanaStepExecutor.ts b/src/core/Solana/SolanaStepExecutor.ts index 94c5779b..9df07150 100644 --- a/src/core/Solana/SolanaStepExecutor.ts +++ b/src/core/Solana/SolanaStepExecutor.ts @@ -6,13 +6,13 @@ import { type SendOptions, type SignatureResult, } from '@solana/web3.js' +import bs58 from 'bs58' import { config } from '../../config.js' +import { LiFiErrorCode } from '../../errors/constants.js' +import { TransactionError } from '../../errors/errors.js' import { getStepTransaction } from '../../services/api.js' import { base64ToUint8Array } from '../../utils/base64ToUint8Array.js' import { getTransactionFailedMessage } from '../../utils/index.js' -import { TransactionError } from '../../errors/errors.js' -import { LiFiErrorCode } from '../../errors/constants.js' -import { parseSolanaErrors } from './parseSolanaErrors.js' import { BaseStepExecutor } from '../BaseStepExecutor.js' import { checkBalance } from '../checkBalance.js' import { getSubstatusMessage } from '../processMessages.js' @@ -25,6 +25,7 @@ import type { import { sleep } from '../utils.js' import { waitForReceivingTransaction } from '../waitForReceivingTransaction.js' import { getSolanaConnection } from './connection.js' +import { parseSolanaErrors } from './parseSolanaErrors.js' export interface SolanaStepExecutorOptions extends StepExecutorOptions { walletAdapter: SignerWalletAdapter @@ -143,8 +144,23 @@ export class SolanaStepExecutor extends BaseStepExecutor { this.checkWalletAdapter(step) - const signedTx = - await this.walletAdapter.signTransaction(versionedTransaction) + const signedTxPromise = + this.walletAdapter.signTransaction(versionedTransaction) + + // We give users 2 minutes to sign the transaction or it should be considered expired + const signedTx = await Promise.race([ + signedTxPromise, + // https://solana.com/docs/advanced/confirmation#transaction-expiration + // Use 2 minutes to account for fluctuations + sleep(120_000), + ]) + + if (!signedTx) { + throw new TransactionError( + LiFiErrorCode.TransactionExpired, + 'Transaction has expired: blockhash is no longer recent enough.' + ) + } process = this.statusManager.updateProcess( step, @@ -152,30 +168,30 @@ export class SolanaStepExecutor extends BaseStepExecutor { 'PENDING' ) - const rawTransactionOptions: SendOptions = { - // Setting max retries to 0 as we are handling retries manually - // Set this manually so that the default is skipped - maxRetries: 0, - // https://solana.com/docs/advanced/confirmation#use-an-appropriate-preflight-commitment-level - preflightCommitment: 'confirmed', - // minContextSlot: blockhashResult.context.slot, - } - - const signedTxSerialized = signedTx.serialize() - const txSignature = await connection.sendRawTransaction( - signedTxSerialized, - rawTransactionOptions + const simulationResult = await connection.simulateTransaction( + signedTx, + { + commitment: 'processed', + replaceRecentBlockhash: true, + } ) - // We can skip preflight check after the first transaction has been sent - // https://solana.com/docs/advanced/retry#the-cost-of-skipping-preflight - rawTransactionOptions.skipPreflight = true + if (simulationResult.value.err) { + throw new TransactionError( + LiFiErrorCode.TransactionSimulationFailed, + 'Transaction simulation failed' + ) + } + + // Create transaction hash (signature) + const txSignature = bs58.encode(signedTx.signatures[0]) // A known weirdness - MAX_RECENT_BLOCKHASHES is 300 // https://github.com/solana-labs/solana/blob/master/sdk/program/src/clock.rs#L123 // but MAX_PROCESSING_AGE is 150 // https://github.com/solana-labs/solana/blob/master/sdk/program/src/clock.rs#L129 // the blockhash queue in the bank tells you 300 + current slot, but it won't be accepted 150 blocks later. + // https://solana.com/docs/advanced/confirmation#transaction-expiration const lastValidBlockHeight = blockhashResult.lastValidBlockHeight - 150 // In the following section, we wait and constantly check for the transaction to be confirmed @@ -197,6 +213,18 @@ export class SolanaStepExecutor extends BaseStepExecutor { let confirmedTx: SignatureResult | null = null let blockHeight = await connection.getBlockHeight() + const rawTransactionOptions: SendOptions = { + // We can skip preflight check after the first transaction has been sent + // https://solana.com/docs/advanced/retry#the-cost-of-skipping-preflight + skipPreflight: true, + // Setting max retries to 0 as we are handling retries manually + maxRetries: 0, + // https://solana.com/docs/advanced/confirmation#use-an-appropriate-preflight-commitment-level + preflightCommitment: 'confirmed', + } + + const signedTxSerialized = signedTx.serialize() + // https://solana.com/docs/advanced/retry#customizing-rebroadcast-logic while (!confirmedTx && blockHeight < lastValidBlockHeight) { await connection.sendRawTransaction( @@ -239,7 +267,7 @@ export class SolanaStepExecutor extends BaseStepExecutor { if (!confirmedTx) { throw new TransactionError( LiFiErrorCode.TransactionExpired, - 'Failed to land the transaction' + 'Transaction has expired: The block height has exceeded the maximum allowed limit.' ) } diff --git a/src/core/Solana/parseSolanaErrors.ts b/src/core/Solana/parseSolanaErrors.ts index f0f5b07f..13e2dbbf 100644 --- a/src/core/Solana/parseSolanaErrors.ts +++ b/src/core/Solana/parseSolanaErrors.ts @@ -30,6 +30,24 @@ const handleSpecificErrors = (e: any) => { ) } + if (e.name === 'SendTransactionError') { + return new TransactionError( + LiFiErrorCode.TransactionFailed, + e.message, + undefined, + e + ) + } + + if (e.message?.includes('simulate')) { + return new TransactionError( + LiFiErrorCode.TransactionSimulationFailed, + e.message, + undefined, + e + ) + } + if (e instanceof BaseError) { return e } diff --git a/src/errors/SDKError.ts b/src/errors/SDKError.ts index 2f7958f3..954ec5ea 100644 --- a/src/errors/SDKError.ts +++ b/src/errors/SDKError.ts @@ -1,7 +1,7 @@ -import type { BaseError } from './baseError.js' -import { type ErrorCode } from './constants.js' import type { LiFiStep, Process } from '@lifi/types' import { version } from '../version.js' +import type { BaseError } from './baseError.js' +import { type ErrorCode } from './constants.js' // Note: SDKError is used to wrapper and present errors at the top level // Where opportunity allows we also add the step and the process related to the error @@ -13,7 +13,7 @@ export class SDKError extends Error { override cause: BaseError constructor(cause: BaseError, step?: LiFiStep, process?: Process) { - const errorMessage = `${cause.message ? `[${cause.name}] ${cause.message}` : 'Unknown error occurred'}\nLiFi SDK version: ${version}` + const errorMessage = `${cause.message ? `[${cause.name}] ${cause.message}` : 'Unknown error occurred'}\nLI.FI SDK version: ${version}` super(errorMessage) this.name = 'SDKError' this.step = step diff --git a/src/errors/SDKError.unit.spec.ts b/src/errors/SDKError.unit.spec.ts index e77e287f..eb00b2a4 100644 --- a/src/errors/SDKError.unit.spec.ts +++ b/src/errors/SDKError.unit.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from 'vitest' -import { ErrorName, LiFiErrorCode } from './constants.js' -import { BaseError } from './baseError.js' -import { SDKError } from './SDKError.js' import { version } from '../version.js' +import { BaseError } from './baseError.js' +import { ErrorName, LiFiErrorCode } from './constants.js' import { HTTPError } from './httpError.js' +import { SDKError } from './SDKError.js' const url = 'http://some.where' const options = { method: 'POST' } @@ -55,7 +55,7 @@ describe('SDKError', () => { } expect(() => testFunction()).toThrowError( - `[HTTPError] [ValidationError] Request failed with status code 400 Bad Request\n responseMessage: Oops\nLiFi SDK version: ${version}` + `[HTTPError] [ValidationError] Request failed with status code 400 Bad Request\n responseMessage: Oops\nLI.FI SDK version: ${version}` ) }) }) @@ -131,7 +131,7 @@ describe('SDKError', () => { } expect(() => testFunction()).toThrowError( - `[UnknownError] There was an error\nLiFi SDK version: ${version}` + `[UnknownError] There was an error\nLI.FI SDK version: ${version}` ) }) @@ -143,7 +143,7 @@ describe('SDKError', () => { } expect(() => testFunction()).toThrowError( - `Unknown error occurred\nLiFi SDK version: ${version}` + `Unknown error occurred\nLI.FI SDK version: ${version}` ) }) diff --git a/src/errors/constants.ts b/src/errors/constants.ts index 3f3feb88..b986c4a2 100644 --- a/src/errors/constants.ts +++ b/src/errors/constants.ts @@ -33,6 +33,7 @@ export enum LiFiErrorCode { ExchangeRateUpdateCanceled = 1016, WalletChangedDuringExecution = 1017, TransactionExpired = 1018, + TransactionSimulationFailed = 1019, } export enum ErrorMessage {