Skip to content
Closed
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
11 changes: 10 additions & 1 deletion src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -62,5 +70,6 @@ export function useTransfers() {
retryable,
reload: safeReload,
addTransfer,
getTransferById,
};
}
50 changes: 48 additions & 2 deletions src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
19 changes: 16 additions & 3 deletions src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions src/services/contracts/transfer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
},
});

Expand Down
118 changes: 118 additions & 0 deletions src/utils/transferIntent.js
Original file line number Diff line number Diff line change
@@ -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);
}
Loading