diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77ea08..5a10974 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,7 +26,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: micopay/backend/package-lock.json - run: npm ci @@ -43,7 +43,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm cache-dependency-path: micopay/frontend/package-lock.json # El lock se genera en Windows y omite los binarios opcionales de Linux diff --git a/docs/AUDIT_APK_MAPA_2026-07.md b/docs/AUDIT_APK_MAPA_2026-07.md new file mode 100644 index 0000000..7a81797 --- /dev/null +++ b/docs/AUDIT_APK_MAPA_2026-07.md @@ -0,0 +1,105 @@ +# Auditoría del APK — funciones, flujos, brechas y mapa real + +**Fecha:** 2026-07-25 · **Base:** main post-merge #320/#321 + fixes AWS (`1db550a`, `02232b4`) +**Contexto:** backend ya vive en `https://api.micopay.app` (AWS ECS/RDS, BD limpia sin seed). + +--- + +## 1. Inventario de flujos del APK + +| Flujo | Pantallas | Estado real | +|---|---|---| +| Onboarding / identidad | `Register`, `Login` | ✅ Real. Genera keypair Stellar en el dispositivo (`keystore.ts`), auth por challenge-firma (estilo SEP-10), sin contraseñas | +| Descubrimiento de agentes (cash-out) | `Explore`, `ExploreMap` | ⚠️ Datos reales, **mapa visual simulado** (ver §3) | +| Depósito | `DepositRequest`, `DepositMap`, `DepositChat`, `DepositQR` | ⚠️ Igual: pipeline real, mapa simulado | +| Trade / escrow HTLC | `TradeDetail`, `TradeConfirmation`, `QRReveal`, `ClaimQR` | ✅ Real. El secreto HTLC se pide al backend con token de seller y el XDR se firma localmente (la llave nunca sale del dispositivo) | +| Pagos directos | `PayHub`, `SendPayment`, `ReceivePayment` | Real (vía backend) | +| Chat por trade | `ChatRoom` | Real (polling) | +| KYC | `KYCScreen` | Integración Didit recién mergeada (#315); gate apagado (`KYC_GATE_ENABLED=false`) | +| DeFi (CETES/Blend) | `CETESScreen`, `BlendScreen` | Gated por `VITE_ENABLE_DEFI_TRADING` (solo builds testnet); no mueve fondos reales (hallazgo B2 del audit móvil) | +| Comercio | `MerchantInbox`, `MerchantSettings`, `MerchantAvailabilityToggle` | ⚠️ Falta captura de ubicación (ver §3.3 — es la brecha estructural del mapa) | +| Offline | `useOfflineQueue`, `offlineQueueManager` | Cola de mutaciones offline presente | + +**Seguridad — lo que está bien hecho:** +- Llave privada en `@aparajita/capacitor-secure-storage` (Android Keystore); firma de challenge y de XDR 100% local. +- Auth sin contraseñas: challenge de un solo uso + verificación de firma ed25519 en el backend. +- `trustProxy: 1`, CORS explícito, Helmet/CSP/HSTS, TLS `verify-full` a la BD (todo verificado en el deploy de AWS). +- Modo demo del QR (`DEMO_QR_PAYLOAD`) correctamente gated por `VITE_DEMO_MODE` y lanza si se usa fuera de él. + +--- + +## 2. Brechas encontradas (no-mapa) + +| # | Brecha | Severidad | Detalle / fix | +|---|---|---|---| +| G1 | `/merchants/available` es público, sin rate limit | **Alta (privacidad)** | ✅ **Resuelto (WP3, rama `feat/map-real`).** Rate limiter por IP añadido al endpoint + coordenadas devueltas redondeadas a ~3 decimales (≈110 m) | +| G2 | `online: true` hardcodeado en `ExploreMap.merchantToOffer` | Media | ✅ **Resuelto (WP4, rama `feat/map-real`).** El campo `online` fue eliminado por completo del tipo `Offer`/`OfferConfirmData` y de sus usos; la disponibilidad se deriva únicamente de `merchant_available` en el backend | +| G3 | Cartel "Agentes reales cercanos" + "CDMX · ZONA CENTRO" hardcodeados en `MapSim` | Media (confianza) | ✅ **Resuelto (WP1, rama `feat/map-real`).** `MapReal` (MapLibre GL) reemplazó a `MapSim` en `ExploreMap`/`DepositMap`; esos carteles hardcodeados ya no existen en la UI viva. `MapSim.tsx` en sí fue borrado en WP5 | +| G4 | Store de challenges y rate limiter en memoria sin limpieza | Media | Ya documentado como SEC-16 (issues GrantFox). Crecimiento sin límite bajo ataque | +| G5 | Suite `TradeDetail` con 21 tests rojos; tests de backend no corren en CI | Media | Ya documentado como TEST-01. El job de CI los marca `continue-on-error` | +| G6 | `seed.ts` (script viejo) inconsistente con `seedDemoMerchants()` de `index.ts` | Baja | ✅ **Resuelto (WP5, rama `feat/map-real`).** `micopay/backend/src/seed.ts` fue borrado (no lo referenciaba nada); el seed real sigue siendo `seedDemoMerchants()` en `index.ts`, sin tocar | +| G7 | **BD de producción AWS está vacía** → mapa siempre en estado "sin agentes" | **Operativa inmediata** | No se seedeó a propósito. Para demos: `SEED_DEMO_DATA=true` + `SEED_ORIGIN_LAT/LNG` en el task def. Para real: resolver §3.3 | +| G8 | Distancia = Haversine línea recta; `walkMinutes = km/5*60` | Baja | Aceptable para MVP; anotar que no es ruta caminable real | + +--- + +## 3. El mapa: qué es simulado exactamente y qué no + +> ✅ **Resuelto (WP1 + WP2, rama `feat/map-real`).** §3.2 (render PNG simulado) quedó resuelto por WP1 (`MapReal.tsx` con MapLibre GL reemplaza a `MapSim`, `map_bg.png` borrado en WP5). §3.3 (falta de pipeline de captura de ubicación de comercios) quedó resuelto por WP2 (picker de ubicación en `MerchantSettings` contra `PATCH /merchants/me/location`). El diagnóstico original abajo se conserva íntegro para contexto histórico. + +### 3.1 Lo que YA es real (no rehacer) +- **GPS del usuario:** `useGeolocation` + `useMerchantsAvailable` usan el plugin Capacitor con flujo de permisos correcto (rationale primero, OS dialog después, re-check al volver de Settings). +- **Query geoespacial:** `GET /merchants/available?lat&lng&radius_km&amount_mxn` calcula Haversine **en SQL**, filtra por radio/monto/disponibilidad y ordena por distancia. Devuelve lat/lng reales, distancia, payout, reputación (tier/completion). +- **Reputación:** trades completados/terminales reales de la BD. + +### 3.2 Lo que es simulado (el problema visual) +`MapSim.tsx` es un **PNG estático de CDMX** (`/map_bg.png`) con: +- Pins proyectados por *bounding box normalizado* — la posición relativa entre pins es correcta, pero no corresponde a calles reales ni a escala. +- El punto del usuario **siempre al centro**, sin relación con su GPS real. +- Sin pan/zoom/tiles. Etiquetas hardcodeadas (G3). + +### 3.3 La brecha estructural (la causa raíz, más importante que el visual) +El backend **ya tiene** `PATCH /merchants/me/location` (lat/lng/address, validado, autenticado)… **pero el frontend nunca lo llama**. No existe ninguna pantalla donde un comercio fije su ubicación. Consecuencia: **los únicos comercios que pueden aparecer en el mapa son los 4 del seed demo** (`farmacia_guadalupe`, etc., posicionados alrededor de `SEED_ORIGIN_LAT/LNG`, default 19.689,-99.179). Un comercio real registrado desde el APK jamás aparecerá en el mapa, con o sin mapa bonito. + +> El "mapa simulado" es entonces dos problemas independientes: (a) el render visual fake, y (b) que no hay pipeline de captura de ubicación de comercios reales. Arreglar solo (a) daría un mapa real… lleno de hongos demo. + +--- + +## 4. Plan para mapa real por ubicación + +### Fase A — Render real (1–2 días) +Reemplazar `MapSim` por **MapLibre GL JS** (recomendado) o Leaflet: + +- **MapLibre GL** (`maplibre-gl`, ~250 KB gz — el bundle actual es 1.7 MB, cabe): open source, vector tiles, WebGL, sin API key propia. Funciona dentro del WebView de Capacitor sin plugin nativo. +- **Tiles:** para MVP, estilo raster/vector de **MapTiler Free** (100k tiles/mes) o **Stadia Maps Free**; los tiles crudos de openstreetmap.org tienen política de uso que prohíbe producción con tráfico real. Cobertura OSM en CDMX es buena. +- **Google Maps SDK**: mejor data en México pero exige API key con billing, restricción por SHA-1 del APK, y la key viaja embebida — descartado para esta etapa. + +Cambios concretos: +1. `npm i maplibre-gl` y nuevo componente `MapReal.tsx` con la misma interfaz de props que `MapSim` (`merchants`, `selectedMerchantId`, `onSelectMerchant`) — swap 1:1 en `ExploreMap`/`DepositMap`. +2. Centro inicial = coords reales del usuario (ya disponibles: `useMerchantsAvailable` las obtiene; hoy las descarta tras el fetch — exponerlas en el estado del hook). +3. `map.fitBounds()` sobre usuario + pins. Markers custom conservando los hongos (`mushroom_*.png` como `Marker element`). +4. Eliminar G3 (labels hardcodeadas); "· agentes cerca" derivado de `merchants.length`. + +### Fase B — Ubicación de comercios reales (el unlock, 1–2 días) +1. `api.ts`: agregar `updateMerchantLocation(lat, lng, address_text?)` → `PATCH /merchants/me/location`. +2. `MerchantSettings.tsx`: sección "Mi ubicación" con botón **"Usar mi ubicación actual"** (reusa `useGeolocation`) + mapa Fase A en modo picker (arrastrar pin para ajustar) + campo dirección opcional. +3. Gate suave: al activar `MerchantAvailabilityToggle` sin ubicación fijada, prompt "para aparecer en el mapa, fija tu ubicación". +4. Opcional siguiente paso: geocodificación inversa (Nominatim/MapTiler) para autollenar `address_text`. + +### Fase C — Endurecimiento (post-lanzamiento) +- G1: rate limit a `/merchants/available` + redondeo de coordenadas públicas (~110 m) — la ubicación exacta solo tras trade aceptado. +- Si el número de comercios crece (>~10k): índice geoespacial (PostGIS `earthdistance` o columna geohash) en lugar de Haversine full-scan. +- Decidir proveedor de tiles definitivo con presupuesto (MapTiler ~$25/mes el primer tier pagado) o self-host de tiles vectoriales de México (OpenMapTiles). + +### Para la demo de HOY (0 días) +La BD de AWS está vacía (G7): si quieres ver el mapa funcionando en el APK que instalamos, hay que setear `SEED_DEMO_DATA=true` y `SEED_ORIGIN_LAT`/`SEED_ORIGIN_LNG` con tus coordenadas actuales en el task def y forzar redeploy — los 4 agentes demo aparecerán alrededor de ti. + +--- + +## 5. Priorización sugerida + +1. **G7** (seed demo en AWS) — desbloquea probar el APK hoy. 15 min. +2. **Fase A** (MapLibre) — impacto visual/credibilidad inmediato. 1–2 días. +3. **Fase B** (ubicación de comercios) — sin esto el mapa nunca será real con usuarios reales. 1–2 días. +4. **G1** (privacidad de ubicaciones) — antes de tener comercios reales en producción. +5. G2/G3 se resuelven de paso en Fase A; G4/G5 ya están en el backlog de GrantFox (SEC-16, TEST-01). diff --git a/micopay/backend/Dockerfile b/micopay/backend/Dockerfile new file mode 100644 index 0000000..9201f26 --- /dev/null +++ b/micopay/backend/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 +# +# Imagen de producción de micopay-backend. +# +# IMPORTANTE — el contexto de build es `micopay/`, NO `micopay/backend/`: +# +# docker build -f micopay/backend/Dockerfile -t micopay-backend micopay +# +# El runner de migraciones (src/db/migrate.ts) resuelve `../../../sql` desde +# su propia ubicación en `dist/db/`, o sea `/app/sql`. Ese directorio vive +# fuera de `backend/`, así que un contexto acotado a `backend/` no podría +# copiarlo y el arranque quedaría sin esquema (el error se loguea pero NO +# tumba el proceso — ver index.ts, boot migrations —, así que la falla sería +# silenciosa hasta la primera query real). + +# ── Etapa 1: compilar TypeScript ────────────────────────────────────────── +FROM node:22-bookworm-slim AS build +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci +COPY backend/tsconfig.json ./ +COPY backend/src ./src +RUN npm run build + +# ── Etapa 2: dependencias de runtime ────────────────────────────────────── +# Nota: package.json declara typescript/tsx/@types en `dependencies`, así que +# --omit=dev no los elimina (solo saca pino-pretty). No se toca package.json +# aquí para no alterar el build de otros entornos; el costo es ~50 MB de imagen. +FROM node:22-bookworm-slim AS deps +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev + +# ── Etapa 3: imagen final ───────────────────────────────────────────────── +FROM node:22-bookworm-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app/backend + +COPY --from=deps /app/backend/node_modules ./node_modules +COPY --from=build /app/backend/dist ./dist + +# package.json es obligatorio en runtime: su `"type": "module"` es lo que hace +# que Node interprete dist/*.js como ESM. +COPY backend/package.json ./ + +# assetlinks.json para App Links de Android (servido desde /.well-known/). +COPY backend/public ./public + +# Migraciones SQL — ver la nota del encabezado sobre el contexto de build. +COPY sql /app/sql + +# CA bundle global de RDS — necesario para sslmode=verify-full. La versión de +# pg-connection-string en uso ya trata sslmode=require como alias de +# verify-full (advertencia en boot), así que sin este bundle la conexión se +# rechaza como "self-signed certificate in certificate chain". Bundle global +# cubre la rotación automática de la CA de RDS, por eso es un COPY de una vez. +ADD https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem /app/rds-global-bundle.pem +RUN chmod 644 /app/rds-global-bundle.pem + +USER node +EXPOSE 3000 +CMD ["node", "dist/index.js"] diff --git a/micopay/backend/package.json b/micopay/backend/package.json index effd622..5345434 100644 --- a/micopay/backend/package.json +++ b/micopay/backend/package.json @@ -14,7 +14,11 @@ "test:kyc-gate": "node --import tsx src/tests/kyc-gate.service.test.ts", "test:compliance": "node --import tsx src/tests/compliance.test.ts", "test:kyc-didit": "node --import tsx src/tests/kyc-didit.test.ts", - "test:security": "node --import tsx src/tests/security.test.ts" + "test:security": "node --import tsx src/tests/security.test.ts", + "test:trade-auth": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/tradeAuth.test.ts", + "test:refund": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/refund.test.ts", + "test:challenge": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/challenge.service.test.ts", + "test:discovery": "ALLOW_IN_MEMORY_DB=true MOCK_STELLAR=true SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 node --import tsx src/tests/merchant.discovery.test.ts" }, "dependencies": { "@fastify/cors": "^8.5.0", diff --git a/micopay/backend/src/index.ts b/micopay/backend/src/index.ts index 8a3e030..c14e1bd 100644 --- a/micopay/backend/src/index.ts +++ b/micopay/backend/src/index.ts @@ -32,7 +32,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const PUBLIC_DIR = join(__dirname, '..', 'public'); const app = Fastify({ - trustProxy: true, + trustProxy: 1, logger: process.env.NODE_ENV === 'development' ? { level: 'info', transport: { diff --git a/micopay/backend/src/routes/kyc.ts b/micopay/backend/src/routes/kyc.ts index 5419515..1878ffc 100644 --- a/micopay/backend/src/routes/kyc.ts +++ b/micopay/backend/src/routes/kyc.ts @@ -2,7 +2,7 @@ import { randomUUID } from 'node:crypto'; import type { FastifyInstance } from 'fastify'; import { authMiddleware } from '../middleware/auth.middleware.js'; import db from '../db/schema.js'; -import { UpstreamError, NotFoundError } from '../utils/errors.js'; +import { UpstreamError, NotFoundError, BadRequestError } from '../utils/errors.js'; import { createOnboardingUrl, getKycStatus } from '../services/etherfuse.service.js'; import { createDiditSession, mapDiditStatus } from '../services/didit.service.js'; import { verifyDiditWebhookSignature } from '../lib/webhook-auth.js'; @@ -17,10 +17,15 @@ interface UserRow { id: string; stellar_address: string; username: string | null; + email: string | null; etherfuse_customer_id: string | null; etherfuse_bank_account_id: string | null; } +// RFC 5322 is a whole thing; this just catches obvious typos before we round-trip +// to Etherfuse (which does its own real validation server-side). +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + interface DiditSessionRow { session_id: string; user_id: string; @@ -54,13 +59,38 @@ async function startEtherfuseKyc(request: any) { const userId = request.user.id; const user = await db.getOne( - 'SELECT id, stellar_address, username, etherfuse_customer_id, etherfuse_bank_account_id FROM users WHERE id = $1', + 'SELECT id, stellar_address, username, email, etherfuse_customer_id, etherfuse_bank_account_id FROM users WHERE id = $1', [userId], ); if (!user) { throw new NotFoundError('User not found'); } + // Etherfuse's userInfo.email was optional until 2026-07-25, when it started + // rejecting onboarding-url requests without it. MicoPay's Stellar-keypair + // auth never collected an email from anyone, so the first time a user hits + // this route we need one — either already on file, or freshly submitted. + let email = user.email; + const submittedEmail = typeof request.body?.email === 'string' ? request.body.email.trim() : undefined; + if (!email && submittedEmail) { + if (!EMAIL_RE.test(submittedEmail)) { + throw new BadRequestError( + 'INVALID_EMAIL', + 'El correo no parece válido.', + `Rejected malformed email for user ${userId}`, + ); + } + await db.execute('UPDATE users SET email = $1 WHERE id = $2', [submittedEmail, userId]); + email = submittedEmail; + } + if (!email) { + throw new BadRequestError( + 'EMAIL_REQUIRED', + 'Etherfuse necesita un correo para verificar tu identidad.', + `User ${userId} has no email on file and none was submitted`, + ); + } + let { etherfuse_customer_id: customerId, etherfuse_bank_account_id: bankAccountId } = user; if (!customerId || !bankAccountId) { customerId = customerId ?? randomUUID(); @@ -76,7 +106,7 @@ async function startEtherfuseKyc(request: any) { customerId, bankAccountId, publicKey: user.stellar_address, - userInfo: { displayName: user.username ?? undefined }, + userInfo: { email, displayName: user.username ?? undefined }, }); const expiresAt = new Date(Date.now() + 15 * 60 * 1000).toISOString(); return { onboardingUrl, expiresAt }; diff --git a/micopay/backend/src/routes/merchants.ts b/micopay/backend/src/routes/merchants.ts index 20311fc..1d427a3 100644 --- a/micopay/backend/src/routes/merchants.ts +++ b/micopay/backend/src/routes/merchants.ts @@ -1,5 +1,6 @@ import type { FastifyInstance } from 'fastify'; import { authMiddleware } from '../middleware/auth.middleware.js'; +import { createRateLimiter } from '../middleware/rateLimit.middleware.js'; import { getOrCreateMerchantConfig, updateMerchantConfig, @@ -7,6 +8,12 @@ import { } from '../services/merchant.service.js'; import db from '../db/schema.js'; +// G1: /merchants/available is public and unauthenticated — without a rate +// limit it lets anyone scrape the full census of merchant locations by +// sweeping lat/lng. 30 req/min per IP is generous for legitimate use (the +// app makes one request per search). +const discoveryRateLimit = createRateLimiter({ windowMs: 60_000, max: 30 }); + export async function merchantRoutes(app: FastifyInstance) { /** * GET /merchants/available @@ -20,6 +27,7 @@ export async function merchantRoutes(app: FastifyInstance) { * flow – 'cashout' | 'deposit' (optional, reserved) */ app.get('/merchants/available', { + preHandler: [discoveryRateLimit], schema: { querystring: { type: 'object', diff --git a/micopay/backend/src/seed.ts b/micopay/backend/src/seed.ts deleted file mode 100644 index 7661f6f..0000000 --- a/micopay/backend/src/seed.ts +++ /dev/null @@ -1,59 +0,0 @@ -import db from './db/schema.js'; -import { randomUUID } from 'crypto'; - -async function seed() { - console.log('🌱 Seeding trades...'); - - // Get or create a buyer and seller - let buyer = await db.getOne("SELECT id FROM users WHERE username = 'juan_test'"); - if (!buyer) { - buyer = await db.getOne("INSERT INTO users (username, stellar_address) VALUES ('juan_test', 'GBUYER...') RETURNING id"); - } - - let seller = await db.getOne("SELECT id FROM users WHERE username = 'farmacia_test'"); - if (!seller) { - seller = await db.getOne("INSERT INTO users (username, stellar_address) VALUES ('farmacia_test', 'GSELLER...') RETURNING id"); - } - - const userId = buyer.id; - const sellerId = seller.id; - - const statuses = ['completed', 'cancelled', 'pending', 'locked', 'revealing']; - const now = new Date(); - - for (let i = 0; i < 15; i++) { - const status = statuses[i % statuses.length]; - const amount = 100 + (i * 50); - const createdAt = new Date(now.getTime() - (i * 3600000)); // Each trade 1 hour apart - const expiresAt = new Date(createdAt.getTime() + 7200000); // 2 hours expiry - - // Make some expired - let finalStatus = status; - if (i > 10) { - // These will be expired if status is pending/locked/revealing - } - - await db.execute( - `INSERT INTO trades - (seller_id, buyer_id, amount_mxn, amount_stroops, platform_fee_mxn, - secret_hash, status, created_at, expires_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, - [ - i % 2 === 0 ? sellerId : userId, // alternate role - i % 2 === 0 ? userId : sellerId, - amount, - (amount * 10000000).toString(), - Math.ceil(amount * 0.008), - `hash_${i}`, - status, - createdAt, - expiresAt - ] - ); - } - - console.log('✅ Seeding complete'); - process.exit(0); -} - -seed().catch(console.error); diff --git a/micopay/backend/src/services/merchant.service.ts b/micopay/backend/src/services/merchant.service.ts index a50e153..a752e71 100644 --- a/micopay/backend/src/services/merchant.service.ts +++ b/micopay/backend/src/services/merchant.service.ts @@ -220,8 +220,13 @@ export async function getAvailableMerchants( min_trade_mxn: r.min_trade_mxn, max_trade_mxn: r.max_trade_mxn, daily_cap_mxn: r.daily_cap_mxn, - latitude: parseFloat(r.latitude as unknown as string), - longitude: parseFloat(r.longitude as unknown as string), + // G1 privacy: coarsen public discovery coordinates to ~110m (3 decimals). + // Exact coordinates are only revealed to a counterparty inside an + // accepted trade, never at discovery time. distance_km above is + // already computed in SQL from the exact, unrounded columns, so it + // stays accurate — only these two output fields are rounded. + latitude: Math.round(parseFloat(r.latitude as unknown as string) * 1000) / 1000, + longitude: Math.round(parseFloat(r.longitude as unknown as string) * 1000) / 1000, address_text: r.address_text, distance_km: Math.round(distanceKm * 1000) / 1000, payout_mxn: payoutMxn, diff --git a/micopay/backend/src/tests/merchant.discovery.test.ts b/micopay/backend/src/tests/merchant.discovery.test.ts new file mode 100644 index 0000000..d646e74 --- /dev/null +++ b/micopay/backend/src/tests/merchant.discovery.test.ts @@ -0,0 +1,163 @@ +/** + * G1 — /merchants/available is public, unauthenticated and (before this fix) + * had no rate limit and returned exact lat/lng, letting anyone scrape the + * full census of merchant locations. + * + * This test covers the two mitigations from docs/PLAN_MAPA_REAL_2026-07.md + * WP3: + * (a) getAvailableMerchants() rounds the *returned* latitude/longitude to + * 3 decimals (~110m) while distance_km keeps its existing precision. + * (b) the discoveryRateLimit limiter (createRateLimiter({ windowMs: 60_000, + * max: 30 })) throws a RateLimitError (429, Retry-After) once a single + * IP exceeds `max` requests inside the window. + * + * Runs against the in-memory DB (ALLOW_IN_MEMORY_DB=true, no PostgreSQL + * needed), following the pattern of tradeAuth.test.ts / refund.test.ts. + * + * NOTE on (a): the in-memory SQL shim in src/db/schema.ts is a small regex + * based mock. It does not evaluate computed SQL columns (the HAVERSINE_SQL + * expression aliased as distance_km, or the seller_id/username/trades_* + * subqueries), only special-cases LEFT JOIN (not the plain INNER JOIN this + * query uses against `users`), and — critically — its WHERE-clause regex + * (`/\bWHERE\b.../`) matches the *first* literal "WHERE" in the raw SQL + * text, which here is the one inside the nested trades_completed/ + * trades_terminal subqueries, not the query's real WHERE. Seeding rows into + * merchant_configs and calling getAvailableMerchants() end-to-end therefore + * can't reliably exercise this query against the mock — it's a limitation of + * the mock, not of getAvailableMerchants() itself (against real PostgreSQL + * the query runs as written). + * + * So instead this test stubs `db.getMany` for the duration of the call, + * returning exactly the shape PostgreSQL would for one seeded merchant, and + * asserts on what getAvailableMerchants() does with that row — i.e. it + * targets the actual code under test (the rounding in the .map() in + * src/services/merchant.service.ts), independent of the mock SQL engine. + */ + +import { strictEqual, ok, notStrictEqual } from "assert"; +import db from "../db/schema.js"; +import { getAvailableMerchants } from "../services/merchant.service.js"; +import { InMemoryStore, createRateLimiter } from "../middleware/rateLimit.middleware.js"; +import { RateLimitError } from "../utils/errors.js"; + +// ── (a) coordinate rounding ───────────────────────────────────────────────── + +async function testAvailableMerchantsRoundsCoordinates() { + const preciseLat = 19.432608123; // exact GPS reading, many decimals + const preciseLng = -99.133209456; + const preciseDistanceKm = 12.34567; // exact haversine result, as Postgres would compute it + + const originalGetMany = db.getMany; + db.getMany = (async (_text: string, _params?: any[]) => [ + { + seller_id: "user-discovery-1", + username: "merchant_discovery_1", + rate_percent: "1.5", + min_trade_mxn: 100, + max_trade_mxn: 50000, + daily_cap_mxn: 250000, + latitude: String(preciseLat), + longitude: String(preciseLng), + address_text: "CDMX", + distance_km: String(preciseDistanceKm), + trades_completed: "3", + trades_terminal: "3", + }, + ]) as typeof db.getMany; + + let results: Awaited>; + try { + results = await getAvailableMerchants({ + lat: preciseLat, + lng: preciseLng, + radius_km: 5, + amount_mxn: 500, + }); + } finally { + db.getMany = originalGetMany; + } + + ok(results.length >= 1, "expected the seeded merchant to be returned"); + const merchant = results.find((m) => m.address_text === "CDMX"); + ok(merchant, "expected to find the seeded merchant by address_text"); + + const expectedLat = Math.round(preciseLat * 1000) / 1000; + const expectedLng = Math.round(preciseLng * 1000) / 1000; + + strictEqual(merchant!.latitude, expectedLat, "latitude must be rounded to 3 decimals"); + strictEqual(merchant!.longitude, expectedLng, "longitude must be rounded to 3 decimals"); + notStrictEqual(merchant!.latitude, preciseLat, "rounded latitude must differ from the precise input"); + notStrictEqual(merchant!.longitude, preciseLng, "rounded longitude must differ from the precise input"); + + // decimal-place check: no more than 3 digits after the decimal point + const decimalsOf = (n: number) => (String(n).split(".")[1] ?? "").length; + ok(decimalsOf(merchant!.latitude) <= 3, "latitude must have at most 3 decimal places"); + ok(decimalsOf(merchant!.longitude) <= 3, "longitude must have at most 3 decimal places"); + + // distance_km keeps its own (already existing) 3-decimal rounding and is + // NOT derived from the coarsened lat/lng — it stays independently accurate. + strictEqual( + merchant!.distance_km, + Math.round(preciseDistanceKm * 1000) / 1000, + "distance_km must reflect the precise coordinates, unaffected by public lat/lng rounding", + ); + + console.log(" ✓ getAvailableMerchants() rounds public latitude/longitude to 3 decimals, distance_km unaffected"); +} + +// ── (b) discovery rate limiter ───────────────────────────────────────────── + +async function testDiscoveryRateLimiterBlocksAfterMax() { + const store = new InMemoryStore(); + const windowMs = 1000; + const max = 30; + + // Same construction as the discoveryRateLimit wired into + // src/routes/merchants.ts (createRateLimiter({ windowMs: 60_000, max: 30 })), + // using a shorter window here so the test doesn't need to wait a full minute. + const discoveryRateLimit = createRateLimiter({ + windowMs, + max, + store, + keyGenerator: (req) => req.ip, + }); + + const mockReq = { ip: "203.0.113.7" }; + const mockReply = { + header: (_name: string, _value: any) => {}, + }; + + for (let i = 0; i < max; i++) { + await (discoveryRateLimit as any)(mockReq, mockReply); + } + console.log(` ✓ ${max} requests from the same IP within the window are allowed`); + + let threw = false; + try { + await (discoveryRateLimit as any)(mockReq, mockReply); + } catch (err) { + threw = true; + ok(err instanceof RateLimitError, `expected RateLimitError, got ${(err as Error)?.constructor?.name}`); + strictEqual((err as RateLimitError).statusCode, 429, "rate-limited response must be 429"); + ok((err as RateLimitError).retryAfter !== undefined, "rate-limited response must carry retryAfter"); + } + ok(threw, `request ${max + 1} should have thrown RateLimitError`); + console.log(" ✓ request past max is rejected with 429 and Retry-After"); + + // A different IP is unaffected by the first IP's exhausted budget. + const otherReq = { ip: "203.0.113.99" }; + await (discoveryRateLimit as any)(otherReq, mockReply); + console.log(" ✓ a different IP is not affected by another IP's rate limit"); +} + +async function main() { + console.log("\nMerchant discovery privacy & rate-limit tests\n"); + await testAvailableMerchantsRoundsCoordinates(); + await testDiscoveryRateLimiterBlocksAfterMax(); + console.log("\nAll merchant.discovery tests passed.\n"); +} + +main().catch((err) => { + console.error("❌ merchant.discovery tests failed:", err); + process.exit(1); +}); diff --git a/micopay/frontend/.env.mainnet b/micopay/frontend/.env.mainnet index ab131df..05e4db3 100644 --- a/micopay/frontend/.env.mainnet +++ b/micopay/frontend/.env.mainnet @@ -8,3 +8,7 @@ VITE_USDC_ISSUER=GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN # Filled in after `stellar contract deploy --network mainnet` (see scripts/deploy-mainnet.sh) — unused by # the signing flow itself (backend builds the XDR), kept here only for reference/debugging. VITE_ESCROW_CONTRACT_ID= +# MapReal tile style (MapTiler free-tier style URL). Pending: Eric needs to +# provide a MapTiler API key/style — until set, MapReal falls back to the +# public MapLibre demo style and shows a small "dev map" notice. +VITE_MAP_STYLE_URL= diff --git a/micopay/frontend/.env.testnet b/micopay/frontend/.env.testnet index a5aef7e..bee57b6 100644 --- a/micopay/frontend/.env.testnet +++ b/micopay/frontend/.env.testnet @@ -6,3 +6,7 @@ VITE_MXNE_CONTRACT_ID=CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC VITE_MXNE_ISSUER_ADDRESS=GBZXN7PIRZGNMHGA7MUUUF4GWMTISGNQ5E72TFL6GDWPE6K4RCAVOALV VITE_USDC_ISSUER=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 VITE_CETES_ISSUER=GCRYUGD5NVARGXT56XEZI5CIFCQETYHAPQQTHO2O3IQZTHDH4LATMYWC +# MapReal tile style (MapTiler free-tier style URL). Pending: Eric needs to +# provide a MapTiler API key/style — until set, MapReal falls back to the +# public MapLibre demo style and shows a small "dev map" notice. +VITE_MAP_STYLE_URL= diff --git a/micopay/frontend/package-lock.json b/micopay/frontend/package-lock.json index 75e44f9..2ecd55c 100644 --- a/micopay/frontend/package-lock.json +++ b/micopay/frontend/package-lock.json @@ -23,6 +23,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", + "maplibre-gl": "^5.24.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -2375,6 +2376,119 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz", + "integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz", + "integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^4.0.2" + } + }, + "node_modules/@mapbox/vector-tile/node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, + "node_modules/@mapbox/whoots-js": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz", + "integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==", + "license": "ISC", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz", + "integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "~2.0.2", + "@mapbox/unitbezier": "^1.0.0", + "json-stringify-pretty-compact": "^4.0.0", + "minimist": "^1.2.8", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "bin": { + "gl-style-format": "dist/gl-style-format.mjs", + "gl-style-migrate": "dist/gl-style-migrate.mjs", + "gl-style-validate": "dist/gl-style-validate.mjs" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@maplibre/mlt": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz", + "integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@noble/curves": { "version": "1.9.7", "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", @@ -3522,6 +3636,12 @@ "@types/node": "*" } }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -5518,6 +5638,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.344", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz", @@ -6260,6 +6386,12 @@ "dev": true, "license": "MIT" }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "13.0.6", "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", @@ -6925,6 +7057,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", @@ -6984,6 +7122,12 @@ "node": "*" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/kind-of": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", @@ -7390,6 +7534,52 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/maplibre-gl": { + "version": "5.24.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz", + "integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.2", + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.1.0", + "@mapbox/unitbezier": "^0.0.1", + "@mapbox/vector-tile": "^2.0.4", + "@mapbox/whoots-js": "^3.1.0", + "@maplibre/geojson-vt": "^6.1.0", + "@maplibre/maplibre-gl-style-spec": "^24.8.1", + "@maplibre/mlt": "^1.1.8", + "@maplibre/vt-pbf": "^4.3.0", + "@types/geojson": "^7946.0.16", + "earcut": "^3.0.2", + "gl-matrix": "^3.4.4", + "kdbush": "^4.0.2", + "murmurhash-js": "^1.0.0", + "pbf": "^4.0.1", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, + "node_modules/maplibre-gl/node_modules/pbf": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz", + "integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -7721,7 +7911,6 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7799,6 +7988,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -8329,6 +8524,18 @@ "dev": true, "license": "MIT" }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/pbkdf2": { "version": "3.1.6", "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.6.tgz", @@ -8530,6 +8737,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prebuild-install": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", @@ -8665,6 +8878,12 @@ "node": ">=6" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -8774,6 +8993,12 @@ "node": ">=8" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -9270,6 +9495,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/rimraf": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.1.3.tgz", @@ -10281,6 +10515,12 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", diff --git a/micopay/frontend/package.json b/micopay/frontend/package.json index a7d4469..26b0359 100644 --- a/micopay/frontend/package.json +++ b/micopay/frontend/package.json @@ -30,6 +30,7 @@ "clsx": "^2.1.1", "i18next": "^26.3.4", "lucide-react": "^0.577.0", + "maplibre-gl": "^5.24.0", "qrcode.react": "^4.2.0", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/micopay/frontend/public/map_bg.png b/micopay/frontend/public/map_bg.png deleted file mode 100644 index a006a5e..0000000 Binary files a/micopay/frontend/public/map_bg.png and /dev/null differ diff --git a/micopay/frontend/src/App.tsx b/micopay/frontend/src/App.tsx index 165635d..0b27443 100644 --- a/micopay/frontend/src/App.tsx +++ b/micopay/frontend/src/App.tsx @@ -38,12 +38,11 @@ import ReceivePayment from "./pages/ReceivePayment"; import Privacy from "./pages/Privacy"; import Terms from "./pages/Terms"; import Profile from "./pages/Profile"; -import ClaimQR from "./pages/ClaimQR"; import Login from "./pages/Login"; import Register from "./pages/Register"; import MerchantSettings from "./pages/MerchantSettings"; import BottomNav from "./components/BottomNav"; -import DebugOverlay from "./components/DebugOverlay"; +import { ConnectionBanner } from "./components/ConnectionBanner"; import { registerUser, @@ -109,7 +108,6 @@ interface AppCtx { isMockStellar: boolean; backendConnected: boolean; backendHealth: any; - setDebugOpen: (b: boolean) => void; } export const AppContext = createContext(null); @@ -272,7 +270,6 @@ function MapRoute() { amountMxn: activeAmount, flow: 'cashout', nearbyCount: offer.nearbyCount, - merchantOnline: offer.online, }, }); }} @@ -303,7 +300,6 @@ function ConfirmRoute() { amountMxn: number; flow: 'cashout' | 'deposit'; nearbyCount: number; - merchantOnline?: boolean; } | null; if (!state?.merchantId) { @@ -319,7 +315,6 @@ function ConfirmRoute() { amountMxn={state.amountMxn} flow={state.flow ?? 'cashout'} nearbyCount={state.nearbyCount} - merchantOnline={state.merchantOnline ?? true} loading={tradeLoading} errorMessage={tradeError?.message ?? null} onBack={() => navigate(-1)} @@ -544,7 +539,11 @@ function CetesRoute() { return ( navigate('/explore')} - onBanco={() => navigate('/deposit')} + // "¿Sin cripto?" without approved KYC: KYC is the actual prerequisite + // for the Etherfuse SPEI ramp (see canDepositSpei in CETESScreen), not + // the P2P cash-agent flow at /deposit — that CTA used to send users + // there by mistake. + onBanco={() => navigate('/kyc')} userToken={buyerUser?.token} showDefi={import.meta.env.VITE_ENABLE_DEFI_TRADING === 'true'} showSpeiRamp={import.meta.env.VITE_ENABLE_SPEI_RAMP === 'true'} @@ -674,16 +673,12 @@ const HIDE_BOTTOMNAV_ROUTES = new Set([ "/terms", ]); -// Claim screens also hide the bottom nav (standalone deep-link UI). -const HIDE_BOTTOMNAV_PREFIX = ['/claim/']; - function BottomNavAdapter() { const navigate = useNavigate(); const location = useLocation(); const { sellerUser } = useAppCtx(); if (HIDE_BOTTOMNAV_ROUTES.has(location.pathname)) return null; - if (HIDE_BOTTOMNAV_PREFIX.some((p) => location.pathname.startsWith(p))) return null; const navMap: Record = { home: "/", @@ -703,6 +698,29 @@ function BottomNavAdapter() { ); } +// ── Connection banner host ─────────────────────────────────────────────────── +// Tracks browser/WebView online-offline state directly (navigator.onLine + +// the online/offline events) — deliberately independent of the merchant +// offline-mutation queue (services/offlineQueue*.ts), which is a different, +// narrower concern (queueing merchant config writes) than "is this device +// connected to the internet at all". +function ConnectionBannerHost() { + const [isOnline, setIsOnline] = useState(navigator.onLine); + + useEffect(() => { + const handleOnline = () => setIsOnline(true); + const handleOffline = () => setIsOnline(false); + window.addEventListener('online', handleOnline); + window.addEventListener('offline', handleOffline); + return () => { + window.removeEventListener('online', handleOnline); + window.removeEventListener('offline', handleOffline); + }; + }, []); + + return ; +} + // ── Root App ───────────────────────────────────────────────────────────────── function App() { @@ -731,7 +749,6 @@ function App() { const [isDemoMode, setIsDemoMode] = useState(true); const [isMockStellar, setIsMockStellar] = useState(true); const [backendUrl, setBackendUrl] = useState(""); - const [debugOpen, setDebugOpen] = useState(false); const envName = import.meta.env.MODE; useEffect(() => { @@ -786,8 +803,12 @@ function App() { console.warn("Backend not reachable during startup:", err); setBackendConnected(false); - // In production, force-block if backend is down. - if (envName === 'production') { + // Force-block if backend is down in any strict (non-demo) build — + // `build:mainnet` sets MODE to 'mainnet', not 'production', so both + // must be checked or a mainnet APK silently falls back to local + // demo mocks when the backend is unreachable (see + // docs/AUDIT_MOBILE_MAINNET.md, "guard de arranque no cubre modo mainnet"). + if (envName === 'production' || envName === 'mainnet') { setStartupError({ title: "Servidor Inalcanzable", message: "No se pudo conectar al servidor de Micopay.", @@ -1001,7 +1022,6 @@ function App() { isMockStellar, backendConnected, backendHealth, - setDebugOpen, }; if (startupError) { @@ -1049,6 +1069,7 @@ function App() {
+ } /> } /> diff --git a/micopay/frontend/src/components/MapReal.tsx b/micopay/frontend/src/components/MapReal.tsx new file mode 100644 index 0000000..7dc0c9f --- /dev/null +++ b/micopay/frontend/src/components/MapReal.tsx @@ -0,0 +1,280 @@ +import { useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import * as maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import type { AvailableMerchant } from '../services/api'; + +interface MapRealProps { + type?: 'cashout' | 'deposit'; + merchants?: AvailableMerchant[]; + selectedMerchantId?: string | null; + onSelectMerchant?: (merchantId: string) => void; + /** Real user position; if null, fit-bounds only over merchants (or default view if none). */ + userPosition?: { lat: number; lng: number } | null; + /** When true, renders a single draggable pin instead of merchant markers (location picker use case). */ + pickerMode?: boolean; + /** Current picker pin position; if null while pickerMode is on, falls back to userPosition as the initial center. */ + pickerPosition?: { lat: number; lng: number } | null; + /** Called with the new position when the picker pin is dragged. */ + onPickerPositionChange?: (position: { lat: number; lng: number }) => void; +} + +const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; + +// OpenFreeMap: OSM completo a nivel calle, sin API key, uso en producción permitido. +// demotiles.maplibre.org NO sirve como fallback: solo tiene fronteras de países, +// a zoom de calle renderiza un fondo vacío (visto en Huatusco, 2026-07-25). +const FALLBACK_STYLE_URL = 'https://tiles.openfreemap.org/styles/liberty'; + +function buildMerchantMarkerElement( + merchant: AvailableMerchant, + image: string, + isSelected: boolean, + onSelectMerchant?: (merchantId: string) => void, +): HTMLElement { + const wrapper = document.createElement('button'); + wrapper.type = 'button'; + wrapper.setAttribute('aria-label', `Seleccionar ${merchant.username}`); + wrapper.className = 'flex flex-col items-center focus:outline-none'; + wrapper.style.background = 'transparent'; + wrapper.style.border = 'none'; + wrapper.style.padding = '0'; + wrapper.style.cursor = onSelectMerchant ? 'pointer' : 'default'; + + const pinWrap = document.createElement('span'); + pinWrap.className = `relative w-14 h-14 block transition-transform ${isSelected ? 'scale-125' : ''}`; + + const glow = document.createElement('span'); + glow.className = `absolute inset-0 rounded-full blur-md animate-pulse ${isSelected ? 'bg-primary/40' : 'bg-primary/20'}`; + pinWrap.appendChild(glow); + + const img = document.createElement('img'); + img.src = image; + img.alt = ''; + img.className = 'w-full h-full object-contain relative z-10 drop-shadow-lg'; + pinWrap.appendChild(img); + + const label = document.createElement('span'); + label.className = `backdrop-blur-sm px-3 py-1 rounded-full mt-1 shadow-md border text-[9px] font-bold whitespace-nowrap block ${isSelected ? 'bg-primary text-white border-primary' : 'bg-white/95 text-on-surface border-outline-variant/20'}`; + label.textContent = merchant.username; + + wrapper.appendChild(pinWrap); + wrapper.appendChild(label); + + wrapper.addEventListener('click', () => onSelectMerchant?.(merchant.seller_id)); + + return wrapper; +} + +function buildPickerMarkerElement(): HTMLElement { + const container = document.createElement('div'); + container.className = 'relative flex flex-col items-center'; + container.style.width = '48px'; + container.style.cursor = 'grab'; + + const glow = document.createElement('span'); + glow.className = 'absolute -top-1 w-12 h-12 rounded-full bg-primary/30 blur-md animate-pulse'; + container.appendChild(glow); + + const pin = document.createElement('div'); + pin.className = 'relative z-10 w-9 h-9 rounded-full bg-primary border-4 border-white shadow-[0_0_15px_rgba(0,105,76,0.5)] flex items-center justify-center'; + const dot = document.createElement('span'); + dot.className = 'w-2.5 h-2.5 rounded-full bg-white'; + pin.appendChild(dot); + container.appendChild(pin); + + return container; +} + +function buildUserMarkerElement(): HTMLElement { + const container = document.createElement('div'); + container.className = 'relative flex items-center justify-center'; + container.style.width = '64px'; + container.style.height = '64px'; + + const pulse = document.createElement('div'); + pulse.className = 'w-16 h-16 bg-primary/20 rounded-full animate-ping absolute'; + container.appendChild(pulse); + + const dot = document.createElement('div'); + dot.className = 'w-6 h-6 bg-primary rounded-full border-2 border-white shadow-[0_0_15px_rgba(0,105,76,0.5)] relative z-10'; + container.appendChild(dot); + + return container; +} + +/** + * Real MapLibre GL map. Drop-in replacement for the deprecated `MapSim` + * (same visual footprint + prop-compatible superset), but renders actual + * tiles/pan/zoom centered on the user's real GPS position instead of a + * static PNG. + */ +const MapReal = ({ + type = 'cashout', + merchants = [], + selectedMerchantId, + onSelectMerchant, + userPosition = null, + pickerMode = false, + pickerPosition = null, + onPickerPositionChange, +}: MapRealProps) => { + const { t } = useTranslation(); + const containerRef = useRef(null); + const mapRef = useRef(null); + const markersRef = useRef([]); + const userMarkerRef = useRef(null); + const pickerMarkerRef = useRef(null); + // Keep the latest callback in a ref so the marker's dragend listener (bound once + // per marker instance) always calls the current handler without re-creating the marker. + const onPickerPositionChangeRef = useRef(onPickerPositionChange); + onPickerPositionChangeRef.current = onPickerPositionChange; + + const styleUrl = import.meta.env.VITE_MAP_STYLE_URL || FALLBACK_STYLE_URL; + + // Create the map once on mount. + useEffect(() => { + if (!containerRef.current) return; + + const map = new maplibregl.Map({ + container: containerRef.current, + style: styleUrl, + center: [-99.1332, 19.4326], // Mexico City default, used only until fitBounds/setCenter runs below. + zoom: 11, + // OSM exige atribución visible; compact la deja como un botón ⓘ discreto. + attributionControl: { compact: true }, + }); + + mapRef.current = map; + map.on('error', (e) => console.error('[MapReal] tile/style error', e?.error?.message ?? e)); + + return () => { + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + userMarkerRef.current?.remove(); + userMarkerRef.current = null; + pickerMarkerRef.current?.remove(); + pickerMarkerRef.current = null; + map.remove(); + mapRef.current = null; + }; + // Intentionally only on mount: style URL is effectively static per build. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Picker mode: single draggable pin, no merchant markers, no fitBounds-over-merchants logic. + useEffect(() => { + if (!pickerMode) return; + const map = mapRef.current; + if (!map) return; + + const applyPickerUpdate = () => { + const initialPosition = pickerPosition ?? userPosition; + + if (!pickerMarkerRef.current) { + if (!initialPosition) return; + const element = buildPickerMarkerElement(); + const marker = new maplibregl.Marker({ element, draggable: true }) + .setLngLat([initialPosition.lng, initialPosition.lat]) + .addTo(map); + marker.on('dragend', () => { + const lngLat = marker.getLngLat(); + onPickerPositionChangeRef.current?.({ lat: lngLat.lat, lng: lngLat.lng }); + }); + pickerMarkerRef.current = marker; + map.setCenter([initialPosition.lng, initialPosition.lat]); + map.setZoom(16); + } else if (pickerPosition) { + pickerMarkerRef.current.setLngLat([pickerPosition.lng, pickerPosition.lat]); + } + }; + + if (map.isStyleLoaded()) { + applyPickerUpdate(); + } else { + map.once('load', applyPickerUpdate); + } + }, [pickerMode, pickerPosition, userPosition]); + + // Update markers + camera whenever merchants/selection/user position change. + // Skipped entirely in picker mode — the picker effect above owns the map in that case. + useEffect(() => { + if (pickerMode) return; + const map = mapRef.current; + if (!map) return; + + const applyUpdate = () => { + // Clear previous merchant markers. + markersRef.current.forEach((marker) => marker.remove()); + markersRef.current = []; + + const validMerchants = merchants.filter( + (merchant) => Number.isFinite(merchant.latitude) && Number.isFinite(merchant.longitude), + ); + + validMerchants.forEach((merchant, index) => { + const isSelected = selectedMerchantId === merchant.seller_id; + const image = type === 'deposit' ? '/mushroom_green.png' : mushroomImages[index % mushroomImages.length]; + const element = buildMerchantMarkerElement(merchant, image, isSelected, onSelectMerchant); + + const marker = new maplibregl.Marker({ element }) + .setLngLat([merchant.longitude, merchant.latitude]) + .addTo(map); + + markersRef.current.push(marker); + }); + + // User marker. + userMarkerRef.current?.remove(); + userMarkerRef.current = null; + if (userPosition) { + const userEl = buildUserMarkerElement(); + userMarkerRef.current = new maplibregl.Marker({ element: userEl }) + .setLngLat([userPosition.lng, userPosition.lat]) + .addTo(map); + } + + // Camera. + if (userPosition && validMerchants.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + bounds.extend([userPosition.lng, userPosition.lat]); + validMerchants.forEach((merchant) => bounds.extend([merchant.longitude, merchant.latitude])); + map.fitBounds(bounds, { padding: 48, maxZoom: 16 }); + } else if (validMerchants.length > 0) { + const bounds = new maplibregl.LngLatBounds(); + validMerchants.forEach((merchant) => bounds.extend([merchant.longitude, merchant.latitude])); + map.fitBounds(bounds, { padding: 48, maxZoom: 16 }); + } else if (userPosition) { + map.setCenter([userPosition.lng, userPosition.lat]); + map.setZoom(14); + } + // Neither user nor merchants: leave the map at its default style center/zoom. + }; + + if (map.isStyleLoaded()) { + applyUpdate(); + } else { + map.once('load', applyUpdate); + } + }, [merchants, selectedMerchantId, userPosition, type, onSelectMerchant, pickerMode]); + + return ( +
+ {/* w-full/h-full explícitos: maplibre-gl.css fuerza position:relative sobre + .maplibregl-map y anula el `absolute` de Tailwind, colapsando el alto a 0. */} +
+ + {merchants.length > 0 && ( +
+ location_on +

+ {t('map.agentsNearby', { count: merchants.length })} +

+
+ )} + +
+ ); +}; + +export default MapReal; diff --git a/micopay/frontend/src/components/MapSim.tsx b/micopay/frontend/src/components/MapSim.tsx deleted file mode 100644 index 696df7e..0000000 --- a/micopay/frontend/src/components/MapSim.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import type { AvailableMerchant } from '../services/api'; - -interface MapSimProps { - type?: 'cashout' | 'deposit'; - merchants?: AvailableMerchant[]; - selectedMerchantId?: string | null; - onSelectMerchant?: (merchantId: string) => void; -} - -interface MerchantPin { - merchant: AvailableMerchant; - top: number; - left: number; -} - -const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); - -function getMerchantPins(merchants: AvailableMerchant[]): MerchantPin[] { - const validMerchants = merchants.filter( - (merchant) => Number.isFinite(merchant.latitude) && Number.isFinite(merchant.longitude), - ); - - if (validMerchants.length === 0) return []; - - const latitudes = validMerchants.map((merchant) => merchant.latitude); - const longitudes = validMerchants.map((merchant) => merchant.longitude); - const minLat = Math.min(...latitudes); - const maxLat = Math.max(...latitudes); - const minLng = Math.min(...longitudes); - const maxLng = Math.max(...longitudes); - const latSpan = maxLat - minLat; - const lngSpan = maxLng - minLng; - - return validMerchants.map((merchant, index) => { - const fallbackOffset = validMerchants.length === 1 ? 0 : (index / Math.max(validMerchants.length - 1, 1)) - 0.5; - const rawLeft = lngSpan === 0 ? 0.5 + fallbackOffset * 0.35 : (merchant.longitude - minLng) / lngSpan; - const rawTop = latSpan === 0 ? 0.5 - fallbackOffset * 0.25 : (maxLat - merchant.latitude) / latSpan; - - return { - merchant, - left: clamp(12 + rawLeft * 76, 12, 88), - top: clamp(16 + rawTop * 68, 16, 84), - }; - }); -} - -const mushroomImages = ['/mushroom_red.png', '/mushroom_green.png', '/mushroom_gold.png']; - -const MapSim = ({ - type = 'cashout', - merchants = [], - selectedMerchantId, - onSelectMerchant, -}: MapSimProps) => { - const pins = getMerchantPins(merchants); - - return ( -
- {/* Real Map Background */} -
- Mexico City Map { - e.currentTarget.style.display = 'none'; - }} - /> -
-
- - {/* Simulated Street Glow Overlay */} -
- - {/* User Location Pulse */} -
-
-
-
- - {/* Merchant pins projected from API latitude/longitude. */} - {pins.map(({ merchant, top, left }, index) => { - const canSelect = Boolean(onSelectMerchant); - const isSelected = selectedMerchantId === merchant.seller_id; - const image = type === 'deposit' ? '/mushroom_green.png' : mushroomImages[index % mushroomImages.length]; - - return ( - - ); - })} - - {/* Location Label Floating */} -
- location_on -

CDMX · ZONA CENTRO

-
- - {/* Live Indicator */} -
-
-

Agentes reales cercanos

-
- - -
- ); -}; - -export default MapSim; diff --git a/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx b/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx index 22b7b2b..5c0a7b0 100644 --- a/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx +++ b/micopay/frontend/src/components/MerchantAvailabilityToggle.tsx @@ -5,6 +5,7 @@ */ import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { updateMerchantAvailabilityWithOfflineSupport } from '../services/api'; import { useOfflineQueue } from '../hooks/useOfflineQueue'; @@ -13,6 +14,13 @@ interface MerchantAvailabilityToggleProps { initialAvailable: boolean; onAvailabilityChange?: (available: boolean) => void; disabled?: boolean; + /** + * Whether the merchant already has a location set (from `getMerchantConfig().latitude`). + * Optional and soft: when omitted, the no-location warning is simply skipped — this + * component does not fetch merchant config itself. Does NOT block activation either way + * (decision: minimal friction, see docs/PLAN_MAPA_REAL_2026-07.md WP2). + */ + hasLocation?: boolean; } export default function MerchantAvailabilityToggle({ @@ -20,7 +28,9 @@ export default function MerchantAvailabilityToggle({ initialAvailable, onAvailabilityChange, disabled = false, + hasLocation, }: MerchantAvailabilityToggleProps) { + const { t } = useTranslation(); const [available, setAvailable] = useState(initialAvailable); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -85,6 +95,12 @@ export default function MerchantAvailabilityToggle({

)} + {available && hasLocation === false && ( +

+ ⚠️ {t('merchantSettings.location.noLocationWarning')} +

+ )} + {offlineQueue.hasPending && (

⏳ {available ? 'Cambio a disponible' : 'Cambio a no disponible'} pendiente de sincronizar diff --git a/micopay/frontend/src/hooks/useMerchantsAvailable.ts b/micopay/frontend/src/hooks/useMerchantsAvailable.ts index bbb582e..d5bc2da 100644 --- a/micopay/frontend/src/hooks/useMerchantsAvailable.ts +++ b/micopay/frontend/src/hooks/useMerchantsAvailable.ts @@ -9,7 +9,7 @@ export type MerchantsState = | { status: 'location_denied'; error: string } | { status: 'error'; error: string } | { status: 'empty' } - | { status: 'success'; merchants: AvailableMerchant[] }; + | { status: 'success'; merchants: AvailableMerchant[]; userPosition: { lat: number; lng: number } }; interface Options { amount_mxn: number; @@ -124,7 +124,11 @@ export function useMerchantsAvailable(options: Options): { if (cancelled) return; - setState(merchants.length === 0 ? { status: 'empty' } : { status: 'success', merchants }); + setState( + merchants.length === 0 + ? { status: 'empty' } + : { status: 'success', merchants, userPosition: { lat, lng } }, + ); } catch { if (!cancelled) { setState({ diff --git a/micopay/frontend/src/i18n/en.json b/micopay/frontend/src/i18n/en.json index 95d4d60..081eded 100644 --- a/micopay/frontend/src/i18n/en.json +++ b/micopay/frontend/src/i18n/en.json @@ -230,6 +230,8 @@ }, "map": { "title": "Convert to cash", + "agentsNearby": "{{count}} agents nearby", + "devMapNotice": "development map", "offer": "offer", "offers": "offers", "for": "for ${{amount}} MXN", @@ -395,7 +397,12 @@ "couldNotQuery": "Could not query", "sessionError": "Session not available. Log in again and try again.", "openingProvider": "Opening {{provider}}…", - "pollError": "Error checking verification status." + "pollError": "Error checking verification status.", + "emailRequiredTitle": "We need your email", + "emailRequiredDesc": "{{provider}} requires it to verify your identity. Used only once, for this.", + "emailPlaceholder": "you@email.com", + "emailInvalid": "That email doesn't look valid.", + "emailContinue": "Continue" }, "errors": { "network": { @@ -436,5 +443,24 @@ "generic": { "fallback": { "title": "Something went wrong", "message": "We couldn't finish this action. Try again.", "action": "If the problem continues, contact support." } } + }, + "merchantSettings": { + "location": { + "title": "My location", + "notSet": "You haven't set your location yet. Customers can't find you on the map.", + "useCurrent": "Use my current location", + "dragHint": "Drag the pin to adjust", + "addressLabel": "Address (optional)", + "addressPlaceholder": "E.g. Av. Insurgentes Sur 123, Col. Roma", + "save": "Save location", + "saving": "Saving…", + "change": "Change location", + "cancel": "Cancel", + "saveSuccess": "Location saved successfully.", + "saveError": "Couldn't save the location. Please try again.", + "gettingLocation": "Getting your location…", + "locationError": "Couldn't get your location. Check your GPS permissions.", + "noLocationWarning": "Without a location set you won't appear on the map. Go to Settings to set it." + } } } diff --git a/micopay/frontend/src/i18n/es.json b/micopay/frontend/src/i18n/es.json index 1867e2e..0d01e88 100644 --- a/micopay/frontend/src/i18n/es.json +++ b/micopay/frontend/src/i18n/es.json @@ -230,6 +230,8 @@ }, "map": { "title": "Convertir a efectivo", + "agentsNearby": "{{count}} agentes cerca", + "devMapNotice": "mapa de desarrollo", "offer": "oferta", "offers": "ofertas", "for": "para ${{amount}} MXN", @@ -395,7 +397,12 @@ "couldNotQuery": "No se pudo consultar", "sessionError": "Sesión no disponible. Vuelve a iniciar sesión e intenta de nuevo.", "openingProvider": "Abriendo {{provider}}…", - "pollError": "Error al consultar el estado de verificación." + "pollError": "Error al consultar el estado de verificación.", + "emailRequiredTitle": "Necesitamos tu correo", + "emailRequiredDesc": "{{provider}} lo requiere para verificar tu identidad. Solo se usa una vez, para esto.", + "emailPlaceholder": "tu@correo.com", + "emailInvalid": "Ese correo no parece válido.", + "emailContinue": "Continuar" }, "errors": { "network": { @@ -436,5 +443,24 @@ "generic": { "fallback": { "title": "Algo salió mal", "message": "No pudimos terminar esta acción. Intenta de nuevo.", "action": "Si el problema sigue, contacta soporte." } } + }, + "merchantSettings": { + "location": { + "title": "Mi ubicación", + "notSet": "Aún no has fijado tu ubicación. Los clientes no pueden encontrarte en el mapa.", + "useCurrent": "Usar mi ubicación actual", + "dragHint": "Arrastra el pin para ajustar", + "addressLabel": "Dirección (opcional)", + "addressPlaceholder": "Ej. Av. Insurgentes Sur 123, Col. Roma", + "save": "Guardar ubicación", + "saving": "Guardando…", + "change": "Cambiar ubicación", + "cancel": "Cancelar", + "saveSuccess": "Ubicación guardada exitosamente.", + "saveError": "No se pudo guardar la ubicación. Intenta de nuevo.", + "gettingLocation": "Obteniendo tu ubicación…", + "locationError": "No se pudo obtener tu ubicación. Verifica los permisos de GPS.", + "noLocationWarning": "Sin ubicación fijada no apareces en el mapa. Ve a Ajustes para fijarla." + } } } diff --git a/micopay/frontend/src/pages/CETESScreen.tsx b/micopay/frontend/src/pages/CETESScreen.tsx index 8f188b6..9643f88 100644 --- a/micopay/frontend/src/pages/CETESScreen.tsx +++ b/micopay/frontend/src/pages/CETESScreen.tsx @@ -812,19 +812,33 @@ const CETESScreen = ({ onBack, onBanco, userToken, showDefi = true, showSpeiRamp )}

- + {/* "¿Sin cripto?" — entry point to the real Etherfuse SPEI ramp built into + this screen (payMethod === 'spei' above), not the P2P cash-agent + network. If KYC is already approved, reveal that tab in place; if + not, KYC is the actual prerequisite for connecting a bank via SPEI, + so send the user there instead of the unrelated /deposit flow. */} + {!(tab === 'buy' && payMethod === 'spei') && ( + + )}

{t('cetes.footer', { network: rate?.network ?? 'TESTNET' })} diff --git a/micopay/frontend/src/pages/DepositMap.tsx b/micopay/frontend/src/pages/DepositMap.tsx index f4d5bfe..5be3dfc 100644 --- a/micopay/frontend/src/pages/DepositMap.tsx +++ b/micopay/frontend/src/pages/DepositMap.tsx @@ -1,4 +1,4 @@ -import MapSim from '../components/MapSim'; +import MapReal from '../components/MapReal'; import { useMerchantsAvailable } from '../hooks/useMerchantsAvailable'; import { effectiveFeePercent, @@ -457,7 +457,11 @@ const DepositMap = ({ {/* Map View Section */}

- +
{/* Offers List */} diff --git a/micopay/frontend/src/pages/ExploreMap.tsx b/micopay/frontend/src/pages/ExploreMap.tsx index 92499b3..60c14b1 100644 --- a/micopay/frontend/src/pages/ExploreMap.tsx +++ b/micopay/frontend/src/pages/ExploreMap.tsx @@ -1,6 +1,6 @@ import { useEffect, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; -import MapSim from '../components/MapSim'; +import MapReal from '../components/MapReal'; import { useMerchantsAvailable } from '../hooks/useMerchantsAvailable'; import { effectiveFeePercent, @@ -41,7 +41,6 @@ interface Offer { tradesCompleted?: number; tier?: string; isBusiness?: boolean; - online?: boolean; } function merchantToOffer(m: AvailableMerchant, index: number): Offer { @@ -59,7 +58,6 @@ function merchantToOffer(m: AvailableMerchant, index: number): Offer { tradesCompleted: m.trades_completed ?? 0, tier: m.tier ?? undefined, isBusiness: (m.seller_type === 'business') || (m.is_business === true) || false, - online: true, }; } @@ -69,7 +67,6 @@ export interface OfferConfirmData { receiveMxn: number; commissionPct: number; nearbyCount: number; - online?: boolean; } interface ExploreMapProps { @@ -200,10 +197,11 @@ const ExploreMap = ({ {/* Map Section */}
-
@@ -304,7 +302,6 @@ const ExploreMap = ({ receiveMxn: offer.receiveMxn, commissionPct: offer.commissionPct, nearbyCount: offers.length, - online: (offer as any).online ?? true, }); } else { onSelectOffer(offer.id); @@ -383,8 +380,6 @@ const ExploreMap = ({ receiveMxn: offer.receiveMxn, commissionPct: offer.commissionPct, nearbyCount: offers.length, - online: (offer as any).online ?? true, - }); } else { onSelectOffer(offer.id); diff --git a/micopay/frontend/src/pages/KYCScreen.tsx b/micopay/frontend/src/pages/KYCScreen.tsx index f2ea4d8..b43328d 100644 --- a/micopay/frontend/src/pages/KYCScreen.tsx +++ b/micopay/frontend/src/pages/KYCScreen.tsx @@ -4,6 +4,9 @@ import { App as CapApp } from '@capacitor/app'; import { startKYC, getKYCStatus, type KYCProvider, type KYCStatus, type KYCStatusResponse } from '../services/api'; import { readJSON, writeJSON } from '../services/secureStorage'; +import { extractApiErrorPayload } from '../utils/apiError'; + +const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const PROVIDER_NAMES: Record = { etherfuse: 'Etherfuse', @@ -71,6 +74,13 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: const [statusPollingError, setStatusPollingError] = useState(null); + // Etherfuse's onboarding call now requires an email MicoPay's Stellar-keypair + // auth never collects; POST /defi/kyc/start responds EMAIL_REQUIRED the first + // time, and we prompt for it inline instead of failing silently. + const [needsEmail, setNeedsEmail] = useState(false); + const [email, setEmail] = useState(''); + const [emailError, setEmailError] = useState(null); + const loadCachedStatus = async () => { const cached = await readJSON<{ status: KYCStatus; reason?: string | null }>(secureStorageKey(provider)); if (cached?.status === 'approved') { @@ -85,7 +95,7 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const handleOpenHostedFlow = async () => { + const handleOpenHostedFlow = async (emailOverride?: string) => { if (!token) { setStatusPollingError(t('kyc.sessionError')); return; @@ -95,7 +105,8 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: setLoading(true); try { - const { onboardingUrl } = await startKYC(token, provider); + const { onboardingUrl } = await startKYC(token, provider, emailOverride); + setNeedsEmail(false); startedAtRef.current = Date.now(); setStartingToken(onboardingUrl); @@ -115,11 +126,30 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }: // Fallback for web builds / when plugin is not present. window.open(onboardingUrl, '_blank', 'noopener,noreferrer'); } + } catch (err) { + // Previously uncaught: a failed startKYC() (e.g. Etherfuse rejecting the + // request) silently opened nothing and left no trace for the user. + const payload = extractApiErrorPayload(err); + if (payload.error === 'EMAIL_REQUIRED') { + setNeedsEmail(true); + } else { + setStatusPollingError(payload.message); + } } finally { setLoading(false); } }; + const handleSubmitEmail = () => { + const trimmed = email.trim(); + if (!EMAIL_RE.test(trimmed)) { + setEmailError(t('kyc.emailInvalid')); + return; + } + setEmailError(null); + void handleOpenHostedFlow(trimmed); + }; + const applyStatus = async (res: KYCStatusResponse) => { setStatus(res.status); setReason(res.reason ?? null); @@ -237,27 +267,64 @@ export default function KYCScreen({ onApproved, token, provider = 'etherfuse' }:
- + {needsEmail ? ( +
+
+

{t('kyc.emailRequiredTitle')}

+

{t('kyc.emailRequiredDesc', { provider: providerName })}

+
+ { setEmail(e.target.value); setEmailError(null); }} + placeholder={t('kyc.emailPlaceholder')} + className="w-full rounded-xl border border-outline-variant/30 px-4 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40" + /> + {emailError &&

{emailError}

} + +
+ ) : ( + + )} {status === 'rejected' && ( +
+ ) : ( +
+ {!pickerPosition && ( + <> +

{t('merchantSettings.location.notSet')}

+ + {geo.error && ( +

{t('merchantSettings.location.locationError')}

+ )} + + )} + + {pickerPosition && ( +
+ +

{t('merchantSettings.location.dragHint')}

+ + + +
+ {editingLocation && ( + + )} + +
+
+ )} +
+ )} + +
-
-
{t('confirm.agentStatus')}
-
- - {merchantOnline ? t('confirm.online') : t('confirm.offline')} -
-
-
{t('confirm.nearbyProviders')}
diff --git a/micopay/frontend/src/services/api.ts b/micopay/frontend/src/services/api.ts index 0aae27d..8e37574 100644 --- a/micopay/frontend/src/services/api.ts +++ b/micopay/frontend/src/services/api.ts @@ -31,8 +31,9 @@ export interface KYCStatusResponse { export async function startKYC( token: string, provider: KYCProvider = 'etherfuse', + email?: string, ): Promise<{ onboardingUrl: string }> { - const res = await http.post('/defi/kyc/start', {}, { ...authHeaders(token), params: { provider } }); + const res = await http.post('/defi/kyc/start', { email }, { ...authHeaders(token), params: { provider } }); return res.data; } @@ -124,6 +125,22 @@ export async function patchMerchantAvailability( return res.data.user; } +/** Mirrors backend `MerchantLocation` after PATCH /merchants/me/location. */ +export interface MerchantLocation { + latitude: number; + longitude: number; + address_text: string | null; + updated_at: string; +} + +export async function updateMerchantLocation( + input: { latitude: number; longitude: number; address_text?: string }, + token: string, +): Promise { + const res = await http.patch('/merchants/me/location', input, authHeaders(token)); + return res.data.location; +} + export async function registerUser(username: string): Promise { const stellar_address = (await getPublicKey()) ?? generateFallbackAddress(username); const res = await http.post("/users/register", { username, stellar_address }); @@ -473,6 +490,9 @@ export interface MerchantConfig { min_trade_mxn: number; max_trade_mxn: number; daily_cap_mxn: number; + latitude?: number | null; + longitude?: number | null; + address_text?: string | null; } export interface UserProfile { diff --git a/micopay/frontend/src/utils/apiError.ts b/micopay/frontend/src/utils/apiError.ts index fbfce1b..7992551 100644 --- a/micopay/frontend/src/utils/apiError.ts +++ b/micopay/frontend/src/utils/apiError.ts @@ -44,7 +44,7 @@ export function toApiError(payload: ApiErrorPayload): ApiError { export function extractApiErrorPayload(err: unknown): ApiErrorPayload { if (axios.isAxiosError(err)) { const data = err.response?.data as - | { message?: string; error?: string; request_id?: string; support_code?: string } + | { message?: string; error?: string; code?: string; request_id?: string; support_code?: string } | undefined; const resolved = resolveErrorMessage({ response: { @@ -57,7 +57,10 @@ export function extractApiErrorPayload(err: unknown): ApiErrorPayload { typeof data?.message === 'string' && data.message.length > 0 ? resolved.message : resolved.message; - const error = typeof data?.error === 'string' ? data.error : undefined; + // Backend's global error handler sends `code` (see index.ts setErrorHandler), + // not `error` — `error` is kept as a fallback for any endpoint that still + // uses the older shape. + const error = typeof data?.code === 'string' ? data.code : typeof data?.error === 'string' ? data.error : undefined; const request_id = typeof data?.request_id === 'string' ? data.request_id : undefined; const support_code = typeof data?.support_code === 'string' ? data.support_code : undefined; diff --git a/micopay/sql/migrations/20260726040000_users_email.down.sql b/micopay/sql/migrations/20260726040000_users_email.down.sql new file mode 100644 index 0000000..8e098fa --- /dev/null +++ b/micopay/sql/migrations/20260726040000_users_email.down.sql @@ -0,0 +1,2 @@ +ALTER TABLE users + DROP COLUMN IF EXISTS email; diff --git a/micopay/sql/migrations/20260726040000_users_email.up.sql b/micopay/sql/migrations/20260726040000_users_email.up.sql new file mode 100644 index 0000000..d9fb280 --- /dev/null +++ b/micopay/sql/migrations/20260726040000_users_email.up.sql @@ -0,0 +1,7 @@ +-- Etherfuse's /ramp/onboarding-url now requires userInfo.email (was optional, +-- their docs flagged it "will become required in a future release" — that +-- release landed in sandbox 2026-07-25, breaking POST /defi/kyc/start with +-- "missing field `email`"). MicoPay's Stellar-keypair auth never collected +-- an email from anyone; this is the first feature that needs one. +ALTER TABLE users + ADD COLUMN IF NOT EXISTS email VARCHAR(254);