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..917d70c
--- /dev/null
+++ b/test/integration/send-money-idempotency.test.jsx
@@ -0,0 +1,145 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { render, screen, waitFor, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+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 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 dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const confirm = within(dialog).getByRole('button', {
+ name: /confirm transfer/i,
+ });
+
+ // 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 listed = await api.listTransfers();
+ const mine = listed.filter(
+ (t) =>
+ t.recipient === 'amina@example.com' &&
+ Number(t.sendAmount) === 25 &&
+ t.idempotencyKey,
+ );
+ expect(mine.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();
+ 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 firstKey = await idempotencyKeyFor(
+ fingerprintTransferPayload({
+ recipient: 'amina@example.com',
+ from: 'USD',
+ to: 'NGN',
+ sendAmount: 10,
+ receiveAmount: 1000,
+ fee: 0.1,
+ rate: 100,
+ }),
+ );
+ const secondKey = await idempotencyKeyFor(
+ fingerprintTransferPayload({
+ recipient: 'amina@example.com',
+ from: 'USD',
+ to: 'NGN',
+ sendAmount: 20,
+ receiveAmount: 2000,
+ fee: 0.2,
+ rate: 100,
+ }),
+ );
+ expect(firstKey).not.toEqual(secondKey);
+
+ 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);
+ });
+});
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);
+ });
+});