Skip to content
Open
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
12 changes: 10 additions & 2 deletions src/hooks/useTransfers.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import { useCallback, useEffect, useState } from 'react';
import { listTransfers, createTransfer } from '../services/api.js';
import { ContractViolationError } from '../services/contracts/schema.js';
Expand Down Expand Up @@ -47,10 +46,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 +69,6 @@ export function useTransfers() {
retryable,
reload: safeReload,
addTransfer,
getTransferById,
};
}
144 changes: 123 additions & 21 deletions src/pages/SendMoney.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,25 @@ 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,
isWithinBalance,
} from '../utils/validate.js';
import { useWallet } from '../hooks/useWallet.js';
import { useTransfers } from '../hooks/useTransfers.js';
import {
fingerprintTransferPayload,
idempotencyKeyFor,
newTransferIntentNonce,
saveTransferOperation,
getLatestRecoverableOperation,
} 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 +47,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 +59,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 +72,30 @@ export default function SendMoney() {
const [phase, setPhase] = useState(null);
const [pendingQuote, setPendingQuote] = useState(null);
const [submittedTransfer, setSubmittedTransfer] = useState(null);

// 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 recoverable = getLatestRecoverableOperation();
if (!recoverable) return;
intentKeyRef.current = recoverable.idempotencyKey;
intentFingerprintRef.current = recoverable.fingerprint;
const existing =
(recoverable.transferId && getTransferById(recoverable.transferId)) ||
transfers.find((transfer) => transfer.idempotencyKey === recoverable.idempotencyKey);
if (existing) {
if (recoverable.status !== 'succeeded' || recoverable.transferId !== existing.id) {
saveTransferOperation({ ...recoverable, transferId: existing.id, status: 'succeeded' });
}
setSubmitError(null);
setSubmittedTransfer(existing);
setPhase('success');
} else if (recoverable.status === 'unknown' || recoverable.status === 'submitting') {
setSubmitError('Transfer status is unknown. Check Transfers, or re-enter the same details to retry safely.');
}
}, [getTransferById, transfers]);

const submitButtonRef = useRef(null);

// Debounce the amount so the quote isn't rebuilt on every keystroke.
Expand Down Expand Up @@ -193,12 +230,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.',
Expand All @@ -209,7 +250,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 +259,27 @@ export default function SendMoney() {
fee: finalQuote.fee,
rate: finalQuote.rate,
expiresAt: finalQuote.expiresAt,
};
// Persist only an opaque fingerprint, never recipient or quote details.
const fingerprint = await idempotencyKeyFor(fingerprintTransferPayload(payload));
if (intentFingerprintRef.current !== fingerprint || !intentKeyRef.current) {
const recoverable = getLatestRecoverableOperation(fingerprint);
intentKeyRef.current = recoverable?.idempotencyKey ??
(await idempotencyKeyFor(fingerprint, newTransferIntentNonce()));
intentFingerprintRef.current = fingerprint;
}
const idempotencyKey = intentKeyRef.current;
saveTransferOperation({
idempotencyKey,
fingerprint,
status: 'submitting',
});
const created = await addTransfer({ ...payload, idempotencyKey });
saveTransferOperation({
idempotencyKey,
fingerprint,
transferId: created?.id,
status: 'succeeded',
});
setSubmittedTransfer(created ?? finalQuote);
setPendingQuote(null);
Expand All @@ -230,30 +292,63 @@ 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;
setSubmitting(false);
}
}

function dismissSuccess() {
if (intentKeyRef.current) {
saveTransferOperation({
idempotencyKey: intentKeyRef.current,
fingerprint: intentFingerprintRef.current,
transferId: submittedTransfer?.id,
status: 'dismissed',
});
}
intentKeyRef.current = null;
intentFingerprintRef.current = null;
setPhase(null);
}

const errorCount = Object.keys(errors).length;

return (
Expand Down Expand Up @@ -292,8 +387,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.
</div>
)}

Expand Down Expand Up @@ -397,7 +492,11 @@ export default function SendMoney() {
)}

{phase === 'success' && submittedTransfer && (
<Modal open onClose={() => setPhase(null)} title="Transfer submitted">
<Modal
open
onClose={dismissSuccess}
title="Transfer submitted"
>
<p className="send-result-status" role="status" aria-live="polite">
Your transfer was submitted successfully. Track its progress under
Transfers.
Expand Down Expand Up @@ -433,7 +532,10 @@ export default function SendMoney() {
</div>
</dl>
<div className="send-dialog-actions">
<Button variant="secondary" onClick={() => setPhase(null)}>
<Button
variant="secondary"
onClick={dismissSuccess}
>
Close
</Button>
<Button onClick={() => navigate('/transfers')}>
Expand Down
67 changes: 61 additions & 6 deletions src/services/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// guess whether a field is present or whether an amount is really a number.

import { ContractViolationError } from './contracts/schema.js';
import { fingerprintTransferPayload } from '../utils/transferIntent.js';
import {
parseTransfer,
parseTransferList,
Expand Down Expand Up @@ -45,7 +46,7 @@ function read() {
} catch {
// ignore parse/storage errors
}
return SEED_TRANSFERS;
return SEED_TRANSFERS.map((transfer) => ({ ...transfer }));
}

function write(transfers) {
Expand Down Expand Up @@ -106,37 +107,91 @@ export function listTransfers() {
});
}

// In-flight create promises keyed by idempotency key so concurrent retries
// (double-click, timeout replay) share one insert instead of racing two writes.
const pendingCreates = new Map();
let transferSequence = 0;

function conflictingIdempotencyKey() {
return new ContractViolationError(
transferContract,
[{
path: 'idempotencyKey',
code: 'conflicting_payload',
expected: 'the original transfer details for this key',
received: 'different transfer details',
}],
{ source: 'createTransfer' },
);
}

/**
* Create a new transfer record.
*
* The assembled record is validated before it is persisted, so a drifted
* payload fails at submission with an actionable diff instead of writing a
* record that later renders as a plausible-looking wrong number.
*
* When `idempotencyKey` is present, a repeated or concurrent create with the
* same key returns the prior transfer instead of inserting a duplicate.
*
* @param {object} payload - transfer details
* @returns {Promise<object>} the created transfer
*/
export function createTransfer(payload) {
return new Promise((resolve, reject) => {
const { idempotencyKey, ...fields } = payload ?? {};
const fingerprint = idempotencyKey ? fingerprintTransferPayload(fields) : null;

if (idempotencyKey && pendingCreates.has(idempotencyKey)) {
const pending = pendingCreates.get(idempotencyKey);
return pending.fingerprint === fingerprint
? pending.promise
: Promise.reject(conflictingIdempotencyKey());
}

const createPromise = new Promise((resolve, reject) => {
setTimeout(() => {
try {
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) {
const parsedPrior = parseTransfer(prior, { source: 'createTransfer.idempotent' });
if (fingerprintTransferPayload(parsedPrior) !== fingerprint) {
throw conflictingIdempotencyKey();
}
resolve(parsedPrior);
return;
}
}

const transfer = parseTransfer(
{
id: 'tx_' + Date.now(),
id: `tx_${Date.now()}_${++transferSequence}`,
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);
} catch (error) {
reject(error);
}
}, 700);
}).finally(() => {
if (idempotencyKey) pendingCreates.delete(idempotencyKey);
});

if (idempotencyKey) pendingCreates.set(idempotencyKey, { fingerprint, promise: createPromise });
return createPromise;
}
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
Loading