-
Notifications
You must be signed in to change notification settings - Fork 100
fix(sdk-provider-solana): prevent false TransactionExpired for confirmed txs #380
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
10197a3
7f4fa32
2d6f88f
b80d030
6a09fea
df3b7f2
405cc28
1e9cb63
36952d1
d212541
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,6 +7,7 @@ import { | |
| type TransactionError, | ||
| } from '@solana/kit' | ||
| import { getSolanaRpcs } from '../rpc/registry.js' | ||
| import { extractBlockhash } from '../utils/extractBlockhash.js' | ||
|
|
||
| type SignatureStatus = { | ||
| slot: bigint | ||
|
|
@@ -21,6 +22,22 @@ type ConfirmedTransactionResult = { | |
| txSignature: string | ||
| } | ||
|
|
||
| function getConfirmedStatus( | ||
| statusResponse: Readonly<{ | ||
| value: readonly (SignatureStatus | null)[] | ||
| }> | ||
| ): SignatureStatus | null { | ||
| const status = statusResponse.value[0] | ||
| if ( | ||
| status && | ||
| (status.confirmationStatus === 'confirmed' || | ||
| status.confirmationStatus === 'finalized') | ||
| ) { | ||
| return status | ||
| } | ||
| return null | ||
| } | ||
|
|
||
| /** | ||
| * Sends a Solana transaction to multiple RPC endpoints and returns the confirmation | ||
| * as soon as any of them confirm the transaction. | ||
|
|
@@ -35,13 +52,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 = 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 | ||
|
|
@@ -66,51 +84,52 @@ 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 | ||
| let blockhashValid = true | ||
|
|
||
| // For durable nonce txs, fall back to block-height-based timeout | ||
| let expiryBlockHeight: bigint | undefined | ||
| if (!txBlockhash) { | ||
| const { value: blockhashResult } = await rpc | ||
| .getLatestBlockhash({ commitment: 'confirmed' }) | ||
| .send() | ||
| expiryBlockHeight = blockhashResult.lastValidBlockHeight | ||
| } | ||
|
|
||
| const pollingPromise = (async () => { | ||
| while ( | ||
| blockHeight < blockhashResult.lastValidBlockHeight && | ||
| !abortController.signal.aborted | ||
| ) { | ||
| const statusResponse = await rpc | ||
| .getSignatureStatuses([txSignature]) | ||
| .send() | ||
|
|
||
| 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 | ||
| 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 | ||
| ) { | ||
|
|
@@ -124,18 +143,25 @@ export async function sendAndConfirmTransaction( | |
|
|
||
| await sleep(1000) | ||
| if (!abortController.signal.aborted) { | ||
| blockHeight = await rpc | ||
| .getBlockHeight({ | ||
| commitment: 'confirmed', | ||
| }) | ||
| .send() | ||
| if (txBlockhash) { | ||
| const { value: isValid } = await rpc | ||
| .isBlockhashValid(txBlockhash, { | ||
| commitment: 'confirmed', | ||
| }) | ||
| .send() | ||
| blockhashValid = isValid | ||
| } else { | ||
| const blockHeight = await rpc | ||
| .getBlockHeight({ commitment: 'confirmed' }) | ||
| .send() | ||
| blockhashValid = blockHeight < expiryBlockHeight! | ||
| } | ||
| } | ||
| } | ||
| return null | ||
| })() | ||
| sendingPromise.catch(() => {}) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why are we swallowing errors here. Won't this cause problems?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Also wrapped the |
||
|
|
||
| // 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) { | ||
|
|
||
|
effie-ms marked this conversation as resolved.
|
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { | ||
| type Blockhash, | ||
| getCompiledTransactionMessageDecoder, | ||
| isTransactionWithDurableNonceLifetime, | ||
| type Transaction, | ||
| } from '@solana/kit' | ||
|
|
||
| const decoder = getCompiledTransactionMessageDecoder() | ||
|
|
||
| export function extractBlockhash( | ||
| signedTransaction: Transaction | ||
| ): Blockhash | null { | ||
| if (isTransactionWithDurableNonceLifetime(signedTransaction)) { | ||
| return null | ||
| } | ||
| const compiledMessage = decoder.decode(signedTransaction.messageBytes) | ||
| return compiledMessage.lifetimeToken as Blockhash | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we move this into it's own util function and reuse in
sendAndConfirmBundle?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done. Moved
getConfirmedStatusandSignatureStatusintoutils/getConfirmedStatus.ts. Also addedisConfirmedCommitment()to replace the inline checks in the bundle path.