Skip to content

fix: refresh Solana blockhash before signing and expire unseen pending txs(OK-63381) - #13522

Draft
weatherstar wants to merge 1 commit into
xfrom
fix/sol-dapp-blockhash-expiry-ok-63381
Draft

weatherstar wants to merge 1 commit into
xfrom
fix/sol-dapp-blockhash-expiry-ok-63381

Conversation

@weatherstar

Copy link
Copy Markdown
Contributor

Summary

  • Re-stamp the Solana blockhash right before signing for wallet-broadcast txs (dApp and internal), skipping multi-signer and durable-nonce txs and leaving dApp sign-only requests byte-identical.
  • Align the custom RPC broadcast with the proxy: preflightCommitment: 'confirmed', the same bounded retry on "Blockhash not found", and a "Transaction expired" message instead of the raw node text.
  • Mark Solana pending txs the chain has never seen after 3 minutes as Dropped so they leave the pending bucket, and show the failure badge on dropped history rows.

Intent & Context

Slack report (Windows desktop, hardware wallet, custom RPC solana-rpc.publicnode.com): a Kamino USDG transfer via signAndSendTransaction failed after hardware confirmation with Error JSON RPC response: Transaction simulation failed: Blockhash not found. Some attempts got a txid and then sat in local history as "Confirming" forever with no on-chain trace, until the user cleared pending manually. Jira OK-63381.

User log timeline: dApp request 22:33:54 → solSignTransaction starts 22:34:05 (PIN / passphrase / device confirm) → signature returned 22:34:51 → immediate broadcast rejected. From the dApp's blockhash fetch to broadcast was ≥ 57 s, past Solana's ~150-slot (60–90 s) validity window. A retry with the device already unlocked signed in seconds and landed.

Root Cause

  • The dApp path never touched the blockhash: _buildUnsignedTxFromEncodedTx only refreshed it for OKX swap, refreshUnsignedTxBeforeBatchSign only ran for the 2nd+ tx of a batch. Once hardware interaction exceeded the window the signed tx could not be repaired.
  • ClientCustomRpcSol.broadcastTransaction sent only { encoding: 'base64' }, so preflight ran at the Solana default finalized, which lags the tip by ~30 slots. A nearly expired blockhash could pass preflight there, return a txid, and then be dropped at the tip. The server proxy uses confirmed.
  • checkShouldRetryBroadcastTx matched only the proxy business code 40028; the custom RPC path throws a plain JsonRPCResponseError without a code, so it got zero retries.
  • The Solana vault never overrode pending-tx expiry; a pending record only flipped when the server tx detail returned Success/Failed, which never happens for a tx that was never included.

