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..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'); + }); +});