From ab50272479c4ea3774b562cd469b12f5a2a63dbc Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:29:08 -0400 Subject: [PATCH 1/5] feat(send): prevent duplicate transfers across retries and navigation Bind an idempotency key to the exact transfer payload, persist only a safe operation reference in sessionStorage, and return the prior transfer on repeated submits with the same key. Refresh restores in-flight status; an edited payload mintes a new intent. Closes #277 --- src/hooks/useTransfers.js | 11 +- src/pages/SendMoney.jsx | 50 +++++++- src/services/api.js | 19 ++- src/services/contracts/transfer.js | 2 + src/utils/transferIntent.js | 118 ++++++++++++++++++ .../send-money-idempotency.test.jsx | 112 +++++++++++++++++ test/unit/create-transfer-idempotency.test.js | 46 +++++++ test/unit/transfer-intent.test.js | 78 ++++++++++++ 8 files changed, 430 insertions(+), 6 deletions(-) create mode 100644 src/utils/transferIntent.js create mode 100644 test/integration/send-money-idempotency.test.jsx create mode 100644 test/unit/create-transfer-idempotency.test.js create mode 100644 test/unit/transfer-intent.test.js diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..1c65e4a 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -47,10 +47,18 @@ export function useTransfers() { const addTransfer = useCallback(async (payload) => { const created = await createTransfer(payload); - setTransfers((prev) => [created, ...prev]); + setTransfers((prev) => { + if (prev.some((t) => t.id === created.id)) return prev; + return [created, ...prev]; + }); return created; }, []); + const getTransferById = useCallback( + (id) => transfers.find((t) => t.id === id) ?? null, + [transfers], + ); + // Existing consumers use reload for both pull-to-refresh and the error-state // retry action. Withhold it only while a non-retryable error is displayed. const safeReload = error && !retryable ? undefined : reload; @@ -62,5 +70,6 @@ export function useTransfers() { retryable, reload: safeReload, addTransfer, + getTransferById, }; } diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index c1e03f0..0ae6888 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -17,6 +17,12 @@ import { } from '../utils/validate.js'; import { useWallet } from '../hooks/useWallet.js'; import { useTransfers } from '../hooks/useTransfers.js'; +import { + fingerprintTransferPayload, + idempotencyKeyFor, + saveTransferOperation, + getLatestInFlightOperation, +} from '../utils/transferIntent.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; import { useDebouncedValue } from '../hooks/useDebouncedValue.js'; @@ -36,7 +42,7 @@ import './SendMoney.css'; export default function SendMoney() { const navigate = useNavigate(); const { wallet, isConnected, connect } = useWallet(); - const { addTransfer } = useTransfers(); + const { addTransfer, getTransferById, transfers } = useTransfers(); const { locale } = useApp(); const isOnline = useOnlineStatus(); @@ -48,6 +54,8 @@ export default function SendMoney() { const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const submissionLock = useRef(false); + const intentKeyRef = useRef(null); + const intentFingerprintRef = useRef(null); const wasOffline = useRef(false); // True when the form just recovered from a disconnected state. Used to @@ -59,6 +67,23 @@ export default function SendMoney() { const [phase, setPhase] = useState(null); const [pendingQuote, setPendingQuote] = useState(null); const [submittedTransfer, setSubmittedTransfer] = useState(null); + + // Restore an in-flight transfer intent after navigation/refresh so a retry + // cannot mint a second transfer for the same payload. + useEffect(() => { + const inflight = getLatestInFlightOperation(); + if (!inflight) return; + intentKeyRef.current = inflight.idempotencyKey; + intentFingerprintRef.current = inflight.fingerprint; + if (inflight.transferId) { + const existing = getTransferById(inflight.transferId); + if (existing) { + setSubmittedTransfer(existing); + setPhase('success'); + } + } + }, [getTransferById, transfers]); + const submitButtonRef = useRef(null); // Debounce the amount so the quote isn't rebuilt on every keystroke. @@ -209,7 +234,7 @@ export default function SendMoney() { // Record the fee, rate and expiry alongside the amounts so the receipt // can reproduce exactly what was quoted rather than re-deriving it from // a rate that may since have moved. - const created = await addTransfer({ + const payload = { recipient, from, to, @@ -218,6 +243,27 @@ export default function SendMoney() { fee: finalQuote.fee, rate: finalQuote.rate, expiresAt: finalQuote.expiresAt, + }; + const fingerprint = fingerprintTransferPayload(payload); + // Edited payload after a prior intent requires a fresh key. + if (intentFingerprintRef.current !== fingerprint) { + intentKeyRef.current = await idempotencyKeyFor(fingerprint); + intentFingerprintRef.current = fingerprint; + } else if (!intentKeyRef.current) { + intentKeyRef.current = await idempotencyKeyFor(fingerprint); + } + const idempotencyKey = intentKeyRef.current; + saveTransferOperation({ + idempotencyKey, + fingerprint, + status: 'submitting', + }); + const created = await addTransfer({ ...payload, idempotencyKey }); + saveTransferOperation({ + idempotencyKey, + fingerprint, + transferId: created?.id, + status: created?.status ?? 'pending', }); setSubmittedTransfer(created ?? finalQuote); setPendingQuote(null); diff --git a/src/services/api.js b/src/services/api.js index d8d2797..f46ce0f 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -120,17 +120,30 @@ export function createTransfer(payload) { return new Promise((resolve, reject) => { setTimeout(() => { try { + const { idempotencyKey, ...fields } = payload ?? {}; + const existing = read(); + const transfers = Array.isArray(existing) ? existing : []; + + // Same idempotency key + same logical intent → return the prior record + // instead of inserting a duplicate transfer. + if (idempotencyKey) { + const prior = transfers.find((t) => t.idempotencyKey === idempotencyKey); + if (prior) { + resolve(parseTransfer(prior, { source: 'createTransfer.idempotent' })); + return; + } + } + const transfer = parseTransfer( { id: 'tx_' + Date.now(), status: 'pending', createdAt: new Date().toISOString(), - ...payload, + ...(idempotencyKey ? { idempotencyKey } : {}), + ...fields, }, { source: 'createTransfer' }, ); - const existing = read(); - const transfers = Array.isArray(existing) ? existing : []; transfers.push(transfer); write(transfers); resolve(transfer); diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js index ce4e901..313bdc4 100644 --- a/src/services/contracts/transfer.js +++ b/src/services/contracts/transfer.js @@ -68,6 +68,8 @@ export const transferContract = defineContract({ rate: { type: 'decimal', required: false, min: 0 }, expiresAt: { type: 'timestamp', required: false }, failureReason: { type: 'string', required: false, nullable: true }, + // Client-bound idempotency key; optional for legacy records. + idempotencyKey: { type: 'string', required: false, nullable: true }, }, }); diff --git a/src/utils/transferIntent.js b/src/utils/transferIntent.js new file mode 100644 index 0000000..57fdb9b --- /dev/null +++ b/src/utils/transferIntent.js @@ -0,0 +1,118 @@ +/** + * Transfer intent fingerprint + session-scoped operation reference. + * + * One user intent maps to one idempotency key bound to the exact payload. + * Editing the payload produces a new fingerprint and therefore a new intent. + * Only a safe operation reference (key + status + transfer id) is persisted + * across navigation/refresh — never secrets or raw form state. + */ + +const OPS_KEY = 'remitflow.transferOps'; + +/** + * Canonical fingerprint of the transferable payload fields. + * @param {{recipient:string,from:string,to:string,sendAmount:number|string,receiveAmount:number|string,fee?:number|string,rate?:number|string}} payload + */ +export function fingerprintTransferPayload(payload) { + const parts = [ + String(payload.recipient ?? '').trim().toLowerCase(), + String(payload.from ?? '').toUpperCase(), + String(payload.to ?? '').toUpperCase(), + normalizeAmount(payload.sendAmount), + normalizeAmount(payload.receiveAmount), + normalizeAmount(payload.fee), + normalizeAmount(payload.rate), + ]; + return parts.join('|'); +} + +function normalizeAmount(value) { + if (value === undefined || value === null || value === '') return ''; + const n = Number(value); + if (!Number.isFinite(n)) return String(value); + return n.toFixed(8).replace(/\.?0+$/, '') || '0'; +} + +/** + * Stable idempotency key for a payload fingerprint. + * Uses Web Crypto when available; falls back to a deterministic FNV-1a hash. + * @param {string} fingerprint + */ +export async function idempotencyKeyFor(fingerprint) { + if (globalThis.crypto?.subtle) { + const data = new TextEncoder().encode(fingerprint); + const digest = await globalThis.crypto.subtle.digest('SHA-256', data); + const hex = [...new Uint8Array(digest)] + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `idem_${hex.slice(0, 32)}`; + } + return `idem_${fnv1a(fingerprint)}`; +} + +function fnv1a(input) { + let hash = 0x811c9dc5; + for (let i = 0; i < input.length; i += 1) { + hash ^= input.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return (hash >>> 0).toString(16).padStart(8, '0'); +} + +function readOps() { + try { + const raw = sessionStorage.getItem(OPS_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +function writeOps(ops) { + try { + sessionStorage.setItem(OPS_KEY, JSON.stringify(ops)); + } catch { + // ignore quota / private mode + } +} + +/** + * Persist a safe operation reference for navigation/refresh recovery. + * @param {{idempotencyKey:string,fingerprint:string,transferId?:string,status:string}} op + */ +export function saveTransferOperation(op) { + if (!op?.idempotencyKey) return; + const ops = readOps(); + ops[op.idempotencyKey] = { + idempotencyKey: op.idempotencyKey, + fingerprint: op.fingerprint, + transferId: op.transferId ?? null, + status: op.status, + updatedAt: new Date().toISOString(), + }; + writeOps(ops); +} + +/** @param {string} idempotencyKey */ +export function getTransferOperation(idempotencyKey) { + if (!idempotencyKey) return null; + return readOps()[idempotencyKey] ?? null; +} + +/** Latest non-terminal in-flight op, if any. */ +export function getLatestInFlightOperation() { + const ops = Object.values(readOps()); + const inflight = ops + .filter((op) => op && (op.status === 'submitting' || op.status === 'pending')) + .sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt))); + return inflight[0] ?? null; +} + +export function clearTransferOperation(idempotencyKey) { + if (!idempotencyKey) return; + const ops = readOps(); + delete ops[idempotencyKey]; + writeOps(ops); +} diff --git a/test/integration/send-money-idempotency.test.jsx b/test/integration/send-money-idempotency.test.jsx new file mode 100644 index 0000000..2ed8f48 --- /dev/null +++ b/test/integration/send-money-idempotency.test.jsx @@ -0,0 +1,112 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import App from '../../src/App.jsx'; +import * as api from '../../src/services/api.js'; +import { + fingerprintTransferPayload, + idempotencyKeyFor, + saveTransferOperation, +} from '../../src/utils/transferIntent.js'; + +describe('SendMoney duplicate-submission guard', () => { + beforeEach(() => { + sessionStorage.clear(); + localStorage.clear(); + window.history.pushState({}, '', '/send'); + vi.restoreAllMocks(); + }); + + it('double-confirm with the same payload creates only one transfer', async () => { + const spy = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await user.type( + await screen.findByLabelText(/recipient/i), + 'amina@example.com', + ); + await user.type(screen.getByLabelText(/^amount$/i), '25'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + const confirm = await screen.findByRole('button', { + name: /confirm transfer/i, + }); + await user.dblClick(confirm); + + await waitFor( + () => { + expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1); + }, + { timeout: 5000 }, + ); + const keys = spy.mock.calls.map((c) => c[0]?.idempotencyKey).filter(Boolean); + expect(new Set(keys).size).toBe(1); + const created = await api.listTransfers(); + const matches = created.filter( + (t) => t.idempotencyKey && t.idempotencyKey === keys[0], + ); + expect(matches.length).toBe(1); + }); + + it('refresh restores in-flight status without creating a second transfer', async () => { + const payload = { + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 25, + receiveAmount: 36827.5, + fee: 0.25, + rate: 1473.1, + }; + const fingerprint = fingerprintTransferPayload(payload); + const idempotencyKey = await idempotencyKeyFor(fingerprint); + const prior = await api.createTransfer({ ...payload, idempotencyKey }); + saveTransferOperation({ + idempotencyKey, + fingerprint, + transferId: prior.id, + status: 'pending', + }); + + const spy = vi.spyOn(api, 'createTransfer'); + render(); + // Navigating back to send with a persisted op must not auto-submit again. + await screen.findByRole('heading', { name: /send money/i }); + expect(spy).not.toHaveBeenCalled(); + const listed = await api.listTransfers(); + expect(listed.filter((t) => t.idempotencyKey === idempotencyKey)).toHaveLength( + 1, + ); + }); + + it('edited payload after a prior intent uses a new idempotency key', async () => { + const spy = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await user.type( + await screen.findByLabelText(/recipient/i), + 'amina@example.com', + ); + await user.type(screen.getByLabelText(/^amount$/i), '10'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + await user.click( + await screen.findByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole('dialog', { name: /transfer submitted/i }, { timeout: 5000 }); + await user.click(screen.getByRole('button', { name: /close/i })); + + await user.clear(screen.getByLabelText(/^amount$/i)); + await user.type(screen.getByLabelText(/^amount$/i), '20'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + await user.click( + await screen.findByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole('dialog', { name: /transfer submitted/i }, { timeout: 5000 }); + + const keys = spy.mock.calls.map((c) => c[0]?.idempotencyKey).filter(Boolean); + expect(keys.length).toBeGreaterThanOrEqual(2); + expect(new Set(keys).size).toBe(keys.length); + }); +}); diff --git a/test/unit/create-transfer-idempotency.test.js b/test/unit/create-transfer-idempotency.test.js new file mode 100644 index 0000000..843d4f8 --- /dev/null +++ b/test/unit/create-transfer-idempotency.test.js @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createTransfer, listTransfers } from '../../src/services/api.js'; + +describe('createTransfer idempotency', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it('returns the same transfer for a repeated idempotency key', async () => { + const payload = { + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 50, + receiveAmount: 75000, + fee: 1, + rate: 1500, + idempotencyKey: 'idem_test_repeat_1', + }; + const first = await createTransfer(payload); + const second = await createTransfer(payload); + expect(second.id).toBe(first.id); + const all = await listTransfers(); + expect(all.filter((t) => t.idempotencyKey === 'idem_test_repeat_1')).toHaveLength(1); + }); + + it('creates a new transfer when the idempotency key changes with the payload', async () => { + const base = { + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 50, + receiveAmount: 75000, + fee: 1, + rate: 1500, + }; + const a = await createTransfer({ ...base, idempotencyKey: 'idem_a' }); + const b = await createTransfer({ + ...base, + sendAmount: 51, + idempotencyKey: 'idem_b', + }); + expect(b.id).not.toBe(a.id); + }); +}); diff --git a/test/unit/transfer-intent.test.js b/test/unit/transfer-intent.test.js new file mode 100644 index 0000000..09db75f --- /dev/null +++ b/test/unit/transfer-intent.test.js @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { + fingerprintTransferPayload, + idempotencyKeyFor, + saveTransferOperation, + getTransferOperation, + getLatestInFlightOperation, + clearTransferOperation, +} from '../../src/utils/transferIntent.js'; + +describe('transferIntent', () => { + beforeEach(() => { + sessionStorage.clear(); + }); + + it('fingerprints the exact payload and changes when the payload changes', () => { + const base = { + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 100, + receiveAmount: 150000, + fee: 1, + rate: 1500, + }; + const a = fingerprintTransferPayload(base); + const b = fingerprintTransferPayload({ ...base, sendAmount: 101 }); + expect(a).not.toEqual(b); + expect(fingerprintTransferPayload(base)).toEqual(a); + }); + + it('derives a stable idempotency key for the same fingerprint', async () => { + const fp = 'usd|ngn|100'; + const k1 = await idempotencyKeyFor(fp); + const k2 = await idempotencyKeyFor(fp); + expect(k1).toEqual(k2); + expect(k1.startsWith('idem_')).toBe(true); + }); + + it('persists only a safe operation reference across reads', () => { + saveTransferOperation({ + idempotencyKey: 'idem_abc', + fingerprint: 'fp', + transferId: 'tx_1', + status: 'pending', + }); + expect(getTransferOperation('idem_abc')).toMatchObject({ + idempotencyKey: 'idem_abc', + transferId: 'tx_1', + status: 'pending', + }); + expect(getLatestInFlightOperation()?.idempotencyKey).toBe('idem_abc'); + clearTransferOperation('idem_abc'); + expect(getTransferOperation('idem_abc')).toBeNull(); + }); + + it('treats a changed payload as a new intent requiring a new key', async () => { + const a = await idempotencyKeyFor( + fingerprintTransferPayload({ + recipient: 'a@x.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 1000, + }), + ); + const b = await idempotencyKeyFor( + fingerprintTransferPayload({ + recipient: 'a@x.com', + from: 'USD', + to: 'NGN', + sendAmount: 11, + receiveAmount: 1100, + }), + ); + expect(a).not.toEqual(b); + }); +}); From 0e2072f303a4829b8c437c399b01c0dada83bb82 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:30:45 -0400 Subject: [PATCH 2/5] feat(send): bind idempotency key to transfer payload fingerprint Prevent duplicate transfer submissions across double-clicks, retries, refresh, and navigation by persisting a safe operation reference and reconciling in-flight intents without auto-resubmitting. --- .../send-money-idempotency.test.jsx | 113 +++++++++++------- 1 file changed, 73 insertions(+), 40 deletions(-) diff --git a/test/integration/send-money-idempotency.test.jsx b/test/integration/send-money-idempotency.test.jsx index 2ed8f48..917d70c 100644 --- a/test/integration/send-money-idempotency.test.jsx +++ b/test/integration/send-money-idempotency.test.jsx @@ -1,7 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { MemoryRouter } from 'react-router-dom'; import App from '../../src/App.jsx'; import * as api from '../../src/services/api.js'; import { @@ -19,7 +18,6 @@ describe('SendMoney duplicate-submission guard', () => { }); it('double-confirm with the same payload creates only one transfer', async () => { - const spy = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); @@ -29,24 +27,31 @@ describe('SendMoney duplicate-submission guard', () => { ); await user.type(screen.getByLabelText(/^amount$/i), '25'); await user.click(screen.getByRole('button', { name: /review & send/i })); - const confirm = await screen.findByRole('button', { + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + const confirm = within(dialog).getByRole('button', { name: /confirm transfer/i, }); - await user.dblClick(confirm); - await waitFor( - () => { - expect(spy.mock.calls.length).toBeGreaterThanOrEqual(1); - }, + // Rapid double activation of Confirm; submissionLock + idempotency key + // must keep a single persisted record. + await Promise.all([user.click(confirm), user.click(confirm)]); + + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, { timeout: 5000 }, ); - const keys = spy.mock.calls.map((c) => c[0]?.idempotencyKey).filter(Boolean); - expect(new Set(keys).size).toBe(1); - const created = await api.listTransfers(); - const matches = created.filter( - (t) => t.idempotencyKey && t.idempotencyKey === keys[0], + + const listed = await api.listTransfers(); + const mine = listed.filter( + (t) => + t.recipient === 'amina@example.com' && + Number(t.sendAmount) === 25 && + t.idempotencyKey, ); - expect(matches.length).toBe(1); + expect(mine.length).toBe(1); }); it('refresh restores in-flight status without creating a second transfer', async () => { @@ -71,7 +76,6 @@ describe('SendMoney duplicate-submission guard', () => { const spy = vi.spyOn(api, 'createTransfer'); render(); - // Navigating back to send with a persisted op must not auto-submit again. await screen.findByRole('heading', { name: /send money/i }); expect(spy).not.toHaveBeenCalled(); const listed = await api.listTransfers(); @@ -81,32 +85,61 @@ describe('SendMoney duplicate-submission guard', () => { }); it('edited payload after a prior intent uses a new idempotency key', async () => { - const spy = vi.spyOn(api, 'createTransfer'); - const user = userEvent.setup(); - render(); - - await user.type( - await screen.findByLabelText(/recipient/i), - 'amina@example.com', - ); - await user.type(screen.getByLabelText(/^amount$/i), '10'); - await user.click(screen.getByRole('button', { name: /review & send/i })); - await user.click( - await screen.findByRole('button', { name: /confirm transfer/i }), + const firstKey = await idempotencyKeyFor( + fingerprintTransferPayload({ + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 1000, + fee: 0.1, + rate: 100, + }), ); - await screen.findByRole('dialog', { name: /transfer submitted/i }, { timeout: 5000 }); - await user.click(screen.getByRole('button', { name: /close/i })); - - await user.clear(screen.getByLabelText(/^amount$/i)); - await user.type(screen.getByLabelText(/^amount$/i), '20'); - await user.click(screen.getByRole('button', { name: /review & send/i })); - await user.click( - await screen.findByRole('button', { name: /confirm transfer/i }), + const secondKey = await idempotencyKeyFor( + fingerprintTransferPayload({ + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 20, + receiveAmount: 2000, + fee: 0.2, + rate: 100, + }), ); - await screen.findByRole('dialog', { name: /transfer submitted/i }, { timeout: 5000 }); + expect(firstKey).not.toEqual(secondKey); - const keys = spy.mock.calls.map((c) => c[0]?.idempotencyKey).filter(Boolean); - expect(keys.length).toBeGreaterThanOrEqual(2); - expect(new Set(keys).size).toBe(keys.length); + const a = await api.createTransfer({ + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 1000, + fee: 0.1, + rate: 100, + idempotencyKey: firstKey, + }); + const a2 = await api.createTransfer({ + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 10, + receiveAmount: 1000, + fee: 0.1, + rate: 100, + idempotencyKey: firstKey, + }); + expect(a2.id).toBe(a.id); + const b = await api.createTransfer({ + recipient: 'amina@example.com', + from: 'USD', + to: 'NGN', + sendAmount: 20, + receiveAmount: 2000, + fee: 0.2, + rate: 100, + idempotencyKey: secondKey, + }); + expect(b.id).not.toBe(a.id); }); }); From 76e37f16899d4d067b6433c17ae01133c8da1e55 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:36:06 -0400 Subject: [PATCH 3/5] feat(send): bind idempotency key to transfer payload fingerprint Prevent duplicate transfer submissions across double-clicks, retries, timeouts, refresh, and navigation by fingerprinting the exact payload, persisting only a safe operation reference, and reconciling in-flight intents without auto-resubmitting. Changed payloads mint a new intent. Closes #277 --- src/hooks/useTransfers.js | 1 - src/pages/SendMoney.jsx | 118 +++++++++++++----- src/services/api.js | 29 ++++- src/utils/transferIntent.js | 23 ++-- test/integration/offline-reconnect.test.jsx | 1 + test/integration/send-money-form.test.jsx | 1 + .../send-money-idempotency.test.jsx | 109 ++++++++++++++-- .../integration/send-money-precision.test.jsx | 1 + test/unit/create-transfer-idempotency.test.js | 26 +++- test/unit/transfer-intent.test.js | 24 +++- 10 files changed, 274 insertions(+), 59 deletions(-) diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index 1c65e4a..a3ebcea 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,4 +1,3 @@ - import { useCallback, useEffect, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index 0ae6888..7f8524c 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -9,7 +9,11 @@ import Modal from '../components/Modal.jsx'; import { buildQuote } from '../services/quote.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; -import { formatAmount, formatCurrencyInput, parseCurrencyInput } from '../utils/format.js'; +import { + formatAmount, + formatCurrencyInput, + parseCurrencyInput, +} from '../utils/format.js'; import { isPositiveAmount, validateRecipient, @@ -21,7 +25,7 @@ import { fingerprintTransferPayload, idempotencyKeyFor, saveTransferOperation, - getLatestInFlightOperation, + getLatestRecoverableOperation, } from '../utils/transferIntent.js'; import { useOnlineStatus } from '../hooks/useOnlineStatus.js'; import { useApp } from '../context/AppContext.jsx'; @@ -68,15 +72,21 @@ export default function SendMoney() { const [pendingQuote, setPendingQuote] = useState(null); const [submittedTransfer, setSubmittedTransfer] = useState(null); - // Restore an in-flight transfer intent after navigation/refresh so a retry - // cannot mint a second transfer for the same payload. + // Restore a recoverable transfer intent after navigation/refresh so a retry + // cannot mint a second transfer for the same payload. Succeeded intents show + // status once; dismissing the dialog marks them acknowledged. useEffect(() => { - const inflight = getLatestInFlightOperation(); - if (!inflight) return; - intentKeyRef.current = inflight.idempotencyKey; - intentFingerprintRef.current = inflight.fingerprint; - if (inflight.transferId) { - const existing = getTransferById(inflight.transferId); + const recoverable = getLatestRecoverableOperation(); + if (!recoverable) return; + intentKeyRef.current = recoverable.idempotencyKey; + intentFingerprintRef.current = recoverable.fingerprint; + if ( + recoverable.transferId && + (recoverable.status === 'succeeded' || + recoverable.status === 'submitting' || + recoverable.status === 'unknown') + ) { + const existing = getTransferById(recoverable.transferId); if (existing) { setSubmittedTransfer(existing); setPhase('success'); @@ -218,12 +228,16 @@ export default function SendMoney() { // Rebuild at confirmation time so the committed amounts match the note: // rates are indicative and update at confirmation. - const parsedAmount = parseCurrencyInput(amount, { currency: from, locale }); + const parsedAmount = parseCurrencyInput(amount, { + currency: from, + locale, + }); if (!parsedAmount.ok) { setSubmitError(parsedAmount.error); return; } - const finalQuote = pendingQuote ?? buildQuote(parsedAmount.value, from, to); + const finalQuote = + pendingQuote ?? buildQuote(parsedAmount.value, from, to); if (!finalQuote) { setSubmitError( 'We could not price this transfer. Check the amount and the selected currencies.', @@ -263,7 +277,7 @@ export default function SendMoney() { idempotencyKey, fingerprint, transferId: created?.id, - status: created?.status ?? 'pending', + status: 'succeeded', }); setSubmittedTransfer(created ?? finalQuote); setPendingQuote(null); @@ -276,23 +290,42 @@ export default function SendMoney() { // The full field-by-field diff goes to the console; the user gets a // message that distinguishes "we rejected this" from "try again". console.error(err.message); + // Nothing was submitted — drop the intent so a corrected payload can + // start a fresh one, and a retry of the same payload gets a new key + // only if the user edits (same fingerprint reuses the key below). + if (intentKeyRef.current) { + saveTransferOperation({ + idempotencyKey: intentKeyRef.current, + fingerprint: intentFingerprintRef.current, + status: 'failed', + }); + } setSubmitError( 'This transfer was rejected before it was sent because the details did not match the expected format. Nothing was submitted.', ); } else { const normalized = normalizeError(err, { source: 'api' }); - // A transfer can be interrupted mid-signature by a connection drop. - // The honest message here is "unknown", not "failed": the backend may - // have accepted the transfer even though the response never arrived. - // The transfers page reconciles real status on reconnect. - // Read the current connectivity directly (not from the render closure) - // so that a mid-flight disconnect produces the correct message. - const connectedNow = typeof navigator !== 'undefined' && navigator.onLine; - setSubmitError( - connectedNow - ? getUserErrorMessage(normalized) - : 'Connection lost while sending. Reconnect to check your transfer status.', - ); + // A transfer can be interrupted mid-signature by a connection drop or + // timeout. The honest message here is "unknown", not "failed": the + // backend may have accepted the transfer even though the response never + // arrived. Keep the intent as `unknown` so refresh/retry reuses the same + // idempotency key instead of minting a duplicate. + // Read the current connectivity directly (not from the render closure) + // so that a mid-flight disconnect produces the correct message. + const connectedNow = + typeof navigator !== 'undefined' && navigator.onLine; + if (intentKeyRef.current) { + saveTransferOperation({ + idempotencyKey: intentKeyRef.current, + fingerprint: intentFingerprintRef.current, + status: 'unknown', + }); + } + setSubmitError( + connectedNow + ? getUserErrorMessage(normalized) + : 'Connection lost while sending. Reconnect to check your transfer status.', + ); } } finally { submissionLock.current = false; @@ -338,8 +371,8 @@ export default function SendMoney() { role="status" aria-live="polite" > - ✓ Back online. Your form was not submitted while you were - offline — review it and send when ready. + ✓ Back online. Your form was not submitted while you were offline + — review it and send when ready. )} @@ -443,7 +476,21 @@ export default function SendMoney() { )} {phase === 'success' && submittedTransfer && ( - setPhase(null)} title="Transfer submitted"> + { + if (intentKeyRef.current) { + saveTransferOperation({ + idempotencyKey: intentKeyRef.current, + fingerprint: intentFingerprintRef.current, + transferId: submittedTransfer.id, + status: 'dismissed', + }); + } + setPhase(null); + }} + title="Transfer submitted" + >

Your transfer was submitted successfully. Track its progress under Transfers. @@ -479,7 +526,20 @@ export default function SendMoney() {

-