From aa0702d6737c7852ac7a6eef0b46fd0a6231fc8f Mon Sep 17 00:00:00 2001 From: Eugene Chybisov Date: Wed, 5 Jun 2024 08:57:13 +0200 Subject: [PATCH] feat: improve Solana rebroadcasting logic for sending transactions --- src/core/Solana/KeypairWalletAdapter.ts | 100 ++++++++ src/core/Solana/SolanaStepExecutor.ts | 243 ++++++++++++------- src/core/Solana/getSolanaBalance.int.spec.ts | 24 ++ src/core/Solana/types.ts | 7 +- src/core/types.ts | 2 +- src/helpers.ts | 10 - src/index.ts | 4 + src/utils/base64ToUint8Array.ts | 9 + 8 files changed, 292 insertions(+), 107 deletions(-) create mode 100644 src/core/Solana/KeypairWalletAdapter.ts create mode 100644 src/utils/base64ToUint8Array.ts diff --git a/src/core/Solana/KeypairWalletAdapter.ts b/src/core/Solana/KeypairWalletAdapter.ts new file mode 100644 index 00000000..5ddb29cb --- /dev/null +++ b/src/core/Solana/KeypairWalletAdapter.ts @@ -0,0 +1,100 @@ +import { ed25519 } from '@noble/curves/ed25519' +import type { + SignerWalletAdapter, + WalletName, +} from '@solana/wallet-adapter-base' +import { + BaseSignerWalletAdapter, + WalletConfigError, + WalletNotConnectedError, + WalletReadyState, + isVersionedTransaction, +} from '@solana/wallet-adapter-base' +import type { + Transaction, + TransactionVersion, + VersionedTransaction, +} from '@solana/web3.js' +import { Keypair } from '@solana/web3.js' +import { decode } from 'bs58' + +export const KeypairWalletName = + 'Keypair Wallet' as WalletName<'Keypair Wallet'> + +/** + * This keypair wallet adapter is unsafe to use on the frontend and is only included to provide an easy way for applications to test + * Wallet Adapter without using a third-party wallet. + */ +export class KeypairWalletAdapter + extends BaseSignerWalletAdapter + implements SignerWalletAdapter +{ + name = KeypairWalletName + url = 'https://github.com/anza-xyz/wallet-adapter' + icon = '' + supportedTransactionVersions: ReadonlySet = new Set([ + 'legacy', + 0, + ]) + + /** + * Storing a keypair locally like this is not safe because any application using this adapter could retrieve the + * secret key, and because the keypair will be lost any time the wallet is disconnected or the window is refreshed. + */ + private _keypair: Keypair | undefined + + constructor(privateKey: string) { + if (!privateKey) { + throw new WalletConfigError() + } + super() + this._keypair = Keypair.fromSecretKey(decode(privateKey)) + } + + get connecting() { + return false + } + + get publicKey() { + return this._keypair?.publicKey || null + } + + get readyState() { + return WalletReadyState.Loadable + } + + async connect(privateKey?: string): Promise { + if (!privateKey) { + throw new WalletConfigError() + } + this._keypair = Keypair.fromSecretKey(decode(privateKey)) + } + + async disconnect(): Promise { + this._keypair = undefined + } + + async signTransaction( + transaction: T + ): Promise { + if (!this._keypair) { + throw new WalletNotConnectedError() + } + + if (isVersionedTransaction(transaction)) { + transaction.sign([this._keypair]) + } else { + transaction.partialSign(this._keypair) + } + + return transaction + } + + async signMessage(message: Uint8Array): Promise { + if (!this._keypair) { + throw new WalletNotConnectedError() + } + + return ed25519.sign(message, this._keypair.secretKey.slice(0, 32)) + } +} diff --git a/src/core/Solana/SolanaStepExecutor.ts b/src/core/Solana/SolanaStepExecutor.ts index a8deeef1..198acdc2 100644 --- a/src/core/Solana/SolanaStepExecutor.ts +++ b/src/core/Solana/SolanaStepExecutor.ts @@ -1,12 +1,13 @@ import type { ExtendedTransactionInfo, FullStatusData } from '@lifi/types' -import type { Adapter } from '@solana/wallet-adapter-base' +import { type SignerWalletAdapter } from '@solana/wallet-adapter-base' import { VersionedTransaction, - type TransactionConfirmationStrategy, - type TransactionSignature, + type SendOptions, + type SignatureResult, } from '@solana/web3.js' import { config } from '../../config.js' import { getStepTransaction } from '../../services/api.js' +import { base64ToUint8Array } from '../../utils/base64ToUint8Array.js' import { LiFiErrorCode, TransactionError, @@ -26,11 +27,15 @@ import { waitForReceivingTransaction } from '../waitForReceivingTransaction.js' import { getSolanaConnection } from './connection.js' export interface SolanaStepExecutorOptions extends StepExecutorOptions { - walletAdapter: Adapter + walletAdapter: SignerWalletAdapter } +const TX_RETRY_INTERVAL = 500 +// https://solana.com/docs/advanced/confirmation +const TIMEOUT_PERIOD = 60_000 + export class SolanaStepExecutor extends BaseStepExecutor { - private walletAdapter: Adapter + private walletAdapter: SignerWalletAdapter constructor(options: SolanaStepExecutorOptions) { super(options) @@ -65,118 +70,173 @@ export class SolanaStepExecutor extends BaseStepExecutor { if (process.status !== 'DONE') { try { const connection = await getSolanaConnection() - let txHash: TransactionSignature - if (process.txHash) { - txHash = process.txHash as TransactionSignature - } else { - process = this.statusManager.updateProcess( - step, - process.type, - 'STARTED' - ) - // Check balance - await checkBalance(this.walletAdapter.publicKey!.toString(), step) - - // Create new transaction - if (!step.transactionRequest) { - const updatedStep = await getStepTransaction(step) - const comparedStep = await stepComparison( - this.statusManager, - step, - updatedStep, - this.allowUserInteraction, - this.executionOptions - ) - step = { - ...comparedStep, - execution: step.execution, - } - } + process = this.statusManager.updateProcess( + step, + process.type, + 'STARTED' + ) - if (!step.transactionRequest?.data) { - throw new TransactionError( - LiFiErrorCode.TransactionUnprepared, - 'Unable to prepare transaction.' - ) - } + // Check balance + await checkBalance(this.walletAdapter.publicKey!.toString(), step) - process = this.statusManager.updateProcess( + // Create new transaction + if (!step.transactionRequest) { + const updatedStep = await getStepTransaction(step) + const comparedStep = await stepComparison( + this.statusManager, step, - process.type, - 'ACTION_REQUIRED' + updatedStep, + this.allowUserInteraction, + this.executionOptions ) - - if (!this.allowUserInteraction) { - return step + step = { + ...comparedStep, + execution: step.execution, } + } - let transactionRequest: TransactionParameters = { - data: step.transactionRequest.data, - } + if (!step.transactionRequest?.data) { + throw new TransactionError( + LiFiErrorCode.TransactionUnprepared, + 'Unable to prepare transaction.' + ) + } - if (this.executionOptions?.updateTransactionRequestHook) { - const customizedTransactionRequest: TransactionParameters = - await this.executionOptions.updateTransactionRequestHook({ - requestType: 'transaction', - ...transactionRequest, - }) + process = this.statusManager.updateProcess( + step, + process.type, + 'ACTION_REQUIRED' + ) + + if (!this.allowUserInteraction) { + return step + } + + let transactionRequest: TransactionParameters = { + data: step.transactionRequest.data, + } - transactionRequest = { + if (this.executionOptions?.updateTransactionRequestHook) { + const customizedTransactionRequest: TransactionParameters = + await this.executionOptions.updateTransactionRequestHook({ + requestType: 'transaction', ...transactionRequest, - ...customizedTransactionRequest, - } - } + }) - if (!transactionRequest.data) { - throw new TransactionError( - LiFiErrorCode.TransactionUnprepared, - 'Unable to prepare transaction.' - ) + transactionRequest = { + ...transactionRequest, + ...customizedTransactionRequest, } + } - const versionedTransaction = VersionedTransaction.deserialize( - Uint8Array.from(atob(transactionRequest.data), (c) => - c.charCodeAt(0) - ) + if (!transactionRequest.data) { + throw new TransactionError( + LiFiErrorCode.TransactionUnprepared, + 'Unable to prepare transaction.' ) + } + + const versionedTransaction = VersionedTransaction.deserialize( + base64ToUint8Array(transactionRequest.data) + ) + + const blockhashResult = await connection.getLatestBlockhashAndContext({ + commitment: 'confirmed', + }) - this.checkWalletAdapter(step) + // Update transaction recent blockhash with the latest blockhash + versionedTransaction.message.recentBlockhash = + blockhashResult.value.blockhash - txHash = await this.walletAdapter.sendTransaction( - versionedTransaction, - connection, + this.checkWalletAdapter(step) + + const signedTx = + await this.walletAdapter.signTransaction(versionedTransaction) + + process = this.statusManager.updateProcess( + step, + process.type, + 'PENDING' + ) + + const rawTransactionOptions: SendOptions = { + // Skipping preflight i.e. tx simulation by RPC as we simulated the tx above + skipPreflight: true, + // 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 txSignature = await connection.sendRawTransaction( + signedTx.serialize(), + rawTransactionOptions + ) + + // In the following section, we wait and constantly check for the transaction to be confirmed + // and resend the transaction if it is not confirmed within a certain time interval + // thus handling tx retries on the client side rather than relying on the RPC + const confirmTransactionPromise = connection + .confirmTransaction( { - maxRetries: 5, - skipPreflight: true, - } + signature: txSignature, + blockhash: blockhashResult.value.blockhash, + lastValidBlockHeight: blockhashResult.value.lastValidBlockHeight, + }, + 'confirmed' ) + .then((result) => result.value) - process = this.statusManager.updateProcess( - step, - process.type, - 'PENDING', - { - txHash: txHash, - txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${txHash}`, - } + let confirmedTx: SignatureResult | null = null + const startTime = Date.now() + + while (!confirmedTx && Date.now() - startTime <= TIMEOUT_PERIOD) { + confirmedTx = await Promise.race([ + confirmTransactionPromise, + new Promise((resolve) => + setTimeout(() => { + resolve(null) + }, TX_RETRY_INTERVAL) + ), + ]) + if (confirmedTx) { + break + } + + await connection.sendRawTransaction( + signedTx.serialize(), + rawTransactionOptions ) } - const signatureResult = await connection.confirmTransaction( - { - signature: txHash, - } as TransactionConfirmationStrategy, - 'confirmed' - ) + if (confirmedTx?.err) { + throw new TransactionError( + LiFiErrorCode.TransactionFailed, + `Transaction failed: ${confirmedTx?.err}` + ) + } - if (signatureResult.value.err) { + if (!confirmedTx) { throw new TransactionError( LiFiErrorCode.TransactionFailed, - `Transaction failed: ${signatureResult.value.err}` + 'Failed to land the transaction' ) } + // Transaction has been confirmed and we can update the process + process = this.statusManager.updateProcess( + step, + process.type, + 'PENDING', + { + txHash: txSignature, + txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${txSignature}`, + } + ) + if (isBridgeExecution) { process = this.statusManager.updateProcess(step, process.type, 'DONE') } @@ -200,7 +260,6 @@ export class SolanaStepExecutor extends BaseStepExecutor { } // STEP 5: Wait for the receiving chain - const processTxHash = process.txHash if (isBridgeExecution) { process = this.statusManager.findOrCreateProcess( step, @@ -210,11 +269,11 @@ export class SolanaStepExecutor extends BaseStepExecutor { } let statusResponse: FullStatusData try { - if (!processTxHash) { + if (!process.txHash) { throw new Error('Transaction hash is undefined.') } statusResponse = (await waitForReceivingTransaction( - processTxHash, + process.txHash, this.statusManager, process.type, step diff --git a/src/core/Solana/getSolanaBalance.int.spec.ts b/src/core/Solana/getSolanaBalance.int.spec.ts index 87df188e..a8b24e89 100644 --- a/src/core/Solana/getSolanaBalance.int.spec.ts +++ b/src/core/Solana/getSolanaBalance.int.spec.ts @@ -89,4 +89,28 @@ describe.sequential('Solana token balance', async () => { }, { retry: retryTimes, timeout } ) + + // it( + // 'should execute route', + // async () => { + // const quote = await getQuote({ + // fromChain: ChainId.SOL, + // fromAmount: '1000000', + // fromToken: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + // toChain: ChainId.ARB, + // toToken: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831', + // fromAddress: '6AUWsSCRFSCbrHKH9s84wfzJXtD6mNzAHs11x6pGEcmJ', + // toAddress: '0x29DaCdF7cCaDf4eE67c923b4C22255A4B2494eD7', + // }) + + // console.log(quote) + + // await executeRoute(convertQuoteToRoute(quote), { + // updateRouteHook: (route) => { + // console.log(route.steps?.[0].execution) + // }, + // }) + // }, + // { timeout: 100000000 } + // ) }) diff --git a/src/core/Solana/types.ts b/src/core/Solana/types.ts index ac481321..9c071831 100644 --- a/src/core/Solana/types.ts +++ b/src/core/Solana/types.ts @@ -1,13 +1,12 @@ -import { ChainType, type ChainId } from '@lifi/types' -import type { Adapter } from '@solana/wallet-adapter-base' +import { ChainType } from '@lifi/types' +import type { SignerWalletAdapter } from '@solana/wallet-adapter-base' import { type SDKProvider } from '../types.js' export interface SolanaProviderOptions { - getWalletAdapter?: () => Promise + getWalletAdapter?: () => Promise } export interface SolanaProvider extends SDKProvider { - rpcUrls?: Record setOptions(options: SolanaProviderOptions): void } diff --git a/src/core/types.ts b/src/core/types.ts index 88a8cc6f..3c561b58 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -72,7 +72,7 @@ export type RouteExecutionDataDictionary = Partial< export type RouteExecutionDictionary = Partial>> -export type UpdateRouteHook = (updatedRoute: Route) => void +export type UpdateRouteHook = (updatedRoute: RouteExtended) => void export interface TransactionRequestParameters extends TransactionParameters { requestType: 'approve' | 'transaction' diff --git a/src/helpers.ts b/src/helpers.ts index b545d729..266b9b3a 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -4,16 +4,6 @@ import type { TenderlyResponse } from './types/index.js' import { ValidationError } from './utils/errors.js' import { name, version } from './version.js' -/** - * Returns a random number between min (inclusive) and max (inclusive) - * @param min - minimum number. - * @param max - maximum number. - * @returns - random number. - */ -export const getRandomNumber = (min: number, max: number): number => { - return Math.floor(Math.random() * (max - min + 1) + min) -} - export const checkPackageUpdates = async ( packageName?: string, packageVersion?: string diff --git a/src/index.ts b/src/index.ts index f015aa31..ea7b83d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,10 @@ export type { MultisigTransaction, MultisigTxDetails, } from './core/EVM/types.js' +export { + KeypairWalletAdapter, + KeypairWalletName, +} from './core/Solana/KeypairWalletAdapter.js' export { Solana } from './core/Solana/Solana.js' export * from './core/index.js' export { createConfig } from './createConfig.js' diff --git a/src/utils/base64ToUint8Array.ts b/src/utils/base64ToUint8Array.ts new file mode 100644 index 00000000..c07782ca --- /dev/null +++ b/src/utils/base64ToUint8Array.ts @@ -0,0 +1,9 @@ +export function base64ToUint8Array(base64String: string) { + const binaryString = atob(base64String) + const len = binaryString.length + const bytes = new Uint8Array(len) + for (let i = 0; i < len; i++) { + bytes[i] = binaryString.charCodeAt(i) + } + return bytes +}