Design Decisions

  • New VaultBase.refreshUnsignedTxBeforeSign hook called in ServiceSend.signTransaction after the password prompt and before withHardwareProcessing, i.e. as late as possible. It runs only when signOnly is false: sign-only dApp requests (signTransaction / signAllTransactions) return the serialized tx to the dApp, which may co-sign or verify it, so their payload stays untouched.
  • Skip rules: any tx with more than one required signature (a co-signer's existing signature would be invalidated) and any durable-nonce tx (first instruction = System AdvanceNonceAccount, or nonceInfo set), where recentBlockhash is the nonce value. Detection is done on the raw compiled instruction for both legacy and v0 messages, so it needs no lookup-table resolution.
  • Blockhash fetch failure falls back to the original tx rather than blocking signing; the broadcast still reports the real outcome.
  • Custom RPC retry parity: the blockhash comes from the proxy node while the broadcast goes to the user's node, so a node lagging a few slots transiently rejects a fresh blockhash. Matching the message text gives the custom path the same bounded retry as 40028 on the proxy.
  • Dropped detection uses getSignatureStatuses (with searchTransactionHistory) and treats only an explicit null for a tx whose local createdAt is ≥ 3 minutes old as dropped; undefined, length mismatch, or an RPC error keeps everything pending. Dropped txs are moved to the confirmed bucket with status: Dropped, isFinal: true through the existing confirmedTxs flow, so the pending-changed detection and LocalPendingTxConfirmed event fire as usual (the DeFi consumer already ignores non-Confirmed statuses).
  • History badge: TxActionCommon now shows the critical "Failed" badge for Dropped too. EVM replaced-prev txs (the only other Dropped source) are hidden from lists by replacedNextId, so this only affects the new Solana case.
  • Expired copy is hardcoded (Transaction expired, please try again.) with a TODO(OK-63381); the Lokalise key will replace it later.
  • Not done on purpose: passing dApp sendOptions (skipPreflight / maxRetries) through to the custom RPC. It needs the options threaded through ~8 UI hops, and honoring skipPreflight: true would reintroduce the silent-drop case this PR removes.

Changes Detail

  • packages/kit-bg/src/vaults/base/VaultBase.ts: refreshUnsignedTxBeforeSign and getDroppedPendingTxs no-op hooks.
  • packages/kit-bg/src/services/ServiceSend.ts: call the refresh hook in signTransaction for non-sign-only requests.
  • packages/kit-bg/src/vaults/impls/sol/utils.ts: isDurableNonceSolTx, canRefreshSolTxBlockhash, serializeSolTx, replaceSolTxRecentBlockhash.
  • packages/kit-bg/src/vaults/impls/sol/Vault.ts: hook overrides, broadcast error normalization for both paths, retry check by code or message, 3-minute dropped timeout.
  • packages/kit-bg/src/vaults/impls/sol/sdkSol/ClientCustomRpcSol.ts: typed options with preflightCommitment: 'confirmed' default.
  • packages/kit-bg/src/services/ServiceHistory.ts: after pending detail polling, ask the vaults for dropped txs and move them out of the pending bucket.
  • packages/kit/src/components/TxAction/TxActionCommon.tsx: failure badge for Dropped.
  • Tests: new sol/Vault.test.ts (14 cases) and 2 new cases in ServiceSend.broadcastDeadline.test.ts.

Risk Assessment

  • Risk Level: Medium
  • Affected Platforms: Extension / Mobile / Desktop / Web (all Solana signing paths)
  • Risk Areas:
    • The refresh changes the message every Solana wallet-broadcast tx signs, including swaps and internal sends. A dApp that confirms with its own original lastValidBlockHeight may report "expired" while the tx actually lands 1–2 s later; this is the trade-off against a guaranteed failure, and a blind user retry could double-send in that window.
    • ServiceSend.signTransaction now awaits one extra RPC round trip before hardware signing on Solana.
    • ServiceHistory.fetchAccountHistory runs one extra getSignatureStatuses call per account only when aged Solana pending txs exist.

Test plan

  • yarn jest packages/kit-bg/src/vaults/impls/sol/Vault.test.ts packages/kit-bg/src/services/ServiceSend.broadcastDeadline.test.ts
  • yarn agent:check --profile commit
  • Hardware wallet (with passphrase): Kamino / Jupiter signAndSendTransaction, take 60–90 s on the device before confirming; tx lands on both default RPC and a custom RPC, legacy and versioned.
  • dApp signTransaction (sign-only) returns the tx with the dApp's original blockhash unchanged.
  • Durable-nonce and multi-signer dApp txs are signed as-is.
  • Broadcast rejected with an expired blockhash shows "Transaction expired, please try again." instead of the raw RPC text.
  • A pending Solana tx that never landed flips to the failure badge within ~3–4 minutes without manual clearing; a landed tx confirms normally.
  • Internal Solana send and swap still succeed after waiting on the confirm page.

…g txs (OK-63381)

Hardware PIN/passphrase/confirm can outlive the ~60-90 s validity of the
blockhash a dApp (or the server) baked into a Solana tx, so the broadcast
failed with "Blockhash not found" and any txid that slipped through a
lagging custom RPC preflight stayed "confirming" forever.

- Add a vault hook that re-stamps short-lived tx fields right before signing
  for wallet-broadcast txs; the Solana vault refreshes the blockhash for
  single-signer, non-durable-nonce txs and leaves dApp sign-only txs intact.
- Send custom RPC broadcasts with preflightCommitment 'confirmed' like the
  proxy, and retry the plain JSON-RPC "Blockhash not found" answer the same
  way as proxy code 40028.
- Surface a "Transaction expired" message instead of the raw node text once
  the retries are exhausted (hardcoded until the i18n key exists).
- Mark Solana pending txs that the chain has never seen after three minutes
  as Dropped so they leave the pending bucket, and show the failure badge on
  dropped history rows.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant