Skip to content
Merged
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
17 changes: 9 additions & 8 deletions app/src/hooks/useTransactionSigner.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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')
Expand Down
136 changes: 136 additions & 0 deletions app/src/lib/__tests__/sendWithRetry.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>() {
let resolve!: (value: T) => void
let reject!: (err: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}

function makeConnection(overrides: Partial<{
sendRawTransaction: ReturnType<typeof vi.fn>
confirmTransaction: ReturnType<typeof vi.fn>
}> = {}): 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<void>((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)
})
})
64 changes: 64 additions & 0 deletions app/src/lib/sendWithRetry.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

export async function sendAndConfirmWithRetry(
connection: Connection,
signedTx: Uint8Array,
blockhash: string,
lastValidBlockHeight: number,
deps: SendAndConfirmDeps = {},
): Promise<string> {
const interval = deps.resubmitIntervalMs ?? RESUBMIT_INTERVAL_MS
const sleep = deps.sleep ?? ((ms) => new Promise<void>((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(() => {})
}
}
Loading