diff --git a/packages/sdk-provider-solana/src/actions/sendAndConfirmBundle.ts b/packages/sdk-provider-solana/src/actions/sendAndConfirmBundle.ts index d079bd46..a4595f1b 100644 --- a/packages/sdk-provider-solana/src/actions/sendAndConfirmBundle.ts +++ b/packages/sdk-provider-solana/src/actions/sendAndConfirmBundle.ts @@ -1,21 +1,16 @@ import { type SDKClient, sleep } from '@lifi/sdk' import { - type Commitment, getBase64EncodedWireTransaction, type Signature, type Transaction, - type TransactionError, } from '@solana/kit' import { getJitoRpcs } from '../rpc/registry.js' - -type SignatureStatus = { - slot: bigint - confirmations: bigint | null - err: TransactionError | null - confirmationStatus: Commitment | null - status: Readonly<{ Err: TransactionError }> | Readonly<{ Ok: null }> -} +import { extractBlockhash } from '../utils/extractBlockhash.js' +import { + isConfirmedCommitment, + type SignatureStatus, +} from '../utils/signatureStatus.js' export type BundleResult = { bundleId: string @@ -50,6 +45,8 @@ export async function sendAndConfirmBundle( getBase64EncodedWireTransaction(tx) ) + const txBlockhash = await extractBlockhash(signedTransactions[0]) + const abortController = new AbortController() const confirmPromises = jitoRpcs.map(async (jitoRpc) => { @@ -62,37 +59,39 @@ export async function sendAndConfirmBundle( return null } - const [{ value: blockhashResult }, initialBlockHeight] = - await Promise.all([ - jitoRpc - .getLatestBlockhash({ - commitment: 'confirmed', - }) - .send(), - jitoRpc - .getBlockHeight({ - commitment: 'confirmed', - }) - .send(), - ]) - - let currentBlockHeight = initialBlockHeight - - while ( - currentBlockHeight < blockhashResult.lastValidBlockHeight && - !abortController.signal.aborted - ) { + let blockhashValid = true + + let checkBlockhashExpired: () => Promise + if (txBlockhash) { + checkBlockhashExpired = async () => { + const { value } = await jitoRpc + .isBlockhashValid(txBlockhash, { commitment: 'confirmed' }) + .send() + return !value + } + } else { + const { value: blockhashResult } = await jitoRpc + .getLatestBlockhash({ commitment: 'confirmed' }) + .send() + const expiryBlockHeight = blockhashResult.lastValidBlockHeight + checkBlockhashExpired = async () => { + const blockHeight = await jitoRpc + .getBlockHeight({ commitment: 'confirmed' }) + .send() + return blockHeight >= expiryBlockHeight + } + } + + while (blockhashValid && !abortController.signal.aborted) { const statusResponse = await jitoRpc .getBundleStatuses([bundleId]) .send() const bundleStatus = statusResponse.value[0] - // Check if bundle is confirmed or finalized if ( bundleStatus && - (bundleStatus.confirmation_status === 'confirmed' || - bundleStatus.confirmation_status === 'finalized') + isConfirmedCommitment(bundleStatus.confirmation_status) ) { // Bundle confirmed! Extract transaction signatures from bundle status const txSignatures = bundleStatus.transactions @@ -102,7 +101,7 @@ export async function sendAndConfirmBundle( .getSignatureStatuses(txSignatures) .send() - if (!sigResponse?.value || !Array.isArray(sigResponse.value)) { + if (!sigResponse?.value) { // Keep polling if can't find signature results await sleep(400) continue @@ -113,17 +112,48 @@ export async function sendAndConfirmBundle( return { bundleId, txSignatures, - signatureResults: sigResponse.value, + signatureResults: [...sigResponse.value], } } await sleep(400) if (!abortController.signal.aborted) { - currentBlockHeight = await jitoRpc - .getBlockHeight({ - commitment: 'confirmed', - }) + try { + if (await checkBlockhashExpired()) { + blockhashValid = false + } + } catch (_) { + // If the validity check fails, keep polling — the blockhash + // may still be valid and other RPCs can confirm independently. + } + } + } + + // Final status check — the bundle may have confirmed between the + // last poll and the blockhash expiring. + if (!abortController.signal.aborted) { + const statusResponse = await jitoRpc + .getBundleStatuses([bundleId]) + .send() + + const bundleStatus = statusResponse.value[0] + if ( + bundleStatus && + isConfirmedCommitment(bundleStatus.confirmation_status) + ) { + const txSignatures = bundleStatus.transactions + const sigResponse = await jitoRpc + .getSignatureStatuses(txSignatures) .send() + + if (sigResponse?.value) { + abortController.abort() + return { + bundleId, + txSignatures, + signatureResults: [...sigResponse.value], + } + } } } diff --git a/packages/sdk-provider-solana/src/actions/sendAndConfirmTransaction.ts b/packages/sdk-provider-solana/src/actions/sendAndConfirmTransaction.ts index d000e914..2156368d 100644 --- a/packages/sdk-provider-solana/src/actions/sendAndConfirmTransaction.ts +++ b/packages/sdk-provider-solana/src/actions/sendAndConfirmTransaction.ts @@ -4,17 +4,13 @@ import { getBase64EncodedWireTransaction, getSignatureFromTransaction, type Transaction, - type TransactionError, } from '@solana/kit' import { getSolanaRpcs } from '../rpc/registry.js' - -type SignatureStatus = { - slot: bigint - confirmations: bigint | null - err: TransactionError | null - confirmationStatus: Commitment | null - status: Readonly<{ Err: TransactionError }> | Readonly<{ Ok: null }> -} +import { extractBlockhash } from '../utils/extractBlockhash.js' +import { + getConfirmedStatus, + type SignatureStatus, +} from '../utils/signatureStatus.js' type ConfirmedTransactionResult = { signatureResult: SignatureStatus | null @@ -39,13 +35,14 @@ export async function sendAndConfirmTransaction( const solanaRpcs = await getSolanaRpcs(client) const signedTxSerialized = getBase64EncodedWireTransaction(signedTransaction) - // Create transaction hash (signature) const txSignature = getSignatureFromTransaction(signedTransaction) if (!txSignature) { throw new Error('Transaction signature is missing.') } + const txBlockhash = await extractBlockhash(signedTransaction) + const rawTransactionOptions = { // We can skip preflight check after the first transaction has been sent // https://solana.com/docs/advanced/retry#the-cost-of-skipping-preflight @@ -70,51 +67,64 @@ export async function sendAndConfirmTransaction( // Continue with confirmation even if initial send fails } - const [{ value: blockhashResult }, initialBlockHeight] = - await Promise.all([ - rpc - .getLatestBlockhash({ - commitment: 'confirmed', - }) - .send(), - rpc - .getBlockHeight({ - commitment: 'confirmed', - }) - .send(), - ]) - let signatureResult: SignatureStatus | null = null - let blockHeight = initialBlockHeight - const pollingPromise = (async () => { - while ( - blockHeight < blockhashResult.lastValidBlockHeight && - !abortController.signal.aborted - ) { - const statusResponse = await rpc - .getSignatureStatuses([txSignature]) + let blockhashValid = true + + let checkBlockhashExpired: () => Promise + if (txBlockhash) { + checkBlockhashExpired = async () => { + const { value } = await rpc + .isBlockhashValid(txBlockhash, { commitment: 'confirmed' }) + .send() + return !value + } + } else { + const { value: blockhashResult } = await rpc + .getLatestBlockhash({ commitment: 'confirmed' }) + .send() + const expiryBlockHeight = blockhashResult.lastValidBlockHeight + checkBlockhashExpired = async () => { + const blockHeight = await rpc + .getBlockHeight({ commitment: 'confirmed' }) .send() + return blockHeight >= expiryBlockHeight + } + } - const status = statusResponse.value[0] - if ( - status && - (status.confirmationStatus === 'confirmed' || - status.confirmationStatus === 'finalized') - ) { - signatureResult = status - // Immediately abort all other RPCs when we find a result + const pollingPromise = (async () => { + while (blockhashValid && !abortController.signal.aborted) { + const confirmed = getConfirmedStatus( + await rpc.getSignatureStatuses([txSignature]).send() + ) + if (confirmed) { + signatureResult = confirmed abortController.abort() - return status + return confirmed } await sleep(400) } + + // Final status check — the tx may have confirmed between the last + // poll and the blockhash expiring. + if (!abortController.signal.aborted) { + const confirmed = getConfirmedStatus( + await rpc.getSignatureStatuses([txSignature]).send() + ) + if (confirmed) { + signatureResult = confirmed + abortController.abort() + return confirmed + } + } + return null })() + // Sending loop runs in the background — only pollingPromise produces results. const sendingPromise = (async () => { while ( - blockHeight < blockhashResult.lastValidBlockHeight && + blockhashValid && !abortController.signal.aborted && !signatureResult ) { @@ -128,18 +138,23 @@ export async function sendAndConfirmTransaction( await sleep(1000) if (!abortController.signal.aborted) { - blockHeight = await rpc - .getBlockHeight({ - commitment: 'confirmed', - }) - .send() + try { + if (await checkBlockhashExpired()) { + blockhashValid = false + } + } catch (_) { + // If the validity check fails, keep resending — the blockhash + // may still be valid and other RPCs can confirm independently. + } } } - return null })() + // Fire-and-forget: the sending loop only resends and checks blockhash + // validity. Confirmation is handled by pollingPromise, so errors here + // can be safely ignored. + sendingPromise.catch(() => {}) - // Wait for polling to find the result - const result = await Promise.race([pollingPromise, sendingPromise]) + const result = await pollingPromise return result } catch (error) { if (abortController.signal.aborted) { diff --git a/packages/sdk-provider-solana/src/core/tasks/SolanaStandardWaitForTransactionTask.ts b/packages/sdk-provider-solana/src/core/tasks/SolanaStandardWaitForTransactionTask.ts index 612f646b..a6db56be 100644 --- a/packages/sdk-provider-solana/src/core/tasks/SolanaStandardWaitForTransactionTask.ts +++ b/packages/sdk-provider-solana/src/core/tasks/SolanaStandardWaitForTransactionTask.ts @@ -54,7 +54,6 @@ export class SolanaStandardWaitForTransactionTask extends BaseStepExecutionTask connection .simulateTransaction(encodedTransaction, { commitment: 'confirmed', - replaceRecentBlockhash: true, encoding: 'base64', }) .send() diff --git a/packages/sdk-provider-solana/src/utils/extractBlockhash.ts b/packages/sdk-provider-solana/src/utils/extractBlockhash.ts new file mode 100644 index 00000000..479bd21f --- /dev/null +++ b/packages/sdk-provider-solana/src/utils/extractBlockhash.ts @@ -0,0 +1,22 @@ +import { + type Blockhash, + getCompiledTransactionMessageDecoder, + getTransactionLifetimeConstraintFromCompiledTransactionMessage, + type Transaction, +} from '@solana/kit' + +const decoder = getCompiledTransactionMessageDecoder() + +export async function extractBlockhash( + signedTransaction: Transaction +): Promise { + const compiledMessage = decoder.decode(signedTransaction.messageBytes) + const constraint = + await getTransactionLifetimeConstraintFromCompiledTransactionMessage( + compiledMessage + ) + if ('blockhash' in constraint) { + return constraint.blockhash + } + return null +} diff --git a/packages/sdk-provider-solana/src/utils/extractBlockhash.unit.spec.ts b/packages/sdk-provider-solana/src/utils/extractBlockhash.unit.spec.ts new file mode 100644 index 00000000..1e08d752 --- /dev/null +++ b/packages/sdk-provider-solana/src/utils/extractBlockhash.unit.spec.ts @@ -0,0 +1,84 @@ +import type { Address, Transaction } from '@solana/kit' +import { + type Blockhash, + getCompiledTransactionMessageEncoder, +} from '@solana/kit' +import { describe, expect, it } from 'vitest' +import { extractBlockhash } from './extractBlockhash.js' + +const SYSTEM_PROGRAM_ADDRESS = + '11111111111111111111111111111111' as Address<'11111111111111111111111111111111'> + +const encoder = getCompiledTransactionMessageEncoder() + +const TEST_BLOCKHASH = 'EETubP5AKHgjPAhzPkA6E6SFrmFW6V5a5kWvs97PFb91' +const TEST_NONCE = '7BpFqxP4VEXCVnT8HXMQ2KGeVxfmPz4dMwYSnFBHNzqL' + +function buildTransaction( + compiledMessage: Parameters[0] +): Transaction { + const messageBytes = encoder.encode(compiledMessage) + return { messageBytes, signatures: {} } as unknown as Transaction +} + +function makeBlockhashTx(): Transaction { + const feePayer = 'Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS' as Address + return buildTransaction({ + version: 'legacy' as const, + header: { + numSignerAccounts: 1, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 0, + }, + staticAccounts: [feePayer], + lifetimeToken: TEST_BLOCKHASH, + instructions: [], + }) +} + +function makeNonceTx(): Transaction { + const feePayer = 'Fg6PaFpoGXkYsidMpWTK6W2BeZ7FEfcYkg476zPFsLnS' as Address + const nonceAccount = '5ZWj7a1f8tWkjBESHKgrLCQvJe2WLAd5PPQGZ7j7rjST' as Address + const nonceAuthority = + 'GjKC3UPRsFs9oRH7x6L2RQFCcgr1mEE6sWG8vqKB3tP1' as Address + const recentBlockhashSysvar = + 'SysvarRecentB1ockHashes11111111111111111111' as Address + + return buildTransaction({ + version: 'legacy' as const, + header: { + numSignerAccounts: 2, + numReadonlySignerAccounts: 0, + numReadonlyNonSignerAccounts: 2, + }, + staticAccounts: [ + feePayer, + nonceAuthority, + nonceAccount, + recentBlockhashSysvar, + SYSTEM_PROGRAM_ADDRESS, + ], + lifetimeToken: TEST_NONCE, + instructions: [ + { + programAddressIndex: 4, + accountIndices: [2, 3, 1], + data: new Uint8Array([4, 0, 0, 0]), + }, + ], + }) +} + +describe('extractBlockhash', () => { + it('returns the blockhash for a blockhash-lifetime transaction', async () => { + const tx = makeBlockhashTx() + const result = await extractBlockhash(tx) + expect(result).toBe(TEST_BLOCKHASH as Blockhash) + }) + + it('returns null for a durable-nonce transaction', async () => { + const tx = makeNonceTx() + const result = await extractBlockhash(tx) + expect(result).toBeNull() + }) +}) diff --git a/packages/sdk-provider-solana/src/utils/signatureStatus.ts b/packages/sdk-provider-solana/src/utils/signatureStatus.ts new file mode 100644 index 00000000..8b561c38 --- /dev/null +++ b/packages/sdk-provider-solana/src/utils/signatureStatus.ts @@ -0,0 +1,27 @@ +import type { Commitment, TransactionError } from '@solana/kit' + +export type SignatureStatus = { + slot: bigint + confirmations: bigint | null + err: TransactionError | null + confirmationStatus: Commitment | null + status: Readonly<{ Err: TransactionError }> | Readonly<{ Ok: null }> +} + +export function isConfirmedCommitment( + commitment: string | null | undefined +): boolean { + return commitment === 'confirmed' || commitment === 'finalized' +} + +export function getConfirmedStatus( + statusResponse: Readonly<{ + value: readonly (SignatureStatus | null)[] + }> +): SignatureStatus | null { + const status = statusResponse.value[0] + if (status && isConfirmedCommitment(status.confirmationStatus)) { + return status + } + return null +} diff --git a/packages/sdk-provider-solana/src/utils/signatureStatus.unit.spec.ts b/packages/sdk-provider-solana/src/utils/signatureStatus.unit.spec.ts new file mode 100644 index 00000000..e186c496 --- /dev/null +++ b/packages/sdk-provider-solana/src/utils/signatureStatus.unit.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + getConfirmedStatus, + isConfirmedCommitment, + type SignatureStatus, +} from './signatureStatus.js' + +describe('isConfirmedCommitment', () => { + it('returns true for confirmed', () => { + expect(isConfirmedCommitment('confirmed')).toBe(true) + }) + + it('returns true for finalized', () => { + expect(isConfirmedCommitment('finalized')).toBe(true) + }) + + it('returns false for processed', () => { + expect(isConfirmedCommitment('processed')).toBe(false) + }) + + it('returns false for null', () => { + expect(isConfirmedCommitment(null)).toBe(false) + }) + + it('returns false for undefined', () => { + expect(isConfirmedCommitment(undefined)).toBe(false) + }) +}) + +const makeStatus = (confirmationStatus: string | null): SignatureStatus => ({ + slot: 100n, + confirmations: 10n, + err: null, + confirmationStatus: + confirmationStatus as SignatureStatus['confirmationStatus'], + status: { Ok: null }, +}) + +describe('getConfirmedStatus', () => { + it('returns status for confirmed transaction', () => { + const status = makeStatus('confirmed') + const result = getConfirmedStatus({ value: [status] }) + expect(result).toBe(status) + }) + + it('returns status for finalized transaction', () => { + const status = makeStatus('finalized') + const result = getConfirmedStatus({ value: [status] }) + expect(result).toBe(status) + }) + + it('returns null for processed transaction', () => { + const result = getConfirmedStatus({ value: [makeStatus('processed')] }) + expect(result).toBeNull() + }) + + it('returns null when status is null', () => { + const result = getConfirmedStatus({ value: [null] }) + expect(result).toBeNull() + }) + + it('returns null for empty value array', () => { + const result = getConfirmedStatus({ value: [] }) + expect(result).toBeNull() + }) +})