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/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..6ed5e50 100644
--- a/src/pages/SendMoney.jsx
+++ b/src/pages/SendMoney.jsx
@@ -7,14 +7,19 @@ 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';
+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 +37,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();
@@ -59,6 +67,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 +75,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 +85,25 @@ 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 live = assertQuoteSignable(pendingQuote, { amount, from, to }, Date.now());
+ if (!live.ok && live.code !== 'expired') {
+ setPendingQuote(null);
+ setPhase(null);
+ setSubmitError(live.reason);
+ }
+ }, [amount, from, to, 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(() => {
@@ -119,8 +149,9 @@ export default function SendMoney() {
) {
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 +160,53 @@ 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') {
+ 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() {
+ 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();
@@ -171,7 +238,18 @@ export default function SendMoney() {
return;
}
+ const signable = assertQuoteSignable(
+ finalQuote,
+ { amount: parsedAmount.value, from, to },
+ Date.now(),
+ );
+ if (!signable.ok) {
+ setSubmitError(signable.reason);
+ return;
+ }
+
setPendingQuote(finalQuote);
+ setQuoteClock(Date.now());
setPhase('confirm');
}
@@ -191,34 +269,50 @@ export default function SendMoney() {
await connect();
}
- // 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 });
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;
}
- // 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);
@@ -255,6 +349,10 @@ export default function SendMoney() {
}
const errorCount = Object.keys(errors).length;
+ const pendingExpired =
+ phase === 'confirm' && pendingQuote
+ ? isQuoteExpired(pendingQuote, quoteClock)
+ : false;
return (
@@ -360,7 +458,7 @@ export default function SendMoney() {
{quote ? (
-
+
) : (
Enter an amount to see your quote.
@@ -377,9 +475,18 @@ export default function SendMoney() {
{recipient}
-
+
- {submitting ? 'Submitting your transfer…' : ''}
+ {submitting
+ ? 'Submitting your transfer…'
+ : pendingExpired
+ ? 'Quote expired — refresh before confirming.'
+ : ''}
-
@@ -427,6 +537,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/quote.js b/src/services/quote.js
index 8b41415..40cdca3 100644
--- a/src/services/quote.js
+++ b/src/services/quote.js
@@ -13,6 +13,10 @@ import {
QUOTE_TTL_MS,
parseQuote,
} from './contracts/quote.js';
+import {
+ mintQuoteId,
+ quoteInputFingerprint,
+} from '../utils/quoteBinding.js';
import {
convertMinorUnits,
currencyExponent,
@@ -79,20 +83,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..b2e4de2
--- /dev/null
+++ b/src/utils/quoteBinding.js
@@ -0,0 +1,225 @@
+/**
+ * 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/integration/send-money-quote-freshness.test.jsx b/test/integration/send-money-quote-freshness.test.jsx
new file mode 100644
index 0000000..20ac6ad
--- /dev/null
+++ b/test/integration/send-money-quote-freshness.test.jsx
@@ -0,0 +1,121 @@
+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 { 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'));
+ });
+
+ 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('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/quote-binding.test.js b/test/unit/quote-binding.test.js
new file mode 100644
index 0000000..6a9e9f8
--- /dev/null
+++ b/test/unit/quote-binding.test.js
@@ -0,0 +1,118 @@
+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');
+ });
+});