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
34 changes: 29 additions & 5 deletions docs/guides/offline-queue.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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):

Expand Down Expand Up @@ -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).
107 changes: 107 additions & 0 deletions e2e/offline-queue.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ({
Expand Down Expand Up @@ -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<string>();
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);
});
});
18 changes: 15 additions & 3 deletions src/components/OfflineQueuePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()}`;
}

Expand Down Expand Up @@ -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 (
<button
type="button"
Expand Down Expand Up @@ -111,7 +117,13 @@ function EntryActions({ entry }: { entry: OfflineQueueEntry }) {
}}
className="h-9 bg-primary px-3 font-heading text-[11px] font-semibold uppercase tracking-widest text-surface disabled:opacity-50"
>
{busy ? 'Broadcasting…' : confirming ? 'Confirm broadcast' : 'Broadcast now'}
{busy
? 'Broadcasting…'
: confirming
? 'Confirm broadcast'
: entry.status === 'conflict'
? 'Retry broadcast'
: 'Broadcast now'}
</button>
{!confirming && (
<button
Expand Down Expand Up @@ -222,7 +234,7 @@ export function OfflineQueuePanel() {
</div>
<p className="mt-1 font-body text-xs text-on-surface-variant">
{entry.kind === 'signed-submit'
? 'Signed while offline — broadcast only happens here, with your confirmation.'
? 'Signed envelope — broadcast only happens after confirmation; retries reuse its operation ID.'
: entry.kind === 'payment-intent'
? 'Unsigned intent — review and sign in Send after reconnect.'
: 'Scan captured offline.'}
Expand Down
Loading
Loading