From 4b790021433eaadc46673e4a058955e94052ab3c Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Thu, 24 Sep 2026 15:51:18 -0400 Subject: [PATCH 1/3] feat(send): bind quote freshness and currency checks into confirmation Show quote source, timestamps, expiry, and currency metadata on the card. Invalidate pending quotes on field or network changes, refuse to confirm expired or mismatched prices, and bind the quote id into the transfer payload so displayed and serialized amounts stay reconciled. Closes #279 --- src/components/QuoteCard.css | 53 +++++ src/components/QuoteCard.jsx | 115 ++++++++- src/pages/SendMoney.css | 6 + src/pages/SendMoney.jsx | 158 +++++++++++-- src/services/contracts/quote.js | 3 + src/services/contracts/transfer.js | 2 + src/services/quote.js | 22 +- src/utils/quoteBinding.js | 222 ++++++++++++++++++ .../send-money-quote-freshness.test.jsx | 121 ++++++++++ test/unit/quote-binding.test.js | 118 ++++++++++ 10 files changed, 788 insertions(+), 32 deletions(-) create mode 100644 src/utils/quoteBinding.js create mode 100644 test/integration/send-money-quote-freshness.test.jsx create mode 100644 test/unit/quote-binding.test.js diff --git a/src/components/QuoteCard.css b/src/components/QuoteCard.css index 4b27d23..d877588 100644 --- a/src/components/QuoteCard.css +++ b/src/components/QuoteCard.css @@ -48,3 +48,56 @@ height: 2px; } } + +.quote-meta { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin: 0 0 0.85rem; + padding: 0.65rem 0.75rem; + border-radius: 8px; + background: rgba(15, 23, 42, 0.45); + font-size: 0.78rem; + color: var(--color-muted); +} + +.quote-meta-line { + display: flex; + justify-content: space-between; + gap: 0.75rem; +} + +.quote-mono { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: 0.02em; +} + +.quote-card--expired { + border-color: #b45309; +} + +.quote-expired-banner { + margin: 0 0 0.85rem; + padding: 0.55rem 0.7rem; + border-radius: 8px; + background: rgba(180, 83, 9, 0.18); + color: #fbbf24; + font-size: 0.85rem; +} + +.quote-refresh { + appearance: none; + border: none; + background: transparent; + color: #fde68a; + font: inherit; + font-weight: 600; + text-decoration: underline; + cursor: pointer; + padding: 0; +} + +.quote-refresh:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; +} diff --git a/src/components/QuoteCard.jsx b/src/components/QuoteCard.jsx index c978915..7f00550 100644 --- a/src/components/QuoteCard.jsx +++ b/src/components/QuoteCard.jsx @@ -2,31 +2,108 @@ import { formatRate, formatPercent } from '../utils/format.js'; import { formatMoney } from '../utils/money.js'; import { FEE_PERCENT } from '../constants/fees.js'; import { DEFAULT_LOCALE } from '../constants/locales.js'; +import { getCurrency } from '../constants/currencies.js'; +import { quoteSecondsRemaining } from '../utils/quoteBinding.js'; +import { isQuoteExpired } from '../services/contracts/quote.js'; import './QuoteCard.css'; /** - * Displays the breakdown of an FX quote: rate, fee and amount received. + * Displays the breakdown of an FX quote: rate, fee and amount received, + * plus freshness metadata (source, timestamps, currency precision). * @param {object} props * @param {object} props.quote - quote object from buildQuote() * @param {string} [props.locale] - locale used for currency formatting + * @param {number|Date} [props.now] - injectable clock for expiry display + * @param {Function} [props.onRefresh] - optional refresh handler when expired */ -export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) { +export default function QuoteCard({ + quote, + locale = DEFAULT_LOCALE, + now = Date.now(), + onRefresh, +}) { if (!quote) return null; - const { from, to, rate, sendAmount, fee, receiveAmount } = quote; + const { from, to, rate, sendAmount, fee, receiveAmount, source, id } = quote; + const fromMeta = getCurrency(from); + const toMeta = getCurrency(to); + const expired = isQuoteExpired(quote, now); + const secondsLeft = quoteSecondsRemaining(quote, now); + const createdLabel = formatTimestamp(quote.createdAt, locale); + const expiresLabel = formatTimestamp(quote.expiresAt, locale); return ( -
+

