diff --git a/src/components/QuoteCard.css b/src/components/QuoteCard.css index 4b27d23..4aa089f 100644 --- a/src/components/QuoteCard.css +++ b/src/components/QuoteCard.css @@ -39,6 +39,82 @@ color: var(--color-muted); } +.quote-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0.75rem 0 0; + padding: 0.6rem 0.75rem; + border: 1px solid #1f2a3a; + border-radius: 8px; + font-size: 0.8rem; + line-height: 1.4; +} + +.quote-meta dt { + margin: 0; + color: var(--color-muted); + font-weight: 500; +} + +.quote-meta dd { + margin: 0; + text-align: right; + font-variant-numeric: tabular-nums; + word-break: break-word; +} + +.quote-meta-value { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.78rem; +} + +.quote-expired { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0.75rem 0 0; + padding: 0.6rem 0.75rem; + border: 1px solid #b45309; + border-left-width: 4px; + border-radius: 8px; + background: #3a2a12; + color: #fde68a; + font-size: 0.85rem; + line-height: 1.4; +} + +.quote-expired-icon { + flex: 0 0 auto; + font-weight: 700; +} + +.quote-expired-text { + flex: 1 1 auto; +} + +[data-theme='light'] .quote-expired { + border-color: #b45309; + background: #fef3c7; + color: #7c2d12; +} + +.quote-invalid-reasons { + margin: 0.5rem 0 0; + padding-left: 1.25rem; + font-size: 0.8rem; + line-height: 1.4; + color: #fca5a5; +} + +.quote-invalid-reasons li { + margin: 0.15rem 0; +} + +[data-theme='light'] .quote-invalid-reasons { + color: #991b1b; +} + @media (prefers-contrast: more) { .quote-card { border-width: 2px; @@ -47,4 +123,17 @@ .quote-divider { height: 2px; } -} + + .quote-meta { + border-width: 2px; + } + + .quote-expired { + border-width: 2px; + border-left-width: 6px; + } + + .quote-invalid-reasons { + font-weight: 600; + } +} \ No newline at end of file diff --git a/src/components/QuoteCard.jsx b/src/components/QuoteCard.jsx index c978915..668dd5b 100644 --- a/src/components/QuoteCard.jsx +++ b/src/components/QuoteCard.jsx @@ -2,23 +2,121 @@ 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 { isQuoteExpired } from '../utils/quote.js'; import './QuoteCard.css'; /** - * Displays the breakdown of an FX quote: rate, fee and amount received. + * Formats a timestamp (ms since epoch) for display, or a dash when absent. + * @param {number|undefined} timestamp + * @param {string} locale + */ +function formatTimestamp(timestamp, locale) { + if (!Number.isFinite(timestamp)) return '—'; + try { + return new Date(timestamp).toLocaleString(locale); + } catch { + return new Date(timestamp).toISOString(); + } +} + +/** + * Displays the breakdown of an FX quote: rate, fee and amount received, + * plus quote provenance (source, timestamps, currency metadata, canonical + * amount) and any validation warnings. * @param {object} props * @param {object} props.quote - quote object from buildQuote() * @param {string} [props.locale] - locale used for currency formatting + * @param {number} [props.now] - clock used to evaluate expiry (ms since epoch) + * @param {boolean} [props.invalid] - whether validateQuoteForTransfer failed + * @param {string[]} [props.reasons] - validation failure reasons */ -export default function QuoteCard({ quote, locale = DEFAULT_LOCALE }) { +export default function QuoteCard({ + quote, + locale = DEFAULT_LOCALE, + now = Date.now(), + invalid = false, + reasons = [], +}) { if (!quote) return null; - const { from, to, rate, sendAmount, fee, receiveAmount } = quote; + const { + from, + to, + rate, + sendAmount, + fee, + receiveAmount, + source, + createdAt, + expiresAt, + fromMeta, + toMeta, + canonicalAmount, + } = quote; + + const expired = isQuoteExpired(quote, now); + const showWarnings = invalid || expired; return (

Transfer summary

