From 5aec8c3a7778c7518091a054dbbd1dfb0b28039b Mon Sep 17 00:00:00 2001 From: pharwasz Date: Sat, 17 Oct 2026 07:53:44 +0100 Subject: [PATCH] fix: add end-to-end submission idempotency (#213) --- docs/guides/offline-queue.md | 34 ++- e2e/offline-queue.spec.ts | 107 +++++++++ src/components/OfflineQueuePanel.tsx | 18 +- src/components/StellarSend.tsx | 151 +++++++++++-- src/lib/offlineQueue.test.ts | 12 + src/lib/offlineQueue.ts | 29 ++- src/lib/submissionIdempotency.test.ts | 145 ++++++++++++ src/lib/submissionIdempotency.ts | 307 ++++++++++++++++++++++++++ src/stores/offlineQueueStore.ts | 23 +- 9 files changed, 781 insertions(+), 45 deletions(-) create mode 100644 src/lib/submissionIdempotency.test.ts create mode 100644 src/lib/submissionIdempotency.ts diff --git a/docs/guides/offline-queue.md b/docs/guides/offline-queue.md index ee2303a..a4f43b8 100644 --- a/docs/guides/offline-queue.md +++ b/docs/guides/offline-queue.md @@ -1,4 +1,4 @@ -# Offline queue and reconciliation policy (Wave 9, #184) +# Offline queue, reconciliation, and submission idempotency (Wave 9, #184; Wave 10, #213) The PWA shell caches assets (see `src/sw/app-sw.ts`), but transaction and scanner work used to have no documented offline policy: a user who lost @@ -8,6 +8,7 @@ This document is the policy. The implementation lives in: - `src/lib/offlineQueue.ts` — queue core: classification, validation, persistence - `src/lib/offlineReconcile.ts` — reconnect reconciliation engine - `src/stores/offlineQueueStore.ts` — persisted zustand store + explicit broadcast +- `src/lib/submissionIdempotency.ts` — persistent operation IDs and serialized Stellar submission - `src/hooks/useOfflineReconcile.ts` — `online` / service-worker triggers - `src/components/OfflineQueuePanel.tsx` — review UI (Send page) - `src/components/OfflineBanner.tsx` — queue-aware connectivity banner @@ -57,7 +58,26 @@ Captured at the source, while offline: The stealth-announcement scanner needs no queue: it resumes from the persisted `lastScannedLedger` checkpoint on every run. -## 3. Reconcile on reconnect with clear conflict handling +## 3. Reuse one operation ID for direct sends and queue retries + +Every Stellar payment intent receives an operation ID before it can be +submitted. The ID travels with the queued intent and into the review URL. +Before the first Horizon broadcast, the signed envelope and its transaction +hash are persisted under `wraith-submission-idempotency-v1`. A retry after a +reload or a lost response reuses that exact envelope; an operation ID cannot +be rebound to a different transaction. + +Broadcasts use the browser Web Locks API and the persisted operation record +to serialize same-operation submissions across tabs. If a response is lost, +the explicit queue retry may submit the same envelope again, but it cannot +create a different Stellar transaction: the signed envelope and transaction +hash are unchanged, and the network deduplicates that transaction hash. +Browsers without cross-tab locking support fail closed rather than claiming +retry safety. A failed direct broadcast is saved as a signed queue entry when +the queue has capacity, with the original operation ID, so queue replay shares +the same idempotency record. + +## 4. Reconcile on reconnect with clear conflict handling Triggers (first one wins; reconciliation is a no-op while offline): @@ -86,12 +106,16 @@ a resolution action (review pre-filled in Send, or discard). Terminal states (`done`, `conflict`, `failed`) are never re-processed; "Clear finished" removes them. -## 4. Coverage +## 5. Coverage - Unit: `src/lib/offlineQueue.test.ts` (classification, validation, persistence round-trip, dedupe, cap pruning) and `src/lib/offlineReconcile.test.ts` (intent/scan outcomes, stale - envelopes, attempt budget, offline no-op, throwing consumers). + envelopes, attempt budget, offline no-op, throwing consumers), plus + `src/lib/submissionIdempotency.test.ts` (stable operation IDs, concurrent + submissions, delayed retry, and transaction binding). - E2E: `e2e/offline-queue.spec.ts` (banner + queue count while offline, real offline send → queued intent, reconnect reconciliation incl. - expired-intent conflict, signed envelopes never auto-broadcast). + expired-intent conflict, signed envelopes never auto-broadcast, + concurrent cross-tab broadcast, and same-envelope retry after a lost + response). diff --git a/e2e/offline-queue.spec.ts b/e2e/offline-queue.spec.ts index 92719b6..68b7b81 100644 --- a/e2e/offline-queue.spec.ts +++ b/e2e/offline-queue.spec.ts @@ -137,6 +137,7 @@ test.describe('Offline queue and reconciliation', () => { // Reviewing prefills the Send form from the queued intent. await entry.getByRole('button', { name: 'Review in Send' }).click(); await expect(page).toHaveURL(/to=/); + await expect(page).toHaveURL(/operationId=/); }); test('expired intents conflict and signed envelopes never auto-broadcast', async ({ @@ -214,4 +215,110 @@ test.describe('Offline queue and reconciliation', () => { await panel.getByRole('button', { name: 'Clear finished' }).click(); await expect(panel.getByTestId('offline-queue-entry')).toHaveCount(0); }); + + test('concurrent queue broadcasts in two tabs submit only once', async ({ page, context }) => { + let broadcastPosts = 0; + await context.route('https://horizon-testnet.stellar.org/transactions', async (route) => { + if (route.request().method() === 'POST') { + broadcastPosts += 1; + await new Promise((resolve) => setTimeout(resolve, 250)); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ hash: 'shared_operation_hash', ledger: 101 }), + }); + } else { + await route.continue(); + } + }); + await seedQueue(context, [ + seedEntry({ + id: 'shared-operation-id', + kind: 'signed-submit', + payload: { + chain: 'stellar', + operationId: 'shared-operation-id', + txHash: 'shared_operation_hash', + signedXdr: 'AAAA', + }, + }), + ]); + + const secondPage = await context.newPage(); + await Promise.all([page.goto('/send'), secondPage.goto('/send')]); + const firstEntry = page.getByTestId('offline-queue-entry'); + const secondEntry = secondPage.getByTestId('offline-queue-entry'); + await expect(firstEntry.getByRole('button', { name: 'Broadcast now' })).toBeVisible(); + await expect(secondEntry.getByRole('button', { name: 'Broadcast now' })).toBeVisible(); + + await Promise.all([ + (async () => { + await firstEntry.getByRole('button', { name: 'Broadcast now' }).click(); + await firstEntry.getByRole('button', { name: 'Confirm broadcast' }).click(); + })(), + (async () => { + await secondEntry.getByRole('button', { name: 'Broadcast now' }).click(); + await secondEntry.getByRole('button', { name: 'Confirm broadcast' }).click(); + })(), + ]); + + await expect(firstEntry).toContainText('Done'); + await expect(secondEntry).toContainText('Done'); + expect(broadcastPosts).toBe(1); + await secondPage.close(); + }); + + test('delayed retry reuses the original signed envelope after a lost response', async ({ + page, + context, + }) => { + const acceptedEnvelopes = new Set(); + const submittedEnvelopes: string[] = []; + await context.route('https://horizon-testnet.stellar.org/transactions', async (route) => { + if (route.request().method() !== 'POST') { + await route.continue(); + return; + } + const body = route.request().postData() ?? ''; + submittedEnvelopes.push(body); + const envelope = new URLSearchParams(body).get('tx') ?? ''; + acceptedEnvelopes.add(envelope); + if (submittedEnvelopes.length === 1) { + // Simulate Horizon accepting the transaction while its response is lost. + await route.abort('failed'); + return; + } + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ hash: 'delayed_retry_hash', ledger: 102 }), + }); + }); + await seedQueue(context, [ + seedEntry({ + id: 'delayed-operation-id', + kind: 'signed-submit', + payload: { + chain: 'stellar', + operationId: 'delayed-operation-id', + txHash: 'delayed_retry_hash', + signedXdr: 'AAAA', + }, + }), + ]); + await page.goto('/send'); + + const entry = page.getByTestId('offline-queue-entry'); + await expect(entry.getByRole('button', { name: 'Broadcast now' })).toBeVisible(); + await entry.getByRole('button', { name: 'Broadcast now' }).click(); + await entry.getByRole('button', { name: 'Confirm broadcast' }).click(); + await expect(entry.getByText('Conflict')).toBeVisible(); + await entry.getByRole('button', { name: 'Retry broadcast' }).click(); + await entry.getByRole('button', { name: 'Confirm broadcast' }).click(); + + await expect(entry.getByText('Done')).toBeVisible(); + expect(submittedEnvelopes).toHaveLength(2); + expect(submittedEnvelopes[0]).toBe(submittedEnvelopes[1]); + expect(acceptedEnvelopes.size).toBe(1); + }); }); diff --git a/src/components/OfflineQueuePanel.tsx b/src/components/OfflineQueuePanel.tsx index 1b3132f..eabb5d6 100644 --- a/src/components/OfflineQueuePanel.tsx +++ b/src/components/OfflineQueuePanel.tsx @@ -43,7 +43,10 @@ function intentLink(payload: PaymentIntentPayload): string | null { if (payload.chain !== 'stellar') return null; const params = new URLSearchParams({ to: payload.recipient }); if (payload.amount) params.set('amount', payload.amount); + if (payload.asset) params.set('asset', payload.asset); if (payload.memo) params.set('memo', payload.memo); + if (payload.expiresAt != null) params.set('exp', String(Math.floor(payload.expiresAt / 1000))); + if (payload.operationId) params.set('operationId', payload.operationId); return `/send?${params.toString()}`; } @@ -81,7 +84,10 @@ function EntryActions({ entry }: { entry: OfflineQueueEntry }) { } if (entry.kind === 'signed-submit') { - if (entry.status !== 'needs-review') { + const canBroadcast = + entry.status === 'needs-review' || + (entry.status === 'conflict' && entry.conflictReason === 'broadcast-rejected'); + if (!canBroadcast) { return ( {!confirming && (