Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions micopay/backend/src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const mem: Record<string, any[]> = {
compliance_filings: [],
device_keys: [],
sign_requests: [],
trade_claim_tokens: [],
};

function memNow() {
Expand Down
20 changes: 20 additions & 0 deletions micopay/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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' }),
},
Expand Down Expand Up @@ -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 ---

Expand Down
90 changes: 68 additions & 22 deletions micopay/backend/src/routes/rate.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
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;
source: string;
fetchedAt: string;
}

let cache: CacheEntry | null = null;
const caches: Record<string, CacheEntry | null> = {};

/** @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;
Expand Down Expand Up @@ -72,26 +71,73 @@ const SOURCES: Array<() => Promise<CacheEntry>> = [
},
];

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<CacheEntry>> = [
// 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<CacheEntry>>,
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));
}
24 changes: 24 additions & 0 deletions micopay/backend/src/routes/trades.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
99 changes: 94 additions & 5 deletions micopay/backend/src/services/trade.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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`,
);
}
}

/**
Expand Down Expand Up @@ -1140,6 +1224,7 @@ export async function merchantConfirmScan(
request: FastifyRequest,
tradeId: string,
merchantId: string,
claimToken: string,
): Promise<MerchantConfirmResult> {
request.log.info(
{ trade_id: tradeId, merchant_id: merchantId, category: 'trade.lifecycle' },
Expand Down Expand Up @@ -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',
Expand Down
Loading