diff --git a/micopay/backend/src/db/schema.ts b/micopay/backend/src/db/schema.ts index aff0c29..1d2e09a 100644 --- a/micopay/backend/src/db/schema.ts +++ b/micopay/backend/src/db/schema.ts @@ -21,6 +21,7 @@ const mem: Record = { compliance_filings: [], device_keys: [], sign_requests: [], + trade_claim_tokens: [], }; function memNow() { diff --git a/micopay/backend/src/index.ts b/micopay/backend/src/index.ts index 6cc8d52..3ba9112 100644 --- a/micopay/backend/src/index.ts +++ b/micopay/backend/src/index.ts @@ -17,6 +17,7 @@ import { rateRoutes } from './routes/rate.js'; import { kycRoutes } from './routes/kyc.js'; import { rampRoutes } from './routes/ramp.js'; import { signRequestsRoutes } from './routes/sign-requests.js'; +import { clientErrorRoutes } from './routes/client-errors.js'; import { AppError } from './utils/errors.js'; import { Keypair } from '@stellar/stellar-sdk'; import fastifyStatic from '@fastify/static'; @@ -32,16 +33,31 @@ import { startComplianceJob, stopComplianceJob } from './services/compliance.ser const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, '..', 'public'); +// SEC-02 pide redactar el material del QR en logs de API/proxy/analytics. Cubre +// tanto el body de la request como cualquier objeto que se loguee con esas +// claves (el preimage ya no viaja, pero el token de cobro sigue siendo una +// capacidad de un solo uso). +const LOG_REDACT_PATHS = [ + 'req.body.claim_token', + 'claim_token', + 'claimToken', + 'qr_payload', + 'qrPayload', + 'secret', +]; + const app = Fastify({ trustProxy: true, logger: process.env.NODE_ENV === 'development' ? { level: 'info', + redact: LOG_REDACT_PATHS, transport: { target: 'pino-pretty', options: { colorize: true, translateTime: 'HH:MM:ss Z' }, }, } : { level: 'info', + redact: LOG_REDACT_PATHS, formatters: { bindings: (o) => ({ ...o, service: 'micopay-backend' }), }, @@ -234,6 +250,10 @@ app.register(rateRoutes, { prefix: '' }); app.register(kycRoutes, { prefix: '' }); app.register(rampRoutes, { prefix: '' }); app.register(signRequestsRoutes, { prefix: '' }); +// El ErrorBoundary del frontend postea aquí; la ruta existía sin registrar, así +// que hasta ahora todo reporte de crash caía en un 404 +// (docs/AUDIT_MOBILE_MAINNET.md §6, "Ruta backend definida pero no registrada"). +app.register(clientErrorRoutes, { prefix: '' }); // --- Start server --- diff --git a/micopay/backend/src/routes/rate.ts b/micopay/backend/src/routes/rate.ts index 2fe29fb..7094790 100644 --- a/micopay/backend/src/routes/rate.ts +++ b/micopay/backend/src/routes/rate.ts @@ -1,9 +1,8 @@ import type { FastifyInstance } from 'fastify'; +import { UpstreamError } from '../utils/errors.js'; const CACHE_TTL_MS = 60_000; const TIMEOUT_MS = 5_000; -// Last-resort estimate only if every live source fails AND there's no cache. -const FALLBACK_RATE = Number(process.env.XLM_MXN_FALLBACK ?? 3.2); interface CacheEntry { rate: number; @@ -11,11 +10,11 @@ interface CacheEntry { fetchedAt: string; } -let cache: CacheEntry | null = null; +const caches: Record = {}; /** @internal — exposed for testing */ export function __resetCache(): void { - cache = null; + for (const key of Object.keys(caches)) delete caches[key]; } const round = (n: number) => Math.round(n * 1e6) / 1e6; @@ -72,26 +71,73 @@ const SOURCES: Array<() => Promise> = [ }, ]; -export async function rateRoutes(app: FastifyInstance) { - app.get('/rate/xlm-mxn', async (request) => { - const now = Date.now(); +/** + * USDC→MXN. USDC is USD-pegged but can drift, so the first source prices the + * peg itself (USDC-USD) instead of assuming 1:1. Same egress ordering as XLM: + * Coinbase first, CoinGecko last (it rate-limits datacenter IPs). + */ +const USDC_SOURCES: Array<() => Promise> = [ + // Coinbase USDC-USD × er-api USD-MXN + async () => { + const d = await j('https://api.coinbase.com/v2/prices/USDC-USD/spot'); + const usdcUsd = Number(d?.data?.amount); + if (!(usdcUsd > 0)) throw new Error('coinbase usdc bad'); + return { rate: round(usdcUsd * (await getUsdMxn())), source: 'coinbase+erapi', fetchedAt: new Date().toISOString() }; + }, + // er-api USD-MXN, assuming the peg holds + async () => { + return { rate: round(await getUsdMxn()), source: 'erapi', fetchedAt: new Date().toISOString() }; + }, + // CoinGecko direct USDC→MXN + async () => { + const d = await j('https://api.coingecko.com/api/v3/simple/price?ids=usd-coin&vs_currencies=mxn'); + const rate = Number(d?.['usd-coin']?.mxn); + if (!(rate > 0)) throw new Error('coingecko usdc bad'); + return { rate, source: 'coingecko', fetchedAt: new Date().toISOString() }; + }, +]; - if (cache && now - new Date(cache.fetchedAt).getTime() < CACHE_TTL_MS) { - return cache; - } +/** + * Cache → live sources → stale cache → 503. + * + * Nunca se inventa un tipo de cambio: `docs/AUDIT_MOBILE_MAINNET.md` §3 ("los + * fallbacks deben mostrar '—' y deshabilitar el submit, no inventar un número") + * y `src/tests/rateCache.test.ts`, que exige 503 `RATE_FETCH_FAILED` cuando no + * hay fuente viva ni caché. El estimado fijo que había aquí (3.2 MXN/XLM) + * contradecía ambos. + */ +async function resolveRate( + pair: string, + sources: Array<() => Promise>, + request: { log: { warn: (obj: unknown, msg: string) => void } }, +) { + const cached = caches[pair]; + if (cached && Date.now() - new Date(cached.fetchedAt).getTime() < CACHE_TTL_MS) { + return cached; + } - for (const source of SOURCES) { - try { - const fresh = await source(); - cache = fresh; - return fresh; - } catch (err) { - request.log.warn({ err: err instanceof Error ? err.message : err, category: 'rate' }, '[rate] source failed, trying next'); - } + for (const source of sources) { + try { + const fresh = await source(); + caches[pair] = fresh; + return fresh; + } catch (err) { + request.log.warn({ err: err instanceof Error ? err.message : err, category: 'rate', pair }, '[rate] source failed, trying next'); } + } + + if (cached) return { ...cached, stale: true }; + + throw new UpstreamError( + 'RATE_FETCH_FAILED', + 'No pudimos obtener el tipo de cambio. Intenta de nuevo en un momento.', + `No live source returned a ${pair} rate and there is no cached value`, + 503, + ); +} + +export async function rateRoutes(app: FastifyInstance) { + app.get('/rate/xlm-mxn', async (request) => resolveRate('xlm-mxn', SOURCES, request)); - // Everything failed: serve last-known cache if any, else a marked estimate. - if (cache) return { ...cache, stale: true }; - return { rate: FALLBACK_RATE, source: 'fallback', fetchedAt: new Date().toISOString(), stale: true }; - }); + app.get('/rate/usdc-mxn', async (request) => resolveRate('usdc-mxn', USDC_SOURCES, request)); } diff --git a/micopay/backend/src/routes/trades.ts b/micopay/backend/src/routes/trades.ts index cef4047..910be1c 100644 --- a/micopay/backend/src/routes/trades.ts +++ b/micopay/backend/src/routes/trades.ts @@ -241,6 +241,30 @@ export async function tradeRoutes(app: FastifyInstance) { return { audit: events }; }); + /** + * POST /trades/:id/merchant-confirm + * El comercio escanea el QR del usuario. Valida trade, participante, estado y + * expiración, y quema el `claim_token` del QR (SEC-02) para que un mismo + * código no sirva dos veces. Devuelve el resumen para la pantalla de + * confirmación — no mueve fondos. + */ + app.post('/trades/:id/merchant-confirm', { + schema: { + body: { + type: 'object', + required: ['claim_token'], + properties: { + claim_token: { type: 'string', pattern: '^[0-9a-fA-F]{64}$' }, + }, + additionalProperties: false, + }, + }, + }, async (request) => { + const { id } = request.params as { id: string }; + const { claim_token } = request.body as { claim_token: string }; + return tradeService.merchantConfirmScan(request, id, request.user.id, claim_token); + }); + /** * GET /merchants/me/trades * List incoming trades for the authenticated merchant, filtered by state. diff --git a/micopay/backend/src/services/trade.service.ts b/micopay/backend/src/services/trade.service.ts index 84056e2..aae1432 100644 --- a/micopay/backend/src/services/trade.service.ts +++ b/micopay/backend/src/services/trade.service.ts @@ -2,7 +2,7 @@ import db from '../db/schema.js'; import { config } from '../config.js'; import pino from 'pino'; import { generateTradeSecret, encryptSecret, decryptSecret } from './secret.service.js'; -import { createHash } from 'crypto'; +import { createHash, randomBytes } from 'crypto'; import type { FastifyRequest } from 'fastify'; import { prepareLockTx, submitLockTx, prepareReleaseTx, submitReleaseTx, callRefundOnChain, verifyLockOnChain, assertNotReplayed } from './stellar.service.js'; import { @@ -61,6 +61,8 @@ const STROOPS_PER_MXN = 10_000_000; // 7 decimals const PLATFORM_FEE_PERCENT = 0.8; // 0.8% platform fee const DEFAULT_TIMEOUT_MINUTES = 120; // 2 hours const UNKNOWN_STATE = 'unknown'; +/** SEC-02: TTL corto del token del QR. Nunca sobrepasa `trades.expires_at`. */ +const CLAIM_TOKEN_TTL_MINUTES = 15; interface TransitionFailureContext { tradeId: string; @@ -541,8 +543,20 @@ export async function getTradeSecret(request: FastifyRequest, tradeId: string, u throw new TradeStateError('TRADE_EXPIRED', 'El intercambio ha expirado', 'Trade has expired'); } - // Decrypt secret - const secret = decryptSecret(trade.secret_enc, trade.secret_nonce); + // SEC-02: el preimage ya no sale del backend. El QR lleva un token opaco de + // un solo uso; quien libera on-chain sigue siendo el backend, que descifra el + // secreto por su cuenta en prepareReleaseTrade/completeTrade. + const claimToken = randomBytes(32).toString('hex'); + const tokenExpiresAt = new Date(Math.min( + Date.now() + CLAIM_TOKEN_TTL_MINUTES * 60 * 1000, + new Date(trade.expires_at).getTime(), + )); + + await db.execute( + `INSERT INTO trade_claim_tokens (token_hash, trade_id, issued_to, expires_at) + VALUES ($1, $2, $3, $4)`, + [hashClaimToken(claimToken), tradeId, userId, tokenExpiresAt], + ); // Log access await db.execute( @@ -551,9 +565,79 @@ export async function getTradeSecret(request: FastifyRequest, tradeId: string, u [tradeId, userId, ip, userAgent], ); - const qrPayload = `micopay://release?trade_id=${tradeId}&secret=${secret}`; + const qrPayload = `micopay://release?trade_id=${tradeId}&claim_token=${claimToken}`; - return { secret, qr_payload: qrPayload, expires_in: 120 }; + return { + qr_payload: qrPayload, + expires_at: tokenExpiresAt.toISOString(), + expires_in: Math.max(0, Math.floor((tokenExpiresAt.getTime() - Date.now()) / 1000)), + }; +} + +/** El token en claro nunca se persiste — mismo principio que `trades.secret_hash`. */ +function hashClaimToken(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +/** + * Marca un token de QR como usado. El UPDATE filtra por `consumed_at IS NULL`, + * así que bajo concurrencia solo un escaneo puede ganarlo; el SELECT posterior + * confirma quién fue. + */ +async function consumeClaimToken(tradeId: string, claimToken: string, consumedBy: string) { + const tokenHash = hashClaimToken(claimToken); + const selectToken = `SELECT consumed_at, consumed_by, expires_at FROM trade_claim_tokens + WHERE token_hash = $1 AND trade_id = $2`; + + const before = await db.getOne<{ + consumed_at: string | null; + consumed_by: string | null; + expires_at: string; + }>(selectToken, [tokenHash, tradeId]); + + if (!before) { + throw new NotFoundError( + 'INVALID_CLAIM_TOKEN', + 'Este código QR no es válido para esta operación', + `No claim token matching trade ${tradeId}`, + ); + } + + // `?? null`: el store in-memory omite las columnas que nunca se escribieron, + // así que un token virgen llega con `consumed_at` undefined, no null. + if ((before.consumed_at ?? null) !== null) { + throw new ConflictError( + 'CLAIM_TOKEN_USED', + 'Este código QR ya fue usado', + `Claim token for trade ${tradeId} was already consumed`, + ); + } + + if (new Date(before.expires_at) < new Date()) { + throw new TradeStateError( + 'CLAIM_TOKEN_EXPIRED', + 'Este código QR expiró. Pide al usuario que genere uno nuevo', + `Claim token for trade ${tradeId} expired at ${before.expires_at}`, + ); + } + + await db.execute( + `UPDATE trade_claim_tokens + SET consumed_at = NOW(), consumed_by = $3 + WHERE token_hash = $1 AND trade_id = $2 AND consumed_at IS NULL`, + [tokenHash, tradeId, consumedBy], + ); + + // Dos escaneos simultáneos pasan los checks de arriba; solo uno gana el + // UPDATE (`consumed_at IS NULL`). Releer dice cuál fue. + const after = await db.getOne<{ consumed_by: string | null }>(selectToken, [tokenHash, tradeId]); + if (after?.consumed_by !== consumedBy) { + throw new ConflictError( + 'CLAIM_TOKEN_USED', + 'Este código QR ya fue usado', + `Claim token for trade ${tradeId} was consumed by another scan`, + ); + } } /** @@ -1140,6 +1224,7 @@ export async function merchantConfirmScan( request: FastifyRequest, tradeId: string, merchantId: string, + claimToken: string, ): Promise { request.log.info( { trade_id: tradeId, merchant_id: merchantId, category: 'trade.lifecycle' }, @@ -1192,6 +1277,10 @@ export async function merchantConfirmScan( ); } + // 5. El QR debe traer un token vivo y sin usar (SEC-02). Se quema aquí, ya + // validado el trade, para que un QR contra un trade inválido no lo gaste. + await consumeClaimToken(tradeId, claimToken, merchantId); + // Fetch buyer info for display const buyer = await db.getOne<{ username: string }>( 'SELECT username FROM users WHERE id = $1', diff --git a/micopay/backend/src/tests/claimToken.test.ts b/micopay/backend/src/tests/claimToken.test.ts new file mode 100644 index 0000000..c55bb84 --- /dev/null +++ b/micopay/backend/src/tests/claimToken.test.ts @@ -0,0 +1,238 @@ +/** + * SEC-02 — Regression tests: the QR must not carry the HTLC preimage. + * + * docs/security-reports/SEC-02-htlc-secret-en-qr.md (severidad Alta) pide un + * "QR opaco de un solo uso: incluir solo un claim_token aleatorio, corto TTL, + * estado server-side y marcado atomico como consumido". Esto verifica esas + * cuatro propiedades: + * - getTradeSecret no devuelve el preimage y el payload no lo contiene + * - el token vive server-side y expira (nunca despues del trade) + * - merchantConfirmScan solo acepta el token emitido para ESE trade + * - el segundo escaneo del mismo token es rechazado + * + * Runs against the in-memory DB (ALLOW_IN_MEMORY_DB=true, no PostgreSQL needed). + */ + +import { strictEqual, ok } from "assert"; +import { createHash, randomBytes } from "crypto"; +import db from "../db/schema.js"; +import { + getTradeSecret, + merchantConfirmScan, +} from "../services/trade.service.js"; +import { AppError } from "../utils/errors.js"; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +const fakeRequest = { + ip: "127.0.0.1", + headers: {}, + log: { + info: () => {}, + warn: () => {}, + error: () => {}, + }, +} as any; + +async function createUser(suffix: string): Promise { + const row = await db.getOne<{ id: string }>( + `INSERT INTO users (stellar_address, username, phone_hash, merchant_available, availability, is_suspended) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id`, + [ + `G${"B".repeat(54)}${suffix.padStart(1, "0")}`, + `user_sec02_${suffix}`, + `hash_sec02_${suffix}`, + true, + "online", + false, + ], + ); + if (!row?.id) throw new Error(`Failed to seed user ${suffix}`); + return row.id; +} + +/** Insert a `revealing` trade and return { tradeId, secret }. */ +async function insertRevealingTrade(sellerId: string, buyerId: string) { + const { encryptSecret, generateTradeSecret } = await import( + "../services/secret.service.js" + ); + const { secret, secretHash } = generateTradeSecret(); + const { encrypted, nonce } = encryptSecret(secret); + const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(); + + const row = await db.getOne<{ id: string }>( + `INSERT INTO trades + (seller_id, buyer_id, amount_mxn, amount_stroops, platform_fee_mxn, + secret_hash, secret_enc, secret_nonce, status, expires_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + RETURNING id`, + [ + sellerId, + buyerId, + 500, + "5000000000", + 4, + secretHash, + encrypted, + nonce, + "revealing", + expiresAt, + ], + ); + if (!row?.id) throw new Error("Failed to insert trade"); + return { tradeId: row.id, secret }; +} + +async function assertAppError( + fn: () => Promise, + expectedCode: string, + label: string, +) { + let threw = false; + try { + await fn(); + } catch (err) { + threw = true; + ok( + err instanceof AppError && err.code === expectedCode, + `${label}: expected AppError(${expectedCode}) but got ${(err as Error)?.constructor?.name} ${(err as any)?.code}`, + ); + } + ok(threw, `${label}: expected an error to be thrown but none was`); +} + +function claimTokenFrom(qrPayload: string): string { + const token = new URL(qrPayload.replace("micopay://", "https://")) + .searchParams.get("claim_token"); + ok(token, "qr_payload must carry a claim_token"); + return token!; +} + +// ── Tests ────────────────────────────────────────────────────────────────── + +async function testSecretNeverLeavesTheBackend() { + const sellerId = await createUser("s1"); + const buyerId = await createUser("b1"); + const { tradeId, secret } = await insertRevealingTrade(sellerId, buyerId); + + const result = await getTradeSecret( + fakeRequest, + tradeId, + sellerId, + "127.0.0.1", + "test", + ); + + strictEqual( + (result as Record).secret, + undefined, + "the response must not include the preimage", + ); + ok( + !result.qr_payload.includes(secret), + "the QR payload must not contain the preimage", + ); + ok( + result.qr_payload.startsWith(`micopay://release?trade_id=${tradeId}&claim_token=`), + `unexpected payload shape: ${result.qr_payload}`, + ); + console.log(" ✓ getTradeSecret: neither the response nor the QR carry the preimage"); +} + +async function testTokenIsStoredHashedAndBoundedByTheTrade() { + const sellerId = await createUser("s2"); + const buyerId = await createUser("b2"); + const { tradeId } = await insertRevealingTrade(sellerId, buyerId); + + const { qr_payload, expires_at } = await getTradeSecret( + fakeRequest, + tradeId, + sellerId, + "127.0.0.1", + "test", + ); + const token = claimTokenFrom(qr_payload); + + const row = await db.getOne<{ token_hash: string; trade_id: string }>( + `SELECT token_hash, trade_id FROM trade_claim_tokens WHERE trade_id = $1`, + [tradeId], + ); + ok(row, "the token must exist server-side"); + strictEqual( + row!.token_hash, + createHash("sha256").update(token).digest("hex"), + "only the sha256 of the token may be stored", + ); + + const trade = await db.getOne<{ expires_at: string }>( + `SELECT expires_at FROM trades WHERE id = $1`, + [tradeId], + ); + ok( + new Date(expires_at) <= new Date(trade!.expires_at), + "the token must never outlive the trade", + ); + console.log(" ✓ claim token: stored hashed, expires with (or before) the trade"); +} + +async function testTokenIsSingleUse() { + const sellerId = await createUser("s3"); + const buyerId = await createUser("b3"); + const { tradeId } = await insertRevealingTrade(sellerId, buyerId); + + const { qr_payload } = await getTradeSecret( + fakeRequest, + tradeId, + sellerId, + "127.0.0.1", + "test", + ); + const token = claimTokenFrom(qr_payload); + + const first = await merchantConfirmScan(fakeRequest, tradeId, sellerId, token); + strictEqual(first.trade_id, tradeId, "first scan should succeed"); + + await assertAppError( + () => merchantConfirmScan(fakeRequest, tradeId, sellerId, token), + "CLAIM_TOKEN_USED", + "second scan of the same token", + ); + console.log(" ✓ claim token: the second scan of the same QR is rejected"); +} + +async function testForeignTokenIsRejected() { + const sellerId = await createUser("s4"); + const buyerId = await createUser("b4"); + const { tradeId } = await insertRevealingTrade(sellerId, buyerId); + + await assertAppError( + () => merchantConfirmScan( + fakeRequest, + tradeId, + sellerId, + randomBytes(32).toString("hex"), + ), + "INVALID_CLAIM_TOKEN", + "scan with a token that was never issued", + ); + console.log(" ✓ claim token: a token that was never issued is rejected"); +} + +// ── Runner ───────────────────────────────────────────────────────────────── + +async function run() { + console.log("\nSEC-02 — Opaque one-time QR token regression tests\n"); + + await testSecretNeverLeavesTheBackend(); + await testTokenIsStoredHashedAndBoundedByTheTrade(); + await testTokenIsSingleUse(); + await testForeignTokenIsRejected(); + + console.log("\nAll SEC-02 claim-token tests passed.\n"); +} + +run().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/micopay/frontend/package-lock.json b/micopay/frontend/package-lock.json index 75e44f9..97f0fbb 100644 --- a/micopay/frontend/package-lock.json +++ b/micopay/frontend/package-lock.json @@ -28,7 +28,8 @@ "react-dom": "^19.0.0", "react-i18next": "^17.0.8", "react-router-dom": "^6.28.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "xrpl": "^5.0.0" }, "devDependencies": { "@capacitor/assets": "^3.0.5", @@ -2839,6 +2840,81 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-2.2.0.tgz", + "integrity": "sha512-zFr7t2F+a9+5tB7QbarF2HQNYrgjCNaoLAupZdKkrFMYMozJf5zqH2WJCQibMzm1qQ0QogrxVGO3qXfQDYMaQg==", + "license": "MIT", + "dependencies": { + "@noble/curves": "2.2.0", + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3724,6 +3800,48 @@ "node": ">=14.6" } }, + "node_modules/@xrplf/isomorphic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@xrplf/isomorphic/-/isomorphic-1.0.2.tgz", + "integrity": "sha512-ncZUdMXr6VlSXtdoiDi0jTH+gBrgGxwVeEidhoegII3PmyErbQsyj6e+j7acmR4LW/lvBkPkzb9QzRfJH0n3rA==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^2.0.1", + "eventemitter3": "5.0.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@xrplf/isomorphic/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@xrplf/isomorphic/node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, + "node_modules/@xrplf/secret-numbers": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@xrplf/secret-numbers/-/secret-numbers-3.0.0.tgz", + "integrity": "sha512-qpGhAZXv5noMDjCtfzq5NK0y5rrdwTVjKhhPcAYSE+a/gogBOgqdpCKyieprVVPCnmVmJnGeRoZKBAqpCGegsA==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "ripple-keypairs": "^3.0.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -5746,6 +5864,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, "node_modules/events": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", @@ -5813,6 +5937,12 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -9359,6 +9489,80 @@ "dev": true, "license": "MIT" }, + "node_modules/ripple-address-codec": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ripple-address-codec/-/ripple-address-codec-5.0.1.tgz", + "integrity": "sha512-JQHLKuVJV8lv9Qobmn4aUM2Dpv9WRRLKnNWfM8tN02fAbUtG8mUPsu9q9UYX8P76G4qzytEc5ZKMp/3JggNYmw==", + "license": "ISC", + "dependencies": { + "@scure/base": "^2.0.0", + "@xrplf/isomorphic": "^1.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-binary-codec": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.8.0.tgz", + "integrity": "sha512-+NKnOi3hdzjm5dDpoZLUEaYon1jahPlSGnp3YrDoNMSR09ICEqgupN5wpEkPuqJvV75PF/g+W1QUwIXVzbEe7w==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "bignumber.js": "^10.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-binary-codec/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/ripple-keypairs": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-3.0.0.tgz", + "integrity": "sha512-lE69pD0E8hFNCqZoVXRyY45Yi8Ku+Qw7Rf1qRwPj4nOi34vp9NAuwzfiJH1IwXGWNCfEkwVfctG99CPTEoUf+g==", + "license": "ISC", + "dependencies": { + "@noble/curves": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-keypairs/node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ripple-keypairs/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rollup": { "version": "4.60.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", @@ -11045,6 +11249,27 @@ "dev": true, "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xcode": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/xcode/-/xcode-3.0.1.tgz", @@ -11140,6 +11365,33 @@ "node": ">=0.6.0" } }, + "node_modules/xrpl": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-5.0.0.tgz", + "integrity": "sha512-YqaTFJUnhOu0mI4bsuHbKGj6w9ATcH8EIgw+gOLnh1rrlLTo5oImLQzhKJixCAPqqWOKnsY7J3jsN+l+zeEWgA==", + "license": "ISC", + "dependencies": { + "@scure/bip32": "^2.0.1", + "@scure/bip39": "^2.0.1", + "@xrplf/isomorphic": "^1.0.2", + "@xrplf/secret-numbers": "^3.0.0", + "bignumber.js": "^10.0.2", + "eventemitter3": "^5.0.1", + "fast-json-stable-stringify": "^2.1.0", + "ripple-address-codec": "^5.0.1", + "ripple-binary-codec": "^2.8.0", + "ripple-keypairs": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/xrpl/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/micopay/frontend/package.json b/micopay/frontend/package.json index a7d4469..64ec9bd 100644 --- a/micopay/frontend/package.json +++ b/micopay/frontend/package.json @@ -35,7 +35,8 @@ "react-dom": "^19.0.0", "react-i18next": "^17.0.8", "react-router-dom": "^6.28.0", - "tailwind-merge": "^3.5.0" + "tailwind-merge": "^3.5.0", + "xrpl": "^5.0.0" }, "devDependencies": { "@capacitor/assets": "^3.0.5", diff --git a/micopay/frontend/src/App.tsx b/micopay/frontend/src/App.tsx index 9d88997..0f0d1f6 100644 --- a/micopay/frontend/src/App.tsx +++ b/micopay/frontend/src/App.tsx @@ -755,6 +755,8 @@ function App() { const [backendUrl, setBackendUrl] = useState(""); const [debugOpen, setDebugOpen] = useState(false); const envName = import.meta.env.MODE; + /** Modos que operan contra dinero real: nunca deben caer a mocks. */ + const STRICT_STARTUP_MODES = new Set(['production', 'mainnet']); useEffect(() => { const initUsers = async () => { @@ -808,8 +810,11 @@ function App() { console.warn("Backend not reachable during startup:", err); setBackendConnected(false); - // In production, force-block if backend is down. - if (envName === 'production') { + // Bloquear si el backend está caído. `build:mainnet` compila con + // --mode mainnet, así que MODE es 'mainnet', no 'production': sin + // incluirlo, el APK de mainnet caía a mocks en silencio + // (docs/AUDIT_MOBILE_MAINNET.md §3). + if (STRICT_STARTUP_MODES.has(envName)) { setStartupError({ title: "Servidor Inalcanzable", message: "No se pudo conectar al servidor de Micopay.", diff --git a/micopay/frontend/src/__tests__/Home.test.tsx b/micopay/frontend/src/__tests__/Home.test.tsx index 4c48bd2..d9bacdb 100644 --- a/micopay/frontend/src/__tests__/Home.test.tsx +++ b/micopay/frontend/src/__tests__/Home.test.tsx @@ -29,6 +29,9 @@ function createProps(overrides = {}) { token: 'buyer-token', merchantToken: 'merchant-token', onNavigateInbox: vi.fn(), + // El saludo sale de esta prop; sin ella Home muestra "Hola, ..." y el + // assert de render no encuentra el nombre. + username: 'juan', ...overrides, }; } @@ -43,6 +46,8 @@ describe('Home — pending-trades badge', () => { loading: false, error: null, refresh: vi.fn(), + tokens: [], + usdMxnRate: 18.5, }); mockGetTradeHistory.mockResolvedValue([]); mockGetCurrentUser.mockResolvedValue({ verification_status: 'verified' } as any); @@ -117,6 +122,8 @@ describe('Home — XLM→MXN rate', () => { loading: false, error: null, refresh: vi.fn(), + tokens: [{ code: 'XLM', balance: 250 }], + usdMxnRate: 18.5, }); mockGetTradeHistory.mockResolvedValue([]); mockGetCurrentUser.mockResolvedValue({ verification_status: 'verified' } as any); @@ -133,14 +140,17 @@ describe('Home — XLM→MXN rate', () => { }); }); - it('shows tilde prefix when rate fetch fails', async () => { + // Antes se mostraba un estimado con tilde (~ balance × 20). docs/AUDIT_MOBILE_MAINNET.md + // §3 lo prohíbe: sin cotización se muestra "—", nunca un FX inventado. + it('shows an em dash instead of an invented rate when the fetch fails', async () => { mockGetXlmMxnRate.mockRejectedValue(new Error('Network error')); render(); await waitFor(() => { - expect(screen.getAllByText(/~5,000/).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText('—').length).toBeGreaterThanOrEqual(1); }); + expect(screen.queryByText(/~5,000/)).not.toBeInTheDocument(); }); }); @@ -161,6 +171,8 @@ describe('Home — non-custodial wallet balance states', () => { loading: false, error: null, refresh: vi.fn(), + tokens: [], + usdMxnRate: 18.5, }); render(); @@ -178,6 +190,8 @@ describe('Home — non-custodial wallet balance states', () => { loading: true, error: null, refresh: vi.fn(), + tokens: [], + usdMxnRate: 18.5, }); render(); @@ -195,6 +209,8 @@ describe('Home — non-custodial wallet balance states', () => { loading: false, error: new Error('Horizon connection failed'), refresh: vi.fn(), + tokens: [], + usdMxnRate: 18.5, }); render(); diff --git a/micopay/frontend/src/__tests__/TradeDetail.test.tsx b/micopay/frontend/src/__tests__/TradeDetail.test.tsx index 79f79a4..236ee0b 100644 --- a/micopay/frontend/src/__tests__/TradeDetail.test.tsx +++ b/micopay/frontend/src/__tests__/TradeDetail.test.tsx @@ -1,10 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { MemoryRouter, Routes, Route } from 'react-router-dom'; import TradeDetail from '../pages/TradeDetail'; -import * as api from '../services/api'; const mockGetTrade = vi.fn(); +const mockCompleteTrade = vi.fn(); // Mock the API module vi.mock('../services/api', () => ({ @@ -12,10 +12,26 @@ vi.mock('../services/api', () => ({ const trade = await mockGetTrade(id, token); return { trade, merchant_unavailable: false, seller_username: 'seller-username' }; }), - completeTrade: vi.fn(), + completeTrade: (...args: unknown[]) => mockCompleteTrade(...args), cancelTradeRequest: vi.fn(), refundTradeRequest: vi.fn(), - getToken: vi.fn(), + lockTrade: vi.fn(), +})); + +vi.mock('../services/payment', () => ({ + ensureTrustline: vi.fn(), +})); + +// La sesión vive en secure storage (Keychain/Keystore en nativo) bajo la clave +// `micopay_user` con shape plano — no en `localStorage.micopay_users`, que era +// el artefacto de doble identidad del demo (SEC-22, SEC-26 y el fix del +// interceptor 401 en docs/AUDIT_MOBILE_MAINNET.md §2). +const mockReadJSON = vi.fn(async () => ({ id: 'buyer-1', token: 'mock-token' })); + +vi.mock('../services/secureStorage', () => ({ + readJSON: (...args: unknown[]) => mockReadJSON(...(args as [])), + writeJSON: vi.fn(), + removeKey: vi.fn(), })); // Robust localStorage mock for tests @@ -39,7 +55,6 @@ if (global.window) { }); } -const mockGetToken = vi.mocked(api.getToken); const createMockTrade = (status: string) => ({ id: 'trade-123', @@ -72,13 +87,11 @@ describe('TradeDetail', () => { beforeEach(() => { vi.clearAllMocks(); localStorage.clear(); - localStorage.setItem('micopay_users', JSON.stringify({ buyer: { token: 'mock-token', id: 'buyer-1' } })); }); describe('Route registration', () => { it('should render TradeDetail when navigating to /trade/:id', async () => { - mockGetToken.mockReturnValue('mock-token'); - mockGetTrade.mockResolvedValue(createMockTrade('pending')); + mockGetTrade.mockResolvedValue(createMockTrade('pending')); renderWithRouter('/trade/test-trade-id'); @@ -92,8 +105,7 @@ describe('TradeDetail', () => { }); it('should correctly read trade ID from URL params', async () => { - mockGetToken.mockReturnValue('mock-token'); - mockGetTrade.mockResolvedValue(createMockTrade('pending')); + mockGetTrade.mockResolvedValue(createMockTrade('pending')); renderWithRouter('/trade/unique-trade-456'); @@ -104,10 +116,6 @@ describe('TradeDetail', () => { }); describe('State rendering', () => { - beforeEach(() => { - mockGetToken.mockReturnValue('mock-token'); - }); - it('should render pending state with cancel button', async () => { mockGetTrade.mockResolvedValue(createMockTrade('pending')); @@ -168,8 +176,11 @@ describe('TradeDetail', () => { }); }); - it('should render cancelled state', async () => { - mockGetTrade.mockResolvedValue(createMockTrade('cancelled')); + it('should render cancelled state when no funds were ever locked', async () => { + mockGetTrade.mockResolvedValue({ + ...createMockTrade('cancelled'), + lock_tx_hash: null, + }); renderWithRouter(); @@ -180,9 +191,24 @@ describe('TradeDetail', () => { }); }); + // Finding B3 de docs/AUDIT_MOBILE_MAINNET.md: cancelar un trade ya + // bloqueado dejaba los fondos atrapados sin ruta de recuperación. Ahora esa + // combinación (lock sin release) ofrece el refund a cualquiera de los dos + // participantes. + it('should offer refund for a cancelled trade with funds still locked', async () => { + mockGetTrade.mockResolvedValue(createMockTrade('cancelled')); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/reembolso pendiente/i)).toBeInTheDocument(); + expect(screen.getByText(/recuperarlos ahora/i)).toBeInTheDocument(); + expect(screen.getByText(/recuperar fondos/i)).toBeInTheDocument(); + }); + }); + it('should render expired state', async () => { mockGetTrade.mockResolvedValue(createMockTrade('expired')); - localStorage.setItem('micopay_users', JSON.stringify({ buyer: { token: 'mock-token', id: 'buyer-different' } })); renderWithRouter(); @@ -194,11 +220,52 @@ describe('TradeDetail', () => { }); }); - describe('Error handling', () => { - beforeEach(() => { - mockGetToken.mockReturnValue('mock-token'); + // docs/AUDIT_MOBILE_MAINNET.md §4: "RevealedView muestra éxito aunque + // completeTrade falle". La pantalla no debe declarar liberados unos fondos + // que siguen en el contrato. + describe('Confirmación de recepción', () => { + it('does not report success when the release fails', async () => { + mockGetTrade.mockResolvedValue(createMockTrade('revealed')); + mockCompleteTrade.mockRejectedValue(new Error('on-chain release failed')); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/ya recibí el efectivo/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(/ya recibí el efectivo/i)); + + await waitFor(() => { + expect(screen.getByText(/no se pudo confirmar/i)).toBeInTheDocument(); + }); + + // Sigue diciendo dónde está el dinero y deja reintentar. + expect(screen.getByText(/sigue retenido en la garantía/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /reintentar/i })).toBeInTheDocument(); + expect(screen.queryByText(/operación completada/i)).not.toBeInTheDocument(); }); + it('confirms when the release succeeds', async () => { + mockGetTrade.mockResolvedValue(createMockTrade('revealed')); + mockCompleteTrade.mockResolvedValue({ status: 'completed', release_tx_hash: 'hash' }); + + renderWithRouter(); + + await waitFor(() => { + expect(screen.getByText(/ya recibí el efectivo/i)).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByText(/ya recibí el efectivo/i)); + + await waitFor(() => { + expect(mockCompleteTrade).toHaveBeenCalledWith('trade-123', 'mock-token'); + }); + expect(screen.queryByText(/no se pudo confirmar/i)).not.toBeInTheDocument(); + }); + }); + + describe('Error handling', () => { it('should show 404 screen when trade is not found', async () => { const error = new Error('Not found'); (error as any).response = { status: 404 }; @@ -243,8 +310,7 @@ describe('TradeDetail', () => { describe('Auth recovery', () => { it('should redirect to home when not authenticated', async () => { - mockGetToken.mockReturnValue(null); - localStorage.removeItem('micopay_users'); + mockReadJSON.mockResolvedValueOnce(null as never); renderWithRouter('/trade/trade-123'); @@ -254,8 +320,7 @@ describe('TradeDetail', () => { }); it('should not redirect when user is authenticated', async () => { - mockGetToken.mockReturnValue('mock-token'); - mockGetTrade.mockResolvedValue(createMockTrade('pending')); + mockGetTrade.mockResolvedValue(createMockTrade('pending')); renderWithRouter('/trade/trade-123'); @@ -267,10 +332,6 @@ describe('TradeDetail', () => { }); describe('Support link visibility', () => { - beforeEach(() => { - mockGetToken.mockReturnValue('mock-token'); - }); - const states = ['pending', 'locked', 'revealing', 'revealed']; states.forEach((state) => { diff --git a/micopay/frontend/src/__tests__/useCountdown.test.ts b/micopay/frontend/src/__tests__/useCountdown.test.ts new file mode 100644 index 0000000..496b43d --- /dev/null +++ b/micopay/frontend/src/__tests__/useCountdown.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import { useCountdown } from '../hooks/useCountdown'; + +afterEach(() => { + vi.useRealTimers(); +}); + +describe('useCountdown', () => { + it('counts down while there is time left', () => { + vi.useFakeTimers(); + const expiresAt = new Date(Date.now() + 5 * 60_000).toISOString(); + + const { result } = renderHook(() => useCountdown(expiresAt)); + + expect(result.current.expired).toBe(false); + expect(result.current.label).toBe('5m 0s'); + + act(() => { + vi.advanceTimersByTime(60_000); + }); + + expect(result.current.label).toBe('4m 0s'); + expect(result.current.expired).toBe(false); + }); + + it('flips to expired when the deadline passes', () => { + vi.useFakeTimers(); + const expiresAt = new Date(Date.now() + 2_000).toISOString(); + + const { result } = renderHook(() => useCountdown(expiresAt)); + expect(result.current.expired).toBe(false); + + act(() => { + vi.advanceTimersByTime(3_000); + }); + + expect(result.current.expired).toBe(true); + expect(result.current.label).toBe(''); + }); + + it('reports hours for long windows', () => { + vi.useFakeTimers(); + const expiresAt = new Date(Date.now() + 2 * 3_600_000).toISOString(); + + const { result } = renderHook(() => useCountdown(expiresAt)); + + expect(result.current.label).toBe('2h 0m'); + }); + + it('stays idle without a deadline', () => { + const { result } = renderHook(() => useCountdown(null)); + + expect(result.current).toEqual({ label: '', expired: false }); + }); +}); diff --git a/micopay/frontend/src/hooks/useCountdown.ts b/micopay/frontend/src/hooks/useCountdown.ts new file mode 100644 index 0000000..5479974 --- /dev/null +++ b/micopay/frontend/src/hooks/useCountdown.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from 'react'; + +export interface Countdown { + /** Tiempo restante ya formateado: "1h 20m" o "4m 32s". Vacío si expiró. */ + label: string; + expired: boolean; +} + +/** + * Cuenta regresiva hasta `expiresAt` (ISO). Tick de 1 s. + * + * UX_MANIFESTO exige que la expiración sea visible en las pantallas de dinero + * ("make expiration visible" en QR/claim; "timeouts, delays, and refunds must + * be visible"). El copy de cada estado lo pone la pantalla, no el hook. + */ +export function useCountdown(expiresAt: string | null): Countdown { + const [state, setState] = useState({ label: '', expired: false }); + + useEffect(() => { + if (!expiresAt) { + setState({ label: '', expired: false }); + return; + } + + const tick = () => { + const diff = new Date(expiresAt).getTime() - Date.now(); + if (diff <= 0) { + setState({ label: '', expired: true }); + return; + } + const h = Math.floor(diff / 3_600_000); + const m = Math.floor((diff % 3_600_000) / 60_000); + const s = Math.floor((diff % 60_000) / 1000); + setState({ label: h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`, expired: false }); + }; + + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [expiresAt]); + + return state; +} diff --git a/micopay/frontend/src/hooks/useWalletBalance.ts b/micopay/frontend/src/hooks/useWalletBalance.ts index 2885d51..1c123ee 100644 --- a/micopay/frontend/src/hooks/useWalletBalance.ts +++ b/micopay/frontend/src/hooks/useWalletBalance.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { getPublicKey } from '../lib/keystore'; +import { getUsdcMxnRate } from '../services/api'; const HORIZON_URL = import.meta.env.VITE_HORIZON_URL || 'https://horizon-testnet.stellar.org'; @@ -23,14 +24,12 @@ export interface UseWalletBalanceResult { // Peso-pegged assets: treat 1 token = 1 MXN const MXN_PEGGED = new Set(['MXNE', 'MXNe', 'CETES', 'GTOKEN', 'MXN']); -async function fetchUsdMxnRate(): Promise { - const res = await fetch( - 'https://api.coingecko.com/api/v3/simple/price?ids=usd-coin&vs_currencies=mxn', - { signal: AbortSignal.timeout(8000) } - ); - const data = await res.json(); - return data['usd-coin']?.mxn ?? 17.5; -} +/** + * El FX viene del backend (multi-fuente + caché), no de CoinGecko desde el + * dispositivo: llamarlo client-side comparte el rate limit por IP y obliga a + * inventar un fallback. Si no hay cotización, `usdMxnRate` queda en null y la + * UI muestra "—" (docs/AUDIT_MOBILE_MAINNET.md §3 y §5). + */ export function useWalletBalance(): UseWalletBalanceResult { const [tokens, setTokens] = useState([]); @@ -43,7 +42,9 @@ export function useWalletBalance(): UseWalletBalanceResult { const refresh = useCallback(() => setTrigger((p) => p + 1), []); useEffect(() => { - fetchUsdMxnRate().then(setUsdMxnRate).catch(() => setUsdMxnRate(17.5)); + getUsdcMxnRate() + .then(({ rate }) => setUsdMxnRate(rate)) + .catch(() => setUsdMxnRate(null)); }, []); useEffect(() => { diff --git a/micopay/frontend/src/i18n/en.json b/micopay/frontend/src/i18n/en.json index e880a36..db113a4 100644 --- a/micopay/frontend/src/i18n/en.json +++ b/micopay/frontend/src/i18n/en.json @@ -335,7 +335,11 @@ "location": "Location", "exchangeCode": "YOUR EXCHANGE CODE", "generatingCode": "Generating secure code…", - "htlcTestnet": "Soroban HTLC", + "codeExpiresIn": "Code expires in {{time}}", + "codeExpired": "The code expired", + "codeExpiredFunds": "Your funds are still in escrow. Generate a new code to continue the handover.", + "newCode": "Generate a new code", + "htlcTestnet": "Soroban HTLC · Testnet", "receivedCash": "I already received the cash", "confirming": "Confirming trade…", "autoCancelNotice": "If not confirmed within 30 min, the trade will be cancelled automatically and your funds will be released.", diff --git a/micopay/frontend/src/i18n/es.json b/micopay/frontend/src/i18n/es.json index 60c91f2..5e7a588 100644 --- a/micopay/frontend/src/i18n/es.json +++ b/micopay/frontend/src/i18n/es.json @@ -335,7 +335,11 @@ "location": "Ubicación", "exchangeCode": "TU CÓDIGO DE INTERCAMBIO", "generatingCode": "Generando código seguro…", - "htlcTestnet": "Soroban HTLC", + "codeExpiresIn": "El código expira en {{time}}", + "codeExpired": "El código expiró", + "codeExpiredFunds": "Tus fondos siguen en garantía. Genera un código nuevo para continuar el cobro.", + "newCode": "Generar código nuevo", + "htlcTestnet": "Soroban HTLC · Testnet", "receivedCash": "Ya recibí el efectivo", "confirming": "Confirmando operación…", "autoCancelNotice": "Si no se confirma en 30 min, la operación se cancelará automáticamente y tus fondos serán liberados.", diff --git a/micopay/frontend/src/pages/BlendScreen.tsx b/micopay/frontend/src/pages/BlendScreen.tsx index bb6a183..446a0bf 100644 --- a/micopay/frontend/src/pages/BlendScreen.tsx +++ b/micopay/frontend/src/pages/BlendScreen.tsx @@ -5,6 +5,7 @@ import { blendBorrow, BlendPool, BlendTxResult, + getUsdcMxnRate, } from '../services/api'; import { extractApiErrorPayload } from '../utils/apiError'; @@ -37,11 +38,17 @@ const BlendScreen = ({ onBack }: BlendScreenProps) => { const [error, setError] = useState(null); + const [usdcMxnRate, setUsdcMxnRate] = useState(null); + useEffect(() => { getBlendPools() .then((data) => setPools(data.pools)) .catch(() => {}) .finally(() => setPoolsLoading(false)); + + getUsdcMxnRate() + .then(({ rate }) => setUsdcMxnRate(rate)) + .catch(() => setUsdcMxnRate(null)); }, []); const mainPool = pools[0] ?? null; @@ -49,7 +56,9 @@ const BlendScreen = ({ onBack }: BlendScreenProps) => { // Health factor mock: if no collateral deposited, show 0 const xlmCollateral = collateralResult ? parseFloat(collateralResult.amount) : 0; const maxBorrow = xlmCollateral * 0.7 * 0.058; // 70% LTV, ~$0.058 per XLM in USDC - const maxBorrowMxn = maxBorrow * 17.5; + // FX del backend; sin cotización el máximo en pesos no se muestra + // (docs/AUDIT_MOBILE_MAINNET.md §3: nada de FX literal en el frontend). + const maxBorrowMxn = usdcMxnRate != null ? maxBorrow * usdcMxnRate : null; const healthFactor = borrowResult ? (xlmCollateral * 0.058 * 1.1) / (parseFloat(borrowResult.amount) * (borrowAsset === 'MXNe' ? 0.057 : 1)) : 999; @@ -251,7 +260,7 @@ const BlendScreen = ({ onBack }: BlendScreenProps) => {

Pedir prestado

{collateralResult - ? `Máx ~${borrowAsset === 'MXNe' ? maxBorrowMxn.toFixed(0) : maxBorrow.toFixed(2)} ${borrowAsset}` + ? `Máx ~${borrowAsset === 'MXNe' ? (maxBorrowMxn?.toFixed(0) ?? '—') : maxBorrow.toFixed(2)} ${borrowAsset}` : 'Deposita garantía primero'}

diff --git a/micopay/frontend/src/pages/CETESScreen.tsx b/micopay/frontend/src/pages/CETESScreen.tsx index 8f188b6..bd34810 100644 --- a/micopay/frontend/src/pages/CETESScreen.tsx +++ b/micopay/frontend/src/pages/CETESScreen.tsx @@ -15,6 +15,7 @@ import { createRampOrder, regenerateRampOrderTx, getRampOrderStatus, + getUsdcMxnRate, } from '../services/api'; import { sendCETESToEtherfuse } from '../services/stellarRamp'; import { buildTxUrl } from '../utils/stellarExplorer'; @@ -47,6 +48,7 @@ const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp const [sourceAsset, setSourceAsset] = useState('XLM'); const [rate, setRate] = useState(null); + const [usdcMxnRate, setUsdcMxnRate] = useState(null); const [rateLoading, setRateLoading] = useState(true); const [txLoading, setTxLoading] = useState(false); const [txResult, setTxResult] = useState(null); @@ -67,6 +69,12 @@ const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp .catch(() => {}) .finally(() => setRateLoading(false)); + // FX del backend; sin él la vista previa muestra "—" en vez de un número + // inventado (docs/AUDIT_MOBILE_MAINNET.md §3). + getUsdcMxnRate() + .then(({ rate: usdcMxn }) => setUsdcMxnRate(usdcMxn)) + .catch(() => setUsdcMxnRate(null)); + if (userToken) { getMyProfile(userToken).then(setProfile).catch(() => {}); } @@ -107,26 +115,28 @@ const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp const cetesPreview = (): string => { if (!amount || isNaN(parseFloat(amount))) return '—'; const num = parseFloat(amount); + const cesPriceMxn = rate?.cesPriceMxn; + // Sin precio de CETES no hay conversión posible; sin FX tampoco, salvo que + // el activo ya esté en pesos. Antes se rellenaban con 10 / 17.5 / 17.24. + if (!cesPriceMxn) return '—'; + const needsFx = sourceAsset === 'XLM' || sourceAsset === 'USDC'; + if (needsFx && (usdcMxnRate == null || !rate?.xlmPerUsdc)) return '—'; + if (tab === 'buy') { if (sourceAsset === 'XLM') { - const xlmPerUsdc = rate?.xlmPerUsdc ?? 17.24; - const usdc = num / xlmPerUsdc; - const mxn = usdc * 17.5; - const cetes = mxn / (rate?.cesPriceMxn ?? 10); - return cetes.toFixed(2); + const usdc = num / rate!.xlmPerUsdc!; + return ((usdc * usdcMxnRate!) / cesPriceMxn).toFixed(2); } if (sourceAsset === 'USDC') { - const mxn = num * 17.5; - return (mxn / (rate?.cesPriceMxn ?? 10)).toFixed(2); + return ((num * usdcMxnRate!) / cesPriceMxn).toFixed(2); } - return (num / (rate?.cesPriceMxn ?? 10)).toFixed(2); + return (num / cesPriceMxn).toFixed(2); } else { - const mxn = num * (rate?.cesPriceMxn ?? 10); + const mxn = num * cesPriceMxn; if (sourceAsset === 'XLM') { - const xlmPerUsdc = rate?.xlmPerUsdc ?? 17.24; - return ((mxn / 17.5) * xlmPerUsdc).toFixed(2); + return ((mxn / usdcMxnRate!) * rate!.xlmPerUsdc!).toFixed(2); } - if (sourceAsset === 'USDC') return (mxn / 17.5).toFixed(2); + if (sourceAsset === 'USDC') return (mxn / usdcMxnRate!).toFixed(2); return mxn.toFixed(2); } }; diff --git a/micopay/frontend/src/pages/Home.tsx b/micopay/frontend/src/pages/Home.tsx index 7b86265..6c4c04a 100644 --- a/micopay/frontend/src/pages/Home.tsx +++ b/micopay/frontend/src/pages/Home.tsx @@ -131,25 +131,29 @@ const Home = ({ }, []); const MXN_PEGGED = new Set(['MXNE', 'MXNe', 'CETES', 'GTOKEN', 'MXN']); - const xlmRate = xlmMxnRate ?? 2.5; - const usdRate = usdMxnRate ?? 17.5; - const totalMxn = tokens.reduce((sum, t) => { - if (t.code === 'XLM') return sum + t.balance * xlmRate; - if (t.code === 'USDC') return sum + t.balance * usdRate; - if (MXN_PEGGED.has(t.code)) return sum + t.balance; - return sum; - }, 0); + // Sin cotización no se muestra un monto: un FX inventado sobre dinero real da + // cifras equivocadas (docs/AUDIT_MOBILE_MAINNET.md §3). + const ratesReady = xlmMxnRate != null && usdMxnRate != null && !rateError; - const mxnBalance = balanceLoading || rateLoading + const totalMxn = ratesReady + ? tokens.reduce((sum, t) => { + if (t.code === 'XLM') return sum + t.balance * xlmMxnRate!; + if (t.code === 'USDC') return sum + t.balance * usdMxnRate!; + if (MXN_PEGGED.has(t.code)) return sum + t.balance; + return sum; + }, 0) + : null; + + const mxnBalance = balanceLoading || rateLoading || totalMxn == null ? "—" : `$${totalMxn.toLocaleString("es-MX", { maximumFractionDigits: 2 })} MXN`; // Per-asset MXN value for the XLM row (its own value, not the grand total). const rawXlm = tokens.find((t) => t.code === 'XLM')?.balance ?? 0; - const xlmMxnValue = balanceLoading || rateLoading + const xlmMxnValue = balanceLoading || rateLoading || rateError || xlmMxnRate == null ? "—" - : `$${(rawXlm * xlmRate).toLocaleString("es-MX", { maximumFractionDigits: 2 })} MXN`; + : `$${(rawXlm * xlmMxnRate).toLocaleString("es-MX", { maximumFractionDigits: 2 })} MXN`; const today = new Date().toLocaleDateString("es-MX", { weekday: "long", diff --git a/micopay/frontend/src/pages/MerchantInbox.tsx b/micopay/frontend/src/pages/MerchantInbox.tsx index 49d6160..161ea0c 100644 --- a/micopay/frontend/src/pages/MerchantInbox.tsx +++ b/micopay/frontend/src/pages/MerchantInbox.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useQRScanner } from '../hooks/useQRScanner'; +import { useCountdown } from '../hooks/useCountdown'; import { getMerchantTrades, merchantConfirmScan, @@ -30,34 +31,6 @@ const STATUS_ICONS: Record = { refunded: 'undo', }; -// ── Countdown hook ───────────────────────────────────────────────────────── - -function useCountdown(expiresAt: string | null) { - const [remaining, setRemaining] = useState(''); - - useEffect(() => { - if (!expiresAt) return; - - const tick = () => { - const diff = new Date(expiresAt).getTime() - Date.now(); - if (diff <= 0) { - setRemaining('Expirado'); - return; - } - const h = Math.floor(diff / 3_600_000); - const m = Math.floor((diff % 3_600_000) / 60_000); - const s = Math.floor((diff % 60_000) / 1000); - setRemaining(h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`); - }; - - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [expiresAt]); - - return remaining; -} - // ── Scan state machine ───────────────────────────────────────────────────── type ScanView = @@ -77,7 +50,8 @@ function TradeConfirmationCard({ onDismiss: () => void; }) { const { t } = useTranslation(); - const countdown = useCountdown(data.expires_at); + const { label: countdownLabel, expired } = useCountdown(data.expires_at); + const countdown = expired ? 'Expirado' : countdownLabel; const statusColor = STATUS_COLORS[data.status] || 'bg-gray-100 text-gray-800'; const statusLabel = t(`home.status.${data.status}`, { defaultValue: data.status }); const statusIcon = STATUS_ICONS[data.status] || 'info'; @@ -285,20 +259,22 @@ const MerchantInbox = ({ token, onBack }: MerchantInboxProps) => { return; } - // The merchant scans the buyer's release QR, which carries the trade_id. - const tradeId = - parsed.payload.type === 'release' ? parsed.payload.tradeId : null; + // The merchant scans the buyer's release QR: trade_id + a one-time claim + // token. The HTLC preimage never travels in the QR (SEC-02). + const release = parsed.payload.type === 'release' ? parsed.payload : null; - if (!tradeId) { + if (!release) { setScanView({ type: 'parse_error', message: 'No se encontró un ID de trade en el QR' }); return; } + const tradeId = release.tradeId; + // Validate with backend. setScanView({ type: 'loading' }); try { - const result = await merchantConfirmScan(tradeId, token); + const result = await merchantConfirmScan(tradeId, release.claimToken, token); setScanView({ type: 'confirmation', data: result }); } catch (e) { const message = e instanceof Error ? e.message : 'Error al verificar el intercambio'; diff --git a/micopay/frontend/src/pages/QRReveal.tsx b/micopay/frontend/src/pages/QRReveal.tsx index c09e997..a18227d 100644 --- a/micopay/frontend/src/pages/QRReveal.tsx +++ b/micopay/frontend/src/pages/QRReveal.tsx @@ -9,6 +9,7 @@ import SupportLink from '../components/SupportLink'; import { mapApiError, type MappedApiError } from '../utils/apiError'; import { getDemoQrPayload, IS_DEMO_MODE } from '../utils/demoMode'; import { buildTxUrl } from '../utils/stellarExplorer'; +import { useCountdown } from '../hooks/useCountdown'; interface QRRevealProps { activeTrade: TradeData | null; @@ -28,6 +29,7 @@ interface QRRevealProps { const QRReveal = ({ activeTrade, sellerToken, buyerToken, amount, counterpartyName, ownName, onBack, onChat, onSuccess }: QRRevealProps) => { const { t } = useTranslation(); const [qrPayload, setQrPayload] = useState(null); + const [qrExpiresAt, setQrExpiresAt] = useState(null); const [secretLoaded, setSecretLoaded] = useState(false); const [secretLoading, setSecretLoading] = useState(false); const [secretError, setSecretError] = useState(null); @@ -53,8 +55,9 @@ const QRReveal = ({ activeTrade, sellerToken, buyerToken, amount, counterpartyNa // Swallow errors here: if the trade was already revealed (stale // local status), the reveal call 409s but getSecret still works. await revealTrade(activeTrade.id, sellerToken).catch(() => {}); - const { qr_payload } = await getSecret(activeTrade.id, sellerToken); + const { qr_payload, expires_at } = await getSecret(activeTrade.id, sellerToken); setQrPayload(qr_payload); + setQrExpiresAt(expires_at); setSecretLoaded(true); const fresh = await getTrade(activeTrade.id, sellerToken).catch(() => null); @@ -62,6 +65,7 @@ const QRReveal = ({ activeTrade, sellerToken, buyerToken, amount, counterpartyNa } catch (e) { if (IS_DEMO_MODE) { setQrPayload(getDemoQrPayload()); + setQrExpiresAt(null); setSecretLoaded(true); } else { setSecretError(mapApiError(e)); @@ -113,7 +117,11 @@ const QRReveal = ({ activeTrade, sellerToken, buyerToken, amount, counterpartyNa - const showQr = secretLoaded && qrPayload && !secretError; + // UX_MANIFESTO, pantalla QR: "make expiration visible". El código caduca + // antes que el trade, así que expirado NO significa fondos perdidos — + // basta con pedir uno nuevo. + const { label: qrCountdown, expired: qrExpired } = useCountdown(qrExpiresAt); + const showQr = secretLoaded && qrPayload && !secretError && !qrExpired; return (
@@ -201,6 +209,18 @@ const QRReveal = ({ activeTrade, sellerToken, buyerToken, amount, counterpartyNa supportTradeId={activeTrade?.id} supportState="QR_REVEAL_SECRET" /> + ) : qrExpired ? ( +
+ +

{t('qrReveal.codeExpired')}

+

{t('qrReveal.codeExpiredFunds')}

+ +
) : showQr ? (

{ownName ?? '—'}

${amount} MXN

+ {qrCountdown && ( +

+ + {t('qrReveal.codeExpiresIn', { time: qrCountdown })} +

+ )} {secretLoaded && (

{t('qrReveal.htlcTestnet')} diff --git a/micopay/frontend/src/pages/TradeDetail.tsx b/micopay/frontend/src/pages/TradeDetail.tsx index 7509c5d..be55426 100644 --- a/micopay/frontend/src/pages/TradeDetail.tsx +++ b/micopay/frontend/src/pages/TradeDetail.tsx @@ -12,6 +12,9 @@ import { import { ensureTrustline } from '../services/payment'; import { errorMessages } from '../constants/errorMessages'; import { readJSON } from '../services/secureStorage'; +import { useCountdown } from '../hooks/useCountdown'; +import { buildTxUrl } from '../utils/stellarExplorer'; +import { mapApiError } from '../utils/apiError'; type TradeDetailData = TradeDetailResponse['trade'] & { platform_fee_mxn?: number; @@ -53,32 +56,6 @@ const SUPPORT_EMAIL = 'support@micopay.io'; const ACTIVE_STATES = ['pending', 'locked', 'revealing']; -function useCountdown(expiresAt: string | null) { - const [remaining, setRemaining] = useState(''); - - useEffect(() => { - if (!expiresAt) return; - - const tick = () => { - const diff = new Date(expiresAt).getTime() - Date.now(); - if (diff <= 0) { - setRemaining('Expirado'); - return; - } - const h = Math.floor(diff / 3_600_000); - const m = Math.floor((diff % 3_600_000) / 60_000); - const s = Math.floor((diff % 60_000) / 1000); - setRemaining(h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`); - }; - - tick(); - const id = setInterval(tick, 1000); - return () => clearInterval(id); - }, [expiresAt]); - - return remaining; -} - const STATUS_CONFIG: Record = { pending: { label: 'Pendiente', color: '#f59e0b', icon: 'hourglass_top' }, locked: { label: 'Bloqueado', color: '#3b82f6', icon: 'lock' }, @@ -141,7 +118,8 @@ function PendingView({ locking: boolean; lockError: string | null; }) { - const countdown = useCountdown(trade.expires_at ?? null); + const { label: countdownLabel, expired: countdownExpired } = useCountdown(trade.expires_at ?? null); + const countdown = countdownExpired ? 'Expirado' : countdownLabel; return (

@@ -212,7 +190,6 @@ function PendingView({ } function LockedView({ trade }: { trade: TradeDetailData }) { - const STELLAR_EXPLORER = 'https://stellar.expert/explorer/testnet/tx'; return (
@@ -228,7 +205,7 @@ function LockedView({ trade }: { trade: TradeDetailData }) { {trade.lock_tx_hash && ( void; token: string | null }) { const [isConfirming, setIsConfirming] = useState(false); + const [confirmError, setConfirmError] = useState(null); + // La pantalla sólo avanza a "completado" si el release confirmó de verdad. + // Antes el catch sólo logueaba y el finally transicionaba igual, así que un + // release fallido —o la falta de token— se mostraba como éxito + // (docs/AUDIT_MOBILE_MAINNET.md §4, "honestidad sobre fondos"). const handleConfirm = async () => { if (isConfirming) return; setIsConfirming(true); + setConfirmError(null); try { const effectiveToken = token ?? (await getStoredToken()); - if (effectiveToken) { - await completeTrade(trade.id, effectiveToken); - } - } catch (e) { - console.warn('Could not complete trade on backend', e); - } finally { + if (!effectiveToken) throw new Error('NO_TOKEN'); + await completeTrade(trade.id, effectiveToken); setTimeout(() => onComplete(), 1500); + } catch (e) { + setIsConfirming(false); + // El motivo lo traduce mapApiError (el mapeo de errores del repo); lo + // único que se añade es dónde quedó el dinero, que UX_MANIFESTO exige + // decir en todo estado de error. + const reason = e instanceof Error && e.message === 'NO_TOKEN' + ? 'Tu sesión expiró. Vuelve a entrar para confirmar.' + : mapApiError(e).message; + setConfirmError(`${reason} El dinero sigue retenido en la garantía.`); } }; @@ -299,15 +287,22 @@ function RevealedView({ trade, onComplete, token }: { trade: TradeDetailData; on ¿Ya recibiste el efectivo? Confirma para liberar los fondos al vendedor.

+ {confirmError && ( +
+

No se pudo confirmar

+

{confirmError}

+
+ )} + {!isConfirming ? ( ) : (
@@ -323,7 +318,6 @@ function RevealedView({ trade, onComplete, token }: { trade: TradeDetailData; on } function CompletedView({ trade }: { trade: TradeDetailData }) { - const STELLAR_EXPLORER = 'https://stellar.expert/explorer/testnet/tx'; return (
@@ -350,7 +344,7 @@ function CompletedView({ trade }: { trade: TradeDetailData }) { {trade.release_tx_hash && ( { +): Promise<{ qr_payload: string; expires_at: string; expires_in: number }> { const res = await http.get( `/trades/${tradeId}/secret`, authHeaders(sellerToken), @@ -386,6 +390,8 @@ export interface XlmMxnRate { rate: number; source: string; fetchedAt: string; + /** true cuando se sirvió la última cotización conocida porque las fuentes fallaron. */ + stale?: boolean; } export async function getXlmMxnRate(): Promise { @@ -393,6 +399,16 @@ export async function getXlmMxnRate(): Promise { return res.data; } +/** + * USDC→MXN desde el backend (multi-fuente + caché). El frontend nunca debe + * llevar un FX literal: si esto falla, la UI muestra "—" + * (docs/AUDIT_MOBILE_MAINNET.md §3). + */ +export async function getUsdcMxnRate(): Promise { + const res = await http.get('/rate/usdc-mxn'); + return res.data; +} + export async function getCETESRate(amount = "100"): Promise { const res = await http.get(`/defi/cetes/rate?amount=${amount}`); return res.data; @@ -634,12 +650,13 @@ export interface MerchantConfirmResult { */ export async function merchantConfirmScan( tradeId: string, + claimToken: string, token: string, ): Promise { try { const res = await http.post( `/trades/${tradeId}/merchant-confirm`, - {}, + { claim_token: claimToken }, authHeaders(token), ); return res.data as MerchantConfirmResult; diff --git a/micopay/frontend/src/utils/qrPayload.test.ts b/micopay/frontend/src/utils/qrPayload.test.ts index f502d68..0be54a7 100644 --- a/micopay/frontend/src/utils/qrPayload.test.ts +++ b/micopay/frontend/src/utils/qrPayload.test.ts @@ -2,37 +2,37 @@ import { describe, it, expect, vi } from 'vitest'; import { parseQRPayload } from './qrPayload'; const TRADE_ID = '550e8400-e29b-41d4-a716-446655440000'; -const SECRET_64 = 'deadbeef'.repeat(8); +const CLAIM_TOKEN_64 = 'deadbeef'.repeat(8); const HTLC_TX_HASH = 'a'.repeat(64); describe('parseQRPayload', () => { // ── Release format ───────────────────────────────────────────────────── describe('micopay://release', () => { it('parses a valid release QR', () => { - const raw = `micopay://release?trade_id=${TRADE_ID}&secret=${SECRET_64}`; + const raw = `micopay://release?trade_id=${TRADE_ID}&claim_token=${CLAIM_TOKEN_64}`; const result = parseQRPayload(raw); expect(result.ok).toBe(true); if (result.ok) { expect(result.payload.type).toBe('release'); if (result.payload.type === 'release') { expect(result.payload.tradeId).toBe(TRADE_ID); - expect(result.payload.secret).toBe(SECRET_64); + expect(result.payload.claimToken).toBe(CLAIM_TOKEN_64); } } }); - it('normalizes secret hex to lowercase', () => { - const upperSecret = 'DEADBEEF'.repeat(8); - const raw = `micopay://release?trade_id=${TRADE_ID}&secret=${upperSecret}`; + it('normalizes claim token hex to lowercase', () => { + const upperToken = 'DEADBEEF'.repeat(8); + const raw = `micopay://release?trade_id=${TRADE_ID}&claim_token=${upperToken}`; const result = parseQRPayload(raw); expect(result.ok).toBe(true); if (result.ok && result.payload.type === 'release') { - expect(result.payload.secret).toBe(SECRET_64); + expect(result.payload.claimToken).toBe(CLAIM_TOKEN_64); } }); it('returns error when trade_id is missing', () => { - const raw = `micopay://release?secret=${SECRET_64}`; + const raw = `micopay://release?claim_token=${CLAIM_TOKEN_64}`; const result = parseQRPayload(raw); expect(result.ok).toBe(false); if (!result.ok) { @@ -41,7 +41,7 @@ describe('parseQRPayload', () => { }); it('returns error when trade_id is not a UUID', () => { - const raw = `micopay://release?trade_id=abc-123&secret=${SECRET_64}`; + const raw = `micopay://release?trade_id=abc-123&claim_token=${CLAIM_TOKEN_64}`; const result = parseQRPayload(raw); expect(result.ok).toBe(false); if (!result.ok) { @@ -49,23 +49,34 @@ describe('parseQRPayload', () => { } }); - it('returns error when secret is missing', () => { + it('returns error when claim token is missing', () => { const raw = `micopay://release?trade_id=${TRADE_ID}`; const result = parseQRPayload(raw); expect(result.ok).toBe(false); if (!result.ok) { - expect(result.error).toContain('secreto HTLC'); + expect(result.error).toContain('código de cobro'); } }); - it('rejects malformed secret (non-hex or wrong length)', () => { - const raw = `micopay://release?trade_id=${TRADE_ID}&secret=ZZZ`; + it('rejects malformed claim token (non-hex or wrong length)', () => { + const raw = `micopay://release?trade_id=${TRADE_ID}&claim_token=ZZZ`; const result = parseQRPayload(raw); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error).toContain('64 caracteres'); } }); + + // SEC-02: el formato viejo llevaba el preimage HTLC en la URL. Un QR así ya + // no debe abrir nada, aunque el hex sea válido. + it('rejects the legacy secret= payload', () => { + const raw = `micopay://release?trade_id=${TRADE_ID}&secret=${CLAIM_TOKEN_64}`; + const result = parseQRPayload(raw); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain('código de cobro'); + } + }); }); // ── Claim format ─────────────────────────────────────────────────────── @@ -194,7 +205,7 @@ describe('parseQRPayload', () => { }); it('trims whitespace before parsing', () => { - const raw = ` micopay://release?trade_id=${TRADE_ID}&secret=${SECRET_64} `; + const raw = ` micopay://release?trade_id=${TRADE_ID}&claim_token=${CLAIM_TOKEN_64} `; const result = parseQRPayload(raw); expect(result.ok).toBe(true); }); diff --git a/micopay/frontend/src/utils/qrPayload.ts b/micopay/frontend/src/utils/qrPayload.ts index 4432e58..66db89f 100644 --- a/micopay/frontend/src/utils/qrPayload.ts +++ b/micopay/frontend/src/utils/qrPayload.ts @@ -2,7 +2,7 @@ * QR payload parser for MicoPay protocol QR codes. * * Supported formats: - * - micopay://release?trade_id=&secret= + * - micopay://release?trade_id=&claim_token= * - micopay://claim?request_id=&amount_mxn=&htlc= * - MICOPAY:: (legacy demo format — demo builds only) * @@ -15,7 +15,11 @@ import { isHex64, isHtlcReference, isRequestId, isUuid } from './qrValidation'; export interface QRPayloadRelease { type: 'release'; tradeId: string; - secret: string; + /** + * Token opaco de un solo uso. El preimage HTLC ya no viaja en el QR — ver + * docs/security-reports/SEC-02-htlc-secret-en-qr.md. + */ + claimToken: string; } export interface QRPayloadClaim { @@ -71,7 +75,7 @@ export function parseQRPayload(raw: string | null | undefined): QRParseResult { if (action === 'release') { const tradeId = url.searchParams.get('trade_id'); - const secret = url.searchParams.get('secret'); + const claimToken = url.searchParams.get('claim_token'); if (!tradeId) { return { ok: false, error: 'El QR no contiene un ID de trade válido' }; @@ -79,19 +83,19 @@ export function parseQRPayload(raw: string | null | undefined): QRParseResult { if (!isUuid(tradeId)) { return { ok: false, error: 'El ID de trade no tiene un formato UUID válido' }; } - if (!secret) { - return { ok: false, error: 'El QR no contiene el secreto HTLC' }; + if (!claimToken) { + return { ok: false, error: 'El QR no contiene el código de cobro' }; } - if (!isHex64(secret)) { + if (!isHex64(claimToken)) { return { ok: false, - error: 'El secreto HTLC debe ser una cadena hexadecimal de 64 caracteres', + error: 'El código de cobro debe ser una cadena hexadecimal de 64 caracteres', }; } return { ok: true, - payload: { type: 'release', tradeId, secret: secret.toLowerCase() }, + payload: { type: 'release', tradeId, claimToken: claimToken.toLowerCase() }, }; } diff --git a/micopay/sql/migrations/20260728220000_trade_claim_tokens.down.sql b/micopay/sql/migrations/20260728220000_trade_claim_tokens.down.sql new file mode 100644 index 0000000..c418a7e --- /dev/null +++ b/micopay/sql/migrations/20260728220000_trade_claim_tokens.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_trade_claim_tokens_trade; +DROP TABLE IF EXISTS trade_claim_tokens; diff --git a/micopay/sql/migrations/20260728220000_trade_claim_tokens.up.sql b/micopay/sql/migrations/20260728220000_trade_claim_tokens.up.sql new file mode 100644 index 0000000..1c61699 --- /dev/null +++ b/micopay/sql/migrations/20260728220000_trade_claim_tokens.up.sql @@ -0,0 +1,22 @@ +-- SEC-02 (docs/security-reports/SEC-02-htlc-secret-en-qr.md, severidad Alta): +-- el QR dejaba viajar el preimage HTLC en la query string +-- (`micopay://release?trade_id=...&secret=...`), lo que permite liberar el +-- escrow directamente contra el contrato sin pasar por la app. +-- +-- El QR ahora lleva un token opaco de un solo uso. Solo se guarda su sha256 +-- (mismo principio que `trades.secret_hash`): el token en claro nunca se +-- persiste. `consumed_at` da el marcado atómico — el UPDATE de consumo filtra +-- por `consumed_at IS NULL`, así que dos escaneos concurrentes no pueden ganar +-- los dos. +CREATE TABLE IF NOT EXISTS trade_claim_tokens ( + token_hash VARCHAR(64) PRIMARY KEY, + trade_id UUID NOT NULL REFERENCES trades(id), + issued_to UUID NOT NULL REFERENCES users(id), + expires_at TIMESTAMPTZ NOT NULL, + consumed_at TIMESTAMPTZ, + consumed_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_trade_claim_tokens_trade + ON trade_claim_tokens (trade_id); diff --git a/package-lock.json b/package-lock.json index f413b70..883e98e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,9 +57,11 @@ "pg": "^8.13.0", "tsx": "^4.19.0", "typescript": "^5.7.3", - "viem": "^2.54.1" + "viem": "^2.54.1", + "xrpl": "^4.6.0" }, "devDependencies": { + "@electric-sql/pglite": "^0.5.4", "fast-check": "^4.8.0", "pino-pretty": "^13.0.0", "vitest": "^4.1.8" @@ -1391,6 +1393,13 @@ "node": ">=20.19.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.4.tgz", + "integrity": "sha512-yYZUyyXrHU7tPlCjwZQJ6hIG9DscdCCn7Uk0mYKwC1FeHX286AbcmFveMiRBEak8e9iPupjsoVImN3yJZVed2g==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -2874,6 +2883,42 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/@xrplf/isomorphic": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@xrplf/isomorphic/-/isomorphic-1.0.2.tgz", + "integrity": "sha512-ncZUdMXr6VlSXtdoiDi0jTH+gBrgGxwVeEidhoegII3PmyErbQsyj6e+j7acmR4LW/lvBkPkzb9QzRfJH0n3rA==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^2.0.1", + "eventemitter3": "5.0.1", + "ws": "^8.20.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@xrplf/isomorphic/node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@xrplf/secret-numbers": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@xrplf/secret-numbers/-/secret-numbers-2.0.0.tgz", + "integrity": "sha512-z3AOibRTE9E8MbjgzxqMpG1RNaBhQ1jnfhNCa1cGf2reZUJzPMYs4TggQTc7j8+0WyV3cr7y/U8Oz99SXIkN5Q==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.1", + "ripple-keypairs": "^2.0.0" + } + }, "node_modules/abitype": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/abitype/-/abitype-1.2.3.tgz", @@ -3677,6 +3722,12 @@ "version": "1.0.1", "license": "MIT" }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "dev": true, @@ -5009,6 +5060,62 @@ "version": "1.4.1", "license": "MIT" }, + "node_modules/ripple-address-codec": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ripple-address-codec/-/ripple-address-codec-5.0.1.tgz", + "integrity": "sha512-JQHLKuVJV8lv9Qobmn4aUM2Dpv9WRRLKnNWfM8tN02fAbUtG8mUPsu9q9UYX8P76G4qzytEc5ZKMp/3JggNYmw==", + "license": "ISC", + "dependencies": { + "@scure/base": "^2.0.0", + "@xrplf/isomorphic": "^1.0.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-address-codec/node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ripple-binary-codec": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.8.0.tgz", + "integrity": "sha512-+NKnOi3hdzjm5dDpoZLUEaYon1jahPlSGnp3YrDoNMSR09ICEqgupN5wpEkPuqJvV75PF/g+W1QUwIXVzbEe7w==", + "license": "ISC", + "dependencies": { + "@xrplf/isomorphic": "^1.0.2", + "bignumber.js": "^10.0.2", + "ripple-address-codec": "^5.0.1" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/ripple-binary-codec/node_modules/bignumber.js": { + "version": "10.0.2", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-10.0.2.tgz", + "integrity": "sha512-E8Wp9O06QA6lneJ4aRUXKYf/1GIomqUEmUMwtIOMtDxf1U52ffJY+y7JBk/8wRafA8qOIqLnXQGqonYXZdBnFQ==", + "license": "MIT" + }, + "node_modules/ripple-keypairs": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ripple-keypairs/-/ripple-keypairs-2.0.0.tgz", + "integrity": "sha512-b5rfL2EZiffmklqZk1W+dvSy97v3V/C7936WxCCgDynaGPp7GE6R2XO7EU9O2LlM/z95rj870IylYnOQs+1Rag==", + "license": "ISC", + "dependencies": { + "@noble/curves": "^1.0.0", + "@xrplf/isomorphic": "^1.0.0", + "ripple-address-codec": "^5.0.0" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/rollup": { "version": "4.60.2", "dev": true, @@ -6260,6 +6367,27 @@ "dev": true, "license": "MIT" }, + "node_modules/xrpl": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/xrpl/-/xrpl-4.6.0.tgz", + "integrity": "sha512-0nXZfqDHRJ6bsDv1WtA9MdCYalMtXuxVa9mtLdqT3xypRKf2LwT5DbuGL/kHcVfuqk3B+ly+SFARlrnX+LHtRQ==", + "license": "ISC", + "dependencies": { + "@scure/bip32": "^1.3.1", + "@scure/bip39": "^1.2.1", + "@xrplf/isomorphic": "^1.0.1", + "@xrplf/secret-numbers": "^2.0.0", + "bignumber.js": "^9.0.0", + "eventemitter3": "^5.0.1", + "fast-json-stable-stringify": "^2.1.0", + "ripple-address-codec": "^5.0.0", + "ripple-binary-codec": "^2.7.0", + "ripple-keypairs": "^2.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "license": "MIT",