Recipient gets
- {formatMoney(receiveAmount, to, locale)}
+
+ {formatMoney(receiveAmount, to, locale)}
+
Fees cover the RemitFlow service and Stellar network cost. Rates are
- indicative and update at confirmation.
+ indicative; confirmation uses the bound quote id until it expires.
);
}
+
+function formatTimestamp(iso, locale) {
+ if (!iso) return '—';
+ const ms = Date.parse(iso);
+ if (Number.isNaN(ms)) return '—';
+ try {
+ return new Intl.DateTimeFormat(locale, {
+ dateStyle: 'medium',
+ timeStyle: 'medium',
+ }).format(new Date(ms));
+ } catch {
+ return new Date(ms).toISOString();
+ }
+}
+
+function shortId(id) {
+ if (!id || id.length <= 16) return id;
+ return `${id.slice(0, 10)}…${id.slice(-4)}`;
+}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx
index 336af1b..452bf9f 100644
--- a/src/context/AppContext.jsx
+++ b/src/context/AppContext.jsx
@@ -1,4 +1,3 @@
-
import { createContext, useContext, useEffect, useState } from 'react';
import {
connectWallet,
diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js
index dea1e61..80160bb 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.css b/src/pages/SendMoney.css
index e5f6de2..799ff20 100644
--- a/src/pages/SendMoney.css
+++ b/src/pages/SendMoney.css
@@ -163,3 +163,9 @@
color: #86efac;
font-size: 0.875rem;
}
+
+.send-quote-id {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 0.85em;
+ word-break: break-all;
+}
diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx
index c1e03f0..df3e0cf 100644
--- a/src/pages/SendMoney.jsx
+++ b/src/pages/SendMoney.jsx
@@ -7,14 +7,20 @@ import Button from '../components/Button.jsx';
import ErrorMessage from '../components/ErrorMessage.jsx';
import Modal from '../components/Modal.jsx';
import { buildQuote } from '../services/quote.js';
+import { isQuoteExpired } from '../services/contracts/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 {
- isPositiveAmount,
- validateRecipient,
- isWithinBalance,
-} from '../utils/validate.js';
+ formatAmount,
+ formatCurrencyInput,
+ parseCurrencyInput,
+} from '../utils/format.js';
+import { validateRecipient, isWithinBalance } from '../utils/validate.js';
+import {
+ amountsReconcile,
+ assertQuoteSignable,
+ validateCurrencyPair,
+} from '../utils/quoteBinding.js';
import { useWallet } from '../hooks/useWallet.js';
import { useTransfers } from '../hooks/useTransfers.js';
import { useOnlineStatus } from '../hooks/useOnlineStatus.js';
@@ -32,6 +38,9 @@ import './SendMoney.css';
* 2. "Confirm transfer" submits it; progress is announced via a live region.
* 3. A result dialog confirms success (or an announced error returns focus to
* the form for retry). Dialogs trap focus and return it on close.
+ *
+ * Quotes carry an id bound into the transfer payload. Field or network changes
+ * invalidate a pending quote so an expired or mismatched price cannot be signed.
*/
export default function SendMoney() {
const navigate = useNavigate();
@@ -48,6 +57,7 @@ export default function SendMoney() {
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState(null);
const submissionLock = useRef(false);
+ const confirmationVersion = useRef(0);
const wasOffline = useRef(false);
// True when the form just recovered from a disconnected state. Used to
@@ -59,6 +69,7 @@ export default function SendMoney() {
const [phase, setPhase] = useState(null);
const [pendingQuote, setPendingQuote] = useState(null);
const [submittedTransfer, setSubmittedTransfer] = useState(null);
+ const [quoteClock, setQuoteClock] = useState(() => Date.now());
const submitButtonRef = useRef(null);
// Debounce the amount so the quote isn't rebuilt on every keystroke.
@@ -66,6 +77,8 @@ export default function SendMoney() {
// Recompute the quote whenever the (debounced) inputs change.
const quote = useMemo(() => {
+ const corridor = validateCurrencyPair(from, to);
+ if (!corridor.ok) return null;
const parsed = parseCurrencyInput(debouncedAmount, {
currency: from,
locale,
@@ -74,6 +87,33 @@ export default function SendMoney() {
return buildQuote(parsed.value, from, to);
}, [debouncedAmount, from, locale, to]);
+ // Tick while a confirmation dialog is open so expiry UI stays honest.
+ useEffect(() => {
+ if (phase !== 'confirm' || !pendingQuote) return undefined;
+ const id = setInterval(() => setQuoteClock(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, [phase, pendingQuote]);
+
+ // Field changes while confirming invalidate the pending quote — the user
+ // must review a fresh price bound to the live inputs.
+ useEffect(() => {
+ if (phase !== 'confirm' || !pendingQuote) return;
+ const parsedAmount = parseCurrencyInput(amount, { currency: from, locale });
+ const live = parsedAmount.ok
+ ? assertQuoteSignable(
+ pendingQuote,
+ { amount: parsedAmount.value, from, to },
+ Date.now(),
+ )
+ : { ok: false, reason: parsedAmount.error };
+ if (!live.ok && live.code !== 'expired') {
+ confirmationVersion.current += 1;
+ setPendingQuote(null);
+ setPhase(null);
+ setSubmitError(live.reason);
+ }
+ }, [amount, from, to, locale, phase, pendingQuote]);
+
// Surface submission failures predictably: announce them and put keyboard
// focus back on the submit control so a retry is one Enter away.
useEffect(() => {
@@ -85,10 +125,16 @@ export default function SendMoney() {
setTo(from);
}
- // Tidy the amount field to two decimals once the user leaves it.
+ // Keep the field in its input locale when padding the currency precision.
function handleAmountBlur(value) {
const formatted = formatCurrencyInput(value, from, locale);
- if (formatted) setAmount(formatted);
+ if (formatted) {
+ const decimal =
+ new Intl.NumberFormat(locale)
+ .formatToParts(1.1)
+ .find((part) => part.type === 'decimal')?.value ?? '.';
+ setAmount(formatted.replace('.', decimal));
+ }
}
function applyErrors(next) {
@@ -111,16 +157,17 @@ export default function SendMoney() {
if (!parsedAmount.ok) {
next.amount = parsedAmount.error;
} else if (
+ // Both values are canonical; do not apply the input locale again.
wallet &&
!isWithinBalance(parsedAmount.value, wallet.balance, {
currency: from,
- locale,
})
) {
next.amount = 'Amount exceeds your wallet balance.';
}
- if (from === to) {
- next.to = 'Source and destination must differ.';
+ const corridor = validateCurrencyPair(from, to);
+ if (!corridor.ok) {
+ next.to = corridor.error;
}
applyErrors(next);
return Object.keys(next).length === 0;
@@ -129,17 +176,57 @@ export default function SendMoney() {
/**
* Track connectivity transitions. When the browser comes back online we do
* NOT blindly resubmit the form (that would duplicate the transfer) — we
- * only clear the stale "offline" error state and inform the user.
+ * only clear the stale "offline" error state and inform the user. A pending
+ * confirmation quote is dropped: rates may have moved while offline.
*/
useEffect(() => {
const recovered = wasOffline.current && isOnline;
+ const dropped = !wasOffline.current && !isOnline;
wasOffline.current = !isOnline;
+ if (dropped && phase === 'confirm') {
+ confirmationVersion.current += 1;
+ setPendingQuote(null);
+ setPhase(null);
+ setSubmitError(
+ "You're offline. The pending quote was cleared — reconnect and review again.",
+ );
+ }
if (recovered) {
setSubmitError(null);
setJustReconnected(true);
setTimeout(() => setJustReconnected(false), 4000);
}
- }, [isOnline]);
+ }, [isOnline, phase]);
+
+ function refreshPendingQuote() {
+ confirmationVersion.current += 1;
+ const parsedAmount = parseCurrencyInput(amount, { currency: from, locale });
+ if (!parsedAmount.ok) {
+ setSubmitError(parsedAmount.error);
+ setPendingQuote(null);
+ setPhase(null);
+ return;
+ }
+ const corridor = validateCurrencyPair(from, to);
+ if (!corridor.ok) {
+ setSubmitError(corridor.error);
+ setPendingQuote(null);
+ setPhase(null);
+ return;
+ }
+ const next = buildQuote(parsedAmount.value, from, to);
+ if (!next) {
+ setSubmitError(
+ 'We could not refresh this quote. Check the amount and currencies.',
+ );
+ setPendingQuote(null);
+ setPhase(null);
+ return;
+ }
+ setPendingQuote(next);
+ setQuoteClock(Date.now());
+ setSubmitError(null);
+ }
async function handleSubmit(e) {
e.preventDefault();
@@ -167,11 +254,25 @@ export default function SendMoney() {
}
const finalQuote = buildQuote(parsedAmount.value, from, to);
if (!finalQuote) {
- applyErrors({ amount: 'Enter an amount greater than zero.' });
+ setSubmitError(
+ 'We could not price this transfer. Check the amount and currencies.',
+ );
return;
}
+ const signable = assertQuoteSignable(
+ finalQuote,
+ { amount: parsedAmount.value, from, to },
+ Date.now(),
+ );
+ if (!signable.ok) {
+ setSubmitError(signable.reason);
+ return;
+ }
+
+ confirmationVersion.current += 1;
setPendingQuote(finalQuote);
+ setQuoteClock(Date.now());
setPhase('confirm');
}
@@ -186,39 +287,88 @@ export default function SendMoney() {
submissionLock.current = true;
setSubmitting(true);
+ const startedVersion = confirmationVersion.current;
try {
- if (!isConnected) {
- await connect();
+ const transferWallet = isConnected ? wallet : await connect();
+ // A cleared or refreshed quote must not resume from the pre-connect render.
+ if (startedVersion !== confirmationVersion.current) return;
+ if (typeof navigator !== 'undefined' && !navigator.onLine) {
+ confirmationVersion.current += 1;
+ setPendingQuote(null);
+ setPhase(null);
+ setSubmitError(
+ "You're offline. The pending quote was cleared — reconnect and review again.",
+ );
+ return;
+ }
+ if (!transferWallet) {
+ setSubmitError(
+ 'Wallet connection did not complete. Connect your wallet and try again.',
+ );
+ setPendingQuote(null);
+ setPhase(null);
+ return;
}
- // 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);
+ setPendingQuote(null);
+ setPhase(null);
return;
}
- const finalQuote = pendingQuote ?? buildQuote(parsedAmount.value, from, to);
- if (!finalQuote) {
- setSubmitError(
- 'We could not price this transfer. Check the amount and the selected currencies.',
- );
+
+ const liveInputs = { amount: parsedAmount.value, from, to };
+ const finalQuote = pendingQuote;
+ const signable = assertQuoteSignable(finalQuote, liveInputs, Date.now());
+ if (!signable.ok) {
+ // Expired quotes get an explicit refresh path; other mismatches close
+ // the dialog so the user reviews a newly priced quote.
+ setSubmitError(signable.reason);
+ if (signable.code === 'expired' && finalQuote) {
+ setQuoteClock(Date.now());
+ return;
+ }
+ setPendingQuote(null);
+ setPhase(null);
+ return;
+ }
+
+ // A wallet connected here was unavailable during the form's balance check.
+ if (
+ !isWithinBalance(finalQuote.sendAmount, transferWallet.balance, {
+ currency: finalQuote.from,
+ })
+ ) {
+ setSubmitError('Amount exceeds your wallet balance.');
+ setPendingQuote(null);
+ setPhase(null);
return;
}
- // 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({
+ // Bind the quote id into the transfer so receipts can prove which price
+ // was confirmed. Amounts come from the bound quote, not a rebuild.
+ const transferPayload = {
recipient,
- from,
- to,
+ from: finalQuote.from,
+ to: finalQuote.to,
sendAmount: finalQuote.sendAmount,
receiveAmount: finalQuote.receiveAmount,
fee: finalQuote.fee,
rate: finalQuote.rate,
expiresAt: finalQuote.expiresAt,
- });
+ quoteId: finalQuote.id,
+ };
+ if (!amountsReconcile(finalQuote, transferPayload)) {
+ setSubmitError('Displayed amounts do not match the transfer payload.');
+ setPendingQuote(null);
+ setPhase(null);
+ return;
+ }
+ const created = await addTransfer(transferPayload);
setSubmittedTransfer(created ?? finalQuote);
setPendingQuote(null);
setSubmitError(null);
@@ -235,18 +385,19 @@ export default function SendMoney() {
);
} 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.
+ // 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.',
+ );
}
} finally {
submissionLock.current = false;
@@ -255,6 +406,10 @@ export default function SendMoney() {
}
const errorCount = Object.keys(errors).length;
+ const pendingExpired =
+ phase === 'confirm' && pendingQuote
+ ? isQuoteExpired(pendingQuote, quoteClock)
+ : false;
return (
@@ -292,8 +447,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.
)}
@@ -360,7 +515,7 @@ export default function SendMoney() {
{quote ? (
-
+
) : (
Enter an amount to see your quote.
@@ -377,9 +532,18 @@ export default function SendMoney() {
{recipient}
-
+
- {submitting ? 'Submitting your transfer…' : ''}
+ {submitting
+ ? 'Submitting your transfer…'
+ : pendingExpired
+ ? 'Quote expired — refresh before confirming.'
+ : ''}
-
@@ -427,6 +594,12 @@ export default function SendMoney() {
)}
+ {submittedTransfer.quoteId && (
+
+
Quote
+
{submittedTransfer.quoteId}
+
+ )}
Status
{submittedTransfer.status}
diff --git a/src/services/contracts/quote.js b/src/services/contracts/quote.js
index 3ca0cb9..479f35a 100644
--- a/src/services/contracts/quote.js
+++ b/src/services/contracts/quote.js
@@ -24,6 +24,9 @@ export const quoteContract = defineContract({
receiveAmount: { type: 'decimal', required: true, min: 0 },
createdAt: { type: 'timestamp', required: true },
expiresAt: { type: 'timestamp', required: true },
+ // Optional enrichment for send-flow binding. Absent on legacy fixtures.
+ id: { type: 'string', required: false, minLength: 1 },
+ source: { type: 'string', required: false, minLength: 1 },
},
});
diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js
index ce4e901..53dbd8a 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 },
+ // Optional bind to the priced quote that authorised the transfer.
+ quoteId: { type: 'string', required: false, minLength: 1 },
},
});
diff --git a/src/services/errors.js b/src/services/errors.js
index bab520f..4ecc020 100644
--- a/src/services/errors.js
+++ b/src/services/errors.js
@@ -86,7 +86,9 @@ export function normalizeError(error, { source = 'api' } = {}) {
}
export function getUserErrorMessage(normalizedError) {
- return SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN];
+ return (
+ SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN]
+ );
}
export function canRetry(normalizedError) {
diff --git a/src/services/quote.js b/src/services/quote.js
index 8b41415..78304da 100644
--- a/src/services/quote.js
+++ b/src/services/quote.js
@@ -13,6 +13,7 @@ import {
QUOTE_TTL_MS,
parseQuote,
} from './contracts/quote.js';
+import { mintQuoteId, quoteInputFingerprint } from '../utils/quoteBinding.js';
import {
convertMinorUnits,
currencyExponent,
@@ -79,20 +80,32 @@ export function buildQuote(amount, from, to, options = {}) {
: (options.now ?? Date.now());
const ttlMs = options.ttlMs ?? QUOTE_TTL_MS;
+ const sendAmount = fromMinorUnits(sendMinor, fromExponent);
+ const createdAt = new Date(nowMs).toISOString();
+ const inputFingerprint = quoteInputFingerprint({
+ amount: sendAmount,
+ from,
+ to,
+ });
+
// Round-trip through the contract so a quote is validated at the point it is
- // produced, not only at the point it is consumed.
+ // produced, not only at the point it is consumed. id + source + fingerprint
+ // bind confirmation to this exact priced quote.
return parseQuote(
{
version: QUOTE_CONTRACT_VERSION,
+ id: mintQuoteId(`${inputFingerprint}:${createdAt}`),
+ source: 'fx.table',
from,
to,
rate,
- sendAmount: fromMinorUnits(sendMinor, fromExponent),
+ sendAmount,
fee: fromMinorUnits(feeMinor, fromExponent),
amountAfterFee: fromMinorUnits(afterFeeMinor, fromExponent),
receiveAmount: fromMinorUnits(receiveMinor, toExponent),
- createdAt: new Date(nowMs).toISOString(),
+ createdAt,
expiresAt: new Date(nowMs + ttlMs).toISOString(),
+ inputFingerprint,
},
{ source: 'buildQuote' },
);
diff --git a/src/utils/quoteBinding.js b/src/utils/quoteBinding.js
new file mode 100644
index 0000000..2e2a825
--- /dev/null
+++ b/src/utils/quoteBinding.js
@@ -0,0 +1,239 @@
+/**
+ * Quote binding helpers for the send flow.
+ *
+ * A quote is only signable when it is still fresh, matches the live form
+ * inputs (amount + corridor), and its displayed amounts reconcile with the
+ * serialized payload. Field or network changes invalidate a pending quote.
+ */
+
+import { getCurrency, getCurrencyMinorUnits } from '../constants/currencies.js';
+import { isQuoteExpired } from '../services/contracts/quote.js';
+import { getRateDecimal } from '../services/fx.js';
+import { parseDecimal } from './money.js';
+
+let quoteIdSequence = 0;
+
+/**
+ * Mint a quote id. When `seed` is provided the id is content-addressed so the
+ * same priced inputs at the same clock reproduce the same id (tests and
+ * rebuilds stay stable). Without a seed the id is random.
+ * @param {string} [seed]
+ * @returns {string}
+ */
+export function mintQuoteId(seed) {
+ if (seed != null && seed !== '') {
+ return `qt_${fnv1a(String(seed))}`;
+ }
+ if (globalThis.crypto?.randomUUID) {
+ return `qt_${globalThis.crypto.randomUUID()}`;
+ }
+ if (globalThis.crypto?.getRandomValues) {
+ const bytes = new Uint8Array(12);
+ globalThis.crypto.getRandomValues(bytes);
+ return `qt_${[...bytes].map((b) => b.toString(16).padStart(2, '0')).join('')}`;
+ }
+ return `qt_${Date.now().toString(36)}_${(++quoteIdSequence).toString(36)}`;
+}
+
+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');
+}
+
+/**
+ * Canonical fingerprint of the inputs a quote was priced against.
+ * @param {{amount: string|number, from: string, to: string}} inputs
+ * @returns {string}
+ */
+export function quoteInputFingerprint(inputs) {
+ const amount = normalizeAmount(inputs?.amount);
+ const from = String(inputs?.from ?? '').toUpperCase();
+ const to = String(inputs?.to ?? '').toUpperCase();
+ return JSON.stringify([amount, from, to]);
+}
+
+function normalizeAmount(value) {
+ if (value === undefined || value === null || value === '') return '';
+ const parsed = parseDecimal(value);
+ return parsed.ok ? parsed.value : String(value);
+}
+
+/**
+ * Validate that both currencies are supported and form a priced corridor.
+ * @param {string} from
+ * @param {string} to
+ * @returns {{ok: true, fromMeta: object, toMeta: object, rate: string}|{ok: false, error: string}}
+ */
+export function validateCurrencyPair(from, to) {
+ const fromCode = String(from ?? '').toUpperCase();
+ const toCode = String(to ?? '').toUpperCase();
+ const fromMeta = getCurrency(fromCode);
+ const toMeta = getCurrency(toCode);
+
+ if (!fromMeta) {
+ return {
+ ok: false,
+ error: `Unsupported source currency: ${fromCode || '(empty)'}.`,
+ };
+ }
+ if (!toMeta) {
+ return {
+ ok: false,
+ error: `Unsupported destination currency: ${toCode || '(empty)'}.`,
+ };
+ }
+ if (fromCode === toCode) {
+ return { ok: false, error: 'Source and destination must differ.' };
+ }
+
+ const rate = getRateDecimal(fromCode, toCode);
+ if (rate == null) {
+ return {
+ ok: false,
+ error: `No rate available for ${fromCode} → ${toCode}.`,
+ };
+ }
+
+ return { ok: true, fromMeta, toMeta, rate };
+}
+
+/**
+ * Does the quote still match the live form inputs?
+ * @param {object} quote
+ * @param {{amount: string|number, from: string, to: string}} inputs
+ */
+export function isQuoteBoundToInputs(quote, inputs) {
+ if (!quote) return false;
+ const expected = quoteInputFingerprint(inputs);
+ if (quote.inputFingerprint) {
+ return quote.inputFingerprint === expected;
+ }
+ // Fallback for quotes minted without an explicit fingerprint: compare the
+ // priced fields directly so a currency swap still invalidates the binding.
+ return (
+ normalizeAmount(quote.sendAmount) === normalizeAmount(inputs?.amount) &&
+ String(quote.from ?? '').toUpperCase() ===
+ String(inputs?.from ?? '').toUpperCase() &&
+ String(quote.to ?? '').toUpperCase() ===
+ String(inputs?.to ?? '').toUpperCase()
+ );
+}
+
+/**
+ * Displayed card amounts must equal the amounts we are about to serialize.
+ * Compared as canonical decimals so float noise cannot pass a mismatch.
+ * @param {object} displayed
+ * @param {object} serialized
+ */
+export function amountsReconcile(displayed, serialized) {
+ if (!displayed || !serialized) return false;
+ const fields = ['sendAmount', 'fee', 'receiveAmount', 'rate'];
+ return fields.every((field) => {
+ const left = normalizeAmount(displayed[field]);
+ const right = normalizeAmount(serialized[field]);
+ return left !== '' && left === right;
+ });
+}
+
+/**
+ * Decide whether a quote may be confirmed / signed.
+ * @param {object} quote
+ * @param {{amount: string|number, from: string, to: string}} inputs
+ * @param {number|Date} [now]
+ * @returns {{ok: true}|{ok: false, reason: string, code: string}}
+ */
+export function assertQuoteSignable(quote, inputs, now = Date.now()) {
+ if (!quote?.id) {
+ return {
+ ok: false,
+ code: 'missing_quote_id',
+ reason: 'Quote is missing an id and cannot be confirmed.',
+ };
+ }
+
+ const corridor = validateCurrencyPair(inputs?.from, inputs?.to);
+ if (!corridor.ok) {
+ return { ok: false, code: 'currency_matrix', reason: corridor.error };
+ }
+
+ if (
+ String(quote.from).toUpperCase() !== String(inputs.from).toUpperCase() ||
+ String(quote.to).toUpperCase() !== String(inputs.to).toUpperCase()
+ ) {
+ return {
+ ok: false,
+ code: 'currency_mismatch',
+ reason: 'Quote currencies no longer match the form. Refresh the quote.',
+ };
+ }
+
+ const precision = getCurrencyMinorUnits(inputs.from);
+ const parsedAmount = parseDecimal(inputs.amount);
+ if (!parsedAmount.ok) {
+ return { ok: false, code: 'precision', reason: parsedAmount.error };
+ }
+ const sendParsed = parseDecimal(quote.sendAmount);
+ if (!sendParsed.ok || sendParsed.value !== parsedAmount.value) {
+ return {
+ ok: false,
+ code: 'changed_input',
+ reason: 'Amount changed after the quote was priced. Refresh the quote.',
+ };
+ }
+ // Reject amounts that would exceed the currency's minor-unit precision when
+ // re-serialized (defensive: parseDecimal already canonicalises).
+ const fraction = parsedAmount.value.split('.')[1] ?? '';
+ if (fraction.length > precision) {
+ return {
+ ok: false,
+ code: 'precision',
+ reason: `${inputs.from} amounts support at most ${precision} decimal places.`,
+ };
+ }
+
+ if (!isQuoteBoundToInputs(quote, inputs)) {
+ return {
+ ok: false,
+ code: 'changed_input',
+ reason: 'Quote no longer matches the form inputs. Refresh the quote.',
+ };
+ }
+
+ if (isQuoteExpired(quote, now)) {
+ return {
+ ok: false,
+ code: 'expired',
+ reason: 'This quote has expired. Refresh it before confirming.',
+ };
+ }
+
+ // Validate quote amounts independently. Reconciliation happens at the
+ // send boundary, where a separately assembled payload exists to compare.
+ const amountFields = ['sendAmount', 'fee', 'receiveAmount', 'rate'];
+ if (amountFields.some((field) => !parseDecimal(quote[field]).ok)) {
+ return {
+ ok: false,
+ code: 'reconcile',
+ reason: 'Quote amounts are invalid and cannot be confirmed.',
+ };
+ }
+
+ return { ok: true };
+}
+
+/**
+ * Seconds remaining until expiry (floored, never negative).
+ * @param {object} quote
+ * @param {number|Date} [now]
+ */
+export function quoteSecondsRemaining(quote, now = Date.now()) {
+ if (!quote?.expiresAt) return 0;
+ const expiry = Date.parse(quote.expiresAt);
+ if (Number.isNaN(expiry)) return 0;
+ const current = now instanceof Date ? now.getTime() : now;
+ return Math.max(0, Math.floor((expiry - current) / 1000));
+}
diff --git a/test/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx
index 46287e2..701fc6f 100644
--- a/test/components/WalletButton.test.jsx
+++ b/test/components/WalletButton.test.jsx
@@ -1,4 +1,3 @@
-
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
@@ -118,7 +117,9 @@ describe('WalletButton', () => {
);
await waitFor(() => {
- expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument();
+ expect(
+ screen.queryByText(/something went wrong/i),
+ ).not.toBeInTheDocument();
expect(screen.getByText(/500 XLM/)).toBeInTheDocument();
});
});
diff --git a/test/integration/offline-reconnect.test.jsx b/test/integration/offline-reconnect.test.jsx
index 10a8761..a6de581 100644
--- a/test/integration/offline-reconnect.test.jsx
+++ b/test/integration/offline-reconnect.test.jsx
@@ -1,4 +1,11 @@
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
+import {
+ act,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import App from '../../src/App.jsx';
@@ -43,6 +50,8 @@ describe('Offline and reconnect state for transfer mutations', () => {
});
window.history.pushState({}, '', '/send');
localStorage.clear();
+ // These cases exercise connectivity, not the demo wallet's random refusal.
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
});
afterEach(() => {
@@ -80,9 +89,7 @@ describe('Offline and reconnect state for transfer mutations', () => {
goOffline();
expect(
- await screen.findByText(
- /you're offline\. some features may not work/i,
- ),
+ await screen.findByText(/you're offline\. some features may not work/i),
).toBeInTheDocument();
});
@@ -119,14 +126,38 @@ describe('Offline and reconnect state for transfer mutations', () => {
goOnline();
// After reconnecting, the button should change back to "Review & Send" and be enabled.
- const submitButton = await screen.findByRole('button', {
- name: /review & send/i,
- }, { timeout: 5000 });
+ const submitButton = await screen.findByRole(
+ 'button',
+ {
+ name: /review & send/i,
+ },
+ { timeout: 5000 },
+ );
expect(submitButton).toBeEnabled();
+ expect(createTransfer).not.toHaveBeenCalled();
await user.click(submitButton);
-
- await screen.findByRole('heading', { name: /your transfers/i }, { timeout: 10000 });
+ const confirmation = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ expect(createTransfer).not.toHaveBeenCalled();
+ await user.click(
+ within(confirmation).getByRole('button', { name: /confirm transfer/i }),
+ );
+ const result = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ await user.click(
+ within(result).getByRole('button', { name: /view transfers/i }),
+ );
+
+ await screen.findByRole(
+ 'heading',
+ { name: /your transfers/i },
+ { timeout: 10000 },
+ );
expect(createTransfer).toHaveBeenCalledTimes(1);
});
@@ -170,9 +201,15 @@ describe('Offline and reconnect state for transfer mutations', () => {
await fillValidForm(user);
- // Start the submission.
- const submitButton = screen.getByRole('button', { name: /review & send/i });
- fireEvent.click(submitButton);
+ // Review first, then dispatch through the actual confirmation dialog.
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const confirmation = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ expect(createTransfer).not.toHaveBeenCalled();
+ await user.click(
+ within(confirmation).getByRole('button', { name: /confirm transfer/i }),
+ );
// After the catch block runs, the error message should be about
// connection loss, NOT a generic "could not submit" message.
@@ -185,5 +222,6 @@ describe('Offline and reconnect state for transfer mutations', () => {
expect(
screen.queryByText(/could not submit the transfer\./i),
).not.toBeInTheDocument();
+ expect(createTransfer).toHaveBeenCalledTimes(1);
});
-});
\ No newline at end of file
+});
diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx
index 43a7381..9ae992d 100644
--- a/test/integration/send-money-form.test.jsx
+++ b/test/integration/send-money-form.test.jsx
@@ -1,4 +1,3 @@
-
import {
act,
fireEvent,
@@ -61,6 +60,8 @@ describe('Send money form flows', () => {
beforeEach(() => {
window.history.pushState({}, '', '/send');
localStorage.clear();
+ // Keep the mock wallet's random rejection out of submission-guard tests.
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
});
afterEach(() => {
@@ -113,7 +114,9 @@ describe('Send money form flows', () => {
within(dialog).getByRole('button', { name: /confirm transfer/i }),
);
- expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled();
+ expect(
+ within(dialog).getByRole('button', { name: /sending/i }),
+ ).toBeDisabled();
expect(createTransfer).not.toHaveBeenCalled();
const resultDialog = await screen.findByRole(
@@ -200,7 +203,9 @@ describe('Send money form flows', () => {
within(dialog).getByRole('button', { name: /confirm transfer/i }),
);
- expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument();
+ expect(
+ await screen.findByText(/something went wrong/i),
+ ).toBeInTheDocument();
const retryButton = screen.getByRole('button', { name: /review & send/i });
expect(retryButton).toBeEnabled();
diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx
index f5cf076..136b66c 100644
--- a/test/integration/send-money-precision.test.jsx
+++ b/test/integration/send-money-precision.test.jsx
@@ -22,6 +22,32 @@ async function fillForm(user, { amount, to = 'NGN' } = {}) {
return amountField;
}
+async function reviewAndConfirm(user) {
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+}
+
+async function viewSubmittedTransfer(user) {
+ const dialog = await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ await user.click(
+ within(dialog).getByRole('button', { name: /view transfers/i }),
+ );
+ await screen.findByRole(
+ 'heading',
+ { name: /your transfers/i },
+ { timeout: 5000 },
+ );
+}
+
describe('Send flow — amount parsing regressions', () => {
beforeEach(() => {
window.history.pushState({}, '', '/send');
@@ -44,17 +70,22 @@ describe('Send flow — amount parsing regressions', () => {
});
it('keeps a negative amount negative so validation can reject it', async () => {
+ const createTransfer = vi.spyOn(api, 'createTransfer');
const user = userEvent.setup();
render();
// "-5" used to be silently rewritten to "5.00" and sent as a real transfer.
await fillForm(user, { amount: '-5' });
- expect(screen.getByLabelText(/^amount$/i)).toHaveValue(-5);
+ expect(screen.getByLabelText(/^amount$/i)).toHaveValue('-5.00');
await user.click(screen.getByRole('button', { name: /review & send/i }));
expect(
- await screen.findByText(/enter an amount greater than zero/i),
+ await screen.findByText(/amount cannot be negative/i),
).toBeInTheDocument();
+ expect(createTransfer).not.toHaveBeenCalled();
+ expect(
+ screen.queryByRole('dialog', { name: /confirm your transfer/i }),
+ ).not.toBeInTheDocument();
});
it('submits the exponent amount that the field actually held', async () => {
@@ -63,13 +94,8 @@ describe('Send flow — amount parsing regressions', () => {
render();
await fillForm(user, { amount: '1e3' });
- await user.click(screen.getByRole('button', { name: /review & send/i }));
-
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
- { timeout: 5000 },
- );
+ await reviewAndConfirm(user);
+ await viewSubmittedTransfer(user);
expect(createTransfer).toHaveBeenCalledTimes(1);
expect(createTransfer.mock.calls[0][0]).toMatchObject({
sendAmount: '1000',
@@ -96,13 +122,8 @@ describe('Send flow — the receipt matches the quote', () => {
render();
await fillForm(user, { amount: '200' });
- await user.click(screen.getByRole('button', { name: /review & send/i }));
-
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
- { timeout: 5000 },
- );
+ await reviewAndConfirm(user);
+ await viewSubmittedTransfer(user);
// 200.00 - 1.10 fee = 198.90, at 1480.5 NGN/USD = 294471.45 exactly.
const payload = createTransfer.mock.calls[0][0];
@@ -141,12 +162,8 @@ describe('Send flow — the receipt matches the quote', () => {
).toBeInTheDocument();
});
- await user.click(screen.getByRole('button', { name: /review & send/i }));
- await screen.findByRole(
- 'heading',
- { name: /your transfers/i },
- { timeout: 5000 },
- );
+ await reviewAndConfirm(user);
+ await viewSubmittedTransfer(user);
// The number on the receipt is the number that was quoted, to the cent.
// The seeded demo transfer shares this recipient, so find the row by the
@@ -205,7 +222,7 @@ describe('Send flow — contract failures do not submit', () => {
render();
await fillForm(user, { amount: '200' });
- await user.click(screen.getByRole('button', { name: /review & send/i }));
+ await reviewAndConfirm(user);
expect(
await screen.findByText(/rejected before it was sent/i, undefined, {
diff --git a/test/integration/send-money-quote-freshness.test.jsx b/test/integration/send-money-quote-freshness.test.jsx
new file mode 100644
index 0000000..55a6ede
--- /dev/null
+++ b/test/integration/send-money-quote-freshness.test.jsx
@@ -0,0 +1,505 @@
+import { act, render, screen, within } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import App from '../../src/App.jsx';
+import * as api from '../../src/services/api.js';
+import * as walletService from '../../src/services/wallet.js';
+import { QUOTE_TTL_MS } from '../../src/services/contracts/quote.js';
+
+async function fillValidForm(user) {
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.type(screen.getByLabelText(/amount/i), '15');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+}
+
+describe('Send money quote freshness', () => {
+ beforeEach(() => {
+ window.history.pushState({}, '', '/send');
+ localStorage.clear();
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ vi.setSystemTime(new Date('2026-09-24T19:00:00Z'));
+ // Quote scenarios connect successfully unless a rejection case overrides this.
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it('shows quote source, expiry, and currency metadata', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+ await fillValidForm(user);
+
+ const card = await screen.findByText(/transfer summary/i);
+ const quoteCard = card.closest('.quote-card');
+ expect(quoteCard).not.toBeNull();
+ expect(within(quoteCard).getByText(/^source$/i)).toBeInTheDocument();
+ expect(within(quoteCard).getByText(/fx\.table/i)).toBeInTheDocument();
+ expect(within(quoteCard).getByText(/^expires$/i)).toBeInTheDocument();
+ expect(within(quoteCard).getByText(/^currencies$/i)).toBeInTheDocument();
+ expect(within(quoteCard).getByText(/^quote id$/i)).toBeInTheDocument();
+ expect(quoteCard).toHaveAttribute('data-quote-id');
+ });
+
+ it('blocks confirm after the quote expires and offers refresh', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ const createSpy = vi.spyOn(api, 'createTransfer').mockResolvedValue({
+ id: 'tx_fresh',
+ recipient: 'amina@example.com',
+ from: 'USD',
+ to: 'NGN',
+ sendAmount: '15.00',
+ receiveAmount: '22000.00',
+ status: 'pending',
+ createdAt: '2026-09-24T19:00:00Z',
+ quoteId: 'qt_test',
+ });
+
+ render();
+ await fillValidForm(user);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+
+ await act(async () => {
+ vi.advanceTimersByTime(QUOTE_TTL_MS + 50);
+ });
+
+ expect(
+ within(dialog).getByText(/this quote has expired/i),
+ ).toBeInTheDocument();
+ expect(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ ).toBeDisabled();
+
+ await user.click(
+ within(dialog).getByRole('button', { name: /refresh quote/i }),
+ );
+ expect(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ ).not.toBeDisabled();
+
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ expect(createSpy).toHaveBeenCalled();
+ expect(createSpy.mock.calls[0][0].quoteId).toMatch(/^qt_/);
+ });
+
+ it('keeps a localized amount signable after keyboard review', async () => {
+ localStorage.setItem('remitflow:locale', JSON.stringify('fr-FR'));
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ await user.type(screen.getByLabelText(/amount/i), '15,00');
+ await user.keyboard('{Enter}');
+
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const card = within(dialog)
+ .getByText(/transfer summary/i)
+ .closest('.quote-card');
+ const quoteId = card.getAttribute('data-quote-id');
+ expect(quoteId).toMatch(/^qt_/);
+ expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent(
+ '15,00',
+ );
+
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+
+ const stored = JSON.parse(localStorage.getItem('remitflow.transfers'));
+ const transfer = stored.find((entry) => entry.quoteId === quoteId);
+ expect(transfer).toMatchObject({
+ recipient: 'amina@example.com',
+ sendAmount: '15',
+ from: 'USD',
+ to: 'NGN',
+ });
+ });
+
+ it('preserves Portuguese decimal input through blur and confirmation', async () => {
+ localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR'));
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ const amountField = screen.getByLabelText(/amount/i);
+ await user.type(amountField, '15,00');
+ await user.tab();
+ expect(amountField).toHaveValue('15,00');
+
+ // Repeated focus changes must leave the same value signable.
+ await user.click(amountField);
+ await user.tab();
+ expect(amountField).toHaveValue('15,00');
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent(
+ '15,00',
+ );
+
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ const stored = JSON.parse(localStorage.getItem('remitflow.transfers'));
+ const transfer = stored.find((entry) => entry.quoteId);
+ expect(transfer.sendAmount).toBe('15');
+ });
+
+ it.each([
+ {
+ label: 'integer balance',
+ amount: '15,00',
+ balance: 100,
+ canonical: '15',
+ },
+ {
+ label: 'exact fractional balance',
+ amount: '25,50',
+ balance: 25.5,
+ canonical: '25.5',
+ },
+ {
+ label: 'canonical string balance',
+ amount: '15,00',
+ balance: '100.00',
+ canonical: '15',
+ },
+ ])(
+ 'compares localized input against a connected $label',
+ async ({ amount, balance, canonical }) => {
+ localStorage.setItem('remitflow.transfers', JSON.stringify([]));
+ localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR'));
+ localStorage.setItem(
+ 'remitflow.wallet',
+ JSON.stringify({
+ publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF',
+ balance,
+ }),
+ );
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ const amountField = screen.getByLabelText(/amount/i);
+ await user.type(amountField, amount);
+ await user.tab();
+ expect(amountField).toHaveValue(amount);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent(
+ amount,
+ );
+ const quoteId = within(dialog)
+ .getByText(/transfer summary/i)
+ .closest('.quote-card')
+ .getAttribute('data-quote-id');
+ expect(quoteId).toMatch(/^qt_/);
+
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ const stored = JSON.parse(localStorage.getItem('remitflow.transfers'));
+ const matching = stored.filter((entry) => entry.quoteId === quoteId);
+ expect(matching).toHaveLength(1);
+ expect(matching[0]).toMatchObject({
+ recipient: 'amina@example.com',
+ sendAmount: canonical,
+ from: 'USD',
+ to: 'NGN',
+ });
+ },
+ );
+
+ it.each([
+ { amount: '100,01', balance: 100 },
+ { amount: '25,51', balance: 25.5 },
+ ])(
+ 'rejects localized $amount above connected balance $balance',
+ async ({ amount, balance }) => {
+ localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR'));
+ localStorage.setItem(
+ 'remitflow.wallet',
+ JSON.stringify({
+ publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF',
+ balance,
+ }),
+ );
+ const createSpy = vi.spyOn(api, 'createTransfer');
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ await user.type(screen.getByLabelText(/amount/i), amount);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+
+ expect(
+ await screen.findByText('Amount exceeds your wallet balance.'),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('dialog', { name: /confirm your transfer/i }),
+ ).not.toBeInTheDocument();
+ expect(createSpy).not.toHaveBeenCalled();
+ expect(localStorage.getItem('remitflow.transfers')).toBeNull();
+ },
+ );
+
+ it.each([
+ {
+ label: 'rejected connection',
+ amount: '15,00',
+ random: 0,
+ message:
+ 'Wallet connection did not complete. Connect your wallet and try again.',
+ },
+ {
+ label: 'newly connected insufficient balance',
+ amount: '1000,01',
+ random: 0.5,
+ message: 'Amount exceeds your wallet balance.',
+ },
+ ])('stops a transfer after a $label', async ({ amount, random, message }) => {
+ localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR'));
+ localStorage.setItem('remitflow.transfers', JSON.stringify([]));
+ vi.spyOn(Math, 'random').mockReturnValue(random);
+ const createSpy = vi.spyOn(api, 'createTransfer');
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ await user.type(screen.getByLabelText(/amount/i), amount);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ expect(
+ await screen.findByText(message, { exact: false }),
+ ).toBeInTheDocument();
+ expect(
+ screen.queryByRole('dialog', { name: /transfer submitted/i }),
+ ).not.toBeInTheDocument();
+ expect(createSpy).not.toHaveBeenCalled();
+ expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([]);
+ expect(
+ screen.getByRole('button', { name: /review & send/i }),
+ ).not.toBeDisabled();
+ });
+
+ it.each([
+ { amount: '15,00', canonical: '15' },
+ { amount: '1000,00', canonical: '1000' },
+ ])(
+ 'checks the returned wallet before saving affordable $amount',
+ async ({ amount, canonical }) => {
+ localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR'));
+ localStorage.setItem('remitflow.transfers', JSON.stringify([]));
+ vi.spyOn(Math, 'random').mockReturnValue(0.5);
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com');
+ await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN');
+ await user.type(screen.getByLabelText(/amount/i), amount);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const quoteId = within(dialog)
+ .getByText(/transfer summary/i)
+ .closest('.quote-card')
+ .getAttribute('data-quote-id');
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+
+ expect(JSON.parse(localStorage.getItem('remitflow.wallet')).balance).toBe(
+ 1000,
+ );
+ expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([
+ expect.objectContaining({ quoteId, sendAmount: canonical }),
+ ]);
+ },
+ );
+
+ it.each([
+ { label: 'the network drops', reconnectBeforeWallet: false },
+ { label: 'the network drops and recovers', reconnectBeforeWallet: true },
+ ])(
+ 'does not resume confirmation when $label during wallet connection',
+ async ({ reconnectBeforeWallet }) => {
+ localStorage.setItem('remitflow.transfers', JSON.stringify([]));
+ const online = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
+ let resolveConnection;
+ const connection = new Promise((resolve) => {
+ resolveConnection = resolve;
+ });
+ vi.spyOn(walletService, 'connectWallet').mockReturnValue(connection);
+ const createSpy = vi.spyOn(api, 'createTransfer');
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ render();
+
+ await fillValidForm(user);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const originalQuoteId = within(dialog)
+ .getByText(/transfer summary/i)
+ .closest('.quote-card')
+ .getAttribute('data-quote-id');
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ act(() => {
+ online.mockReturnValue(false);
+ window.dispatchEvent(new Event('offline'));
+ });
+ expect(
+ screen.queryByRole('dialog', { name: /confirm your transfer/i }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/the pending quote was cleared/i),
+ ).toBeInTheDocument();
+
+ if (reconnectBeforeWallet) {
+ act(() => {
+ online.mockReturnValue(true);
+ window.dispatchEvent(new Event('online'));
+ });
+ }
+
+ await act(async () => {
+ resolveConnection({
+ publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF',
+ balance: 1000,
+ });
+ await connection;
+ });
+ await act(async () => {
+ vi.advanceTimersByTime(700);
+ });
+
+ expect(createSpy).not.toHaveBeenCalled();
+ expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual(
+ [],
+ );
+ expect(
+ screen.queryByRole('dialog', { name: /transfer submitted/i }),
+ ).not.toBeInTheDocument();
+
+ if (!reconnectBeforeWallet) {
+ act(() => {
+ online.mockReturnValue(true);
+ window.dispatchEvent(new Event('online'));
+ });
+ }
+ const reviewButton = screen.getByRole('button', {
+ name: /review & send/i,
+ });
+ expect(reviewButton).toBeEnabled();
+ await user.click(reviewButton);
+ const retryDialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ const retryQuoteId = within(retryDialog)
+ .getByText(/transfer summary/i)
+ .closest('.quote-card')
+ .getAttribute('data-quote-id');
+ expect(retryQuoteId).not.toBe(originalQuoteId);
+ await user.click(
+ within(retryDialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ expect(createSpy).toHaveBeenCalledTimes(1);
+ expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([
+ expect.objectContaining({ quoteId: retryQuoteId, sendAmount: '15' }),
+ ]);
+ },
+ );
+
+ it('binds the quote id into the createTransfer payload', async () => {
+ const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
+ const createSpy = vi
+ .spyOn(api, 'createTransfer')
+ .mockImplementation(async (payload) => ({
+ id: 'tx_bound',
+ status: 'pending',
+ createdAt: '2026-09-24T19:00:00Z',
+ ...payload,
+ }));
+
+ render();
+ await fillValidForm(user);
+ await user.click(screen.getByRole('button', { name: /review & send/i }));
+ const dialog = await screen.findByRole('dialog', {
+ name: /confirm your transfer/i,
+ });
+ await user.click(
+ within(dialog).getByRole('button', { name: /confirm transfer/i }),
+ );
+
+ await screen.findByRole(
+ 'dialog',
+ { name: /transfer submitted/i },
+ { timeout: 5000 },
+ );
+ const payload = createSpy.mock.calls[0][0];
+ expect(payload.quoteId).toMatch(/^qt_/);
+ expect(payload.sendAmount).toBeDefined();
+ expect(payload.from).toBe('USD');
+ expect(payload.to).toBe('NGN');
+ });
+});
diff --git a/test/unit/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx
index 20c04bf..4436e1d 100644
--- a/test/unit/AppContext.wallet.test.jsx
+++ b/test/unit/AppContext.wallet.test.jsx
@@ -1,4 +1,3 @@
-
import { describe, expect, it, beforeEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { AppProvider, useApp } from '../../src/context/AppContext.jsx';
diff --git a/test/unit/quote-binding.test.js b/test/unit/quote-binding.test.js
new file mode 100644
index 0000000..22ecd79
--- /dev/null
+++ b/test/unit/quote-binding.test.js
@@ -0,0 +1,131 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { buildQuote } from '../../src/services/quote.js';
+import { QUOTE_TTL_MS } from '../../src/services/contracts/quote.js';
+import {
+ amountsReconcile,
+ assertQuoteSignable,
+ isQuoteBoundToInputs,
+ mintQuoteId,
+ quoteInputFingerprint,
+ quoteSecondsRemaining,
+ validateCurrencyPair,
+} from '../../src/utils/quoteBinding.js';
+
+const NOW = Date.parse('2026-09-24T19:00:00Z');
+
+describe('quoteBinding', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(NOW);
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('mints unique quote ids', () => {
+ const a = mintQuoteId();
+ const b = mintQuoteId();
+ expect(a).toMatch(/^qt_/);
+ expect(b).toMatch(/^qt_/);
+ expect(a).not.toBe(b);
+ });
+
+ it('validates the currency matrix', () => {
+ expect(validateCurrencyPair('USD', 'NGN').ok).toBe(true);
+ expect(validateCurrencyPair('USD', 'USD').ok).toBe(false);
+ expect(validateCurrencyPair('USD', 'XXX').ok).toBe(false);
+ expect(validateCurrencyPair('YYY', 'NGN').ok).toBe(false);
+ });
+
+ it('fingerprints inputs canonically', () => {
+ expect(
+ quoteInputFingerprint({ amount: '10.00', from: 'usd', to: 'ngn' }),
+ ).toBe(quoteInputFingerprint({ amount: '10', from: 'USD', to: 'NGN' }));
+ });
+
+ it('binds a built quote to its priced inputs', () => {
+ const quote = buildQuote('25.50', 'USD', 'NGN', { now: NOW });
+ expect(quote.id).toMatch(/^qt_/);
+ expect(quote.source).toBe('fx.table');
+ expect(
+ isQuoteBoundToInputs(quote, { amount: '25.50', from: 'USD', to: 'NGN' }),
+ ).toBe(true);
+ expect(
+ isQuoteBoundToInputs(quote, { amount: '25.51', from: 'USD', to: 'NGN' }),
+ ).toBe(false);
+ expect(
+ isQuoteBoundToInputs(quote, { amount: '25.50', from: 'USD', to: 'INR' }),
+ ).toBe(false);
+ });
+
+ it('rejects expired quotes at confirmation', () => {
+ const quote = buildQuote('10', 'USD', 'NGN', {
+ now: NOW,
+ ttlMs: QUOTE_TTL_MS,
+ });
+ const inputs = { amount: '10.00', from: 'USD', to: 'NGN' };
+ expect(assertQuoteSignable(quote, inputs, NOW + QUOTE_TTL_MS - 1).ok).toBe(
+ true,
+ );
+ const expired = assertQuoteSignable(quote, inputs, NOW + QUOTE_TTL_MS);
+ expect(expired.ok).toBe(false);
+ expect(expired.code).toBe('expired');
+ });
+
+ it('rejects changed inputs after pricing', () => {
+ const quote = buildQuote('10', 'USD', 'NGN', { now: NOW });
+ const result = assertQuoteSignable(
+ quote,
+ {
+ amount: '11.00',
+ from: 'USD',
+ to: 'NGN',
+ },
+ NOW,
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('changed_input');
+ });
+
+ it('rejects unsupported currency pairs', () => {
+ const quote = buildQuote('10', 'USD', 'NGN', { now: NOW });
+ const result = assertQuoteSignable(
+ { ...quote, from: 'USD', to: 'XXX' },
+ { amount: '10.00', from: 'USD', to: 'XXX' },
+ NOW,
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('currency_matrix');
+ });
+
+ it('reconciles displayed and serialized amounts', () => {
+ const quote = buildQuote('100.10', 'USD', 'NGN', { now: NOW });
+ expect(amountsReconcile(quote, { ...quote })).toBe(true);
+ expect(amountsReconcile(quote, { ...quote, receiveAmount: '1.00' })).toBe(
+ false,
+ );
+ });
+
+ it('reports remaining seconds without going negative', () => {
+ const quote = buildQuote('10', 'USD', 'EUR', { now: NOW, ttlMs: 5_000 });
+ expect(quoteSecondsRemaining(quote, NOW)).toBe(5);
+ expect(quoteSecondsRemaining(quote, NOW + 10_000)).toBe(0);
+ });
+
+ it('refuses to sign a quote without an id', () => {
+ const quote = buildQuote('10', 'USD', 'NGN', { now: NOW });
+ const { id, ...rest } = quote;
+ const result = assertQuoteSignable(
+ rest,
+ {
+ amount: '10.00',
+ from: 'USD',
+ to: 'NGN',
+ },
+ NOW,
+ );
+ expect(result.ok).toBe(false);
+ expect(result.code).toBe('missing_quote_id');
+ });
+});
diff --git a/test/unit/quote.test.js b/test/unit/quote.test.js
index 0fcfe52..9461138 100644
--- a/test/unit/quote.test.js
+++ b/test/unit/quote.test.js
@@ -4,18 +4,26 @@ import { buildQuote } from '../../src/services/quote.js';
describe('buildQuote', () => {
it('keeps submitted, fee, and receipt amounts as canonical decimal strings', () => {
const quote = buildQuote('100.10', 'USD', 'NGN');
+ // The quote contract removes redundant zeros while preserving exact values.
expect(quote).toMatchObject({
- sendAmount: '100.10',
- fee: '0.60',
- amountAfterFee: '99.50',
+ sendAmount: '100.1',
+ fee: '0.6',
+ amountAfterFee: '99.5',
receiveAmount: '147309.75',
});
});
it('regresses binary floating-point drift at the receipt boundary', () => {
const quote = buildQuote('0.30', 'USD', 'MXN');
- expect(quote.sendAmount).toBe('0.30');
- expect(quote.amountAfterFee).toBe('0.00');
- expect(quote.receiveAmount).toBe('0.00');
+ expect(quote.sendAmount).toBe('0.3');
+ // The minimum fee is 0.25: 0.05 * 17.1 = 0.855, rounded to 0.86 MXN.
+ expect(quote.fee).toBe('0.25');
+ expect(quote.amountAfterFee).toBe('0.05');
+ expect(quote.receiveAmount).toBe('0.86');
+
+ expect(buildQuote('0.20', 'USD', 'MXN')).toMatchObject({
+ amountAfterFee: '0',
+ receiveAmount: '0',
+ });
});
});
diff --git a/test/unit/useTransfers.errors.test.jsx b/test/unit/useTransfers.errors.test.jsx
index 071426f..11741da 100644
--- a/test/unit/useTransfers.errors.test.jsx
+++ b/test/unit/useTransfers.errors.test.jsx
@@ -14,7 +14,9 @@ describe('useTransfers error retry policy', () => {
const { result } = renderHook(() => useTransfers());
await waitFor(() => {
- expect(result.current.error).toBe('Something went wrong. Please try again.');
+ expect(result.current.error).toBe(
+ 'Something went wrong. Please try again.',
+ );
});
expect(result.current.retryable).toBe(false);
diff --git a/vite-plugin-security-headers.js b/vite-plugin-security-headers.js
index af430cf..d3fb1d7 100644
--- a/vite-plugin-security-headers.js
+++ b/vite-plugin-security-headers.js
@@ -66,8 +66,8 @@ export function buildCsp(apiOrig = apiOrigin()) {
const directives = [
"default-src 'self'",
"script-src 'self'",
- "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag
- "img-src 'self' data:", // data: URIs used by chart canvas toDataURL
+ "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag
+ "img-src 'self' data:", // data: URIs used by chart canvas toDataURL
`connect-src ${connectSrc.join(' ')}`,
"font-src 'self'",
"object-src 'none'",
@@ -90,8 +90,7 @@ export const SECURITY_HEADERS = {
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
'Referrer-Policy': 'strict-origin-when-cross-origin',
- 'Permissions-Policy':
- 'camera=(), microphone=(), geolocation=(), payment=()',
+ 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Resource-Policy': 'same-origin',
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
diff --git a/work/validation/rf296-existing-suite-relay17-20261004/README.md b/work/validation/rf296-existing-suite-relay17-20261004/README.md
new file mode 100644
index 0000000..35725f6
--- /dev/null
+++ b/work/validation/rf296-existing-suite-relay17-20261004/README.md
@@ -0,0 +1,93 @@
+# RF296: existing CI gates restored
+
+The complete package test selection passes on
+[`17dd96c4593fcd599286d263ed7cb72f6a4b7c20`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/17dd96c4593fcd599286d263ed7cb72f6a4b7c20):
+**485 passed, zero failed, zero skipped or pending, across all 49 files**.
+The original [PR296](https://github.com/RemitFlow/RemitFlow-Frontend/pull/296)
+and its prior quote-freshness, locale, balance, wallet, and connection repairs
+are preserved.
+
+[Issue279](https://github.com/RemitFlow/RemitFlow-Frontend/issues/279) explicitly
+requires existing tests and CI to remain passing. This continuation repairs the
+four files behind the twelve previously recorded failures and the existing
+formatting gate. One genuine product error was corrected: an already-valid
+amount that cannot be priced now receives a pricing explanation instead of an
+instruction to enter a positive amount.
+
+## Executed results
+
+| Gate | Result |
+| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
+| Existing Prettier check | Passed with Prettier 3.9.6; fifteen flagged files formatted, configuration and ignores unchanged |
+| Complete package test selection | 485 passed / 0 failed / 0 skipped, all 49 files passed |
+| Test duration | Vitest reported 54.94 seconds; complete process took 55.56 seconds |
+| `npm audit --audit-level=critical` | Exit 0; zero critical vulnerabilities |
+| Remaining audit findings | 20 high, 12 moderate, 2 low |
+| `npm run build` | Exit 0; Vite reported 1.46 seconds, complete command took 1.99 seconds |
+| Source identity | All 348 tracked file blobs and modes matched the published tree before execution; tracked contents remained identical afterward |
+
+The [earlier recorded run](https://tokenjunkielabs.slack.com/archives/C0BVANHNB26/p1791106741866919)
+at `6fe01b6fd00f4063dfa1dc7c0ea826d4f3ac2077` had 473 passing and 12 failing
+tests across the same 49 files. That observation was retained, rather than
+rerunning the old complete suite. The corrected source was executed once as a
+combined suite. Existing tests and strict money assertions were retained. The
+combined run used no retries, exclusions or skipped cases, and added or changed
+no snapshots.
+These durations describe validation commands on this host, not application or
+network performance.
+
+## Repairs included
+
+| Contribution | Preserved behavior and correction |
+| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| [`84510d0b`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/84510d0b9d50d8178d287c4acccc16903cad77e4) | Honest null-price feedback; precision fixtures traverse review, confirmation, result and receipt navigation. Exact amounts, fee/rate payloads, receipt reconciliation, rejection and no-create assertions remain. |
+| [`eb89ddb3`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/eb89ddb3dd3f7b128ce29e9f3e79f676a1478d05) | Offline/reconnect fixtures use the real confirmation flow. Offline blocking, no automatic resubmission, explicit recovery, exactly-once creation and unknown-status handling remain asserted. |
+| [`9f4f56fb`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/9f4f56fbc0470e3824be7b95234c429cfa8d1089) | Pending-wallet assertions locate the Sending button inside the confirmation dialog; the unrelated demo-wallet random refusal is deterministic. Existing submission locks, native-submit and retry checks remain. |
+| [`4f74e1fa`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/4f74e1fa9add4e0f541b9c565d35df17e7800427) | Strict canonical-decimal expectations match the existing parser contract. The original USD 0.30 case retains exact fee, remainder and rounding checks; a below-fee USD 0.20 example retains zero clamping. Product pricing code is unchanged. |
+| [`17dd96c4`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/17dd96c4593fcd599286d263ed7cb72f6a4b7c20) | Only the existing formatter's output on the fifteen flagged files; all other 333 tracked blobs unchanged. |
+
+The affected maintained files also passed individually before composition:
+precision 7/7, offline/reconnect 6/6, form 7/7, and quote 2/2. The combined
+execution above covers the subsequently formatted source.
+
+## Environment and reproduction
+
+Execution used Node 24.19.0 and Vitest 4.1.10 with the retained matching
+installation. The package manifest and lockfile were unchanged. The complete
+test selection is exactly the one in `package.json`:
+
+```bash
+npm test -- --maxWorkers=2
+npm audit --audit-level=critical
+npm run build
+```
+
+Run these after installing dependencies from the committed lockfile. The local
+execution additionally selected the default and JSON reporters, used a dedicated
+temporary directory, and imported the repository's Vite configuration with only
+`cacheDir` overridden to isolate writable files. There were no altered test
+timeouts, exclusions, retry settings, snapshots, or dependency installs for this
+aggregate run. The exact command and temporary configuration are in the evidence.
+
+The mounted-app tests use the repository's demonstration wallet and transfer
+service in jsdom. Raw output retains its React/Vite warnings. The audit passes
+the workflow's critical threshold while retaining the noncritical findings above.
+
+## Hosted status and retained evidence
+
+At the executed source, GitHub reports `action_required` for both
+[CI](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208479) and
+[Lighthouse CI](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208458).
+The current GitHub connection reports pull-only upstream permissions.
+These are local gate results; hosted workflow approval and maintainer acceptance
+remain external steps. The existing PR-description handoff and contributor
+custody are preserved.
+
+[receipt.json](receipt.json) records source identities, commands, timestamps,
+results, artifact hashes and hosted status. [evidence.zip](evidence.zip) preserves
+the original complete Vitest JSON/output, the source manifest and execution
+wrapper, audit JSON, build output, and formatter before/after logs and diff.
+The archive's SHA-256 and each member's hash are in the receipt; raw execution
+files in the archive are unchanged.
+
+The work was published to the existing contribution without force pushes.
diff --git a/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip b/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip
new file mode 100644
index 0000000..2d97a9e
Binary files /dev/null and b/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip differ
diff --git a/work/validation/rf296-existing-suite-relay17-20261004/receipt.json b/work/validation/rf296-existing-suite-relay17-20261004/receipt.json
new file mode 100644
index 0000000..44e54db
--- /dev/null
+++ b/work/validation/rf296-existing-suite-relay17-20261004/receipt.json
@@ -0,0 +1,235 @@
+{
+ "schema": "rf296-existing-suite/v1",
+ "pr_url": "https://github.com/RemitFlow/RemitFlow-Frontend/pull/296",
+ "issue_url": "https://github.com/RemitFlow/RemitFlow-Frontend/issues/279",
+ "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20",
+ "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b",
+ "previous_observation": {
+ "source_commit": "6fe01b6fd00f4063dfa1dc7c0ea826d4f3ac2077",
+ "tests_passed": 473,
+ "tests_failed": 12,
+ "files_passed": 45,
+ "files_failed": 4,
+ "vitest_reported_duration_seconds": 67.79,
+ "receipt_url": "https://tokenjunkielabs.slack.com/archives/C0BVANHNB26/p1791106741866919",
+ "rerun_by_this_aggregate_gate": false
+ },
+ "formatting": {
+ "prettier_version": "3.9.6",
+ "flagged_files_before": 15,
+ "exit_code_after": 0,
+ "formatted_files": 15,
+ "unchanged_original_files": 333,
+ "configuration_ignore_and_dependency_files_preserved": true
+ },
+ "suite": {
+ "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20",
+ "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b",
+ "verified_source_file_count_before": 348,
+ "verified_source_file_count_after": 348,
+ "started_at_utc": "2026-10-04T10:06:07.332838+00:00",
+ "finished_at_utc": "2026-10-04T10:07:02.901491+00:00",
+ "elapsed_seconds": 55.55961997700069,
+ "node_version": "v24.19.0",
+ "vitest_version": "4.1.10",
+ "command": [
+ "/opt/codex/runtimes/codex-primary-runtime/dependencies/node/bin/node",
+ "node_modules/vitest/vitest.mjs",
+ "run",
+ "test/integration",
+ "test/unit",
+ "test/components",
+ "test/services",
+ "test/lighthouse-config.test.js",
+ "test/security",
+ "--config",
+ "relay17.vitest.config.mjs",
+ "--maxWorkers=2",
+ "--reporter=default",
+ "--reporter=json",
+ "--outputFile.json=/dev/shm/relay17-rf296-combined/validation/relay17-combined/vitest-results.json"
+ ],
+ "temporary_directory": "/dev/shm/relay17-rf296-combined/validation/relay17-combined/tmp",
+ "config_change": "Imports the repository Vite config; overrides only cacheDir.",
+ "exit_code": 0,
+ "success": true,
+ "tests_total": 485,
+ "tests_passed": 485,
+ "tests_failed": 0,
+ "tests_pending": 0,
+ "tests_todo": 0,
+ "files_total": 49,
+ "files_passed": 49,
+ "files_failed": 0,
+ "failed_cases": [],
+ "snapshot": {
+ "added": 0,
+ "failure": false,
+ "filesAdded": 0,
+ "filesRemoved": 0,
+ "filesRemovedList": [],
+ "filesUnmatched": 0,
+ "filesUpdated": 0,
+ "matched": 0,
+ "total": 0,
+ "unchecked": 0,
+ "uncheckedKeysByFile": [],
+ "unmatched": 0,
+ "updated": 0,
+ "didUpdate": false
+ },
+ "pinned-source.json_sha256": "480385bcda69557c1fb6da6d013c617fd14f5582ce151e4e13cf9b681f2f29f6",
+ "vitest-output.log_sha256": "60211e449b3e241ac46cd1ccd4ff61e6ac2a0d5626352bcbef8e0cc388c0b36d",
+ "vitest-results.json_sha256": "7990edb5a5878120ef9554eb1437973d1c00e890b1f599ad1c3ab1bc62e1e3dd",
+ "vitest_reported_duration_seconds": 54.94
+ },
+ "audit": {
+ "command": [
+ "npm",
+ "audit",
+ "--audit-level=critical",
+ "--json",
+ "--fetch-retries=0",
+ "--fetch-timeout=20000"
+ ],
+ "started_at_utc": "2026-10-04T10:00:59.609040+00:00",
+ "elapsed_seconds": 23.48439453699757,
+ "exit_code": 0,
+ "vulnerabilities": {
+ "info": 0,
+ "low": 2,
+ "moderate": 12,
+ "high": 20,
+ "critical": 0,
+ "total": 34
+ },
+ "error": null,
+ "dependency_file_blobs": {
+ "package.json": "625d0f3895fcc7afbfcfa131713249f5cd65dcd6",
+ "package-lock.json": "70d29c1865dac7ce76ba7d4e6ca5ee454be54aae"
+ },
+ "audit_results_sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8"
+ },
+ "build": {
+ "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20",
+ "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b",
+ "command": ["npm", "run", "build"],
+ "started_at_utc": "2026-10-04T10:09:40.644314+00:00",
+ "elapsed_seconds": 1.9855508239998017,
+ "exit_code": 0,
+ "tracked_files_unchanged": true,
+ "changed_tracked_paths": [],
+ "build_output_sha256": "8a643fead8908619bb82f7f297adb7d2bd18e2e67702839a253b052f2d743262",
+ "vite_reported_duration_seconds": 1.46
+ },
+ "hosted_runs": [
+ {
+ "name": "CI",
+ "run_id": 37194208479,
+ "head_sha": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20",
+ "conclusion": "action_required",
+ "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208479"
+ },
+ {
+ "name": "Lighthouse CI",
+ "run_id": 37194208458,
+ "head_sha": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20",
+ "conclusion": "action_required",
+ "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208458"
+ }
+ ],
+ "upstream_permissions_observed": {
+ "admin": false,
+ "maintain": false,
+ "push": false,
+ "triage": false,
+ "pull": true
+ },
+ "archive": {
+ "path": "evidence.zip",
+ "bytes": 55516,
+ "sha256": "8e443916e11ec2bb299a9a254c0a1b263614705df758fc26d6257c54236dcd7a",
+ "entries": [
+ {
+ "path": "run/receipt.json",
+ "bytes": 1967,
+ "sha256": "368863c8d736a0371c27b82e00d90d6e885a4c71fdab44be468643af8d411891"
+ },
+ {
+ "path": "run/pinned-source.json",
+ "bytes": 48742,
+ "sha256": "480385bcda69557c1fb6da6d013c617fd14f5582ce151e4e13cf9b681f2f29f6"
+ },
+ {
+ "path": "run/vitest-results.json",
+ "bytes": 144366,
+ "sha256": "7990edb5a5878120ef9554eb1437973d1c00e890b1f599ad1c3ab1bc62e1e3dd"
+ },
+ {
+ "path": "run/vitest-output.log",
+ "bytes": 37811,
+ "sha256": "60211e449b3e241ac46cd1ccd4ff61e6ac2a0d5626352bcbef8e0cc388c0b36d"
+ },
+ {
+ "path": "run/audit-results.json",
+ "bytes": 51078,
+ "sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8"
+ },
+ {
+ "path": "run/audit-stderr.log",
+ "bytes": 307,
+ "sha256": "7d61cb2e2deb776dfec4d6f54693f9edeed0af5c4da52f091c53c995d862fb65"
+ },
+ {
+ "path": "run/audit-receipt.json",
+ "bytes": 663,
+ "sha256": "113e36b0f21bec11a538aae6cf9949fe9636d0bb96df1f4f2a13f6bb98ff2cba"
+ },
+ {
+ "path": "run/build-output.log",
+ "bytes": 477,
+ "sha256": "8a643fead8908619bb82f7f297adb7d2bd18e2e67702839a253b052f2d743262"
+ },
+ {
+ "path": "run/build-receipt.json",
+ "bytes": 455,
+ "sha256": "56c5e222bf04e6f29224465cfc6442d6cd8646aded00594b5a1d23061b317838"
+ },
+ {
+ "path": "run/run.py",
+ "bytes": 3536,
+ "sha256": "7e506fac8e41905d98b959d8f06d2e1051d5aee8da320e8890240412109606fb"
+ },
+ {
+ "path": "run/relay17.vitest.config.mjs",
+ "bytes": 222,
+ "sha256": "33a69c3b33652c3a50f9334f4097dfb512b0cdee37180e4f1550a2599a09be46"
+ },
+ {
+ "path": "format/check-before.log",
+ "bytes": 700,
+ "sha256": "621841c13145d58854e525a4f0b5bd9aca72594792e9ed68e03c7afb2824e8c8"
+ },
+ {
+ "path": "format/check-after.log",
+ "bytes": 66,
+ "sha256": "17aa973d3f004560237d9a95171210b0671deff23d61628eecf7322ff5938f20"
+ },
+ {
+ "path": "format/write.log",
+ "bytes": 561,
+ "sha256": "0d577fd9daa1ad077f7edd3bd27126aac87150a261dcd7dac75c60970d95d142"
+ },
+ {
+ "path": "format/format.patch",
+ "bytes": 23185,
+ "sha256": "85c2c18123b373936cce3205582d40e09d8534997fbe394b55588e17889cdaf2"
+ },
+ {
+ "path": "format/changes.json",
+ "bytes": 3214,
+ "sha256": "9ad1a19059164f2c82d2e25718218a0bb51e8a898a9a499f189d8e7e3dfe7d36"
+ }
+ ]
+ }
+}