Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 69 additions & 39 deletions packages/sdk-provider-solana/src/actions/sendAndConfirmBundle.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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) => {
Expand All @@ -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<boolean>
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
Expand All @@ -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
Expand All @@ -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],
}
}
}
}

Expand Down
115 changes: 65 additions & 50 deletions packages/sdk-provider-solana/src/actions/sendAndConfirmTransaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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<boolean>
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
) {
Expand All @@ -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(() => {})

@tomiiide tomiiide May 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we swallowing errors here. Won't this cause problems?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. The sending loop is fire-and-forget — confirmation is handled by pollingPromise, so errors here don't affect the result. Added a comment explaining this.

Also wrapped the isBlockhashValid/getBlockHeight calls in try-catch inside the loop. Previously, if those RPC calls threw, the loop would die silently while blockhashValid stayed true. Now transient failures are handled and the loop continues.


// 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ export class SolanaStandardWaitForTransactionTask extends BaseStepExecutionTask
connection
.simulateTransaction(encodedTransaction, {
commitment: 'confirmed',
replaceRecentBlockhash: true,
encoding: 'base64',
})
.send()
Expand Down
22 changes: 22 additions & 0 deletions packages/sdk-provider-solana/src/utils/extractBlockhash.ts
Comment thread
effie-ms marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {
type Blockhash,
getCompiledTransactionMessageDecoder,
getTransactionLifetimeConstraintFromCompiledTransactionMessage,
type Transaction,
} from '@solana/kit'

const decoder = getCompiledTransactionMessageDecoder()

export async function extractBlockhash(
signedTransaction: Transaction
): Promise<Blockhash | null> {
const compiledMessage = decoder.decode(signedTransaction.messageBytes)
const constraint =
await getTransactionLifetimeConstraintFromCompiledTransactionMessage(
compiledMessage
)
if ('blockhash' in constraint) {
return constraint.blockhash
}
return null
}
Loading