diff --git a/docs/review/quote-balance-after.png b/docs/review/quote-balance-after.png new file mode 100644 index 0000000..9cb1763 Binary files /dev/null and b/docs/review/quote-balance-after.png differ diff --git a/docs/review/quote-balance-before.png b/docs/review/quote-balance-before.png new file mode 100644 index 0000000..ce62c05 Binary files /dev/null and b/docs/review/quote-balance-before.png differ diff --git a/docs/review/wallet-connection-after.png b/docs/review/wallet-connection-after.png new file mode 100644 index 0000000..b5849e9 Binary files /dev/null and b/docs/review/wallet-connection-after.png differ diff --git a/docs/review/wallet-connection-before.png b/docs/review/wallet-connection-before.png new file mode 100644 index 0000000..5f49b25 Binary files /dev/null and b/docs/review/wallet-connection-before.png differ 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..3bc102f 100644 --- a/src/components/QuoteCard.jsx +++ b/src/components/QuoteCard.jsx @@ -2,31 +2,107 @@ 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 +114,34 @@ 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/context/AppContext.jsx b/src/context/AppContext.jsx index 336af1b..452bf9f 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -1,4 +1,3 @@ - import { createContext, useContext, useEffect, useState } from 'react'; import { connectWallet, diff --git a/src/hooks/useTransfers.js b/src/hooks/useTransfers.js index dea1e61..80160bb 100644 --- a/src/hooks/useTransfers.js +++ b/src/hooks/useTransfers.js @@ -1,4 +1,3 @@ - import { useCallback, useEffect, useState } from 'react'; import { listTransfers, createTransfer } from '../services/api.js'; import { ContractViolationError } from '../services/contracts/schema.js'; 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..df3e0cf 100644 --- a/src/pages/SendMoney.jsx +++ b/src/pages/SendMoney.jsx @@ -7,14 +7,20 @@ 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'; + formatAmount, + formatCurrencyInput, + parseCurrencyInput, +} from '../utils/format.js'; +import { 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 +38,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(); @@ -48,6 +57,7 @@ export default function SendMoney() { const [submitting, setSubmitting] = useState(false); const [submitError, setSubmitError] = useState(null); const submissionLock = useRef(false); + const confirmationVersion = useRef(0); const wasOffline = useRef(false); // True when the form just recovered from a disconnected state. Used to @@ -59,6 +69,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 +77,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 +87,33 @@ 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 parsedAmount = parseCurrencyInput(amount, { currency: from, locale }); + const live = parsedAmount.ok + ? assertQuoteSignable( + pendingQuote, + { amount: parsedAmount.value, from, to }, + Date.now(), + ) + : { ok: false, reason: parsedAmount.error }; + if (!live.ok && live.code !== 'expired') { + confirmationVersion.current += 1; + setPendingQuote(null); + setPhase(null); + setSubmitError(live.reason); + } + }, [amount, from, to, locale, 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(() => { @@ -85,10 +125,16 @@ export default function SendMoney() { setTo(from); } - // Tidy the amount field to two decimals once the user leaves it. + // Keep the field in its input locale when padding the currency precision. function handleAmountBlur(value) { const formatted = formatCurrencyInput(value, from, locale); - if (formatted) setAmount(formatted); + if (formatted) { + const decimal = + new Intl.NumberFormat(locale) + .formatToParts(1.1) + .find((part) => part.type === 'decimal')?.value ?? '.'; + setAmount(formatted.replace('.', decimal)); + } } function applyErrors(next) { @@ -111,16 +157,17 @@ export default function SendMoney() { if (!parsedAmount.ok) { next.amount = parsedAmount.error; } else if ( + // Both values are canonical; do not apply the input locale again. wallet && !isWithinBalance(parsedAmount.value, wallet.balance, { currency: from, - locale, }) ) { 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 +176,57 @@ 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') { + confirmationVersion.current += 1; + 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() { + confirmationVersion.current += 1; + 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(); @@ -167,11 +254,25 @@ export default function SendMoney() { } const finalQuote = buildQuote(parsedAmount.value, from, to); if (!finalQuote) { - applyErrors({ amount: 'Enter an amount greater than zero.' }); + setSubmitError( + 'We could not price this transfer. Check the amount and currencies.', + ); return; } + const signable = assertQuoteSignable( + finalQuote, + { amount: parsedAmount.value, from, to }, + Date.now(), + ); + if (!signable.ok) { + setSubmitError(signable.reason); + return; + } + + confirmationVersion.current += 1; setPendingQuote(finalQuote); + setQuoteClock(Date.now()); setPhase('confirm'); } @@ -186,39 +287,88 @@ export default function SendMoney() { submissionLock.current = true; setSubmitting(true); + const startedVersion = confirmationVersion.current; try { - if (!isConnected) { - await connect(); + const transferWallet = isConnected ? wallet : await connect(); + // A cleared or refreshed quote must not resume from the pre-connect render. + if (startedVersion !== confirmationVersion.current) return; + if (typeof navigator !== 'undefined' && !navigator.onLine) { + confirmationVersion.current += 1; + setPendingQuote(null); + setPhase(null); + setSubmitError( + "You're offline. The pending quote was cleared — reconnect and review again.", + ); + return; + } + if (!transferWallet) { + setSubmitError( + 'Wallet connection did not complete. Connect your wallet and try again.', + ); + setPendingQuote(null); + setPhase(null); + return; } - // 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 }); + 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; + } + + // A wallet connected here was unavailable during the form's balance check. + if ( + !isWithinBalance(finalQuote.sendAmount, transferWallet.balance, { + currency: finalQuote.from, + }) + ) { + setSubmitError('Amount exceeds your wallet balance.'); + 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); @@ -235,18 +385,19 @@ export default function SendMoney() { ); } else { const normalized = normalizeError(err, { source: 'api' }); - // A transfer can be interrupted mid-signature by a connection drop. - // The honest message here is "unknown", not "failed": the backend may - // have accepted the transfer even though the response never arrived. - // The transfers page reconciles real status on reconnect. - // Read the current connectivity directly (not from the render closure) - // so that a mid-flight disconnect produces the correct message. - const connectedNow = typeof navigator !== 'undefined' && navigator.onLine; - setSubmitError( - connectedNow - ? getUserErrorMessage(normalized) - : 'Connection lost while sending. Reconnect to check your transfer status.', - ); + // A transfer can be interrupted mid-signature by a connection drop. + // The honest message here is "unknown", not "failed": the backend may + // have accepted the transfer even though the response never arrived. + // The transfers page reconciles real status on reconnect. + // Read the current connectivity directly (not from the render closure) + // so that a mid-flight disconnect produces the correct message. + const connectedNow = + typeof navigator !== 'undefined' && navigator.onLine; + setSubmitError( + connectedNow + ? getUserErrorMessage(normalized) + : 'Connection lost while sending. Reconnect to check your transfer status.', + ); } } finally { submissionLock.current = false; @@ -255,6 +406,10 @@ export default function SendMoney() { } const errorCount = Object.keys(errors).length; + const pendingExpired = + phase === 'confirm' && pendingQuote + ? isQuoteExpired(pendingQuote, quoteClock) + : false; return (
@@ -292,8 +447,8 @@ export default function SendMoney() { role="status" aria-live="polite" > - ✓ Back online. Your form was not submitted while you were - offline — review it and send when ready. + ✓ Back online. Your form was not submitted while you were offline + — review it and send when ready.
)} @@ -360,7 +515,7 @@ export default function SendMoney() {
{quote ? ( - + ) : (

Enter an amount to see your quote. @@ -377,9 +532,18 @@ export default function SendMoney() {

{recipient}
- +

- {submitting ? 'Submitting your transfer…' : ''} + {submitting + ? 'Submitting your transfer…' + : pendingExpired + ? 'Quote expired — refresh before confirming.' + : ''}

-
@@ -427,6 +594,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/errors.js b/src/services/errors.js index bab520f..4ecc020 100644 --- a/src/services/errors.js +++ b/src/services/errors.js @@ -86,7 +86,9 @@ export function normalizeError(error, { source = 'api' } = {}) { } export function getUserErrorMessage(normalizedError) { - return SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN]; + return ( + SAFE_MESSAGES[normalizedError?.code] ?? SAFE_MESSAGES[ERROR_CODES.UNKNOWN] + ); } export function canRetry(normalizedError) { diff --git a/src/services/quote.js b/src/services/quote.js index 8b41415..78304da 100644 --- a/src/services/quote.js +++ b/src/services/quote.js @@ -13,6 +13,7 @@ import { QUOTE_TTL_MS, parseQuote, } from './contracts/quote.js'; +import { mintQuoteId, quoteInputFingerprint } from '../utils/quoteBinding.js'; import { convertMinorUnits, currencyExponent, @@ -79,20 +80,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..2e2a825 --- /dev/null +++ b/src/utils/quoteBinding.js @@ -0,0 +1,239 @@ +/** + * 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/components/WalletButton.test.jsx b/test/components/WalletButton.test.jsx index 46287e2..701fc6f 100644 --- a/test/components/WalletButton.test.jsx +++ b/test/components/WalletButton.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -118,7 +117,9 @@ describe('WalletButton', () => { ); await waitFor(() => { - expect(screen.queryByText(/something went wrong/i)).not.toBeInTheDocument(); + expect( + screen.queryByText(/something went wrong/i), + ).not.toBeInTheDocument(); expect(screen.getByText(/500 XLM/)).toBeInTheDocument(); }); }); diff --git a/test/integration/offline-reconnect.test.jsx b/test/integration/offline-reconnect.test.jsx index 10a8761..a6de581 100644 --- a/test/integration/offline-reconnect.test.jsx +++ b/test/integration/offline-reconnect.test.jsx @@ -1,4 +1,11 @@ -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, + 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'; @@ -43,6 +50,8 @@ describe('Offline and reconnect state for transfer mutations', () => { }); window.history.pushState({}, '', '/send'); localStorage.clear(); + // These cases exercise connectivity, not the demo wallet's random refusal. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -80,9 +89,7 @@ describe('Offline and reconnect state for transfer mutations', () => { goOffline(); expect( - await screen.findByText( - /you're offline\. some features may not work/i, - ), + await screen.findByText(/you're offline\. some features may not work/i), ).toBeInTheDocument(); }); @@ -119,14 +126,38 @@ describe('Offline and reconnect state for transfer mutations', () => { goOnline(); // After reconnecting, the button should change back to "Review & Send" and be enabled. - const submitButton = await screen.findByRole('button', { - name: /review & send/i, - }, { timeout: 5000 }); + const submitButton = await screen.findByRole( + 'button', + { + name: /review & send/i, + }, + { timeout: 5000 }, + ); expect(submitButton).toBeEnabled(); + expect(createTransfer).not.toHaveBeenCalled(); await user.click(submitButton); - - await screen.findByRole('heading', { name: /your transfers/i }, { timeout: 10000 }); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); + const result = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(result).getByRole('button', { name: /view transfers/i }), + ); + + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 10000 }, + ); expect(createTransfer).toHaveBeenCalledTimes(1); }); @@ -170,9 +201,15 @@ describe('Offline and reconnect state for transfer mutations', () => { await fillValidForm(user); - // Start the submission. - const submitButton = screen.getByRole('button', { name: /review & send/i }); - fireEvent.click(submitButton); + // Review first, then dispatch through the actual confirmation dialog. + await user.click(screen.getByRole('button', { name: /review & send/i })); + const confirmation = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(createTransfer).not.toHaveBeenCalled(); + await user.click( + within(confirmation).getByRole('button', { name: /confirm transfer/i }), + ); // After the catch block runs, the error message should be about // connection loss, NOT a generic "could not submit" message. @@ -185,5 +222,6 @@ describe('Offline and reconnect state for transfer mutations', () => { expect( screen.queryByText(/could not submit the transfer\./i), ).not.toBeInTheDocument(); + expect(createTransfer).toHaveBeenCalledTimes(1); }); -}); \ 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..9ae992d 100644 --- a/test/integration/send-money-form.test.jsx +++ b/test/integration/send-money-form.test.jsx @@ -1,4 +1,3 @@ - import { act, fireEvent, @@ -61,6 +60,8 @@ describe('Send money form flows', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); localStorage.clear(); + // Keep the mock wallet's random rejection out of submission-guard tests. + vi.spyOn(Math, 'random').mockReturnValue(0.5); }); afterEach(() => { @@ -113,7 +114,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(screen.getByRole('button', { name: /sending/i })).toBeDisabled(); + expect( + within(dialog).getByRole('button', { name: /sending/i }), + ).toBeDisabled(); expect(createTransfer).not.toHaveBeenCalled(); const resultDialog = await screen.findByRole( @@ -200,7 +203,9 @@ describe('Send money form flows', () => { within(dialog).getByRole('button', { name: /confirm transfer/i }), ); - expect(await screen.findByText(/something went wrong/i)).toBeInTheDocument(); + expect( + await screen.findByText(/something went wrong/i), + ).toBeInTheDocument(); const retryButton = screen.getByRole('button', { name: /review & send/i }); expect(retryButton).toBeEnabled(); diff --git a/test/integration/send-money-precision.test.jsx b/test/integration/send-money-precision.test.jsx index f5cf076..136b66c 100644 --- a/test/integration/send-money-precision.test.jsx +++ b/test/integration/send-money-precision.test.jsx @@ -22,6 +22,32 @@ async function fillForm(user, { amount, to = 'NGN' } = {}) { return amountField; } +async function reviewAndConfirm(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 }), + ); +} + +async function viewSubmittedTransfer(user) { + const dialog = await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + await user.click( + within(dialog).getByRole('button', { name: /view transfers/i }), + ); + await screen.findByRole( + 'heading', + { name: /your transfers/i }, + { timeout: 5000 }, + ); +} + describe('Send flow — amount parsing regressions', () => { beforeEach(() => { window.history.pushState({}, '', '/send'); @@ -44,17 +70,22 @@ describe('Send flow — amount parsing regressions', () => { }); it('keeps a negative amount negative so validation can reject it', async () => { + const createTransfer = vi.spyOn(api, 'createTransfer'); const user = userEvent.setup(); render(); // "-5" used to be silently rewritten to "5.00" and sent as a real transfer. await fillForm(user, { amount: '-5' }); - expect(screen.getByLabelText(/^amount$/i)).toHaveValue(-5); + expect(screen.getByLabelText(/^amount$/i)).toHaveValue('-5.00'); await user.click(screen.getByRole('button', { name: /review & send/i })); expect( - await screen.findByText(/enter an amount greater than zero/i), + await screen.findByText(/amount cannot be negative/i), ).toBeInTheDocument(); + expect(createTransfer).not.toHaveBeenCalled(); + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); }); it('submits the exponent amount that the field actually held', async () => { @@ -63,13 +94,8 @@ describe('Send flow — amount parsing regressions', () => { render(); await fillForm(user, { amount: '1e3' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); expect(createTransfer).toHaveBeenCalledTimes(1); expect(createTransfer.mock.calls[0][0]).toMatchObject({ sendAmount: '1000', @@ -96,13 +122,8 @@ describe('Send flow — the receipt matches the quote', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // 200.00 - 1.10 fee = 198.90, at 1480.5 NGN/USD = 294471.45 exactly. const payload = createTransfer.mock.calls[0][0]; @@ -141,12 +162,8 @@ describe('Send flow — the receipt matches the quote', () => { ).toBeInTheDocument(); }); - await user.click(screen.getByRole('button', { name: /review & send/i })); - await screen.findByRole( - 'heading', - { name: /your transfers/i }, - { timeout: 5000 }, - ); + await reviewAndConfirm(user); + await viewSubmittedTransfer(user); // The number on the receipt is the number that was quoted, to the cent. // The seeded demo transfer shares this recipient, so find the row by the @@ -205,7 +222,7 @@ describe('Send flow — contract failures do not submit', () => { render(); await fillForm(user, { amount: '200' }); - await user.click(screen.getByRole('button', { name: /review & send/i })); + await reviewAndConfirm(user); expect( await screen.findByText(/rejected before it was sent/i, undefined, { 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..55a6ede --- /dev/null +++ b/test/integration/send-money-quote-freshness.test.jsx @@ -0,0 +1,505 @@ +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 * as walletService from '../../src/services/wallet.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')); + // Quote scenarios connect successfully unless a rejection case overrides this. + vi.spyOn(Math, 'random').mockReturnValue(0.5); + }); + + 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('keeps a localized amount signable after keyboard review', async () => { + localStorage.setItem('remitflow:locale', JSON.stringify('fr-FR')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + await user.type(screen.getByLabelText(/amount/i), '15,00'); + await user.keyboard('{Enter}'); + + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + const card = within(dialog) + .getByText(/transfer summary/i) + .closest('.quote-card'); + const quoteId = card.getAttribute('data-quote-id'); + expect(quoteId).toMatch(/^qt_/); + expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent( + '15,00', + ); + + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + + const stored = JSON.parse(localStorage.getItem('remitflow.transfers')); + const transfer = stored.find((entry) => entry.quoteId === quoteId); + expect(transfer).toMatchObject({ + recipient: 'amina@example.com', + sendAmount: '15', + from: 'USD', + to: 'NGN', + }); + }); + + it('preserves Portuguese decimal input through blur and confirmation', async () => { + localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR')); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + const amountField = screen.getByLabelText(/amount/i); + await user.type(amountField, '15,00'); + await user.tab(); + expect(amountField).toHaveValue('15,00'); + + // Repeated focus changes must leave the same value signable. + await user.click(amountField); + await user.tab(); + expect(amountField).toHaveValue('15,00'); + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent( + '15,00', + ); + + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + const stored = JSON.parse(localStorage.getItem('remitflow.transfers')); + const transfer = stored.find((entry) => entry.quoteId); + expect(transfer.sendAmount).toBe('15'); + }); + + it.each([ + { + label: 'integer balance', + amount: '15,00', + balance: 100, + canonical: '15', + }, + { + label: 'exact fractional balance', + amount: '25,50', + balance: 25.5, + canonical: '25.5', + }, + { + label: 'canonical string balance', + amount: '15,00', + balance: '100.00', + canonical: '15', + }, + ])( + 'compares localized input against a connected $label', + async ({ amount, balance, canonical }) => { + localStorage.setItem('remitflow.transfers', JSON.stringify([])); + localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR')); + localStorage.setItem( + 'remitflow.wallet', + JSON.stringify({ + publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF', + balance, + }), + ); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + const amountField = screen.getByLabelText(/amount/i); + await user.type(amountField, amount); + await user.tab(); + expect(amountField).toHaveValue(amount); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + expect(within(dialog).getByTestId('quote-send-amount')).toHaveTextContent( + amount, + ); + const quoteId = within(dialog) + .getByText(/transfer summary/i) + .closest('.quote-card') + .getAttribute('data-quote-id'); + expect(quoteId).toMatch(/^qt_/); + + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + const stored = JSON.parse(localStorage.getItem('remitflow.transfers')); + const matching = stored.filter((entry) => entry.quoteId === quoteId); + expect(matching).toHaveLength(1); + expect(matching[0]).toMatchObject({ + recipient: 'amina@example.com', + sendAmount: canonical, + from: 'USD', + to: 'NGN', + }); + }, + ); + + it.each([ + { amount: '100,01', balance: 100 }, + { amount: '25,51', balance: 25.5 }, + ])( + 'rejects localized $amount above connected balance $balance', + async ({ amount, balance }) => { + localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR')); + localStorage.setItem( + 'remitflow.wallet', + JSON.stringify({ + publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF', + balance, + }), + ); + const createSpy = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + await user.type(screen.getByLabelText(/amount/i), amount); + await user.click(screen.getByRole('button', { name: /review & send/i })); + + expect( + await screen.findByText('Amount exceeds your wallet balance.'), + ).toBeInTheDocument(); + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); + expect(createSpy).not.toHaveBeenCalled(); + expect(localStorage.getItem('remitflow.transfers')).toBeNull(); + }, + ); + + it.each([ + { + label: 'rejected connection', + amount: '15,00', + random: 0, + message: + 'Wallet connection did not complete. Connect your wallet and try again.', + }, + { + label: 'newly connected insufficient balance', + amount: '1000,01', + random: 0.5, + message: 'Amount exceeds your wallet balance.', + }, + ])('stops a transfer after a $label', async ({ amount, random, message }) => { + localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR')); + localStorage.setItem('remitflow.transfers', JSON.stringify([])); + vi.spyOn(Math, 'random').mockReturnValue(random); + const createSpy = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + await user.type(screen.getByLabelText(/amount/i), amount); + 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 }), + ); + + expect( + await screen.findByText(message, { exact: false }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('dialog', { name: /transfer submitted/i }), + ).not.toBeInTheDocument(); + expect(createSpy).not.toHaveBeenCalled(); + expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([]); + expect( + screen.getByRole('button', { name: /review & send/i }), + ).not.toBeDisabled(); + }); + + it.each([ + { amount: '15,00', canonical: '15' }, + { amount: '1000,00', canonical: '1000' }, + ])( + 'checks the returned wallet before saving affordable $amount', + async ({ amount, canonical }) => { + localStorage.setItem('remitflow:locale', JSON.stringify('pt-BR')); + localStorage.setItem('remitflow.transfers', JSON.stringify([])); + vi.spyOn(Math, 'random').mockReturnValue(0.5); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + render(); + + await user.type(screen.getByLabelText(/recipient/i), 'amina@example.com'); + await user.selectOptions(screen.getByLabelText(/^to$/i), 'NGN'); + await user.type(screen.getByLabelText(/amount/i), amount); + await user.click(screen.getByRole('button', { name: /review & send/i })); + const dialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + const quoteId = within(dialog) + .getByText(/transfer summary/i) + .closest('.quote-card') + .getAttribute('data-quote-id'); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + + expect(JSON.parse(localStorage.getItem('remitflow.wallet')).balance).toBe( + 1000, + ); + expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([ + expect.objectContaining({ quoteId, sendAmount: canonical }), + ]); + }, + ); + + it.each([ + { label: 'the network drops', reconnectBeforeWallet: false }, + { label: 'the network drops and recovers', reconnectBeforeWallet: true }, + ])( + 'does not resume confirmation when $label during wallet connection', + async ({ reconnectBeforeWallet }) => { + localStorage.setItem('remitflow.transfers', JSON.stringify([])); + const online = vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(true); + let resolveConnection; + const connection = new Promise((resolve) => { + resolveConnection = resolve; + }); + vi.spyOn(walletService, 'connectWallet').mockReturnValue(connection); + const createSpy = vi.spyOn(api, 'createTransfer'); + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }); + 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, + }); + const originalQuoteId = within(dialog) + .getByText(/transfer summary/i) + .closest('.quote-card') + .getAttribute('data-quote-id'); + await user.click( + within(dialog).getByRole('button', { name: /confirm transfer/i }), + ); + + act(() => { + online.mockReturnValue(false); + window.dispatchEvent(new Event('offline')); + }); + expect( + screen.queryByRole('dialog', { name: /confirm your transfer/i }), + ).not.toBeInTheDocument(); + expect( + screen.getByText(/the pending quote was cleared/i), + ).toBeInTheDocument(); + + if (reconnectBeforeWallet) { + act(() => { + online.mockReturnValue(true); + window.dispatchEvent(new Event('online')); + }); + } + + await act(async () => { + resolveConnection({ + publicKey: 'GBQAZ7Z3X7DEMOPUBLICKEY4REMITFLOWWALLET123456789ABCDEF', + balance: 1000, + }); + await connection; + }); + await act(async () => { + vi.advanceTimersByTime(700); + }); + + expect(createSpy).not.toHaveBeenCalled(); + expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual( + [], + ); + expect( + screen.queryByRole('dialog', { name: /transfer submitted/i }), + ).not.toBeInTheDocument(); + + if (!reconnectBeforeWallet) { + act(() => { + online.mockReturnValue(true); + window.dispatchEvent(new Event('online')); + }); + } + const reviewButton = screen.getByRole('button', { + name: /review & send/i, + }); + expect(reviewButton).toBeEnabled(); + await user.click(reviewButton); + const retryDialog = await screen.findByRole('dialog', { + name: /confirm your transfer/i, + }); + const retryQuoteId = within(retryDialog) + .getByText(/transfer summary/i) + .closest('.quote-card') + .getAttribute('data-quote-id'); + expect(retryQuoteId).not.toBe(originalQuoteId); + await user.click( + within(retryDialog).getByRole('button', { name: /confirm transfer/i }), + ); + await screen.findByRole( + 'dialog', + { name: /transfer submitted/i }, + { timeout: 5000 }, + ); + expect(createSpy).toHaveBeenCalledTimes(1); + expect(JSON.parse(localStorage.getItem('remitflow.transfers'))).toEqual([ + expect.objectContaining({ quoteId: retryQuoteId, sendAmount: '15' }), + ]); + }, + ); + + 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/AppContext.wallet.test.jsx b/test/unit/AppContext.wallet.test.jsx index 20c04bf..4436e1d 100644 --- a/test/unit/AppContext.wallet.test.jsx +++ b/test/unit/AppContext.wallet.test.jsx @@ -1,4 +1,3 @@ - import { describe, expect, it, beforeEach, vi } from 'vitest'; import { render, screen, waitFor } from '@testing-library/react'; import { AppProvider, useApp } from '../../src/context/AppContext.jsx'; diff --git a/test/unit/quote-binding.test.js b/test/unit/quote-binding.test.js new file mode 100644 index 0000000..22ecd79 --- /dev/null +++ b/test/unit/quote-binding.test.js @@ -0,0 +1,131 @@ +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'); + }); +}); diff --git a/test/unit/quote.test.js b/test/unit/quote.test.js index 0fcfe52..9461138 100644 --- a/test/unit/quote.test.js +++ b/test/unit/quote.test.js @@ -4,18 +4,26 @@ import { buildQuote } from '../../src/services/quote.js'; describe('buildQuote', () => { it('keeps submitted, fee, and receipt amounts as canonical decimal strings', () => { const quote = buildQuote('100.10', 'USD', 'NGN'); + // The quote contract removes redundant zeros while preserving exact values. expect(quote).toMatchObject({ - sendAmount: '100.10', - fee: '0.60', - amountAfterFee: '99.50', + sendAmount: '100.1', + fee: '0.6', + amountAfterFee: '99.5', receiveAmount: '147309.75', }); }); it('regresses binary floating-point drift at the receipt boundary', () => { const quote = buildQuote('0.30', 'USD', 'MXN'); - expect(quote.sendAmount).toBe('0.30'); - expect(quote.amountAfterFee).toBe('0.00'); - expect(quote.receiveAmount).toBe('0.00'); + expect(quote.sendAmount).toBe('0.3'); + // The minimum fee is 0.25: 0.05 * 17.1 = 0.855, rounded to 0.86 MXN. + expect(quote.fee).toBe('0.25'); + expect(quote.amountAfterFee).toBe('0.05'); + expect(quote.receiveAmount).toBe('0.86'); + + expect(buildQuote('0.20', 'USD', 'MXN')).toMatchObject({ + amountAfterFee: '0', + receiveAmount: '0', + }); }); }); diff --git a/test/unit/useTransfers.errors.test.jsx b/test/unit/useTransfers.errors.test.jsx index 071426f..11741da 100644 --- a/test/unit/useTransfers.errors.test.jsx +++ b/test/unit/useTransfers.errors.test.jsx @@ -14,7 +14,9 @@ describe('useTransfers error retry policy', () => { const { result } = renderHook(() => useTransfers()); await waitFor(() => { - expect(result.current.error).toBe('Something went wrong. Please try again.'); + expect(result.current.error).toBe( + 'Something went wrong. Please try again.', + ); }); expect(result.current.retryable).toBe(false); diff --git a/vite-plugin-security-headers.js b/vite-plugin-security-headers.js index af430cf..d3fb1d7 100644 --- a/vite-plugin-security-headers.js +++ b/vite-plugin-security-headers.js @@ -66,8 +66,8 @@ export function buildCsp(apiOrig = apiOrigin()) { const directives = [ "default-src 'self'", "script-src 'self'", - "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag - "img-src 'self' data:", // data: URIs used by chart canvas toDataURL + "style-src 'self' 'unsafe-inline'", // CSS-in-JS / Vite inlines a tiny style tag + "img-src 'self' data:", // data: URIs used by chart canvas toDataURL `connect-src ${connectSrc.join(' ')}`, "font-src 'self'", "object-src 'none'", @@ -90,8 +90,7 @@ export const SECURITY_HEADERS = { 'X-Content-Type-Options': 'nosniff', 'X-Frame-Options': 'DENY', 'Referrer-Policy': 'strict-origin-when-cross-origin', - 'Permissions-Policy': - 'camera=(), microphone=(), geolocation=(), payment=()', + 'Permissions-Policy': 'camera=(), microphone=(), geolocation=(), payment=()', 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Resource-Policy': 'same-origin', 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains', diff --git a/work/validation/rf296-existing-suite-relay17-20261004/README.md b/work/validation/rf296-existing-suite-relay17-20261004/README.md new file mode 100644 index 0000000..35725f6 --- /dev/null +++ b/work/validation/rf296-existing-suite-relay17-20261004/README.md @@ -0,0 +1,93 @@ +# RF296: existing CI gates restored + +The complete package test selection passes on +[`17dd96c4593fcd599286d263ed7cb72f6a4b7c20`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/17dd96c4593fcd599286d263ed7cb72f6a4b7c20): +**485 passed, zero failed, zero skipped or pending, across all 49 files**. +The original [PR296](https://github.com/RemitFlow/RemitFlow-Frontend/pull/296) +and its prior quote-freshness, locale, balance, wallet, and connection repairs +are preserved. + +[Issue279](https://github.com/RemitFlow/RemitFlow-Frontend/issues/279) explicitly +requires existing tests and CI to remain passing. This continuation repairs the +four files behind the twelve previously recorded failures and the existing +formatting gate. One genuine product error was corrected: an already-valid +amount that cannot be priced now receives a pricing explanation instead of an +instruction to enter a positive amount. + +## Executed results + +| Gate | Result | +| ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| Existing Prettier check | Passed with Prettier 3.9.6; fifteen flagged files formatted, configuration and ignores unchanged | +| Complete package test selection | 485 passed / 0 failed / 0 skipped, all 49 files passed | +| Test duration | Vitest reported 54.94 seconds; complete process took 55.56 seconds | +| `npm audit --audit-level=critical` | Exit 0; zero critical vulnerabilities | +| Remaining audit findings | 20 high, 12 moderate, 2 low | +| `npm run build` | Exit 0; Vite reported 1.46 seconds, complete command took 1.99 seconds | +| Source identity | All 348 tracked file blobs and modes matched the published tree before execution; tracked contents remained identical afterward | + +The [earlier recorded run](https://tokenjunkielabs.slack.com/archives/C0BVANHNB26/p1791106741866919) +at `6fe01b6fd00f4063dfa1dc7c0ea826d4f3ac2077` had 473 passing and 12 failing +tests across the same 49 files. That observation was retained, rather than +rerunning the old complete suite. The corrected source was executed once as a +combined suite. Existing tests and strict money assertions were retained. The +combined run used no retries, exclusions or skipped cases, and added or changed +no snapshots. +These durations describe validation commands on this host, not application or +network performance. + +## Repairs included + +| Contribution | Preserved behavior and correction | +| ------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`84510d0b`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/84510d0b9d50d8178d287c4acccc16903cad77e4) | Honest null-price feedback; precision fixtures traverse review, confirmation, result and receipt navigation. Exact amounts, fee/rate payloads, receipt reconciliation, rejection and no-create assertions remain. | +| [`eb89ddb3`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/eb89ddb3dd3f7b128ce29e9f3e79f676a1478d05) | Offline/reconnect fixtures use the real confirmation flow. Offline blocking, no automatic resubmission, explicit recovery, exactly-once creation and unknown-status handling remain asserted. | +| [`9f4f56fb`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/9f4f56fbc0470e3824be7b95234c429cfa8d1089) | Pending-wallet assertions locate the Sending button inside the confirmation dialog; the unrelated demo-wallet random refusal is deterministic. Existing submission locks, native-submit and retry checks remain. | +| [`4f74e1fa`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/4f74e1fa9add4e0f541b9c565d35df17e7800427) | Strict canonical-decimal expectations match the existing parser contract. The original USD 0.30 case retains exact fee, remainder and rounding checks; a below-fee USD 0.20 example retains zero clamping. Product pricing code is unchanged. | +| [`17dd96c4`](https://github.com/woahwhattheheck/RemitFlow-Frontend/commit/17dd96c4593fcd599286d263ed7cb72f6a4b7c20) | Only the existing formatter's output on the fifteen flagged files; all other 333 tracked blobs unchanged. | + +The affected maintained files also passed individually before composition: +precision 7/7, offline/reconnect 6/6, form 7/7, and quote 2/2. The combined +execution above covers the subsequently formatted source. + +## Environment and reproduction + +Execution used Node 24.19.0 and Vitest 4.1.10 with the retained matching +installation. The package manifest and lockfile were unchanged. The complete +test selection is exactly the one in `package.json`: + +```bash +npm test -- --maxWorkers=2 +npm audit --audit-level=critical +npm run build +``` + +Run these after installing dependencies from the committed lockfile. The local +execution additionally selected the default and JSON reporters, used a dedicated +temporary directory, and imported the repository's Vite configuration with only +`cacheDir` overridden to isolate writable files. There were no altered test +timeouts, exclusions, retry settings, snapshots, or dependency installs for this +aggregate run. The exact command and temporary configuration are in the evidence. + +The mounted-app tests use the repository's demonstration wallet and transfer +service in jsdom. Raw output retains its React/Vite warnings. The audit passes +the workflow's critical threshold while retaining the noncritical findings above. + +## Hosted status and retained evidence + +At the executed source, GitHub reports `action_required` for both +[CI](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208479) and +[Lighthouse CI](https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208458). +The current GitHub connection reports pull-only upstream permissions. +These are local gate results; hosted workflow approval and maintainer acceptance +remain external steps. The existing PR-description handoff and contributor +custody are preserved. + +[receipt.json](receipt.json) records source identities, commands, timestamps, +results, artifact hashes and hosted status. [evidence.zip](evidence.zip) preserves +the original complete Vitest JSON/output, the source manifest and execution +wrapper, audit JSON, build output, and formatter before/after logs and diff. +The archive's SHA-256 and each member's hash are in the receipt; raw execution +files in the archive are unchanged. + +The work was published to the existing contribution without force pushes. diff --git a/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip b/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip new file mode 100644 index 0000000..2d97a9e Binary files /dev/null and b/work/validation/rf296-existing-suite-relay17-20261004/evidence.zip differ diff --git a/work/validation/rf296-existing-suite-relay17-20261004/receipt.json b/work/validation/rf296-existing-suite-relay17-20261004/receipt.json new file mode 100644 index 0000000..44e54db --- /dev/null +++ b/work/validation/rf296-existing-suite-relay17-20261004/receipt.json @@ -0,0 +1,235 @@ +{ + "schema": "rf296-existing-suite/v1", + "pr_url": "https://github.com/RemitFlow/RemitFlow-Frontend/pull/296", + "issue_url": "https://github.com/RemitFlow/RemitFlow-Frontend/issues/279", + "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20", + "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b", + "previous_observation": { + "source_commit": "6fe01b6fd00f4063dfa1dc7c0ea826d4f3ac2077", + "tests_passed": 473, + "tests_failed": 12, + "files_passed": 45, + "files_failed": 4, + "vitest_reported_duration_seconds": 67.79, + "receipt_url": "https://tokenjunkielabs.slack.com/archives/C0BVANHNB26/p1791106741866919", + "rerun_by_this_aggregate_gate": false + }, + "formatting": { + "prettier_version": "3.9.6", + "flagged_files_before": 15, + "exit_code_after": 0, + "formatted_files": 15, + "unchanged_original_files": 333, + "configuration_ignore_and_dependency_files_preserved": true + }, + "suite": { + "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20", + "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b", + "verified_source_file_count_before": 348, + "verified_source_file_count_after": 348, + "started_at_utc": "2026-10-04T10:06:07.332838+00:00", + "finished_at_utc": "2026-10-04T10:07:02.901491+00:00", + "elapsed_seconds": 55.55961997700069, + "node_version": "v24.19.0", + "vitest_version": "4.1.10", + "command": [ + "/opt/codex/runtimes/codex-primary-runtime/dependencies/node/bin/node", + "node_modules/vitest/vitest.mjs", + "run", + "test/integration", + "test/unit", + "test/components", + "test/services", + "test/lighthouse-config.test.js", + "test/security", + "--config", + "relay17.vitest.config.mjs", + "--maxWorkers=2", + "--reporter=default", + "--reporter=json", + "--outputFile.json=/dev/shm/relay17-rf296-combined/validation/relay17-combined/vitest-results.json" + ], + "temporary_directory": "/dev/shm/relay17-rf296-combined/validation/relay17-combined/tmp", + "config_change": "Imports the repository Vite config; overrides only cacheDir.", + "exit_code": 0, + "success": true, + "tests_total": 485, + "tests_passed": 485, + "tests_failed": 0, + "tests_pending": 0, + "tests_todo": 0, + "files_total": 49, + "files_passed": 49, + "files_failed": 0, + "failed_cases": [], + "snapshot": { + "added": 0, + "failure": false, + "filesAdded": 0, + "filesRemoved": 0, + "filesRemovedList": [], + "filesUnmatched": 0, + "filesUpdated": 0, + "matched": 0, + "total": 0, + "unchecked": 0, + "uncheckedKeysByFile": [], + "unmatched": 0, + "updated": 0, + "didUpdate": false + }, + "pinned-source.json_sha256": "480385bcda69557c1fb6da6d013c617fd14f5582ce151e4e13cf9b681f2f29f6", + "vitest-output.log_sha256": "60211e449b3e241ac46cd1ccd4ff61e6ac2a0d5626352bcbef8e0cc388c0b36d", + "vitest-results.json_sha256": "7990edb5a5878120ef9554eb1437973d1c00e890b1f599ad1c3ab1bc62e1e3dd", + "vitest_reported_duration_seconds": 54.94 + }, + "audit": { + "command": [ + "npm", + "audit", + "--audit-level=critical", + "--json", + "--fetch-retries=0", + "--fetch-timeout=20000" + ], + "started_at_utc": "2026-10-04T10:00:59.609040+00:00", + "elapsed_seconds": 23.48439453699757, + "exit_code": 0, + "vulnerabilities": { + "info": 0, + "low": 2, + "moderate": 12, + "high": 20, + "critical": 0, + "total": 34 + }, + "error": null, + "dependency_file_blobs": { + "package.json": "625d0f3895fcc7afbfcfa131713249f5cd65dcd6", + "package-lock.json": "70d29c1865dac7ce76ba7d4e6ca5ee454be54aae" + }, + "audit_results_sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8" + }, + "build": { + "source_commit": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20", + "source_tree": "0d8d7424e6850402f126b23228e7960633b4b06b", + "command": ["npm", "run", "build"], + "started_at_utc": "2026-10-04T10:09:40.644314+00:00", + "elapsed_seconds": 1.9855508239998017, + "exit_code": 0, + "tracked_files_unchanged": true, + "changed_tracked_paths": [], + "build_output_sha256": "8a643fead8908619bb82f7f297adb7d2bd18e2e67702839a253b052f2d743262", + "vite_reported_duration_seconds": 1.46 + }, + "hosted_runs": [ + { + "name": "CI", + "run_id": 37194208479, + "head_sha": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20", + "conclusion": "action_required", + "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208479" + }, + { + "name": "Lighthouse CI", + "run_id": 37194208458, + "head_sha": "17dd96c4593fcd599286d263ed7cb72f6a4b7c20", + "conclusion": "action_required", + "url": "https://github.com/RemitFlow/RemitFlow-Frontend/actions/runs/37194208458" + } + ], + "upstream_permissions_observed": { + "admin": false, + "maintain": false, + "push": false, + "triage": false, + "pull": true + }, + "archive": { + "path": "evidence.zip", + "bytes": 55516, + "sha256": "8e443916e11ec2bb299a9a254c0a1b263614705df758fc26d6257c54236dcd7a", + "entries": [ + { + "path": "run/receipt.json", + "bytes": 1967, + "sha256": "368863c8d736a0371c27b82e00d90d6e885a4c71fdab44be468643af8d411891" + }, + { + "path": "run/pinned-source.json", + "bytes": 48742, + "sha256": "480385bcda69557c1fb6da6d013c617fd14f5582ce151e4e13cf9b681f2f29f6" + }, + { + "path": "run/vitest-results.json", + "bytes": 144366, + "sha256": "7990edb5a5878120ef9554eb1437973d1c00e890b1f599ad1c3ab1bc62e1e3dd" + }, + { + "path": "run/vitest-output.log", + "bytes": 37811, + "sha256": "60211e449b3e241ac46cd1ccd4ff61e6ac2a0d5626352bcbef8e0cc388c0b36d" + }, + { + "path": "run/audit-results.json", + "bytes": 51078, + "sha256": "8b87a13f1e39c94f710de1e541d3ba88f8a47661af825c6004a41b2a812eb5a8" + }, + { + "path": "run/audit-stderr.log", + "bytes": 307, + "sha256": "7d61cb2e2deb776dfec4d6f54693f9edeed0af5c4da52f091c53c995d862fb65" + }, + { + "path": "run/audit-receipt.json", + "bytes": 663, + "sha256": "113e36b0f21bec11a538aae6cf9949fe9636d0bb96df1f4f2a13f6bb98ff2cba" + }, + { + "path": "run/build-output.log", + "bytes": 477, + "sha256": "8a643fead8908619bb82f7f297adb7d2bd18e2e67702839a253b052f2d743262" + }, + { + "path": "run/build-receipt.json", + "bytes": 455, + "sha256": "56c5e222bf04e6f29224465cfc6442d6cd8646aded00594b5a1d23061b317838" + }, + { + "path": "run/run.py", + "bytes": 3536, + "sha256": "7e506fac8e41905d98b959d8f06d2e1051d5aee8da320e8890240412109606fb" + }, + { + "path": "run/relay17.vitest.config.mjs", + "bytes": 222, + "sha256": "33a69c3b33652c3a50f9334f4097dfb512b0cdee37180e4f1550a2599a09be46" + }, + { + "path": "format/check-before.log", + "bytes": 700, + "sha256": "621841c13145d58854e525a4f0b5bd9aca72594792e9ed68e03c7afb2824e8c8" + }, + { + "path": "format/check-after.log", + "bytes": 66, + "sha256": "17aa973d3f004560237d9a95171210b0671deff23d61628eecf7322ff5938f20" + }, + { + "path": "format/write.log", + "bytes": 561, + "sha256": "0d577fd9daa1ad077f7edd3bd27126aac87150a261dcd7dac75c60970d95d142" + }, + { + "path": "format/format.patch", + "bytes": 23185, + "sha256": "85c2c18123b373936cce3205582d40e09d8534997fbe394b55588e17889cdaf2" + }, + { + "path": "format/changes.json", + "bytes": 3214, + "sha256": "9ad1a19059164f2c82d2e25718218a0bb51e8a898a9a499f189d8e7e3dfe7d36" + } + ] + } +}