From 4e8250c62b7c38047b6ee4fd60254048577100d9 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 23 May 2026 13:54:45 +0700 Subject: [PATCH] =?UTF-8?q?fix(app):=20resubmit=20signed=20tx=20until=20co?= =?UTF-8?q?nfirmed=20=E2=80=94=20closes=20#291?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public Solana RPCs (api.devnet.solana.com, api.mainnet-beta.solana.com) drop transactions silently under load. The current broadcast → confirmTransaction flow doesn't resubmit if the first send was lost, so we get spurious "block height exceeded" failures even when the user signed promptly. This adds an aggressive resubmit loop that re-broadcasts the signed bytes every 2s (idempotent on Solana RPCs — duplicate sends return the same signature) while confirmation polls in parallel. First confirmation wins; the loop stops in a finally{}. - new app/src/lib/sendWithRetry.ts (helper + 5 unit tests) - app/src/hooks/useTransactionSigner.ts wired to use it - existing SignTxCard tests still green (hook external API unchanged) - app test suite: 572 → 577 - full workspace typecheck clean --- app/src/hooks/useTransactionSigner.ts | 17 +-- app/src/lib/__tests__/sendWithRetry.test.ts | 136 ++++++++++++++++++++ app/src/lib/sendWithRetry.ts | 64 +++++++++ 3 files changed, 209 insertions(+), 8 deletions(-) create mode 100644 app/src/lib/__tests__/sendWithRetry.test.ts create mode 100644 app/src/lib/sendWithRetry.ts diff --git a/app/src/hooks/useTransactionSigner.ts b/app/src/hooks/useTransactionSigner.ts index cc41141f..f2c45f04 100644 --- a/app/src/hooks/useTransactionSigner.ts +++ b/app/src/hooks/useTransactionSigner.ts @@ -1,6 +1,7 @@ import { useConnection, useWallet } from '@solana/wallet-adapter-react' import { Transaction, VersionedTransaction } from '@solana/web3.js' import { useCallback, useState } from 'react' +import { sendAndConfirmWithRetry } from '../lib/sendWithRetry' export type SignStatus = 'idle' | 'signing' | 'broadcasting' | 'confirmed' | 'error' @@ -57,14 +58,14 @@ export function useTransactionSigner() { setStatus('broadcasting') - const signature = await connection.sendRawTransaction(signed.serialize(), { - skipPreflight: true, - maxRetries: 3, - }) - - await connection.confirmTransaction( - { signature, blockhash, lastValidBlockHeight }, - 'confirmed', + // sendAndConfirmWithRetry resubmits the signed bytes every 2s while + // confirmation polls — defends against public RPC drops/rate-limits + // that surface as spurious "block height exceeded" (sipher#291). + const signature = await sendAndConfirmWithRetry( + connection, + signed.serialize(), + blockhash, + lastValidBlockHeight, ) setStatus('confirmed') diff --git a/app/src/lib/__tests__/sendWithRetry.test.ts b/app/src/lib/__tests__/sendWithRetry.test.ts new file mode 100644 index 00000000..2f1added --- /dev/null +++ b/app/src/lib/__tests__/sendWithRetry.test.ts @@ -0,0 +1,136 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { Connection } from '@solana/web3.js' +import { sendAndConfirmWithRetry } from '../sendWithRetry' + +function deferred() { + let resolve!: (value: T) => void + let reject!: (err: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function makeConnection(overrides: Partial<{ + sendRawTransaction: ReturnType + confirmTransaction: ReturnType +}> = {}): Connection { + const send = overrides.sendRawTransaction ?? vi.fn().mockResolvedValue('SIG_FAKE') + const confirm = overrides.confirmTransaction ?? vi.fn().mockResolvedValue({ value: { err: null } }) + return { + sendRawTransaction: send, + confirmTransaction: confirm, + } as unknown as Connection +} + +describe('sendAndConfirmWithRetry', () => { + const SIGNED = new Uint8Array([1, 2, 3]) + const BLOCKHASH = 'fake-blockhash' + const LAST_VALID = 12345 + const FAST_INTERVAL = 10 + + beforeEach(() => { + vi.useRealTimers() + }) + + it('returns the signature on first-attempt confirmation without resubmits', async () => { + const send = vi.fn().mockResolvedValue('SIG_OK') + const confirm = vi.fn().mockResolvedValue({ value: { err: null } }) + const conn = makeConnection({ sendRawTransaction: send, confirmTransaction: confirm }) + + const result = await sendAndConfirmWithRetry(conn, SIGNED, BLOCKHASH, LAST_VALID, { + resubmitIntervalMs: FAST_INTERVAL, + }) + + expect(result).toBe('SIG_OK') + expect(send).toHaveBeenCalledTimes(1) + expect(send).toHaveBeenCalledWith(SIGNED, { skipPreflight: true, maxRetries: 0 }) + expect(confirm).toHaveBeenCalledWith( + { signature: 'SIG_OK', blockhash: BLOCKHASH, lastValidBlockHeight: LAST_VALID }, + 'confirmed', + ) + }) + + it('resubmits in the background while confirmation is pending', async () => { + const send = vi.fn().mockResolvedValue('SIG_RESEND') + const confirmDeferred = deferred<{ value: { err: null } }>() + const confirm = vi.fn().mockReturnValue(confirmDeferred.promise) + const conn = makeConnection({ sendRawTransaction: send, confirmTransaction: confirm }) + + const sleep = vi.fn().mockImplementation( + (ms: number) => new Promise((r) => setTimeout(r, ms)), + ) + + const inFlight = sendAndConfirmWithRetry(conn, SIGNED, BLOCKHASH, LAST_VALID, { + resubmitIntervalMs: 5, + sleep, + }) + + // Wait long enough for ~3 resubmit ticks (5ms each + scheduler jitter) + await new Promise((r) => setTimeout(r, 50)) + + expect(send.mock.calls.length).toBeGreaterThan(1) + + confirmDeferred.resolve({ value: { err: null } }) + const result = await inFlight + expect(result).toBe('SIG_RESEND') + + const sendsBeforeStop = send.mock.calls.length + await new Promise((r) => setTimeout(r, 30)) + expect(send.mock.calls.length).toBe(sendsBeforeStop) + }) + + it('swallows resubmit errors without aborting the confirm flow', async () => { + let callCount = 0 + const send = vi.fn().mockImplementation(() => { + callCount += 1 + if (callCount === 1) return Promise.resolve('SIG_TRANSIENT') + return Promise.reject(new Error('rate limited')) + }) + const confirmDeferred = deferred<{ value: { err: null } }>() + const confirm = vi.fn().mockReturnValue(confirmDeferred.promise) + const conn = makeConnection({ sendRawTransaction: send, confirmTransaction: confirm }) + + const inFlight = sendAndConfirmWithRetry(conn, SIGNED, BLOCKHASH, LAST_VALID, { + resubmitIntervalMs: 5, + }) + + await new Promise((r) => setTimeout(r, 30)) + expect(send.mock.calls.length).toBeGreaterThan(1) + + confirmDeferred.resolve({ value: { err: null } }) + await expect(inFlight).resolves.toBe('SIG_TRANSIENT') + }) + + it('propagates the first-send error without entering the resubmit loop', async () => { + const send = vi.fn().mockRejectedValue(new Error('wallet not connected')) + const confirm = vi.fn() + const conn = makeConnection({ sendRawTransaction: send, confirmTransaction: confirm }) + + await expect( + sendAndConfirmWithRetry(conn, SIGNED, BLOCKHASH, LAST_VALID, { + resubmitIntervalMs: FAST_INTERVAL, + }), + ).rejects.toThrow('wallet not connected') + + expect(send).toHaveBeenCalledTimes(1) + expect(confirm).not.toHaveBeenCalled() + }) + + it('propagates "block height exceeded" from confirmTransaction and stops resubmits', async () => { + const send = vi.fn().mockResolvedValue('SIG_EXPIRED') + const confirm = vi.fn().mockRejectedValue(new Error('block height exceeded')) + const conn = makeConnection({ sendRawTransaction: send, confirmTransaction: confirm }) + + await expect( + sendAndConfirmWithRetry(conn, SIGNED, BLOCKHASH, LAST_VALID, { + resubmitIntervalMs: 5, + }), + ).rejects.toThrow('block height exceeded') + + const sendsAtFailure = send.mock.calls.length + await new Promise((r) => setTimeout(r, 30)) + expect(send.mock.calls.length).toBe(sendsAtFailure) + }) +}) diff --git a/app/src/lib/sendWithRetry.ts b/app/src/lib/sendWithRetry.ts new file mode 100644 index 00000000..611bdab0 --- /dev/null +++ b/app/src/lib/sendWithRetry.ts @@ -0,0 +1,64 @@ +import type { Connection } from '@solana/web3.js' + +const RESUBMIT_INTERVAL_MS = 2000 + +/** + * Send a signed transaction and aggressively resubmit until confirmed or expired. + * + * Public Solana RPCs (api.devnet.solana.com, api.mainnet-beta.solana.com) are + * rate-limited and drop transactions silently under load. The default + * sendRawTransaction + confirmTransaction flow waits up to ~60-90s for + * confirmation but does NOT resubmit if the first send was dropped — leading + * to spurious "block height exceeded" errors when the tx never actually landed. + * + * This helper resubmits the same signed bytes every 2s in the background + * (idempotent: Solana RPCs return the same signature for duplicate sends) + * while polling for confirmation. First confirmation wins; the loop stops. + * + * See sip-protocol/sipher#291 for the failure mode this addresses. + * + * Exported for testing; injected interval/sleep keeps tests deterministic. + */ +export interface SendAndConfirmDeps { + /** Delay between background resubmits. Override in tests for speed. */ + resubmitIntervalMs?: number + /** Awaitable sleep. Override in tests with fake timers. */ + sleep?: (ms: number) => Promise +} + +export async function sendAndConfirmWithRetry( + connection: Connection, + signedTx: Uint8Array, + blockhash: string, + lastValidBlockHeight: number, + deps: SendAndConfirmDeps = {}, +): Promise { + const interval = deps.resubmitIntervalMs ?? RESUBMIT_INTERVAL_MS + const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))) + + const submitOnce = () => + connection.sendRawTransaction(signedTx, { skipPreflight: true, maxRetries: 0 }) + + const signature = await submitOnce() + + let stopped = false + const resubmit = async () => { + while (!stopped) { + await sleep(interval) + if (stopped) return + submitOnce().catch(() => {}) + } + } + const resubmitPromise = resubmit() + + try { + await connection.confirmTransaction( + { signature, blockhash, lastValidBlockHeight }, + 'confirmed', + ) + return signature + } finally { + stopped = true + await resubmitPromise.catch(() => {}) + } +}