+
+ Quote source + {source || '—'} +
+ +
+ Created + {formatTimestamp(createdAt, locale)} +
+ +
+ Expires + + {formatTimestamp(expiresAt, locale)} + {expired ? ' (expired)' : ''} + +
+ +
+ From currency + + {from} + {fromMeta?.name ? ` — ${fromMeta.name}` : ''} + {Number.isFinite(fromMeta?.minorUnits) + ? ` (${fromMeta.minorUnits} minor units)` + : ''} + +
+ +
+ To currency + + {to} + {toMeta?.name ? ` — ${toMeta.name}` : ''} + {Number.isFinite(toMeta?.minorUnits) + ? ` (${toMeta.minorUnits} minor units)` + : ''} + +
+ +
+ Canonical amount + {canonicalAmount ?? '—'} +
+ + {showWarnings && ( + + )} +
You send {formatMoney(sendAmount, from, locale)} diff --git a/src/constants/currencies.js b/src/constants/currencies.js index 51fce76..b7b1cca 100644 --- a/src/constants/currencies.js +++ b/src/constants/currencies.js @@ -30,6 +30,10 @@ export const CURRENCIES = [ export const DEFAULT_SOURCE = 'USD'; export const DEFAULT_DEST = 'NGN'; +// Flat list of supported ISO currency codes, derived from CURRENCIES. +// Used for currency-matrix validation in the send flow. +export const SUPPORTED_CURRENCY_CODES = CURRENCIES.map((c) => c.code); + // Popular remittance corridors highlighted on the landing page. export const POPULAR_CORRIDORS = [ { from: 'USD', to: 'NGN' }, @@ -52,3 +56,20 @@ export function getCurrency(code) { export function getCurrencyMinorUnits(code) { return getCurrency(code)?.minorUnits ?? 2; } + +/** + * Return display metadata for a currency, suitable for rendering in the + * quote card and amount entry fields. + * @param {string} code - ISO currency code, e.g. "USD" + * @returns {{code: string, name: string, symbol: string, minorUnits: number}|undefined} + */ +export function getCurrencyMetadata(code) { + const currency = getCurrency(code); + if (!currency) return undefined; + return { + code: currency.code, + name: currency.name, + symbol: currency.symbol, + minorUnits: currency.minorUnits, + }; +} \ No newline at end of file diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..f8571d1 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -45,10 +45,30 @@ export function useTransfers() { reload(); }, [reload]); + // The payload is forwarded verbatim so the quote ID that priced this + // transfer reaches the backend and binds the transfer to that quote. const addTransfer = useCallback(async (payload) => { - const created = await createTransfer(payload); - setTransfers((prev) => [created, ...prev]); - return created; + try { + const created = await createTransfer(payload); + setTransfers((prev) => [created, ...prev]); + return created; + } catch (err) { + if (err instanceof ContractViolationError) { + // A missing or mismatched quote ID is a contract violation, not a + // transient failure; surface it through the same non-retryable path + // used when loading transfers. + console.error(err.message); + setError( + `Your transfer could not be created: the quote could not be verified (${err.contract}). Nothing has been sent — please refresh the quote and try again.`, + ); + setRetryable(false); + } else { + const normalized = normalizeError(err, { source: 'api' }); + setError(getUserErrorMessage(normalized)); + setRetryable(normalized.retryable); + } + throw err; + } }, []); // Existing consumers use reload for both pull-to-refresh and the error-state diff --git a/src/pages/SendMoney.css b/src/pages/SendMoney.css index e5f6de2..711b7de 100644 --- a/src/pages/SendMoney.css +++ b/src/pages/SendMoney.css @@ -73,6 +73,10 @@ border-style: solid; border-width: 2px; } + + .send-quote-invalid { + border-width: 2px; + } } .send-quote-hint { @@ -84,6 +88,57 @@ text-align: center; } +/* Active quote invalidated (expired or mismatched with current inputs) */ +.send-quote-invalid { + display: flex; + align-items: flex-start; + gap: 0.5rem; + padding: 0.6rem 1rem; + margin-bottom: 1rem; + background: rgba(248, 113, 113, 0.12); + border: 1px solid rgba(248, 113, 113, 0.35); + border-radius: 8px; + color: #fca5a5; + font-size: 0.875rem; +} + +.send-quote-invalid strong { + color: #fecaca; +} + +[data-theme='light'] .send-quote-invalid { + background: #fef2f2; + border-color: #fecaca; + color: #b91c1c; +} + +[data-theme='light'] .send-quote-invalid strong { + color: #991b1b; +} + +/* Expanded quote metadata layout */ +.send-quote-meta { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0.75rem 0 0; + font-size: 0.85rem; +} + +.send-quote-meta dt { + color: var(--color-muted); +} + +.send-quote-meta dd { + margin: 0; + word-break: break-all; +} + +.send-quote-meta .send-quote-expiry-expired { + color: #f87171; + font-weight: 600; +} + /* Confirmation / result dialog content */ .send-dialog-summary { margin: 0 0 0.75rem; diff --git a/src/pages/SendMoney.jsx b/src/pages/SendMoney.jsx index c1e03f0..b6201f4 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -6,7 +6,7 @@ import QuoteCard from '../components/QuoteCard.jsx'; 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 { buildQuote, validateQuoteForTransfer } from '../services/quote.js'; import { ContractViolationError } from '../services/contracts/schema.js'; import { getUserErrorMessage, normalizeError } from '../services/errors.js'; import { formatAmount, formatCurrencyInput, parseCurrencyInput } from '../utils/format.js'; @@ -64,6 +64,14 @@ export default function SendMoney() { // Debounce the amount so the quote isn't rebuilt on every keystroke. const debouncedAmount = useDebouncedValue(amount, 250); + // A ticking clock so expiry is evaluated against real time, not just when + // the inputs happen to change. + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, []); + // Recompute the quote whenever the (debounced) inputs change. const quote = useMemo(() => { const parsed = parseCurrencyInput(debouncedAmount, { @@ -74,6 +82,41 @@ export default function SendMoney() { return buildQuote(parsed.value, from, to); }, [debouncedAmount, from, locale, to]); + // The inputs (and connectivity) that produced the active pending quote. + // Any divergence means the quote is stale and must not be signed. + const pendingQuoteInputs = useRef(null); + + // Invalidate the active quote whenever the inputs or network state change. + useEffect(() => { + if (!pendingQuote) return; + const snapshot = pendingQuoteInputs.current; + const changed = + !snapshot || + snapshot.amount !== amount || + snapshot.from !== from || + snapshot.to !== to || + snapshot.recipient !== recipient || + snapshot.isOnline !== isOnline; + if (changed) { + setPendingQuote(null); + pendingQuoteInputs.current = null; + setPhase((current) => (current === 'confirm' ? null : current)); + } + }, [amount, from, to, recipient, isOnline, pendingQuote]); + + // Validate the pending quote against the current inputs and clock. + const pendingQuoteCheck = useMemo(() => { + if (!pendingQuote) return { valid: false, reasons: [] }; + return validateQuoteForTransfer(pendingQuote, { + amount, + from, + to, + recipient, + now, + locale, + }); + }, [pendingQuote, amount, from, to, recipient, now, locale]); + // Surface submission failures predictably: announce them and put keyboard // focus back on the submit control so a retry is one Enter away. useEffect(() => { @@ -171,6 +214,26 @@ export default function SendMoney() { return; } + // Refuse to open the confirmation dialog for an expired or mismatched + // quote: the user must never be able to sign a stale rate. + const check = validateQuoteForTransfer(finalQuote, { + amount, + from, + to, + recipient, + now: Date.now(), + locale, + }); + if (!check.valid) { + setSubmitError( + check.reasons.length > 0 + ? check.reasons.join(' ') + : 'This quote is no longer valid. Please review the amount and currencies.', + ); + return; + } + + pendingQuoteInputs.current = { amount, from, to, recipient, isOnline }; setPendingQuote(finalQuote); setPhase('confirm'); } @@ -179,6 +242,7 @@ export default function SendMoney() { if (submitting) return; setPhase(null); setPendingQuote(null); + pendingQuoteInputs.current = null; } async function handleConfirmTransfer() { @@ -206,10 +270,34 @@ export default function SendMoney() { return; } + // Re-validate against the current inputs and clock right before + // submitting: the quote may have expired while the dialog was open. + const check = validateQuoteForTransfer(finalQuote, { + amount, + from, + to, + recipient, + now: Date.now(), + locale, + }); + if (!check.valid) { + setPendingQuote(null); + pendingQuoteInputs.current = null; + setPhase(null); + setSubmitError( + check.reasons.length > 0 + ? check.reasons.join(' ') + : 'This quote is no longer valid. Please review the amount and currencies.', + ); + 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. + // a rate that may since have moved. The quote ID binds the payload to + // the exact quote the user reviewed. const created = await addTransfer({ + quoteId: finalQuote.quoteId, recipient, from, to, @@ -221,10 +309,12 @@ export default function SendMoney() { }); setSubmittedTransfer(created ?? finalQuote); setPendingQuote(null); + pendingQuoteInputs.current = null; setSubmitError(null); setPhase('success'); } catch (err) { setPendingQuote(null); + pendingQuoteInputs.current = null; setPhase(null); if (err instanceof ContractViolationError) { // The full field-by-field diff goes to the console; the user gets a @@ -360,7 +450,7 @@ export default function SendMoney() {
{quote ? ( - + ) : (

Enter an amount to see your quote. @@ -377,7 +467,13 @@ export default function SendMoney() {

{recipient}
- +

{submitting ? 'Submitting your transfer…' : ''}

@@ -389,7 +485,10 @@ export default function SendMoney() { > Back -
diff --git a/src/services/contracts/quote.js b/src/services/contracts/quote.js index 3ca0cb9..6565aa3 100644 --- a/src/services/contracts/quote.js +++ b/src/services/contracts/quote.js @@ -24,6 +24,15 @@ export const quoteContract = defineContract({ receiveAmount: { type: 'decimal', required: true, min: 0 }, createdAt: { type: 'timestamp', required: true }, expiresAt: { type: 'timestamp', required: true }, + // Freshness / provenance metadata. Required so a quote can always be + // traced back to its feed and re-validated at signing time. + source: { type: 'string', required: true }, + quoteId: { type: 'string', required: true, minLength: 1 }, + // Currency metadata: minor-unit precision for each side of the pair. + fromMinorUnits: { type: 'integer', required: true, min: 0 }, + toMinorUnits: { type: 'integer', required: true, min: 0 }, + // The exact amount the user is agreeing to send, as a decimal string. + canonicalAmount: { type: 'decimal', required: true, min: 0 }, }, }); @@ -49,3 +58,99 @@ export function isQuoteExpired(quote, now = Date.now()) { if (Number.isNaN(expiry)) return true; return (now instanceof Date ? now.getTime() : now) >= expiry; } + +/** + * Quantize a decimal string to a given number of minor units, using + * round-half-up on the digit after the last kept place. Returns a decimal + * string with exactly `minorUnits` fraction digits, or null if unparseable. + * @param {string} amount + * @param {number} minorUnits + * @returns {string|null} + */ +export function quantizeAmount(amount, minorUnits) { + if (typeof amount !== 'string') return null; + const match = /^(-?)(\d+)(?:\.(\d+))?$/.exec(amount.trim()); + if (!match) return null; + const [, sign, whole, frac = ''] = match; + const places = Math.max(0, Math.trunc(minorUnits)); + const padded = frac.padEnd(places + 1, '0'); + const kept = padded.slice(0, places); + const roundDigit = Number(padded[places] ?? '0'); + let digits = `${whole}${kept}`; + if (roundDigit >= 5) { + // Increment the integer representation of the kept digits. + let carry = 1; + const chars = digits.split(''); + for (let i = chars.length - 1; i >= 0 && carry; i -= 1) { + const next = Number(chars[i]) + carry; + chars[i] = String(next % 10); + carry = next >= 10 ? 1 : 0; + } + digits = (carry ? '1' : '') + chars.join(''); + } + const intPart = places > 0 ? digits.slice(0, digits.length - places) || '0' : digits; + const fracPart = places > 0 ? digits.slice(digits.length - places) : ''; + const normalizedInt = intPart.replace(/^0+(?=\d)/, ''); + const result = places > 0 ? `${normalizedInt}.${fracPart}` : normalizedInt; + return sign === '-' && /[1-9]/.test(result) ? `-${result}` : result; +} + +/** + * Validate a quote against the transfer the user is about to sign. + * + * Returns `{ valid, reasons }` where `reasons` is a list of stable machine + * codes (plus human-readable detail) explaining every failure. A quote is + * only valid when it is unexpired, matches the requested currency pair, and + * its canonical amount reconciles with the requested amount at the source + * currency's precision. + * + * @param {object} quote - a parsed quote + * @param {{now?: number|Date, from?: string, to?: string, amount?: string}} [request] + * @returns {{valid: boolean, reasons: Array<{code: string, message: string}>}} + */ +export function validateQuoteForTransfer(quote, request = {}) { + const reasons = []; + const { now = Date.now(), from, to, amount } = request; + + if (!quote) { + return { + valid: false, + reasons: [{ code: 'missing_quote', message: 'No quote is available.' }], + }; + } + + if (isQuoteExpired(quote, now)) { + reasons.push({ + code: 'expired', + message: `Quote expired at ${quote.expiresAt}.`, + }); + } + + if (from != null && quote.from !== from) { + reasons.push({ + code: 'from_mismatch', + message: `Quote source currency ${quote.from} does not match requested ${from}.`, + }); + } + + if (to != null && quote.to !== to) { + reasons.push({ + code: 'to_mismatch', + message: `Quote target currency ${quote.to} does not match requested ${to}.`, + }); + } + + if (amount != null) { + const minorUnits = Number.isInteger(quote.fromMinorUnits) ? quote.fromMinorUnits : 2; + const expected = quantizeAmount(String(amount), minorUnits); + const canonical = quantizeAmount(String(quote.canonicalAmount ?? ''), minorUnits); + if (expected == null || canonical == null || expected !== canonical) { + reasons.push({ + code: 'amount_mismatch', + message: `Quote amount ${quote.canonicalAmount} does not reconcile with requested ${amount}.`, + }); + } + } + + return { valid: reasons.length === 0, reasons }; +} diff --git a/src/services/contracts/transfer.js b/src/services/contracts/transfer.js index ce4e901..92e056d 100644 --- a/src/services/contracts/transfer.js +++ b/src/services/contracts/transfer.js @@ -63,6 +63,10 @@ export const transferContract = defineContract({ aliases: TRANSFER_STATUS_ALIASES, }, createdAt: { type: 'timestamp', required: true }, + // The quote that priced this transfer. New transfers always carry it so the + // signed payload can be bound to the exact quote; legacy records predate + // quotes, so it stays optional to keep them parseable. + quoteId: { type: 'string', required: false, minLength: 1 }, // Optional enrichment. Absent on legacy records, so never required. fee: { type: 'decimal', required: false, min: 0 }, rate: { type: 'decimal', required: false, min: 0 }, diff --git a/src/services/fx.js b/src/services/fx.js index a38a24e..efafdde 100644 --- a/src/services/fx.js +++ b/src/services/fx.js @@ -6,7 +6,11 @@ // point yields 1609.2391304347825, which is already wrong in the last place; // at NGN scale that error is visible on a receipt. -import { divideDecimal, parseDecimal } from '../utils/money.js'; +import { divideDecimal, parseDecimal, currencyExponent } from '../utils/money.js'; + +// Identifies the feed a quote's rate came from. The mock service is the only +// source today; a backend or DEX path-payment feed would report its own id. +export const FX_SOURCE = 'mock-fx'; // Rates expressed relative to 1 USD. const USD_RATES = { @@ -78,3 +82,25 @@ export function getInverseRate(from, to) { export function listRatedCurrencies() { return Object.keys(USD_RATES); } + +/** + * Metadata describing the rate used for a currency pair, so a quote can show + * where its rate came from and the precision each side is quoted at. + * @param {string} from - source currency code + * @param {string} to - destination currency code + * @returns {{source: string, from: string, to: string, rate: string, + * fromMinorUnits: number, toMinorUnits: number}|null} null when the pair is + * unsupported + */ +export function getRateMetadata(from, to) { + const rate = getRateDecimal(from, to); + if (rate == null) return null; + return { + source: FX_SOURCE, + from, + to, + rate, + fromMinorUnits: currencyExponent(from), + toMinorUnits: currencyExponent(to), + }; +} diff --git a/src/services/quote.js b/src/services/quote.js index 8b41415..8c06e76 100644 --- a/src/services/quote.js +++ b/src/services/quote.js @@ -10,6 +10,7 @@ import { getRateDecimal } from './fx.js'; import { FEE_PERCENT, FLAT_FEE, MIN_FEE } from '../constants/fees.js'; import { QUOTE_CONTRACT_VERSION, + QUOTE_SOURCE, QUOTE_TTL_MS, parseQuote, } from './contracts/quote.js'; @@ -48,8 +49,9 @@ export function calculateFee(amount, currency = 'USD') { * @param {number|string} amount - amount to send in the source currency * @param {string} from - source currency code * @param {string} to - destination currency code - * @param {{now?: number|Date, ttlMs?: number}} [options] - injectable clock so - * quote expiry is deterministic under test + * @param {{now?: number|Date, ttlMs?: number, quoteId?: string, + * source?: string}} [options] - injectable clock so quote expiry is + * deterministic under test; optional explicit quote id and FX feed source * @returns {object|null} a Quote v1 payload, or null when the pair is * unsupported or the amount is not a parseable decimal */ @@ -79,21 +81,68 @@ 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(); + // Round-trip through the contract so a quote is validated at the point it is // produced, not only at the point it is consumed. return parseQuote( { version: QUOTE_CONTRACT_VERSION, + quoteId: buildQuoteId({ + from, + to, + rate, + sendAmount, + createdAt, + quoteId: options.quoteId, + }), + source: options.source ?? QUOTE_SOURCE, 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(), + canonicalAmount: sendAmount, + fromMinorUnits: fromExponent, + toMinorUnits: toExponent, + createdAt, expiresAt: new Date(nowMs + ttlMs).toISOString(), }, { source: 'buildQuote' }, ); } + +/** + * Derive a stable quote identifier from the inputs that define a quote. + * Callers may supply an explicit id (e.g. a server-issued one); otherwise a + * deterministic id is derived from the quote inputs and creation timestamp so + * the same inputs at the same instant always yield the same id. This keeps + * quote binding reproducible under test without depending on randomness. + * @param {{from: string, to: string, rate: string, sendAmount: string, + * createdAt: string, quoteId?: string}} parts + * @returns {string} quote identifier + */ +export function buildQuoteId(parts) { + if (typeof parts.quoteId === 'string' && parts.quoteId.length > 0) { + return parts.quoteId; + } + const seed = [ + parts.from, + parts.to, + parts.rate, + parts.sendAmount, + parts.createdAt, + ].join('|'); + // FNV-1a 32-bit hash: deterministic, dependency-free, and stable across + // runtimes. Collisions are acceptable here because the id is only used to + // bind a quote to a payload, not as a security token. + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i += 1) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return `q_${hash.toString(16).padStart(8, '0')}`; +} diff --git a/src/utils/validate.js b/src/utils/validate.js index 98e77af..7bd945d 100644 --- a/src/utils/validate.js +++ b/src/utils/validate.js @@ -1,5 +1,10 @@ // Simple validation helpers for the Send Money form. import { parseCurrencyInput } from './format.js'; +import { + isQuoteValid, + getQuoteCurrencyPair, + getQuoteCanonicalAmount, +} from './quote.js'; export function isPositiveAmount(value, options = {}) { return parseCurrencyInput(value, options).ok; @@ -37,3 +42,63 @@ export function isWithinBalance(amount, balance, options = {}) { if (!parsedAmount.ok || !parsedBalance.ok) return false; return parsedAmount.minorUnits <= parsedBalance.minorUnits; } + +/** + * Validate that a quote may be used to sign a transfer for the given form + * inputs. + * + * Centralises the expiry, currency-pair, and precision checks used by the + * Send Money flow so they stay consistent and unit-testable. Returns a list of + * human-readable reasons (empty when valid) so callers can surface why a quote + * was rejected. + * + * @param {object} quote FX quote from the backend. + * @param {object} [form] + * @param {number|string} [form.now] Current time (ms since epoch); defaults to Date.now(). + * @param {string} [form.from] Source currency code entered in the form. + * @param {string} [form.to] Destination currency code entered in the form. + * @param {number|string} [form.amount] Amount entered in the form (source currency). + * @param {string} [form.locale] Locale used when parsing the amount. + * @returns {{ valid: boolean, reasons: string[] }} + */ +export function validateQuoteForTransfer(quote, form = {}) { + const reasons = []; + + if (!quote || typeof quote !== 'object') { + return { valid: false, reasons: ['missing-quote'] }; + } + + const now = form.now === undefined ? Date.now() : form.now; + + // Delegate expiry/status checks to the quote contract. + if (!isQuoteValid(quote, { now })) { + reasons.push('quote-invalid'); + } + + // Verify the quote's currency pair matches the form's from/to. + const pair = getQuoteCurrencyPair(quote); + const from = form.from; + const to = form.to; + if (!pair || !from || !to) { + reasons.push('currency-mismatch'); + } else if (pair.from !== from || pair.to !== to) { + reasons.push('currency-mismatch'); + } + + // Verify the quote's canonical amount equals the form amount quantized to + // the source currency's minor unit. + const canonical = getQuoteCanonicalAmount(quote); + const parsedAmount = parseCurrencyInput(form.amount, { + locale: form.locale, + currency: from, + }); + if (!parsedAmount.ok) { + reasons.push('amount-invalid'); + } else if (canonical === undefined || canonical === null) { + reasons.push('amount-mismatch'); + } else if (String(canonical) !== String(parsedAmount.minorUnits)) { + reasons.push('amount-mismatch'); + } + + return { valid: reasons.length === 0, reasons }; +} \ No newline at end of file diff --git a/test/integration/send-money-form.test.jsx b/test/integration/send-money-form.test.jsx index 43a7381..7643c7e 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -12,6 +12,26 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import App from '../../src/App.jsx'; import * as api from '../../src/services/api.js'; +// Quote metadata surfaced by the send flow. Tests pin a deterministic quote so +// expiry, currency and precision assertions do not depend on wall-clock time. +function quoteFixture(overrides = {}) { + return { + id: 'quote_test_1', + source: 'test-fx-provider', + fromCurrency: 'USD', + toCurrency: 'NGN', + amount: '15', + canonicalAmount: '15.00', + rate: '1500.00', + expiresAt: new Date(Date.now() + 5 * 60 * 1000).toISOString(), + ...overrides, + }; +} + +function mockQuote(overrides) { + return vi.spyOn(api, 'getQuote').mockResolvedValue(quoteFixture(overrides)); +} + async function fillValidForm(user) { await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); await user.type(screen.getByLabelText(/amount/i), '15'); @@ -223,6 +243,156 @@ describe('Send money form flows', () => { expect(createTransfer).toHaveBeenCalledTimes(2); }); + it('blocks confirmation when the quote has expired and explains why', async () => { + mockQuote({ expiresAt: new Date(Date.now() - 60 * 1000).toISOString() }); + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + render(); + + await fillValidForm(user); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + expect(await screen.findByText(/quote (has )?expired/i)).toBeInTheDocument(); + const dialog = screen.queryByRole('dialog', { + name: /confirm your transfer/i, + }); + if (dialog) { + const confirm = within(dialog).queryByRole('button', { + name: /confirm transfer/i, + }); + if (confirm) { + expect(confirm).toBeDisabled(); + await user.click(confirm); + } + } + expect(createTransfer).not.toHaveBeenCalled(); + }); + + it('invalidates the quote when inputs change after review', async () => { + const getQuote = mockQuote(); + const createTransfer = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup(); + 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, + }); + expect(getQuote).toHaveBeenCalledTimes(1); + + // Changing the amount after review must discard the stale quote. + await user.clear(screen.getByLabelText(/amount/i)); + await user.type(screen.getByLabelText(/amount/i), '25'); + + await waitFor(() => { + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /review & send/i })); + const freshDialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(getQuote).toHaveBeenCalledTimes(2); + await user.click( + within(freshDialog).getByRole('button', { name: /confirm transfer/i }), + ); + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await finishOnTransfersPage(user, resultDialog); + expect(createTransfer).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['USD', 'INR', '15', '15.00'], + ['EUR', 'NGN', '15', '15.00'], + ])( + 'confirms a %s->%s transfer and binds the active quoteId to the payload', + async (fromCurrency, toCurrency, amount, canonicalAmount) => { + const getQuote = mockQuote({ + fromCurrency, + toCurrency, + amount, + canonicalAmount, + }); + const createTransfer = vi + .spyOn(api, 'createTransfer') + .mockImplementation(async (payload) => createdTransfer(payload)); + const user = userEvent.setup(); + render(); + + await user.selectOptions( + screen.getByLabelText(/^from$/i), + fromCurrency, + ); + await user.type( + screen.getByLabelText(/recipient/i), + 'amina@example.com', + ); + await user.type(screen.getByLabelText(/amount/i), amount); + await user.selectOptions(screen.getByLabelText(/^to$/i), toCurrency); + + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + // The canonical quoted amount is what the user reviews. + expect(within(dialog).getByText(canonicalAmount)).toBeInTheDocument(); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await finishOnTransfersPage(user, resultDialog); + + expect(getQuote).toHaveBeenCalled(); + expect(createTransfer).toHaveBeenCalledTimes(1); + const payload = createTransfer.mock.calls[0][0]; + expect(payload.quoteId).toBe('quote_test_1'); + expect(payload.amount).toBe(canonicalAmount); + expect(payload.fromCurrency).toBe(fromCurrency); + expect(payload.toCurrency).toBe(toCurrency); + }, + ); + + it('shows the canonical quoted amount that is later serialized', async () => { + mockQuote({ amount: '15', canonicalAmount: '15.00' }); + const createTransfer = vi + .spyOn(api, 'createTransfer') + .mockImplementation(async (payload) => createdTransfer(payload)); + const user = userEvent.setup(); + 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, + }); + expect(within(dialog).getByText('15.00')).toBeInTheDocument(); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + + const resultDialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await finishOnTransfersPage(user, resultDialog); + + const payload = createTransfer.mock.calls[0][0]; + expect(payload.amount).toBe('15.00'); + }); + it('allows a valid submission after an invalid submission', async () => { const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx index f5cf076..7303c2c 100644 --- a/test/integration/send-money-precision.test.jsx +++ b/test/integration/send-money-precision.test.jsx @@ -115,6 +115,11 @@ describe('Send flow — the receipt matches the quote', () => { receiveAmount: '294471.45', }); expect(payload.expiresAt).toEqual(expect.any(String)); + // The quote that was displayed is the quote that gets signed: the payload + // carries the quote's id so the backend can reject a stale or mismatched + // price instead of trusting the client's numbers. + expect(payload.quoteId).toEqual(expect.any(String)); + expect(payload.quoteId.length).toBeGreaterThan(0); // The stored record satisfies the contract it will later be read back with. expect(() => parseTransfer({ @@ -141,6 +146,22 @@ describe('Send flow — the receipt matches the quote', () => { ).toBeInTheDocument(); }); + // The card must surface the quote's provenance and lifetime so the user + // can tell whether the price they are about to sign is still current. + expect(within(quoteCard).getByText(/source/i)).toBeInTheDocument(); + expect(within(quoteCard).getByText(/expires/i)).toBeInTheDocument(); + // The canonical amount is the exact decimal the payload will carry. + expect( + within(quoteCard).getByText(money('USD 200.00')), + ).toBeInTheDocument(); + // A timestamp is rendered in a machine-readable