Transfer summary

+
+
+ Source + {source || 'unknown'} +
+ {id && ( +
+ Quote id + {shortId(id)} +
+ )} +
+ Priced at + {createdLabel} +
+
+ Expires + + {expiresLabel} + {!expired && secondsLeft > 0 ? ` · ${secondsLeft}s left` : ''} + +
+
+ Currencies + + {fromMeta + ? `${fromMeta.flag} ${from} (${fromMeta.minorUnits}dp)` + : from}{' '} + →{' '} + {toMeta ? `${toMeta.flag} ${to} (${toMeta.minorUnits}dp)` : to} + +
+
+ + {expired && ( +
+ This quote has expired. + {onRefresh ? ( + <> + {' '} + + + ) : ( + ' Refresh to continue.' + )} +
+ )} +
You send - {formatMoney(sendAmount, from, locale)} + + {formatMoney(sendAmount, from, locale)} +
RemitFlow fee ({formatPercent(FEE_PERCENT, 1)} + flat) - - {formatMoney(fee, from, locale)} + - {formatMoney(fee, from, locale)}
@@ -38,13 +115,35 @@ export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) {
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..a9ddc55 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,33 +269,49 @@ 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; + } + + if (!amountsReconcile(finalQuote, finalQuote)) { + setSubmitError('Displayed amounts do not match the quote payload.'); + 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. + // 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 created = await addTransfer({ 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, }); setSubmittedTransfer(created ?? finalQuote); setPendingQuote(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..232149f --- /dev/null +++ b/src/utils/quoteBinding.js @@ -0,0 +1,222 @@ +/** + * 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.', + }; + } + + if (!amountsReconcile(quote, quote)) { + return { + ok: false, + code: 'reconcile', + reason: 'Displayed amounts do not reconcile with the quote payload.', + }; + } + + 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'); + }); +}); From 126479b4a3f2a26124dc3e3b2e994b9a20a1cf9d Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 09:34:04 -0400 Subject: [PATCH 2/3] fix: validate quote values independently of payload reconciliation --- src/utils/quoteBinding.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/utils/quoteBinding.js b/src/utils/quoteBinding.js index 232149f..b2e4de2 100644 --- a/src/utils/quoteBinding.js +++ b/src/utils/quoteBinding.js @@ -197,11 +197,14 @@ export function assertQuoteSignable(quote, inputs, now = Date.now()) { }; } - if (!amountsReconcile(quote, quote)) { + // 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: 'Displayed amounts do not reconcile with the quote payload.', + reason: 'Quote amounts are invalid and cannot be confirmed.', }; } From 63d50c61f3cdf9df2c1994a7bf282bdb3797ba79 Mon Sep 17 00:00:00 2001 From: woahwhattheheck Date: Sat, 26 Sep 2026 09:34:16 -0400 Subject: [PATCH 3/3] fix: reconcile reviewed quote against serialized transfer payload --- src/pages/SendMoney.jsx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index a9ddc55..6ed5e50 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -293,16 +293,9 @@ export default function SendMoney() { return; } - if (!amountsReconcile(finalQuote, finalQuote)) { - setSubmitError('Displayed amounts do not match the quote payload.'); - setPendingQuote(null); - setPhase(null); - return; - } - // 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 created = await addTransfer({ + const transferPayload = { recipient, from: finalQuote.from, to: finalQuote.to, @@ -312,7 +305,14 @@ export default function SendMoney() { 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);