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 && (
+
+ {expired && - This quote has expired. Refresh to get a new rate.
}
+ {reasons.map((reason) => (
+ - {reason}
+ ))}
+
+ )}
+
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