diff --git a/packages/sdk-provider-solana/src/SolanaStepExecutor.ts b/packages/sdk-provider-solana/src/SolanaStepExecutor.ts index 2d8d94db..27865536 100644 --- a/packages/sdk-provider-solana/src/SolanaStepExecutor.ts +++ b/packages/sdk-provider-solana/src/SolanaStepExecutor.ts @@ -15,6 +15,7 @@ import { VersionedTransaction } from '@solana/web3.js' import { sendAndConfirmTransaction } from './actions/sendAndConfirmTransaction.js' import { callSolanaWithRetry } from './client/connection.js' import { parseSolanaErrors } from './errors/parseSolanaErrors.js' +import { sendAndConfirmBundle } from './jito/sendAndConfirmBundle.js' import type { SolanaStepExecutorOptions } from './types.js' import { base64ToUint8Array } from './utils/base64ToUint8Array.js' import { withTimeout } from './utils/withTimeout.js' @@ -37,6 +38,61 @@ export class SolanaStepExecutor extends BaseStepExecutor { } } + /** + * Deserializes base64-encoded transaction data into VersionedTransaction objects. + * Handles both single transactions and arrays of transactions. + * + * @param transactionRequest - Transaction parameters containing base64-encoded transaction data + * @returns {VersionedTransaction[]} Array of deserialized VersionedTransaction objects + * @throws {TransactionError} If transaction data is missing or empty + */ + private deserializeTransactions(transactionRequest: TransactionParameters) { + if (!transactionRequest.data?.length) { + throw new TransactionError( + LiFiErrorCode.TransactionUnprepared, + 'Unable to prepare transaction.' + ) + } + + if (Array.isArray(transactionRequest.data)) { + return transactionRequest.data.map((tx) => + VersionedTransaction.deserialize(base64ToUint8Array(tx)) + ) + } else { + return [ + VersionedTransaction.deserialize( + base64ToUint8Array(transactionRequest.data) + ), + ] + } + } + + /** + * Determines whether to use Jito bundle submission for the given transactions. + * Multiple transactions require Jito bundle support to be enabled in config. + * + * @param client - The SDK client + * @param transactions - Array of transactions to evaluate + * @returns {Boolean} True if Jito bundle should be used (multiple transactions + Jito enabled), false otherwise + * @throws {TransactionError} If multiple transactions are provided but Jito bundle is not enabled + */ + private shouldUseJitoBundle( + client: SDKClient, + transactions: VersionedTransaction[] + ): boolean { + const isJitoBundleEnabled = Boolean(client.config.routeOptions?.jitoBundle) + // If we received multiple transactions but Jito is not enabled, + // this indicates an unexpected state (possibly an API error or misconfiguration) + if (transactions.length > 1 && !isJitoBundleEnabled) { + throw new TransactionError( + LiFiErrorCode.TransactionUnprepared, + `Received ${transactions.length} transactions but Jito bundle is not enabled. Multiple transactions require Jito bundle support. Please enable jitoBundle in routeOptions.` + ) + } + + return transactions.length > 1 && isJitoBundleEnabled + } + executeStep = async ( client: SDKClient, step: LiFiStepExtended @@ -122,22 +178,18 @@ export class SolanaStepExecutor extends BaseStepExecutor { } } - if (!transactionRequest.data) { - throw new TransactionError( - LiFiErrorCode.TransactionUnprepared, - 'Unable to prepare transaction.' - ) - } + const transactions = this.deserializeTransactions(transactionRequest) - const versionedTransaction = VersionedTransaction.deserialize( - base64ToUint8Array(transactionRequest.data) + const shouldUseJitoBundle = this.shouldUseJitoBundle( + client, + transactions ) this.checkWalletAdapter(step) // We give users 2 minutes to sign the transaction or it should be considered expired - const signedTx = await withTimeout( - () => this.walletAdapter.signTransaction(versionedTransaction), + const signedTransactions = await withTimeout( + () => this.walletAdapter.signAllTransactions(transactions), { // https://solana.com/docs/advanced/confirmation#transaction-expiration // Use 2 minutes to account for fluctuations @@ -155,36 +207,97 @@ export class SolanaStepExecutor extends BaseStepExecutor { 'PENDING' ) - const simulationResult = await callSolanaWithRetry( - client, - (connection) => - connection.simulateTransaction(signedTx, { - commitment: 'confirmed', - replaceRecentBlockhash: true, - }) - ) - - if (simulationResult.value.err) { + // Verify wallet adapter returned signed transactions + if (!signedTransactions.length) { throw new TransactionError( - LiFiErrorCode.TransactionSimulationFailed, - 'Transaction simulation failed' + LiFiErrorCode.TransactionUnprepared, + 'There was a problem signing the transactions. Wallet adapter did not return any signed transactions.' ) } - const confirmedTx = await sendAndConfirmTransaction(client, signedTx) + let confirmedTransaction: any + + if (shouldUseJitoBundle) { + // Use Jito bundle for multiple transactions + const bundleResult = await sendAndConfirmBundle( + client, + signedTransactions + ) + + // Check if all transactions in the bundle were confirmed + // All transactions must succeed for the bundle to be considered successful + const allConfirmed = bundleResult.signatureResults.every( + (result) => result !== null + ) + + if (!allConfirmed) { + throw new TransactionError( + LiFiErrorCode.TransactionExpired, + 'One or more bundle transactions were not confirmed within the expected time frame.' + ) + } + + // Check if any transaction in the bundle has an error + const failedResult = bundleResult.signatureResults.find( + (result) => result?.err !== null + ) + + if (failedResult) { + const reason = + typeof failedResult.err === 'object' + ? JSON.stringify(failedResult.err) + : failedResult.err + throw new TransactionError( + LiFiErrorCode.TransactionFailed, + `Bundle transaction failed: ${reason}` + ) + } + + // Use the first transaction's signature result for reporting + // (all transactions succeeded if we reach here) + confirmedTransaction = { + signatureResult: bundleResult.signatureResults[0], + txSignature: bundleResult.txSignatures[0], + bundleId: bundleResult.bundleId, + } + } else { + // Use regular transaction for single transaction + const signedTransaction = signedTransactions[0] + + const simulationResult = await callSolanaWithRetry( + client, + (connection) => + connection.simulateTransaction(signedTransaction, { + commitment: 'confirmed', + replaceRecentBlockhash: true, + }) + ) + + if (simulationResult.value.err) { + throw new TransactionError( + LiFiErrorCode.TransactionSimulationFailed, + 'Transaction simulation failed' + ) + } + + confirmedTransaction = await sendAndConfirmTransaction( + client, + signedTransaction + ) + } - if (!confirmedTx.signatureResult) { + if (!confirmedTransaction.signatureResult) { throw new TransactionError( LiFiErrorCode.TransactionExpired, 'Transaction has expired: The block height has exceeded the maximum allowed limit.' ) } - if (confirmedTx.signatureResult.err) { + if (confirmedTransaction.signatureResult.err) { const reason = - typeof confirmedTx.signatureResult.err === 'object' - ? JSON.stringify(confirmedTx.signatureResult.err) - : confirmedTx.signatureResult.err + typeof confirmedTransaction.signatureResult.err === 'object' + ? JSON.stringify(confirmedTransaction.signatureResult.err) + : confirmedTransaction.signatureResult.err throw new TransactionError( LiFiErrorCode.TransactionFailed, `Transaction failed: ${reason}` @@ -197,8 +310,8 @@ export class SolanaStepExecutor extends BaseStepExecutor { process.type, 'PENDING', { - txHash: confirmedTx.txSignature, - txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${confirmedTx.txSignature}`, + txHash: confirmedTransaction.txSignature, + txLink: `${fromChain.metamask.blockExplorerUrls[0]}tx/${confirmedTransaction.txSignature}`, } ) diff --git a/packages/sdk-provider-solana/src/client/connection.ts b/packages/sdk-provider-solana/src/client/connection.ts index 8a95699a..cf6a9b14 100644 --- a/packages/sdk-provider-solana/src/client/connection.ts +++ b/packages/sdk-provider-solana/src/client/connection.ts @@ -1,7 +1,8 @@ import { ChainId, type SDKClient } from '@lifi/sdk' import { Connection } from '@solana/web3.js' +import { JitoConnection } from '../jito/JitoConnection.js' -const connections = new Map() +const connections = new Map() /** * Initializes the Solana connections if they haven't been initialized yet. @@ -11,7 +12,9 @@ const ensureConnections = async (client: SDKClient): Promise => { const rpcUrls = await client.getRpcUrlsByChainId(ChainId.SOL) for (const rpcUrl of rpcUrls) { if (!connections.get(rpcUrl)) { - const connection = new Connection(rpcUrl) + const connection = (await JitoConnection.isJitoRpc(rpcUrl)) + ? new JitoConnection(rpcUrl) + : new Connection(rpcUrl) connections.set(rpcUrl, connection) } } @@ -19,13 +22,36 @@ const ensureConnections = async (client: SDKClient): Promise => { /** * Wrapper around getting the connection (RPC provider) for Solana - * @returns - Solana RPC connections + * Returns only non-Jito RPC connections (excludes JitoConnection instances) + * @param client - The SDK client + * @returns - Solana RPC connections (excluding Jito connections) */ export const getSolanaConnections = async ( client: SDKClient ): Promise => { await ensureConnections(client) - return Array.from(connections.values()) + return Array.from(connections.values()).filter( + (conn): conn is Connection => + conn instanceof Connection && !(conn instanceof JitoConnection) + ) +} + +/** + * Get Jito-enabled connections only. + * @param client - The SDK client + * @returns - Array of JitoConnection instances + */ +export const getJitoConnections = async ( + client?: SDKClient +): Promise => { + // If client is provided, ensure connections are initialized + // Otherwise, return from existing cache (used by sendAndConfirmBundle) + if (client) { + await ensureConnections(client) + } + return Array.from(connections.values()).filter( + (conn): conn is JitoConnection => conn instanceof JitoConnection + ) } /** @@ -50,5 +76,5 @@ export async function callSolanaWithRetry( } } // Throw the last encountered error - throw lastError + throw lastError || new Error('No Solana RPC connections available') } diff --git a/packages/sdk-provider-solana/src/jito/JitoConnection.ts b/packages/sdk-provider-solana/src/jito/JitoConnection.ts new file mode 100644 index 00000000..800e53fd --- /dev/null +++ b/packages/sdk-provider-solana/src/jito/JitoConnection.ts @@ -0,0 +1,190 @@ +import { Connection, type VersionedTransaction } from '@solana/web3.js' +import { uint8ArrayToBase64 } from '../utils/uint8ArrayToBase64.js' +import { JITO_TIP_ACCOUNTS } from './constants.js' + +export type SimulateBundleResult = { + value: { + summary: 'succeeded' | { failed: { error: any; tx_signature: string } } + transactionResults: Array<{ + err: any + logs: string[] | null + unitsConsumed?: number + }> + } +} + +export type BundleStatus = { + bundle_id: string + transactions: string[] + slot: number + confirmation_status: 'processed' | 'confirmed' | 'finalized' + err: + | { + Ok: null + } + | any +} + +export type BundleStatusResult = { + context: { + slot: number + } + value: BundleStatus[] +} + +/** + * Makes a direct RPC request to an endpoint + * + */ +async function rpcRequest( + endpoint: string, + method: string, + params: any[] +): Promise { + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params, + }), + }) + if (!response.ok) { + throw new Error(`Jito RPC Error: ${response.status} ${response.statusText}`) + } + const data = await response.json() + if (data.error) { + throw new Error(`Jito RPC Error: ${data.error.message}`) + } + return data.result +} + +/** + * Extended Connection class with Jito bundle support + * Adds simulateBundle, sendBundle, and getTipAccounts methods + */ +export class JitoConnection extends Connection { + private tipAccountsCache: string[] | null = null + + /** + * Check if an RPC endpoint supports Jito bundles + * @param rpcUrl - The RPC endpoint URL to check + * @returns true if the endpoint supports Jito bundle methods + */ + static async isJitoRpc(rpcUrl: string): Promise { + try { + // method exists if the request is successfull and doesn't throw an error + await rpcRequest(rpcUrl, 'getTipAccounts', []) + return true + } catch { + return false + } + } + + /** + * Makes a direct RPC request to the Jito-enabled endpoint + */ + protected async rpcRequest(method: string, params: any[]): Promise { + try { + return await rpcRequest(this.rpcEndpoint, method, params) + } catch (error) { + console.error(`Jito RPC request failed: ${method}`, { + endpoint: this.rpcEndpoint, + params, + error, + }) + throw error + } + } + + /** + * Serialize a transaction to base64 for RPC submission + */ + private serializeTransaction(transaction: VersionedTransaction): string { + return uint8ArrayToBase64(transaction.serialize()) + } + + /** + * Get the tip accounts from the Jito endpoint, using fallbacks if results are empty + * Results are cached to avoid repeated RPC calls + */ + async getTipAccounts(): Promise { + if (this.tipAccountsCache) { + return this.tipAccountsCache + } + + try { + const accounts = await this.rpcRequest('getTipAccounts', []) + if (!accounts.length) { + throw new Error('RPC has no tip accounts') + } + this.tipAccountsCache = accounts + return accounts + } catch (error) { + const fallbackAccounts = JITO_TIP_ACCOUNTS + console.warn( + `Failed to fetch tip accounts from RPC, using fallback`, + error + ) + return fallbackAccounts + } + } + + /** + * Get a random Jito tip account to reduce contention + */ + async getRandomTipAccount(): Promise { + const accounts = await this.getTipAccounts() + return accounts[Math.floor(Math.random() * accounts.length)] + } + + /** + * Manually refresh the tip accounts cache + * Useful for long-running processes that may need updated tip accounts + */ + async refreshTipAccounts(): Promise { + this.tipAccountsCache = null + return this.getTipAccounts() + } + + /** + * Simulate a bundle before sending it + * @param bundle - Array of signed transactions + * @returns Simulation result + */ + async simulateBundle( + bundle: VersionedTransaction[] + ): Promise { + const encodedTransactions = bundle.map((tx) => + this.serializeTransaction(tx) + ) + return this.rpcRequest('simulateBundle', [ + { encodedTransactions }, + ]) + } + + /** + * Send a bundle to the Jito block engine + * @param bundle - Array of signed transactions + * @returns Bundle UUID + */ + async sendBundle(bundle: VersionedTransaction[]): Promise { + const encodedTransactions = bundle.map((tx) => + this.serializeTransaction(tx) + ) + return this.rpcRequest('sendBundle', [encodedTransactions]) + } + + /** + * Get the status of submitted bundles + * @param bundleIds - Array of bundle UUIDs to check + * @returns Bundle status information + */ + async getBundleStatuses(bundleIds: string[]): Promise { + return this.rpcRequest('getBundleStatuses', [bundleIds]) + } +} diff --git a/packages/sdk-provider-solana/src/jito/constants.ts b/packages/sdk-provider-solana/src/jito/constants.ts new file mode 100644 index 00000000..cf188211 --- /dev/null +++ b/packages/sdk-provider-solana/src/jito/constants.ts @@ -0,0 +1,11 @@ +// Jito Tip accounts gotten from https://jito-foundation.gitbook.io/mev/mev-payment-and-distribution/on-chain-addresses +export const JITO_TIP_ACCOUNTS = [ + 'Cw8CFyM9FkoMi7K7Crf6HNQqf4uEMzpKw6QNghXLvLkY', + 'DttWaMuVvTiduZRnguLF7jNxTgiMBZ1hyAumKUiL2KRL', + '96gYZGLnJYVFmbjzopPSU6QiEV5fGqZNyN9nmNhvrZU5', + '3AVi9Tg9Uo68tJfuvoKvqKNWKkC5wPdSSdeBnizKZ6jT', + 'HFqU5x63VTqvQss8hp11i4wVV8bD44PvwucfZ2bU7gRe', + 'ADaUMid9yfUytqMBgopwjb2DTLSokTSzL1zt6iGPaS49', + 'ADuUkR4vqLUMWXxW9gh6D6L8pMSawimctcNZ5pGwDcEt', + 'DfXygSm4jCyNCybVYYK6DwvWqjKee8pbDmJGcLWNDXjh', +] diff --git a/packages/sdk-provider-solana/src/jito/sendAndConfirmBundle.ts b/packages/sdk-provider-solana/src/jito/sendAndConfirmBundle.ts new file mode 100644 index 00000000..adfe1d24 --- /dev/null +++ b/packages/sdk-provider-solana/src/jito/sendAndConfirmBundle.ts @@ -0,0 +1,112 @@ +import { type SDKClient, sleep } from '@lifi/sdk' +import type { SignatureResult, VersionedTransaction } from '@solana/web3.js' +import { getJitoConnections } from '../client/connection.js' + +export type BundleResult = { + bundleId: string + txSignatures: string[] + signatureResults: (SignatureResult | null)[] +} + +/** + * Send and confirm a bundle of transactions using Jito + * Automatically selects a Jito-enabled RPC connection and polls for confirmation + * across multiple Jito RPCs in parallel + * @param client - The SDK client + * @param signedTransactions - an Array of signed transactions + * @returns - {@link BundleResult} object containing Bundle ID, transaction signatures, and confirmation results + */ +export async function sendAndConfirmBundle( + client: SDKClient, + signedTransactions: VersionedTransaction[] +): Promise { + const jitoConnections = await getJitoConnections(client) + + if (jitoConnections.length === 0) { + throw new Error( + 'No Jito-enabled RPC connection available for bundle submission' + ) + } + + const abortController = new AbortController() + + const confirmPromises = jitoConnections.map(async (jitoConnection) => { + try { + // Send initial bundle for this connection + let bundleId: string + try { + bundleId = await jitoConnection.sendBundle(signedTransactions) + } catch (_) { + return null + } + + const [blockhashResult, initialBlockHeight] = await Promise.all([ + jitoConnection.getLatestBlockhash('confirmed'), + jitoConnection.getBlockHeight('confirmed'), + ]) + let currentBlockHeight = initialBlockHeight + + while ( + currentBlockHeight < blockhashResult.lastValidBlockHeight && + !abortController.signal.aborted + ) { + const statusResponse = await jitoConnection.getBundleStatuses([ + bundleId, + ]) + + const bundleStatus = statusResponse.value[0] + + // Check if bundle is confirmed or finalized + if ( + bundleStatus && + (bundleStatus.confirmation_status === 'confirmed' || + bundleStatus.confirmation_status === 'finalized') + ) { + // Bundle confirmed! Extract transaction signatures from bundle status + const txSignatures = bundleStatus.transactions + // Fetch individual signature results + const sigResponse = + await jitoConnection.getSignatureStatuses(txSignatures) + + if (!sigResponse?.value || !Array.isArray(sigResponse.value)) { + // Keep polling, if can't find signature results. + continue + } + + // Immediately abort all other connections when we find a result + abortController.abort() + return { + bundleId, + txSignatures, + signatureResults: sigResponse.value, + } + } + + await sleep(400) + if (!abortController.signal.aborted) { + currentBlockHeight = await jitoConnection.getBlockHeight('confirmed') + } + } + + return null + } catch (error) { + if (abortController.signal.aborted) { + return null // Don't treat abortion as an error + } + throw error + } + }) + + // Wait for first successful confirmation + const result = await Promise.any(confirmPromises).catch(() => null) + + if (!abortController.signal.aborted) { + abortController.abort() + } + + if (!result) { + throw new Error('Failed to send and confirm bundle') + } + + return result +} diff --git a/packages/sdk-provider-solana/src/utils/uint8ArrayToBase64.ts b/packages/sdk-provider-solana/src/utils/uint8ArrayToBase64.ts new file mode 100644 index 00000000..7c0bb92f --- /dev/null +++ b/packages/sdk-provider-solana/src/utils/uint8ArrayToBase64.ts @@ -0,0 +1,12 @@ +export function uint8ArrayToBase64(bytes: Uint8Array): string { + // Node.js environment + if (typeof Buffer !== 'undefined') { + return Buffer.from(bytes).toString('base64') + } + + // Browser environment + const binaryString = Array.from(bytes, (byte) => + String.fromCharCode(byte) + ).join('') + return btoa(binaryString) +} diff --git a/packages/sdk/src/actions/getStepTransaction.ts b/packages/sdk/src/actions/getStepTransaction.ts index 530d92b5..63c97caa 100644 --- a/packages/sdk/src/actions/getStepTransaction.ts +++ b/packages/sdk/src/actions/getStepTransaction.ts @@ -1,4 +1,5 @@ import type { LiFiStep, RequestOptions, SignedLiFiStep } from '@lifi/types' +import { ChainId } from '@lifi/types' import type { SDKClient } from '../types/core.js' import { isStep } from '../utils/isStep.js' import { request } from '../utils/request.js' @@ -21,16 +22,21 @@ export const getStepTransaction = async ( console.warn('SDK Validation: Invalid Step', step) } - return await request( - client.config, - `${client.config.apiUrl}/advanced/stepTransaction`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(step), - signal: options?.signal, - } - ) + let requestUrl = `${client.config.apiUrl}/advanced/stepTransaction` + const isJitoBundleEnabled = Boolean(client.config.routeOptions?.jitoBundle) + + if (isJitoBundleEnabled && step.action.fromChainId === ChainId.SOL) { + // add jitoBundle param to url if from chain is SVM and jitoBundle is enabled in config + const queryParams = new URLSearchParams({ jitoBundle: 'true' }) + requestUrl = `${requestUrl}?${queryParams}` + } + + return await request(client.config, requestUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(step), + signal: options?.signal, + }) }