diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f77ea083..7c654898 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,12 +43,22 @@ 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 # (rollup/lightningcss) — bug npm #4828. El fix documentado es borrar el # lock y reinstalar fresco para que npm resuelva el binario de la plataforma. + # npm 10.9.8 (el que trae Node 22) revienta resolviendo este árbol desde + # cero con "Cannot read properties of null (reading 'edgesOut')". Es un + # bug del propio npm, no del proyecto: con npm 10.8.2 (Node 20) y con + # npm >= 11 el mismo package.json resuelve bien. Como el paso de abajo + # necesita instalar SIN lock, subimos npm antes de instalar. + - name: Actualizar npm (evita el bug de resolución de 10.9.8) + run: npm i -g npm@11 + # El lock se genera en Windows y omite los binarios opcionales de Linux + # (rollup/lightningcss) — bug npm #4828. El fix documentado es borrar el + # lock y reinstalar fresco para que npm resuelva el binario de la plataforma. - name: Install (regenera deps opcionales por plataforma — npm #4828) run: | rm -f package-lock.json @@ -61,3 +71,57 @@ jobs: - name: Tests (vitest) — informativo, NO bloqueante todavía run: npm run test continue-on-error: true + + image: + name: Imagen Docker de producción (micopay/backend) + runs-on: ubuntu-latest + # El contenedor es el artefacto que se despliega en AWS (ver + # docs/AWS_MIGRATION_PLAN_2026-07.md). Este job lo construye y lo arranca + # en cada PR para que no pueda romperse sin que nadie se entere. + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - name: Build — BLOQUEANTE + uses: docker/build-push-action@v6 + with: + # Contexto `micopay/` (no `micopay/backend/`): la imagen necesita + # copiar sql/, que vive fuera de backend/. Ver el Dockerfile. + context: micopay + file: micopay/backend/Dockerfile + platforms: linux/amd64 + push: false + load: true + tags: micopay-backend:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: Las migraciones SQL tienen que viajar en la imagen + # Sin esto, runMigrations() falla en boot, el error solo se loguea y el + # servicio arranca contra una BD sin esquema. + run: | + docker run --rm --entrypoint sh micopay-backend:ci -c \ + 'ls -1 /app/sql/init.sql && ls -1 /app/sql/migrations | head -3' + + - name: Smoke test — el contenedor arranca y responde /health + run: | + docker run -d --name micopay-smoke -p 3000:3000 \ + -e NODE_ENV=test \ + -e MOCK_STELLAR=true \ + -e ALLOW_IN_MEMORY_DB=true \ + -e PORT=3000 \ + -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 \ + micopay-backend:ci + for i in $(seq 1 30); do + curl -fsS http://localhost:3000/health -o health.json && break + sleep 2 + done + docker logs micopay-smoke + cat health.json + grep -q '"status":"ok"' health.json + curl -fsS http://localhost:3000/.well-known/assetlinks.json > /dev/null + + - name: Limpieza + if: always() + run: docker rm -f micopay-smoke || true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..5a637684 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,56 @@ +# Changelog + +All notable changes to MicoPay are documented here going forward, per +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). This file starts +from the pre-mainnet audit (2026-07-01) — see `git log` for the full history +before that point. + +## [Unreleased] + +### Security +- `POST /users/register` now requires a signed challenge (same SEP-10-style + flow already used by login), closing an address-squatting gap where anyone + could register another user's public Stellar address before they did. +- `PATCH /users/me/availability`, and auth applied to all fund-moving + `/defi/*` routes (`cetes/buy`, `cetes/sell`, `blend/supply`, + `blend/borrow`), which were previously callable without a token. +- `/defi/ramp/order/:orderId` and its `regenerate_tx` sibling now verify the + caller created the order (new `ramp_orders` ownership table) instead of + allowing any authenticated user to poll any order. +- Production boot now refuses to start with a weak/default `JWT_SECRET`, + `MOCK_STELLAR=true`, or a malformed `SECRET_ENCRYPTION_KEY`. + +### Fixed +- Cancelling a `locked`/`revealing` trade now has a real path to recover the + on-chain funds: either participant can trigger a refund once the + contract's timeout passes, and a background sweep does it automatically + every 5 minutes without requiring any user action. +- SPEI (CETES onramp/offramp) quote/order flow, broken by a frontend/backend + payload mismatch, now works end-to-end. +- KYC start/status calls, previously sent without an auth token and always + rejected, now authenticate correctly. +- The deposit flow's QR code was a hardcoded stock image — it now encodes + the real trade for the agent to scan. +- The 401 session-expiry handler was clearing the wrong local storage key, + leaving orphaned sessions behind. +- `GET /trades/history` no longer loads every user in the database to + resolve counterparty usernames — replaced with a SQL join and + server-side pagination. + +### Added +- `GET /rate/usdc-mxn`, mirroring the existing `/rate/xlm-mxn` multi-source + live rate endpoint, so screens showing USDC-MXN conversions stop using a + hardcoded `17.5`. +- `POST /client-errors` is now actually registered (the route existed but + was never wired up, so client crash reports were silently dropped). + +### Removed +- CETES buy/sell and Blend supply/borrow are hidden behind + `VITE_ENABLE_DEFI_TRADING` until they're implemented against the user's + own wallet instead of the platform account. +- Dead code: an `updateMerchantReputation` function that wrote to a + `merchants` table that doesn't exist in this schema (silently caught and + logged on every completed trade). + +See `docs/AUDIT_MOBILE_MAINNET.md` for the full pre-mainnet audit, findings, +and remaining open items. diff --git a/docs/AUDITORIA_APK_PILOTO_2026-08.md b/docs/AUDITORIA_APK_PILOTO_2026-08.md new file mode 100644 index 00000000..94b9d73c --- /dev/null +++ b/docs/AUDITORIA_APK_PILOTO_2026-08.md @@ -0,0 +1,181 @@ +# Auditoría del APK — aptitud para piloto ampliado + +**Fecha:** 2026-08-03 +**Alcance:** APK Android de MicoPay (`com.micopay.app`) y el backend al que apunta. +**Pregunta que responde:** ¿es seguro empezar a probar la app con más personas? + +--- + +## Veredicto + +**Sí, con una condición: hay que repartir un build de _release_ firmado, no el de _debug_ que se está usando hoy.** + +No hay hallazgos que comprometan el diseño de seguridad de la app. El manejo de llaves, el endurecimiento de Android y el backend están bien construidos. El problema es que el APK que actualmente funciona es un build de depuración, y ese tipo de build desactiva justo las protecciones que el proyecto ya tiene correctamente configuradas para release. + +Riesgo financiero acotado: el backend corre en **testnet** (`mockStellar: false` pero red de prueba), así que no hay fondos reales en juego durante el piloto. + +--- + +## Hallazgo principal — el APK en uso es un build de debug + +### Qué se encontró + +Existen tres artefactos de APK en el repositorio local: + +| Archivo | Fecha | API horneada | +|---|---|---| +| `dist/micopay-testnet-20260630.apk` | 2026-07-01 | `micopay-api.onrender.com` (obsoleto) | +| `android/app/build/outputs/apk/release/app-release.apk` | 2026-07-02 | `micopay-api.onrender.com` (obsoleto) | +| `android/app/build/outputs/apk/debug/app-debug.apk` | 2026-07-25 | **`api.micopay.app`** ✅ | + +El único que apunta a la infraestructura actual de AWS es el de **debug**, compilado el mismo día que se completó la migración. Los dos builds de release son anteriores a la migración y apuntan a Render, cuyo backend hoy responde `{"status":"unavailable","dbConnected":false}`. + +### Por qué importa + +El build de debug desactiva cuatro protecciones: + +1. **`android:debuggable="true"`** (verificado en el manifiesto empaquetado de debug). Con el teléfono en la mano y `adb`, se puede adjuntar un depurador al proceso y extraer la llave privada Stellar de la memoria. Esto anula la protección del Android Keystore. +2. **`usesCleartextTraffic="true"`** — `app/src/debug/AndroidManifest.xml` sobrescribe explícitamente el `false` del manifiesto principal, permitiendo HTTP en claro. +3. **Confianza en CAs instaladas por el usuario** — el bloque `` de `network_security_config.xml` añade ``, lo que hace el tráfico interceptable con un certificado tipo Charles/Proxyman. +4. **Firma con la llave de debug**, que es compartida y pública. Además impide actualizar después a un release sin desinstalar. + +### Acción + +```bash +npm run build:testnet +cd android && ./gradlew assembleRelease +``` + +Verificar antes de repartir que el APK resultante trae `api.micopay.app` horneado: + +```bash +unzip -o -q app-release.apk -d /tmp/apk 'assets/public/assets/*.js' +grep -rhoE "https://[a-zA-Z0-9.-]*micopay[a-zA-Z0-9./-]*" /tmp/apk | sort -u +``` + +--- + +## Lo que está correcto + +Estos puntos se verificaron y no requieren acción. + +### Almacenamiento de la llave privada + +`src/services/secureStorage.ts` usa `@aparajita/capacitor-secure-storage` (v8) cuando corre en nativo, respaldado por Android Keystore. `localStorage` solo se usa en la ruta web/PWA. El issue **SEC-05** ("llave en localStorage plano") aplica al build web, **no al APK**. + +La llave nunca sale del dispositivo: `keystore.ts` firma retos y XDR localmente y solo devuelve la firma o el XDR ya firmado. + +### Endurecimiento de Android (configuración de release) + +| Control | Estado | +|---|---| +| `android:allowBackup` | `false` | +| `android:usesCleartextTraffic` | `false` | +| `network_security_config` | solo CAs del sistema; CAs de usuario únicamente en debug | +| `minifyEnabled` / `shrinkResources` | `true` | +| `debuggable` (release) | `false` | +| Firma | desde `keystore.properties`, fuera del control de versiones | + +Permisos declarados, todos justificados: `INTERNET`, `CAMERA` (escaneo QR), `ACCESS_COARSE/FINE_LOCATION` (mapa de agentes), `POST_NOTIFICATIONS`. + +### Llave de firma fuera de git + +`micopay-release.jks` y `keystore.properties` **no están rastreados**. `git ls-files` solo devuelve `keystore.properties.example`, y `.gitignore` cubre `*.jks`, `keystore.properties` y `local.properties`. + +### Backend + +`https://api.micopay.app/health` responde `200`: + +```json +{"status":"ok","mockStellar":false,"dbConnected":true, + "eventListenerState":"disabled","configCheck":{...todo true}} +``` + +Cabeceras de seguridad correctas: HSTS con `preload`, CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, COOP/CORP, `Referrer-Policy: strict-origin-when-cross-origin`. + +Validación de configuración al arranque (`config.ts`): rechaza `JWT_SECRET` ausente o débil en producción, bloquea `MOCK_STELLAR=true` en producción, exige `SECRET_ENCRYPTION_KEY` de 64 hex. CORS con allowlist explícita que rechaza todo si está vacía. `trustProxy: 1` (no `true`), por lo que los rate limits por IP no se evaden con una cabecera `X-Forwarded-For`. + +--- + +## Hallazgos menores + +### 1. El build de mainnet miente sobre la red — no distribuir + +`.env.mainnet` define `VITE_STELLAR_NETWORK=PUBLIC`, Horizon de mainnet y el emisor USDC de mainnet, pero apunta a `api.micopay.app`, que corre en modo testnet. Un usuario vería saldos de mainnet contra un escrow de prueba. El propio archivo ya lo advierte en un comentario. **Usar solo `build:testnet` mientras no exista un backend real en mainnet.** + +### 2. Deep links rotos + +El manifiesto declara App Links para `https://app.micopay.xyz/claim/*` con `autoVerify="true"`. Ese host **no resuelve** (NXDOMAIN), por lo que la verificación de Digital Asset Links falla y los enlaces `/claim/` no abren la app. Hay que decidir el dominio definitivo (`app.micopay.xyz` vs. algo bajo `micopay.app`) y publicar `assetlinks.json`. + +### 3. Event listener deshabilitado en producción + +`eventListenerState: "disabled"` en `/health`. Conviene confirmar que ningún flujo del piloto dependa de la escucha de eventos on-chain. + +### 4. Override de estado de trade — presente pero inerte + +`getTradeStateDebugOverride()` sigue leyendo `?trade_state=` de la URL y `micopay_trade_state_override` de localStorage, pese a que **SEC-24** figura cerrado. + +**No es explotable como vector de fraude**, y se verificó punto por punto: + +- En `QRReveal.tsx` el valor se escribe con `setTradeState(...)` pero **nunca se renderiza** — es estado muerto. +- En `CashoutRequest.tsx` y `DepositRequest.tsx` alimenta un `TradeStateBadge` decorativo en la pantalla de captura de monto, **antes de que exista un trade**; no hay estado de backend que pueda tergiversarse frente a una contraparte. + +Vale limpiarlo por higiene, pero no bloquea el piloto. + +### 5. Gate de KYC apagado por defecto + +`kycGateEnabled` requiere `KYC_GATE_ENABLED=true`. Es coherente con que el dictamen legal siga pendiente, pero conviene tenerlo presente si el piloto crece en número de personas o montos. + +--- + +## Pendiente: consumo de AWS + +**No se pudo obtener.** La sesión de AWS CLI está expirada y `aws login` no completa desde la red actual. + +Causa identificada — la red local descarta silenciosamente ciertas IPs públicas: + +| Endpoint | Resultado | +|---|---| +| `portal.sso.us-east-1.amazonaws.com` | **timeout** ← lo requiere `aws login` | +| `console.aws.amazon.com` | **timeout** ← lo requiere el navegador | +| `sts.amazonaws.com` | OK | +| `ce.us-east-1.amazonaws.com` (Cost Explorer) | OK | +| `github.com` | **timeout** | +| `1.1.1.1` | OK | + +No es un problema de credenciales ni de permisos: el host del flujo de autenticación es uno de los que la red bloquea. + +**Cómo resolverlo:** completar `aws login --profile micopay-admin` desde otra red (hotspot del celular). El endpoint de Cost Explorer sí responde, así que con una sesión válida la consulta funciona de inmediato. Alternativa por navegador: entrar directo a `https://us-east-1.console.aws.amazon.com/costmanagement/home` (ese host sí responde; `console.aws.amazon.com` a secas no). + +**Referencia, no dato real:** el plan de migración estimaba **~$44 USD/mes** (ALB ~$17, ECS Fargate 0.25 vCPU/0.5 GB, RDS `db.t4g.micro`), con un budget configurado y alerta a $60/mes. Es una estimación del plan, **no el gasto facturado**. + +--- + +## Correcciones a hallazgos preliminares + +Dos hallazgos que se reportaron en la revisión inicial resultaron **falsos** y se retiran. Se dejan documentados para que no se vuelvan a levantar. + +### "El ALB está medio muerto" — RETIRADO + +Se midió que una de las dos IPs de `api.micopay.app` (`54.88.67.3`) daba timeout mientras la otra (`3.214.109.33`) respondía en 0.12 s, y se concluyó que una AZ del ALB estaba rota. + +**Era la red local, no AWS.** Desde la misma máquina, `github.com` y una IP de Google también dan timeout, mientras `1.1.1.1` responde normal. El error fue medir un endpoint y culpar al servidor sin descartar antes al cliente. + +> **Regla para futuras revisiones:** antes de declarar caído un endpoint de AWS desde un `curl` local, verificar `curl -k https://140.82.113.6/` (GitHub) y `https://1.1.1.1/`. Si IPs públicas sin relación también fallan, la falla es local. Confirmar contra la salud del target group o desde otra red. + +### "El APK apunta a Render, que está muerto" — RETIRADO + +Se auditó `app-release.apk` del 2 de julio, anterior a la migración, asumiendo que era el artefacto que se distribuye. El APK realmente en uso (`app-debug.apk`, 25 de julio) apunta correctamente a `api.micopay.app`. Render está efectivamente decomisionado, pero eso no afecta al APK en uso. + +--- + +## Acciones recomendadas + +| # | Acción | Prioridad | +|---|---|---| +| 1 | Compilar y firmar un APK de **release** con `build:testnet` y repartir ese | **Bloqueante** | +| 2 | Resolver el dominio de deep links y publicar `assetlinks.json` | Alta | +| 3 | Completar `aws login` desde otra red y revisar el gasto real contra el budget de $60 | Alta | +| 4 | Confirmar que ningún flujo del piloto dependa del event listener | Media | +| 5 | Eliminar `getTradeStateDebugOverride` y cerrar SEC-24 de verdad | Baja | +| 6 | No distribuir builds de `.env.mainnet` hasta tener backend en mainnet | Permanente | diff --git a/docs/AUDIT_APK_MAPA_2026-07.md b/docs/AUDIT_APK_MAPA_2026-07.md new file mode 100644 index 00000000..7a81797e --- /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/docs/AUDIT_MOBILE_MAINNET.md b/docs/AUDIT_MOBILE_MAINNET.md index 2c620fff..e729e117 100644 --- a/docs/AUDIT_MOBILE_MAINNET.md +++ b/docs/AUDIT_MOBILE_MAINNET.md @@ -132,6 +132,7 @@ El núcleo del producto (cashout/deposit P2P con escrow HTLC en Soroban) está * (El segundo check cubre que `auth.ts:95` salta la verificación de firma Ed25519 cuando `mockStellar=true`; el tercero cubre `secret.service.ts:4`, que hoy crashearía en runtime con una key de longitud incorrecta.) #### Finding: Registro emite JWT sin probar posesión de la llave (address squatting) +- ✅ **FIXED (2026-07-02):** Extraída la lógica de challenge/response de `auth.ts` a `backend/src/services/challenge.service.ts` (`issueChallenge`/`verifyAndConsumeChallenge`), compartida ahora por `/auth/token` y `/users/register`. `POST /users/register` exige `challenge`+`signature` en el body, valida el formato con `StrKey.isValidEd25519PublicKey`, y el JWT emitido ahora incluye `jti` (antes no lo tenía, así que un token de registro no era revocable hasta el primer login). **Frontend:** `registerUser()` en `api.ts` ahora hace el mismo baile challenge→firma→registro que `getAuthToken()`; se eliminó `generateFallbackAddress` de ambas funciones (lanzan error explícito si no hay keypair en vez de fabricar una dirección inválida). ⚠️ **Es un cambio de contrato de API** (2 campos nuevos requeridos) — backend y frontend deben desplegarse juntos, o el registro de usuarios nuevos se rompe para quien tenga el APK viejo apuntando al backend nuevo. Cubierto por 5 tests nuevos en `challenge.service.test.ts` (incluye el caso central: un challenge emitido para la dirección A es rechazado si se usa para registrar la dirección B). - **Severity:** WARNING - **Location:** `micopay/backend/src/routes/users.ts:19-81` - **Description:** `POST /users/register` acepta cualquier `stellar_address` (solo valida longitud 56, ni siquiera `StrKey.isValidEd25519PublicKey`) y devuelve un JWT válido de 24h. Un atacante puede registrar la dirección pública de otra persona (las direcciones son públicas) antes que ella, bloqueando su registro legítimo y operando una identidad ligada a esa address (no puede firmar lock/release on-chain, pero sí crear trades, chatear, y quemar reputación). @@ -159,6 +160,7 @@ El núcleo del producto (cashout/deposit P2P con escrow HTLC en Soroban) está * - **Description:** `assertNotReplayed` (tabla `processed_tx` con INSERT único) cubre lock/complete/refund ✅. `assertInvocationMatches` (`stellar.service.ts:47-99`) impide que un XDR firmado de un trade se envíe contra otro (contract id + function + args fund-relevant) ✅. Challenges de auth expiran en 5 min y son single-use ✅ — pero viven en un `Map` en memoria (`auth.ts:16`): con >1 instancia o restart de Render, los logins en vuelo fallan. Igual el rate-limit (`rateLimit.middleware.ts:8-26`) y la revocación de tokens. Aceptable para 1 instancia; documentar que escalar horizontalmente requiere Redis. #### Finding: `/defi/ramp/order/:orderId` sin check de pertenencia +- ✅ **FIXED (2026-07-02):** Nueva tabla `ramp_orders (order_id, user_id)` (migración `20260702090000_ramp_order_ownership`) que registra el dueño al crear la orden (`recordRampOrderOwner`) y se valida en `GET /defi/ramp/order/:orderId` y en `regenerate_tx` (`assertRampOrderOwnership`, 403 si no coincide). Fail-open para órdenes creadas antes de esta migración (sin fila de ownership → se permite + se loguea warning) para no romper órdenes en curso. - **Severity:** WARNING - **Location:** `micopay/backend/src/routes/ramp.ts:129-148` - **Description:** Cualquier usuario autenticado puede consultar el status de la orden de otro (los `orderId` son UUIDs generados por el backend, difíciles de adivinar, pero es IDOR). @@ -294,6 +296,7 @@ El núcleo del producto (cashout/deposit P2P con escrow HTLC en Soroban) está * - **Recommended Fix (barato):** Pausar polls con `CapApp.addListener('appStateChange')` (patrón ya usado en KYCScreen) y backoff a 10-15s después de 2 min sin cambios. (Ideal a mediano plazo: SSE en `GET /trades/:id/events`.) #### Finding: `getTradeHistory` carga todos los usuarios y pagina en memoria +- ✅ **FIXED (2026-07-02):** Reescrito con `JOIN users su/bu` (por FK, nunca excluye filas — `seller_id`/`buyer_id` siempre referencian un usuario existente) y `LIMIT`/`OFFSET` parametrizados en SQL; el filtro `expired` (derivado, nunca se persiste) también se empujó a la cláusula WHERE (`status NOT IN (...) AND expires_at < NOW()`). Nota: el store en memoria usado en tests locales sin Postgres no soporta `JOIN` doble ni `LIMIT` parametrizado (limitación pre-existente del mock, no de esta query) — verificado que no crashea, solo cae a `merchant_username: 'Usuario Micopay'` en ese entorno; contra Postgres real (producción) resuelve el username correcto. - **Severity:** WARNING - **Location:** `micopay/backend/src/services/trade.service.ts:305-345` (`SELECT id, username FROM users` completo + `filtered.slice(offset...)` tras traer todos los trades del usuario) - **Recommended Fix:** JOIN con alias y LIMIT/OFFSET en SQL: @@ -330,16 +333,15 @@ El núcleo del producto (cashout/deposit P2P con escrow HTLC en Soroban) está * - **Recommended Fix:** Un PR de "wire or delete": montar los 4 primeros, borrar el resto. #### Finding: Interfaces duplicadas `RampQuote`/`RampOrder` se fusionan silenciosamente +- ✅ **FIXED (2026-07-01, como parte del fix de B5):** Las interfaces y funciones ramp viejas (`getOfframpQuote`, `createOfframpOrder`, `regenerateOfframpTx`, `getRampOrder`, y el primer par de interfaces `RampQuote`/`RampOrder`) se eliminaron al reparar SPEI — ver el finding "SPEI offramp roto" en §1. - **Severity:** WARNING -- **Location:** `micopay/frontend/src/services/api.ts:90-103` y `:677-695` -- **Description:** TypeScript hace declaration-merging de las dos declaraciones (no hay error de compilación porque no colisionan propiedades), produciendo un tipo mentiroso donde `quote.id` y `quote.quoteId` "existen" ambas — exactamente el bug que rompió SPEI (§1). Borrar la pareja vieja. #### Finding: Backend `updateMerchantReputation` escribe en una tabla `merchants` que no existe en este schema +- ✅ **FIXED (2026-07-02):** Función y su llamada en `completeTrade` eliminadas por completo (la reputación ya se calcula on-read en `GET /users/me` desde `trades`, sin depender de esta tabla inexistente). Confirmado por grep que `getTier`/`TIERS`/`db.query` no se usaban en ningún otro lado del archivo antes de borrar. - **Severity:** WARNING -- **Location:** `micopay/backend/src/services/trade.service.ts:674-749` (`UPDATE merchants ...`, `trades.updated_at` tampoco existe); envuelto en try/catch "non-critical" así que solo loguea warning en cada trade completado -- **Recommended Fix:** Borrar la función y el bloque de `completeTrade:647-654` (la reputación ya se calcula on-read en `GET /users/me` desde `trades`), o migrarla a `merchant_configs`. #### Finding: Ruta backend definida pero no registrada: `client-errors` +- ✅ **FIXED (2026-07-02):** Registrada en `index.ts` (`import { clientErrorRoutes } from './routes/client-errors.js'` + `app.register(clientErrorRoutes, { prefix: '' })`). Combinado con el fix de `reportError.ts` de ayer (leía la key de storage equivocada), el reporte de errores del cliente ahora funciona de punta a punta. - **Severity:** WARNING - **Location:** `micopay/backend/src/routes/client-errors.ts` existe; `micopay/backend/src/index.ts:218-228` no lo registra → `reportClientError` del frontend (ErrorBoundary) postea a un 404 - **Recommended Fix:** `import { clientErrorRoutes } from './routes/client-errors.js';` + `app.register(clientErrorRoutes, { prefix: '' });` diff --git a/docs/AWS_GUIA_CONCEPTOS.md b/docs/AWS_GUIA_CONCEPTOS.md new file mode 100644 index 00000000..cf289dba --- /dev/null +++ b/docs/AWS_GUIA_CONCEPTOS.md @@ -0,0 +1,347 @@ +# AWS explicado con MicoPay + +**Para:** entender qué estamos construyendo, por qué tiene esa forma y cómo depurarlo cuando falle. +**Complemento de:** `docs/AWS_MIGRATION_PLAN_2026-07.md` (ese tiene los comandos; este tiene el *porqué*). +**Fecha:** 2026-07-22 + +--- + +## 0. El cambio mental: de una caja a un Lego + +Hoy en Render tienes **una caja**. Le das un repo, te da una URL con HTTPS. Render decide por ti la red, el certificado, el balanceador, dónde viven los secretos y cómo llega el tráfico. No los ves porque están incluidos. + +AWS no vende cajas, vende **piezas**. Ninguna pieza hace nada sola: un contenedor sin red no recibe tráfico, una base de datos sin grupo de seguridad no acepta conexiones, un balanceador sin certificado no habla HTTPS. Eso es lo que hace que AWS se sienta abrumador al principio y por qué la consola tiene 200 servicios. + +La buena noticia: **las piezas de Render también existían, solo que ocultas**. No estamos añadiendo complejidad, la estamos haciendo visible. A cambio obtienes control (backups de verdad, secretos con permisos, alarmas) y un techo de crecimiento que Render free no tiene. + +Este documento arma el Lego pieza por pieza. + +--- + +## 1. El mapa completo: 9 piezas + +``` + Internet + │ + │ (1) Route53 "api.micopay.app → esta dirección" + ▼ + ┌──────────────────────────────────────────────┐ + │ (2) ALB ── (3) certificado ACM │ subredes públicas + │ firewall: SG-alb (443 desde el mundo) │ + └───────────────────┬──────────────────────────┘ + │ HTTP :3000 + ┌───────────────────▼──────────────────────────┐ + │ (4) ECS Fargate — 1 tarea │ subred pública, + │ corre (5) la imagen de ECR │ con IP pública + │ lee (6) secretos de SSM al arrancar │ + │ escribe (7) logs a CloudWatch │ + │ firewall: SG-app (3000 solo desde ALB) │ + └───────────────────┬──────────────────────────┘ + │ TLS :5432 + ┌───────────────────▼──────────────────────────┐ + │ (8) RDS PostgreSQL 16 │ sin IP pública + │ firewall: SG-db (5432 solo desde SG-app)│ + └──────────────────────────────────────────────┘ + + (9) IAM — quién puede hacer qué. Atraviesa todo lo anterior. +``` + +| # | Pieza | Qué es, en una frase | Qué hacía Render por ti | +|---|---|---|---| +| 1 | **Route53** | El DNS: traduce `api.micopay.app` a una dirección | Te daba `*.onrender.com` gratis | +| 2 | **ALB** (Application Load Balancer) | La puerta de entrada: termina HTTPS y reparte tráfico | Incluido e invisible | +| 3 | **ACM** (Certificate Manager) | Emite y renueva el certificado TLS, gratis | Incluido e invisible | +| 4 | **ECS Fargate** | Corre tu contenedor sin que administres servidores | Era literalmente "el servicio" | +| 5 | **ECR** (Elastic Container Registry) | El almacén de imágenes Docker, privado | Render construía desde el repo | +| 6 | **SSM Parameter Store** | Guarda secretos cifrados con control de acceso | El dashboard de env vars | +| 7 | **CloudWatch** | Logs y alarmas | La pestaña "Logs" | +| 8 | **RDS** | PostgreSQL gestionado con backups | Render Postgres (plan free, expira) | +| 9 | **IAM** | El sistema de permisos de todo AWS | No existía equivalente | + +--- + +## 2. Recorrido de una petición real + +La mejor forma de entender la arquitectura es seguir **una** petición. Alguien abre el APK y toca "Mis trades", lo que dispara `GET https://api.micopay.app/trades`. + +**Paso 1 — DNS (Route53).** El teléfono pregunta "¿dónde está `api.micopay.app`?". Route53 tiene un registro *alias* que responde con las IPs del ALB. Es un alias y no una IP fija porque **el ALB cambia de IPs solo**; si hubiéramos escrito una IP a mano, un día dejaría de funcionar sin avisar. + +**Paso 2 — TLS (ALB + ACM).** El teléfono abre una conexión TLS al ALB. El ALB presenta el certificado de ACM para `api.micopay.app`. Aquí termina el cifrado del cliente: de este punto en adelante viajamos dentro de la VPC. ACM renueva el certificado solo mientras el registro DNS de validación siga existiendo — por eso ese CNAME de validación **nunca se borra**. + +**Paso 3 — Primer firewall (SG-alb).** El grupo de seguridad del ALB permite 443 desde `0.0.0.0/0`. Si intentaras conectarte al puerto 3000 del ALB, no habría nada escuchando. + +**Paso 4 — El ALB elige un destino.** Mira su *target group*: la lista de destinos sanos. Ahí está la IP privada de nuestra única tarea Fargate, en el puerto 3000. "Sana" significa que respondió 200 a `GET /health` en los últimos chequeos. **Si `/health` devuelve 503 porque la BD está caída, el ALB saca la tarea de rotación y responde 503 él mismo.** Eso es deseable: mejor un error honesto que servir datos rotos. + +**Paso 5 — Segundo firewall (SG-app).** El SG de la tarea solo permite entrada en 3000 *desde SG-alb*. Nota bien: la regla no dice una IP, dice **otro grupo de seguridad**. Es lo más elegante de los SG en AWS: expresas "solo el balanceador puede hablarme" sin conocer ninguna IP, y sigue siendo cierto cuando el balanceador cambie de IP. + +**Paso 6 — Tu código.** Fastify recibe la petición. Aquí importan dos cosas de configuración: +- `trustProxy` — el teléfono ya no es quien conecta, es el ALB. La IP real del cliente llega en la cabecera `X-Forwarded-For`. Por eso Fastify tiene que "confiar en el proxy" para que los rate limits por IP funcionen. Y por eso debe ser `trustProxy: 1` y no `true`: con `true`, Fastify se cree la entrada *más a la izquierda* de esa cabecera, que la escribe el cliente y por tanto se puede falsear. +- **CORS** — la petición trae `Origin: https://localhost` (el APK es un WebView sirviendo desde ese origen). Si el backend no devuelve `Access-Control-Allow-Origin`, el WebView tira la respuesta a la basura aunque el servidor haya respondido perfecto. De ahí que `CORS_ALLOWED_ORIGINS` sea obligatoria. + +**Paso 7 — Tercer firewall (SG-db) y la BD.** El código consulta PostgreSQL. SG-db permite 5432 *solo desde SG-app*. RDS además **no tiene IP pública**: no existe una ruta desde internet hacia ella, aunque adivinaras la contraseña. La conexión va cifrada con TLS porque RDS PostgreSQL 16 lo exige (`rds.force_ssl=1` viene activado por defecto) — de ahí el `?sslmode=require` en la cadena de conexión. + +**Paso 8 — Vuelta y logs.** La respuesta regresa por el mismo camino. En paralelo, cada línea de log que escribe Fastify a stdout la captura el driver `awslogs` y aparece en CloudWatch en el grupo `/ecs/micopay-backend`. + +**Y hay un paso 0 que ocurrió antes de todo:** cuando la tarea arrancó, ECS bajó la imagen de ECR, leyó los secretos de SSM y los inyectó como variables de entorno, y solo entonces ejecutó `node dist/index.js`. Si cualquiera de esos tres pasos falla, el contenedor nunca arranca — y es de lejos la causa #1 de deploys fallidos (§6). + +--- + +## 3. Conceptos base, en el orden en que los vas a necesitar + +### 3.1 Región y zona de disponibilidad + +Una **región** es una zona geográfica (`us-east-1` = norte de Virginia). Dentro de cada región hay varias **zonas de disponibilidad (AZ)**: centros de datos separados físicamente pero conectados con fibra rápida. `us-east-1a` y `us-east-1b` son AZ distintas. + +Sirven para tolerar fallos: si se cae un centro de datos, lo que esté en otra AZ sigue vivo. + +**En MicoPay:** el ALB está en dos AZ (es un requisito de AWS, y sale gratis). La tarea Fargate y RDS están en una sola. Poner RDS en Multi-AZ duplicaría su costo para protegernos de un fallo de centro de datos completo — con cero usuarios reales, no vale la pena todavía. El día que sí, es un cambio de un flag, no una re-arquitectura. + +**Por qué `us-east-1` y no México:** `mx-central-1` (Querétaro) existe pero tiene menos servicios y precios más altos. Los ~70 ms extra de latencia CDMX↔Virginia son irrelevantes cuando cada operación ya espera segundos por Soroban RPC o por SPEI. Si algún día la regulación exige residencia de datos en México, se mueve la misma task definition a `mx-central-1` — está previsto, no es deuda. + +### 3.2 VPC, subredes y cómo se sale a internet + +Una **VPC** es tu red privada dentro de AWS: un rango de IPs que nadie más usa. Toda cuenta trae una VPC "default" ya creada, y para MicoPay esa basta. + +La VPC se divide en **subredes**, una por AZ. La diferencia entre "pública" y "privada" **no es una casilla**, es una sola cosa: si su tabla de rutas tiene o no una ruta hacia el **Internet Gateway (IGW)**. + +- **Subred pública** = tiene ruta al IGW. Lo que viva ahí *puede* salir a internet, **si además tiene una IP pública asignada**. +- **Subred privada** = no tiene ruta al IGW. Para salir a internet necesita un **NAT Gateway**: una pieza que vive en una subred pública y hace de intermediario. El NAT permite salir sin permitir entrar. + +Ese NAT Gateway cuesta **~$32/mes fijos** más tráfico. Es, de lejos, la sorpresa de factura más común de quien empieza en AWS. + +**En MicoPay:** la tarea Fargate va en **subred pública con IP pública**. Necesita salir a internet constantemente — Soroban RPC, Horizon, la API de Etherfuse, Firebase Cloud Messaging — y así sale por el IGW **sin pagar NAT**. ¿Y no es inseguro tener IP pública? No: tener IP pública significa *poder salir*; que alguien pueda *entrar* lo decide el grupo de seguridad, y el nuestro solo acepta el puerto 3000 desde el ALB. Nadie de internet puede tocar la tarea. + +RDS es el caso opuesto: **no necesita salir a internet ni recibir de internet**. Por eso va con `--no-publicly-accessible`. + +> Esta decisión es exactamente la que descartó App Runner. App Runner con conector VPC enruta *todo* su tráfico de salida por la VPC, y si esa VPC no tiene NAT, el servicio se queda sin internet: mueren Soroban, Etherfuse y FCM. La opción era pagar los $32 del NAT o dejar la base de datos expuesta. Fargate en subred pública esquiva ambas. + +### 3.3 Grupos de seguridad + +Un **security group (SG)** es un firewall con estado que se pega a un recurso. Dos propiedades que lo hacen distinto de un firewall clásico: + +1. **Solo tiene reglas de permiso.** No hay "denegar": lo que no está permitido, está bloqueado. No existe orden de reglas ni prioridades. +2. **Con estado:** si permites la entrada, la respuesta sale automáticamente. No hay que abrir el puerto de vuelta. + +Y lo más útil: **una regla puede referenciar otro SG en vez de una IP**. + +En MicoPay hay tres, en cadena: + +| SG | Permite entrada de | En el puerto | Se lee como | +|---|---|---|---| +| `micopay-alb` | `0.0.0.0/0` | 443, 80 | "el mundo puede tocar la puerta" | +| `micopay-app` | `micopay-alb` | 3000 | "solo la puerta puede hablarme" | +| `micopay-db` | `micopay-app` | 5432 | "solo la app puede consultarme" | + +Esto es defensa en profundidad: para llegar a la base de datos desde internet habría que comprometer el ALB y luego la aplicación. Y como las reglas nombran grupos, siguen siendo correctas aunque todas las IPs cambien. + +### 3.4 IAM: el modelo de permisos + +**IAM** responde "¿quién puede hacer qué sobre cuál recurso?". Dos conceptos: + +- **Usuario**: una persona, con contraseña o llaves de acceso. Las llaves son permanentes: si se filtran, es un problema serio. +- **Rol**: un conjunto de permisos que algo *asume temporalmente*, obteniendo credenciales que caducan en minutos u horas. Nadie "tiene" un rol; se asume. + +**La regla práctica: casi todo debería ser un rol.** Las llaves de acceso permanentes son la fuga de credenciales más común de AWS. + +MicoPay usa tres roles, y la distinción entre los dos primeros es la que más confusión causa: + +| Rol | Quién lo asume | Para qué | Si falta un permiso | +|---|---|---|---| +| **Execution role** | El agente de ECS, **antes** de arrancar tu contenedor | Bajar la imagen de ECR, crear el log stream, **leer los secretos de SSM** | La tarea muere antes de ejecutar una línea de tu código: `ResourceInitializationError` | +| **Task role** | **Tu proceso**, ya corriendo | Llamar APIs de AWS desde el código | Tu código recibe `AccessDenied` en runtime | +| **Deploy role** | GitHub Actions | Push a ECR, actualizar el servicio ECS | El workflow falla | + +Que los secretos los lea el *execution role* y no el *task role* es contraintuitivo pero tiene lógica: los secretos se resuelven **antes** de que exista tu proceso, para poder inyectarlos como variables de entorno. Es el permiso que más se olvida. + +El **deploy role** usa **OIDC**: GitHub presenta un token firmado que prueba "soy el workflow del repo Micopay/micopay-protocol en la rama main", y AWS lo cambia por credenciales temporales. Así no existe ninguna llave de AWS guardada en los secrets de GitHub que pueda filtrarse. + +### 3.5 Contenedores: imagen, registro, tarea, servicio + +Cuatro palabras que se confunden todo el tiempo: + +- **Imagen** — un sistema de archivos congelado con tu app y sus dependencias. Es el "ejecutable". Inmutable. +- **ECR** — el almacén privado de imágenes. Docker Hub, pero tuyo. +- **Task definition** — la *receta*: qué imagen, cuánta CPU y RAM, qué variables de entorno, qué secretos, a dónde van los logs. Es un JSON versionado: cada cambio crea una revisión nueva (`micopay-backend:1`, `:2`, …). Volver atrás es apuntar a la revisión anterior. +- **Tarea (task)** — una instancia corriendo de una task definition. Un contenedor vivo. +- **Servicio (service)** — el supervisor: "quiero N tareas de esta receta corriendo siempre, registradas en este target group". Si una tarea muere, el servicio levanta otra. + +**Fargate** significa que no administras servidores: le dices "0.25 vCPU y 512 MB" y AWS pone la máquina. La alternativa (ECS sobre EC2) te haría gestionar, parchear y escalar instancias — exactamente lo que queremos dejar atrás. + +**En MicoPay, `desiredCount = 1`, y eso es una decisión, no una limitación de presupuesto.** El proceso no es solo un servidor HTTP: dentro corre el *refund sweep* cada 5 minutos, que manda transacciones a la blockchain para devolver fondos de trades cancelados. Con dos tareas, ese barrido corre dos veces y podría intentar reembolsar el mismo trade dos veces. Escalar a más de una instancia requiere primero un candado distribuido (`pg_advisory_lock`) para que solo una haga el trabajo de fondo. + +Ese mismo razonamiento explica una configuración rara del servicio: `maximumPercent=100, minimumHealthyPercent=0`. Por defecto ECS despliega *sin corte* levantando la tarea nueva antes de matar la vieja — pero eso significa **dos tareas conviviendo unos segundos**, justo lo que queremos evitar. Con esos valores, ECS mata primero y levanta después: ~40 segundos de corte por deploy, a cambio de garantizar que nunca hay dos barridos simultáneos. Sin usuarios reales, es un intercambio obvio. + +> Este es también el motivo real por el que App Runner quedó descartado: cobra la memoria siempre pero **la CPU solo mientras procesa peticiones**, y estrangula la CPU de las instancias ociosas. Un `setInterval` de 5 minutos que mueve dinero on-chain no puede depender de que llegue tráfico para ejecutarse. + +### 3.6 Secretos + +Dos servicios hacen casi lo mismo: + +- **SSM Parameter Store** — parámetros cifrados con KMS. **Gratis** en el tier estándar. +- **Secrets Manager** — igual, más rotación automática. **$0.40 por secreto al mes.** + +Con ~9 secretos, Secrets Manager serían ~$43/año por una rotación automática que solo funciona para credenciales que AWS sabe rotar (como usuarios de RDS). Nuestras llaves de Stellar y Etherfuse no entran en esa categoría. **Decisión: SSM.** + +Lo importante del modelo: los secretos **no viven en el repo, ni en el `taskdef.json`, ni en el historial de git**. El JSON solo contiene el *ARN* (la dirección) del parámetro: + +```json +{ "name": "PLATFORM_SECRET_KEY", + "valueFrom": "arn:aws:ssm:us-east-1:123456789012:parameter/micopay/prod/PLATFORM_SECRET_KEY" } +``` + +Ese ARN se puede versionar tranquilamente: sin el permiso IAM correspondiente, no sirve de nada. + +Tres advertencias específicas de MicoPay: + +- **`PLATFORM_SECRET_KEY` es una hot wallet de Stellar.** Quien la lee, controla los fondos. No es "un secreto más". "Rotarla" no es cambiar una variable: es crear una cuenta nueva y mover fondos. Por eso va sobre su **propia llave KMS** (`alias/micopay-hotwallet`), separada de los demás secretos: así el permiso de descifrado se controla y se audita aparte, y solo el *execution role* puede leerla. Matiz del modelo de permisos: los secretos sobre la llave por defecto (`aws/ssm`) no necesitan que el rol tenga `kms:Decrypt` explícito; una llave propia sí lo exige — y ese permiso explícito es justo lo que da el control extra. +- **`SECRET_ENCRYPTION_KEY` cifra los secretos HTLC ya guardados en la base.** Si copias los datos de Render, esta llave tiene que ser bit a bit la misma o esos registros quedan indescifrables para siempre. Solo se regenera si arrancas con base limpia. +- **`FIREBASE_PRIVATE_KEY` lleva saltos de línea.** El código hace `.replace(/\\n/g, '\n')`, así que hay que guardarla con los `\n` **escapados**, literalmente como dos caracteres. + +### 3.7 CloudWatch + +Todo lo que tu contenedor escribe a stdout/stderr acaba en un **log group** (`/ecs/micopay-backend`), dividido en **log streams** (uno por tarea). Pino ya escribe JSON estructurado, así que CloudWatch Logs Insights puede consultarlo por campo: + +``` +fields @timestamp, category, msg +| filter category = "refund-sweep" +| sort @timestamp desc +``` + +Las **alarmas** vigilan métricas y avisan. Las cuatro que importan para MicoPay: + +| Alarma | Significa | +|---|---| +| `UnHealthyHostCount > 0` (target group) | La app dejó de responder `/health` con 200 | +| `HTTPCode_Target_5XX_Count` alto | La app responde, pero con errores | +| `FreeStorageSpace` bajo (RDS) | Los 20 GB se están acabando | +| `CPUUtilization` alto (RDS) | Falta un índice, o llegó tráfico de verdad | + +Y una que no es de CloudWatch pero es igual de importante: **el Budget alert**. Sin usuarios reales, si la factura se dispara es porque algo está mal configurado, no porque estés creciendo. + +--- + +## 4. Las decisiones, en formato pregunta → respuesta + +**¿Por qué no App Runner, si es más simple?** +Por dos razones que se descubren tarde. (a) Con conector VPC pierde la salida a internet salvo que pagues un NAT Gateway de ~$32/mes; sin conector, la base de datos tendría que estar expuesta. (b) Solo asigna CPU mientras procesa peticiones, y el refund sweep necesita ejecutarse esté o no llegando tráfico. Con NAT incluido salía **más caro** que Fargate *y* con los jobs en riesgo. + +**¿Por qué un ALB si solo hay una tarea?** +Porque una tarea de Fargate cambia de IP cada vez que se reinicia, y `api.micopay.app` necesita apuntar a algo estable. El ALB da la dirección fija, además de terminar TLS con un certificado que se renueva solo y de sacar la tarea de rotación cuando `/health` falla. Es la pieza más cara del stack (~$17/mes) y la única que no tiene alternativa razonable. + +**¿Por qué RDS y no un contenedor de Postgres?** +Porque los datos tienen que sobrevivir al contenedor. Un Postgres en Fargate pierde todo al reiniciarse. RDS da backups automáticos con retención de 7 días, restauración a un punto en el tiempo, cifrado en reposo y parches gestionados. Es exactamente el problema que nos hizo salir de Render: la base free expira cada 90 días y ya se perdió una vez. Un detalle importante para no esperar magia: **restaurar un backup de RDS crea una instancia NUEVA con endpoint nuevo** — no es un botón de "deshacer", es "levantar la copia y repuntar la app a ella" (actualizar `DATABASE_URL` en SSM + redeploy). El runbook paso a paso está en §11 del plan de migración, y conviene ensayarlo una vez: un backup que nunca restauraste no sabes si sirve. + +**¿Por qué `db.t4g.micro`?** +Es la instancia más pequeña con ARM (Graviton), ~20% más barata que su equivalente Intel a igual rendimiento. Para esta carga sobra. Subir de tamaño después es un `modify-db-instance` con unos minutos de corte. + +**¿Por qué la base de datos no tiene IP pública, si es más incómodo?** +Porque es incómodo *también para un atacante*. El costo es real: para correr SQL a mano hay que entrar desde dentro de la VPC (`aws ecs execute-command` a la tarea). Es un buen intercambio para la base que guarda las llaves de custodia. + +**¿Por qué SSM y no Secrets Manager?** Gratis vs ~$43/año por una rotación que no podemos usar. + +**¿Por qué OIDC y no llaves de acceso en GitHub?** Porque una llave permanente en los secrets de un repo es una fuga esperando a ocurrir. Con OIDC no existe llave que filtrar. + +**¿Por qué el contexto de build es `micopay/` y no `micopay/backend/`?** +Porque las migraciones SQL viven en `micopay/sql/` y el runner las busca en una ruta relativa que sale de `backend/`. Con un contexto acotado a `backend/`, la imagen se construye sin errores, arranca sin errores, y falla en la primera query real — el peor tipo de fallo. Por eso el CI verifica explícitamente que `/app/sql` exista dentro de la imagen. + +--- + +## 5. Qué cuesta cada pieza y qué pasa si la apagas + +| Pieza | ~USD/mes | Si la apagas | +|---|---|---| +| ALB | 17 | No hay HTTPS ni dominio; la app queda inalcanzable | +| RDS db.t4g.micro + 20 GB | 14 | No hay datos | +| Fargate 0.25 vCPU / 0.5 GB | 9 | No hay app | +| CloudWatch, ECR, transferencia | 2 | Te quedas ciego | +| Route53 hosted zone | 1 | El dominio no resuelve | +| SSM Parameter Store | 0 | — | +| **Total** | **~43** | | + +Referencia: la alternativa App Runner + NAT Gateway obligatorio salía ~$61/mes. + +Sobre el free tier: desde julio de 2025 AWS dejó de dar "12 meses gratis" a las cuentas nuevas. El esquema actual son créditos (~$100 al registrarse, hasta $100 más por completar actividades). Traducido: unos **4 meses cubiertos** y después precio completo. Poner el Budget alert **el primer día**, no cuando llegue la factura. + +--- + +## 6. Los seis errores que vas a ver, y qué significan + +**`ResourceInitializationError: unable to pull secrets or registry auth`** +El **execution role** no tiene permiso sobre los parámetros de SSM, o el ARN del secreto está mal escrito. Error #1 en primeros despliegues. Se arregla en IAM, no en el código. + +**`CannotPullContainerError: image not found`** +La imagen no está en ECR con ese tag, o el nombre del registro no coincide con tu ID de cuenta. + +**La tarea arranca y muere en bucle, `exit code 1`** +Es tu aplicación fallando, no AWS. Mira CloudWatch: `aws logs tail /ecs/micopay-backend --since 15m`. Los dos culpables típicos en MicoPay: +- `Configuration Validation Failed` — falta o está mal alguna variable (`validateConfig()` lista exactamente cuál). +- `PostgreSQL unavailable in production ... Exiting` — el `?sslmode=require` falta en `DATABASE_URL`, o el SG de la base no permite al SG de la app. **Nunca** lo "arregles" poniendo `ALLOW_IN_MEMORY_DB=true`: eso hace que el backend sirva desde un almacén en memoria que se borra en cada reinicio. + +**El target group dice `unhealthy` pero los logs se ven bien** +Casi siempre es el puerto: el target group apunta a 3000 y el contenedor escucha en otro, o el SG de la app no permite entrada desde el SG del ALB. Recuerda también que `/health` devuelve **503 a propósito** si la base no responde — en ese caso el "unhealthy" es correcto y el problema está en la base. + +**La tarea muere justo después de arrancar, sin error claro** +`initPg()` reintenta la conexión 5 veces con backoff: hasta ~95 segundos antes de rendirse. Si el health check grace period es menor, ECS mata la tarea *mientras todavía estaba conectando*. Por eso `--health-check-grace-period-seconds 180`. + +**El APK no carga datos, pero `curl https://api.micopay.app/trades` funciona** +Es CORS. `curl` no manda `Origin`; el WebView sí. Si `CORS_ALLOWED_ORIGINS` no incluye `https://localhost`, el servidor responde bien y el WebView descarta la respuesta. Se ve en la consola del WebView, no en los logs del servidor — que mostrarán 200 tan tranquilos. + +--- + +## 7. Los comandos que resuelven el 90% + +```powershell +# ¿Qué está pasando ahora mismo? +aws ecs describe-services --cluster micopay --services micopay-backend --query "services[0].{running:runningCount,desired:desiredCount,status:status}" + +# ¿Por qué murió la última tarea? (stoppedReason es la respuesta) +aws ecs describe-tasks --cluster micopay --tasks (aws ecs list-tasks --cluster micopay --desired-status STOPPED --query "taskArns[0]" --output text) --query "tasks[0].{reason:stoppedReason,containers:containers[].reason}" + +# Logs en vivo +aws logs tail /ecs/micopay-backend --follow + +# ¿El balanceador considera sana a la tarea? +aws elbv2 describe-target-health --target-group-arn $TgArn + +# Entrar al contenedor (requiere --enable-execute-command en el servicio) +aws ecs execute-command --cluster micopay --task --container api --interactive --command "/bin/sh" + +# Redesplegar la misma imagen (útil tras cambiar un secreto en SSM) +aws ecs update-service --cluster micopay --service micopay-backend --force-new-deployment +``` + +Ese último merece énfasis: **los secretos se leen al arrancar la tarea.** Cambiar un parámetro en SSM no afecta a un contenedor que ya está corriendo; hace falta un despliegue nuevo. + +--- + +## 8. Glosario + +| Sigla | Nombre | En MicoPay | +|---|---|---| +| **VPC** | Virtual Private Cloud | La red donde vive todo | +| **AZ** | Availability Zone | Centro de datos; el ALB usa dos | +| **IGW** | Internet Gateway | La salida a internet de las subredes públicas | +| **NAT** | Network Address Translation gateway | La salida de las subredes privadas — **la evitamos** (~$32/mes) | +| **SG** | Security Group | Firewall por recurso; usamos tres en cadena | +| **IAM** | Identity and Access Management | Permisos; 3 roles | +| **ECR** | Elastic Container Registry | Donde vive la imagen Docker | +| **ECS** | Elastic Container Service | El orquestador que corre el contenedor | +| **Fargate** | — | El modo de ECS sin servidores que administrar | +| **ALB** | Application Load Balancer | La puerta HTTPS | +| **ACM** | AWS Certificate Manager | El certificado TLS, gratis y auto-renovado | +| **RDS** | Relational Database Service | PostgreSQL 16 gestionado | +| **SSM** | Systems Manager (Parameter Store) | Los secretos | +| **KMS** | Key Management Service | Las llaves que cifran SSM y RDS | +| **ARN** | Amazon Resource Name | El identificador único de cualquier recurso | +| **OIDC** | OpenID Connect | Cómo GitHub Actions se autentica sin llaves | +| **TTL** | Time To Live | Cuánto cachea el DNS una respuesta | + +--- + +## 9. Si solo te llevas cinco ideas + +1. **AWS son piezas sueltas.** Render también las tenía; aquí son visibles y por eso configurables. +2. **La red se define por rutas y grupos de seguridad**, no por casillas. "Pública" significa "tiene ruta al IGW"; "solo el ALB puede entrar" se expresa apuntando a otro grupo de seguridad. +3. **Casi todo debería ser un rol, no una llave.** Y los secretos los lee el *execution role* antes de que tu código exista — es el permiso que más se olvida. +4. **Una sola instancia es una decisión de corrección**, no de presupuesto: dentro del proceso corre un barrido que mueve dinero on-chain. Escalar exige antes un candado distribuido. +5. **El contexto de build Docker es `micopay/`.** Si no, la imagen se construye, arranca, y falla en la primera consulta real. diff --git a/docs/AWS_MIGRATION_PLAN_2026-07.md b/docs/AWS_MIGRATION_PLAN_2026-07.md new file mode 100644 index 00000000..298cb32b --- /dev/null +++ b/docs/AWS_MIGRATION_PLAN_2026-07.md @@ -0,0 +1,702 @@ +# Plan de migración a AWS — MicoPay (backend + BD) + +**Fecha:** 2026-07-19 · **Revisión v2 (auditada):** 2026-07-22 +**Estado:** listo para ejecutar +**Si no tienes contexto de AWS, lee primero:** [`docs/AWS_GUIA_CONCEPTOS.md`](./AWS_GUIA_CONCEPTOS.md) — explica cada pieza y por qué la arquitectura tiene esta forma. Este documento es el runbook; ese es el mapa. +**Alcance:** el servicio `micopay-backend` (Fastify, `micopay/backend`) y su PostgreSQL. El frontend es una app móvil (Capacitor/APK) que consume esta API; no se "migra", solo se recompila apuntando al dominio nuevo. El bloque `micopay-api` (`apps/api`) de `render.yaml` no está desplegado y queda fuera de alcance. + +**Premisa de esta revisión: no hay usuarios reales.** Eso elimina de raíz la parte más cara y frágil del plan v1 (convivencia de dominios, ventana de mantenimiento, congelar escrituras, runbook de cutover, rollback DNS). Render deja de ser una restricción: se apaga cuando AWS esté verde. Lo que queda es *construir bien* el destino, no *mudarse sin romper*. + +--- + +## 1. Motivación + +- La BD de Render está en plan `free`, que expira cada ~90 días. Ya expiró una vez. Es el riesgo operativo #1. +- El servicio live no se sincroniza desde `render.yaml`: los env vars se aplican a mano en el dashboard, así que **el manifiesto del repo no es la verdad** (ver hallazgo A4, que es consecuencia directa de esto). +- AWS da BD con backups automáticos, secretos gestionados, alarmas y camino de crecimiento sin re-arquitectura. + +## 2. Inventario verificado (contra el código, 2026-07-22) + +| Pieza | Estado real | Implicación | +|---|---|---| +| API backend | Node + Fastify, `config.port` default **3000** (`src/config.ts:92`), health `/health` | Render lo corre en 3002 por env var; en AWS fijamos `PORT=3000` | +| Migraciones | `runMigrations()` en boot (`src/index.ts:450`) + `preDeployCommand` de Render | En AWS basta el boot-migrate — **pero ver A1** | +| Ruta de los SQL | `src/db/migrate.ts:20` resuelve `../../../sql` desde `dist/db/` → `micopay/sql` | **El contexto de build Docker debe ser `micopay/`, no `micopay/backend/`** | +| Jobs in-process | Refund sweep `setInterval` 5 min (`src/index.ts:426`) + event listener con cursor persistido en BD | Exigen CPU continua y una sola instancia — ver A2/A3/A10 | +| Conexión BD | `new Pool({ connectionString })` sin `ssl` (`src/db/schema.ts:266`); en producción, si no conecta → `process.exit(1)` (`schema.ts:306`) | RDS PG16 fuerza TLS — ver A5 | +| Arranque | `await initPg()` top-level, 5 intentos × 15 s + backoff ≈ 95 s peor caso | Grace period del health check ≥ 180 s — ver A12 | +| Estáticos | `public/.well-known/assetlinks.json` con ruta explícita (`src/index.ts:68`) | Viaja en la imagen; solo verificar tras deploy | +| Webhooks Etherfuse | Rutas reales: `POST /defi/ramp/webhook/order` y `/defi/ramp/webhook/kyc` (`src/routes/ramp.ts:217,228`) | El plan v1 citaba `/ramp/webhook`, que no existe | +| CORS | `getCorsOptions()` devuelve `origin:false` en prod si no hay `CORS_ALLOWED_ORIGINS` (`config.ts:245`) | El APK hace fetch/axios desde el WebView → **CORS aplica** — ver A4 | +| Capacitor | `androidScheme: 'https'`, sin plugin `CapacitorHttp` (`micopay/frontend/capacitor.config.ts`) | Origin del APK = `https://localhost` | +| Frontend env | `.env.production` ya apunta a `https://api.micopay.app`; `.env.mainnet`/`.env.testnet` a onrender | Solo hay que editar los dos de modo — ver A14 | +| Docker | **No existe Dockerfile ni `.dockerignore`** | Se escriben en Fase 0 (contenido completo en §6) | +| CI | `.github/workflows/ci.yml` ya existe y bloquea el merge si backend o frontend no buildean; corre Node 20 | Solo hay que añadirle el `docker build` y subir a Node 22 — ver A11/A13 | +| Compilación | `npm run build` pasa **hoy** en backend (`tsc`) y frontend (`tsc && vite build`) | La premisa "main no compila" del v1 está obsoleta — ver A13 | + +## 3. Auditoría del plan v1 + +### Bloqueantes + +**A1 — El Dockerfile descrito no incluye `micopay/sql/`.** +El v1 dice "runtime con `dist/` + `public/` + prod deps". Falta el directorio de migraciones, que vive **fuera** de `micopay/backend/`. `migrate.ts:20` resuelve `resolve(__dirname, '../../../sql')`; con `dist/db/` en `/app/backend/dist/db`, eso apunta a `/app/sql`. Si no está, `runMigrations()` lanza, `index.ts:454` lo captura y **solo lo loguea** — el servidor arranca contra una BD vacía y falla en la primera query real. Falla silenciosa y difícil de leer. +→ **Fix:** contexto de build = `micopay/`, `COPY sql /app/sql`. Verificación explícita en Fase 0. + +**A2 — App Runner + VPC connector deja al servicio sin salida a internet.** +Al enrutar el egress por una VPC, App Runner pierde acceso público salvo que la VPC tenga NAT Gateway. Este backend llama Soroban RPC, Horizon, la API de Etherfuse y FCM: sin NAT, todo eso muere. Un NAT Gateway son **~$32/mes + $0.045/GB**, lo que casi duplica el estimado de $30–35 del v1. + +**A3 — App Runner no garantiza CPU cuando no procesa requests.** +Cobra memoria siempre y CPU solo durante requests activos; las instancias en reposo quedan con CPU estrangulada. El refund sweep (`setInterval` cada 5 min) es precisamente el job que **manda transacciones on-chain** para devolver fondos. Depender de que el health check "despierte" el contenedor lo suficiente para que corra un timer es comportamiento no documentado, y no es aceptable para dinero. +→ **Fix elegido:** ECS Fargate, donde la CPU está asignada siempre y los jobs corren exactamente igual que hoy en Render. (Variante App Runner en §9, si se prefiere, con el trabajo de código que implica.) + +### Altos + +**A4 — Falta `CORS_ALLOWED_ORIGINS` en la matriz de env vars.** +No está en `render.yaml`. En producción, sin esa variable, `@fastify/cors` se registra con `origin: false`: el servidor responde, pero **sin cabecera `Access-Control-Allow-Origin`**, así que el WebView descarta la respuesta. El APK no usa el plugin `CapacitorHttp`, así que sus llamadas son XHR/fetch reales del WebView con `Origin: https://localhost` (por `androidScheme: 'https'`) — es decir, CORS sí aplica. Conclusión: o Render la tiene puesta a mano (y entonces **no está en el repo: hay que ir a leerla al dashboard antes de apagarlo**), o el APK está roto hoy. En cualquier caso, migrar copiando solo lo que dice `render.yaml` rompe la app. +→ **Fix:** setear explícitamente `CORS_ALLOWED_ORIGINS=https://localhost,capacitor://localhost,http://localhost`. + +**A5 — RDS PostgreSQL ≥15 fuerza TLS y el pool no lo pide.** +Los parameter groups por defecto de RDS PG15+ traen `rds.force_ssl=1`. `schema.ts:266` construye el Pool solo con `connectionString`. Si el string no lleva `sslmode`, la conexión se rechaza → 5 reintentos → `process.exit(1)` en producción → la tarea nunca pasa el health check y el deploy hace rollback en bucle. +→ **Fix:** `DATABASE_URL` con `?sslmode=require`. **Decisión consciente (Raúl, 2026-07-23):** `require` cifra pero **no verifica la identidad del servidor** — no protege contra MITM. Dentro de la VPC, con la BD sin IP pública y accesible solo desde el SG de la app, `require` es aceptable **como interino**. `verify-full` (que sí valida cert + hostname) queda agendado con gatillo explícito: **antes de fondos reales / mainnet** (ver Fase 9 y pregunta abierta #6). No se adopta ya porque un `verify-full` mal configurado bloquea la conexión, y el costo es embarcar el root CA de RDS una vez. + +**A6 — `trustProxy: true` detrás de un ALB permite falsear la IP.** +`src/index.ts:35` usa `trustProxy: true`, que hace que Fastify tome la entrada **más a la izquierda** de `X-Forwarded-For` — la que controla el cliente. Todos los rate limits por IP (`IP_RATE_LIMIT_MAX`, etc.) se evaden mandando una cabecera. Ya pasa en Render; el ALB no lo arregla. +→ **Fix (1 línea):** `trustProxy: 1` (un solo hop de proxy). + +### Medios + +**A7 — La matriz de secretos/env del v1 está incompleta.** Faltan `CORS_ALLOWED_ORIGINS`, `EVENT_LISTENER_ENABLED`, `XLM_MXN_FALLBACK`, `USDC_MXN_FALLBACK`, `KYC_GATE_ENABLED`, `KYC_LEVEL_EXPIRY_DAYS`, `CETES_ISSUER`, `BLEND_POOL_ID`, `JWT_EXPIRY`. Matriz completa en §5. + +**A8 — Rutas de webhook equivocadas.** §4.3 y §6 del v1 dicen `POST /ramp/webhook`. Las reales son `/defi/ramp/webhook/order` y `/defi/ramp/webhook/kyc`. Registrar la URL mal contra Etherfuse quema los secretos (se entregan una sola vez) y obliga a rehacer las suscripciones. + +**A9 — El criterio de aceptación `eventListenerHealthy: true` es inalcanzable con la config actual.** `EVENT_LISTENER_ENABLED` no está en `render.yaml` y su default es `false` (`config.ts:134`), así que `/health` reporta `eventListenerState: "disabled"`. O se habilita explícitamente en AWS, o el criterio correcto es `"disabled"`. + +**A10 — El despliegue rolling por defecto corre dos tareas a la vez.** ECS usa `maximumPercent=200 / minimumHealthyPercent=100`: durante cada deploy conviven la tarea vieja y la nueva. Eso reintroduce exactamente el doble-submit que §4.2 del v1 quería evitar, y además dos procesos corriendo migraciones simultáneamente. +→ **Fix:** `minimumHealthyPercent=0, maximumPercent=100` (deploy con ~40 s de corte, irrelevante sin usuarios). Alternativa sin corte: `pg_advisory_lock` en el sweep, el listener y `runMigrations()`. + +**A11 — Node 20 está EOL** (fin de mantenimiento abril 2026), y `.github/workflows/ci.yml` lo pinea en ambos jobs. Subir la imagen y el CI a `node:22` a la vez, para no construir con un runtime distinto al de producción. `npm ci` sí es válido: existe `micopay/backend/package-lock.json`. + +**A12 — Arranque lento vs. health check.** Con reintentos, `initPg()` puede tardar ~95 s antes de rendirse. `--health-check-grace-period-seconds 180` en el servicio ECS. + +### Bajos / informativos + +**A13 — "main no compila" ya no aplica, y el gate de CI ya existe.** Verificado hoy: `npm run build` pasa limpio en backend y frontend, y `.github/workflows/ci.yml` ya bloquea merges que no compilen. La tarea de Fase 0 no es *crear* CI sino *añadirle el `docker build`*, para que el contenedor no pueda romperse sin que nadie se entere. + +**A14 — `.env.production.local` pisa `api.micopay.app` con `micopay-api.onrender.com`.** Solo afecta a `npm run build:prod` (modo `production`), no a `build:mainnet`/`build:testnet`. Aun así, borrarlo o actualizarlo evita una sorpresa. + +**A15 — Rotación de secretos: no todos son rotables.** +- `JWT_SECRET`, `ADMIN_API_KEY`: rotar libremente (invalida sesiones activas, sin usuarios da igual). +- `SECRET_ENCRYPTION_KEY`: cifra los secretos HTLC **ya guardados**. Solo se puede regenerar si se arranca con BD limpia; si se copian datos de Render, tiene que ser bit a bit el mismo valor. +- `PLATFORM_SECRET_KEY`: es la hot wallet. "Rotar" = crear cuenta nueva y mover fondos + reapuntar el contrato. No es un cambio de env var. + +**A16 — `/health` es público y expone `configCheck`.** Solo booleanos, sin valores, pero es superficie innecesaria. Opcional: mover el detalle a `/health?verbose` con `ADMIN_API_KEY`. + +**A17 — `deletion-protection` bloquea el borrado.** Correcto tenerlo, pero recordar que hay que hacer `modify-db-instance --no-deletion-protection` antes de cualquier `delete-db-instance`. + +**A18 — Free tier: las cuentas nuevas ya no tienen 12 meses gratis, y el plan gratuito *termina* (no factura solo).** Desde julio 2025 AWS reemplazó el free tier de 12 meses por un "Free Plan" basado en créditos ($100 al registrarse + hasta $100 más por completar actividades). Dos matices que corrige Raúl (2026-07-23): +- **No es "se agotan los créditos y empieza a cobrar".** Cuando el Free Plan expira (6 meses o créditos agotados, lo que ocurra primero), la cuenta **se pausa/cierra** salvo que se haga *upgrade manual al Paid Plan*. Es decir, el modo de falla es una **caída sorpresa del servicio**, no una factura inesperada. +- **El "~4 meses" asume los $200 completos.** Con solo el crédito base de $100 y ~$44/mes, el runway real es **~2.3 meses**; con los $200, ~4.5. +→ **Mitigación (ver Fase 1):** activar el Paid Plan desde el día 1 (los créditos se consumen igual primero, pero se elimina el precipicio) **o** anotar la fecha exacta de expiración y poner un recordatorio ~2 semanas antes, además del Budget alert. + +**A19 — Hot wallet en su propia llave KMS (Raúl, 2026-07-23).** El plan v2 dejaba `PLATFORM_SECRET_KEY` sobre la llave gestionada por defecto (`aws/ssm`), igual que los demás secretos. Como es una hot wallet (quien la descifra controla los fondos), conviene aislarla en una **CMK dedicada** (`alias/micopay-hotwallet`): así el permiso de descifrado se gobierna por separado del resto, se audita de forma independiente en CloudTrail y se puede restringir a **solo** el execution role. Cuesta ~$1/mes por la llave. Implementado en Fase 2.3 (creación) y Fase 4.1 (grant de `kms:Decrypt` acotado a esa llave). Nota de precisión sobre el modelo de permisos: con `aws/ssm` el `kms:Decrypt` es implícito (lo da la key policy de la llave gestionada); con una CMK **hay que concederlo explícito** — que es exactamente lo que da el control que buscamos. + +### Lo que sobra sin usuarios reales + +Se eliminan del plan: §4.1 (custom domain en Render + esperar adopción del APK), §4.5 (estrategia de migración de datos), §6 completo (runbook de cutover con congelación de escrituras), el plan de rollback DNS, y el paso 7 del runbook (mantener Render vivo apuntando a RDS). El "cutover" se convierte en: desplegar en AWS → verificar → recompilar APK → apagar Render. + +## 4. Arquitectura destino (corregida) + +``` +APK ──HTTPS──> api.micopay.app (Route53 alias → ALB, cert ACM) + │ + ALB (subredes públicas, SG: 443 desde 0.0.0.0/0) + │ HTTP :3000, target-type ip + ECS Fargate 0.25 vCPU / 0.5 GB · desiredCount=1 + │ (subred pública, assignPublicIp=ENABLED → salida a internet + │ por el IGW, sin NAT Gateway) + RDS PostgreSQL 16 db.t4g.micro · no publicly accessible + SG: 5432 solo desde el SG de la tarea + +Secretos: SSM Parameter Store SecureString → task definition (`secrets`) +Logs: CloudWatch /ecs/micopay-backend +CI/CD: GitHub Actions (OIDC, sin llaves largas) → ECR → ECS deploy +``` + +**Por qué Fargate y no App Runner:** por A3 (CPU en reposo) y A2 (NAT). Fargate en subred pública con IP pública tiene salida a internet sin NAT, la CPU está asignada siempre — los jobs se comportan igual que en Render, sin cambios de código — y es el mismo contenedor que ya se necesitaría para App Runner. El costo del ALB (~$17/mes) es el precio de no tocar el código de los jobs y de no pagar NAT. + +**Región `us-east-1`.** La latencia CDMX↔Virginia (~60–80 ms) es irrelevante frente a Soroban RPC y SPEI. Si algún día se exige residencia en México, el mismo task definition se mueve a `mx-central-1`. + +**Descartadas:** App Runner (A2/A3), Lightsail (sin camino de crecimiento ni secretos gestionados), Elastic Beanstalk (legacy), EC2 pelón (todo el mantenimiento a mano). + +**IaC:** consola/CLI + este documento como runbook es aceptable en el primer pase. Terraform es deseable pero no bloqueante — no retrasar la migración por él. Los comandos de §6 están escritos para poder traducirse 1:1 después. + +## 5. Matriz completa de configuración + +Verificada con `grep -o 'process\.env\.[A-Z0-9_]*' src/` sobre el backend. Todo lo `SecureString` va a SSM; lo `plain` va inline en la task definition. + +| Variable | Tipo | Valor en AWS | +|---|---|---| +| `NODE_ENV` | plain | `production` | +| `PORT` | plain | `3000` | +| `DATABASE_URL` | **SecureString** | `postgresql://micopay:PASS@:5432/micopay?sslmode=require` (A5) | +| `CORS_ALLOWED_ORIGINS` | plain | `https://localhost,capacitor://localhost,http://localhost` (A4) | +| `STELLAR_RPC_URL` | plain | `https://soroban-testnet.stellar.org` | +| `STELLAR_NETWORK` | plain | `TESTNET` | +| `MOCK_STELLAR` | plain | `false` | +| `ESCROW_CONTRACT_ID` | plain | `CB4M5777YFQWKGDUULCX5W6PXEDJSJARDTMH4VV6FXC4W4UPANALO3HZ` | +| `MXNE_CONTRACT_ID` | plain | `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` | +| `MXNE_ISSUER_ADDRESS` | plain | `GBZXN7PIRZGNMHGA7MUUUF4GWMTISGNQ5E72TFL6GDWPE6K4RCAVOALV` | +| `ETHERFUSE_API_URL` | plain | `https://api.sand.etherfuse.com` (prod: `https://api.etherfuse.com`) | +| `CETES_ISSUER` | plain | dejar default o fijar explícito | +| `BLEND_POOL_ID` | plain | dejar default o fijar explícito | +| `JWT_EXPIRY` | plain | `24h` | +| `EVENT_LISTENER_ENABLED` | plain | decidir: `false` (paridad con Render) o `true` (y entonces A9 aplica) | +| `KYC_GATE_ENABLED` | plain | `false` hasta el dictamen legal (Fase 4 de compliance) | +| `XLM_MXN_FALLBACK`, `USDC_MXN_FALLBACK` | plain | copiar de Render si están puestos | +| `PLATFORM_SECRET_KEY` | **SecureString (CMK propia)** | mismo valor; sobre `alias/micopay-hotwallet`, no la llave default (hot wallet, A15/A19) | +| `JWT_SECRET` | **SecureString** | regenerar | +| `SECRET_ENCRYPTION_KEY` | **SecureString** | mismo valor si se copian datos; nuevo si BD limpia (A15) | +| `ETHERFUSE_API_KEY` | **SecureString** | mismo valor | +| `ETHERFUSE_WEBHOOK_SECRET_ORDER` | **SecureString** | **nuevo** (re-registro de suscripción, A8) | +| `ETHERFUSE_WEBHOOK_SECRET_KYC` | **SecureString** | **nuevo** (re-registro de suscripción, A8) | +| `ADMIN_API_KEY` | **SecureString** | regenerar | +| `FIREBASE_PROJECT_ID` / `FIREBASE_CLIENT_EMAIL` | plain / plain | copiar | +| `FIREBASE_PRIVATE_KEY` | **SecureString** | copiar **con los `\n` escapados** — `push.service.ts:51` hace `.replace(/\\n/g,'\n')` | +| `ALLOW_IN_MEMORY_DB` | — | **NO definir** (B-3: fail-fast si no hay BD) | +| `SEED_DEMO_DATA` | — | **NO definir** en prod | + +> **Antes de apagar Render:** exportar el listado completo de env vars del dashboard y diffearlo contra esta tabla. Es la única forma de cazar variables puestas a mano que no están en `render.yaml` (A4 es exactamente ese caso). + +--- + +## 6. Pasos de ejecución + +Los comandos están en PowerShell (shell primario en esta máquina). En bash, cambiar `$Var = "x"` por `Var=x` y `$Var` por `$Var` sin más. Todo asume `--region us-east-1` y un perfil `micopay` ya configurado. + +### Fase 0 — Contenedor y CI (local, sin tocar AWS) · ~½ día + +**0.1 — `micopay/backend/Dockerfile`** ✅ *creado* + +Contexto de build `micopay/` (no `micopay/backend/`) por A1. + +```dockerfile +# syntax=docker/dockerfile:1 +# Contexto de build: micopay/ (necesario para copiar sql/, ver migrate.ts:20) +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 + +FROM node:22-bookworm-slim AS deps +WORKDIR /app/backend +COPY backend/package.json backend/package-lock.json ./ +RUN npm ci --omit=dev + +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 +COPY backend/package.json ./ +COPY backend/public ./public +# migrate.ts resuelve ../../../sql desde dist/db → /app/sql +COPY sql /app/sql +USER node +EXPOSE 3000 +CMD ["node", "dist/index.js"] +``` + +**0.2 — `micopay/.dockerignore`** ✅ *creado* + +``` +**/node_modules +**/dist +**/.env +**/.env.* +frontend +contracts +scripts +android +ios +**/*.md +``` + +**0.3 — Build y smoke test local** ✅ *ejecutado y verde el 2026-07-22* + +Resultados: imagen de **511 MB**; `/app/sql` con `init.sql` + 25 archivos; `/health` responde `status:"ok"` a los ~25 s (el retry loop de `initPg()` domina el arranque — A12); `assetlinks.json` en 200 `application/json`. Contra un `postgres:16` real: `dbConnected: true` y **16 migraciones aplicadas desde `/app/sql`** (init.sql + 15 `.up`, los 10 `.down` correctamente omitidos), 18 tablas creadas. A1 queda cerrado con evidencia, no por inspección. + +```powershell +docker build -f micopay/backend/Dockerfile -t micopay-backend:local micopay +# Verificar A1: los SQL tienen que estar en /app/sql +docker run --rm --entrypoint ls micopay-backend:local /app/sql +docker run --rm --entrypoint ls micopay-backend:local /app/sql/migrations +``` + +Arranque en modo mock (`NODE_ENV=test` porque `validateConfig()` prohíbe `MOCK_STELLAR=true` con `NODE_ENV=production`): + +```powershell +docker run --rm -p 3000:3000 -e NODE_ENV=test -e MOCK_STELLAR=true -e ALLOW_IN_MEMORY_DB=true -e PORT=3000 -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 micopay-backend:local +``` + +En otra terminal: `curl http://localhost:3000/health` → `status: "ok"`; `curl http://localhost:3000/.well-known/assetlinks.json` → 200 `application/json`. + +Prueba completa con BD real (es la que de verdad valida A1, porque `runMigrations()` solo corre si `pingDb()` conecta): + +```powershell +docker network create micopay-test +docker run -d --name micopay-pg --network micopay-test -e POSTGRES_PASSWORD=dev -e POSTGRES_DB=micopay postgres:16 +docker run -d --name micopay-smoke --network micopay-test -p 3000:3000 -e NODE_ENV=test -e MOCK_STELLAR=true -e PORT=3000 -e "DATABASE_URL=postgresql://postgres:dev@micopay-pg:5432/micopay" -e SECRET_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 micopay-backend:local +docker logs micopay-smoke # esperar "🎉 Migrations complete (16 applied this run)." +curl http://localhost:3000/health # dbConnected: true +docker rm -f micopay-smoke micopay-pg; docker network rm micopay-test +``` + +**0.4 — Aplicar el fix de A6** en `micopay/backend/src/index.ts:35`: `trustProxy: true` → `trustProxy: 1`. + +**0.5 — CI extendido** ✅ *hecho parcialmente* + +`.github/workflows/ci.yml` ya tiene un job `image` que en cada PR construye la imagen, **verifica que `/app/sql` existe dentro** (regresión de A1) y arranca el contenedor en modo mock para comprobar `/health` y `assetlinks.json`. + +Pendiente: subir `node-version` de 20 a 22 en los jobs `backend` y `frontend`, para que el CI compile con el mismo runtime que corre en producción (A11). Se dejó aparte por ser un cambio con riesgo propio. + +**0.6 — Confirmar el dominio.** `micopay.app` (o el que sea) registrado y con NS controlables. Todo lo demás cuelga de esto. + +--- + +### Fase 1 — Cuenta y base AWS · ~2 h + +```powershell +# Tras crear la cuenta: MFA en root, usuario admin vía IAM Identity Center, nunca operar como root. +aws configure --profile micopay # región us-east-1, output json +aws sts get-caller-identity --profile micopay +``` + +- Budget con alerta a $60/mes + alerta de anomalías de costo (Billing → Budgets). +- **Free tier — evitar el precipicio (A18).** Al abrir la cuenta, en Billing → Free Tier anotar el **plan** (Free vs Paid) y la **fecha de expiración** del Free Plan. Decidir explícitamente: + - **Recomendado:** hacer upgrade al Paid Plan de una vez. Los créditos se siguen consumiendo primero, pero el servicio ya no se cae solo cuando el Free Plan termine. + - Si se prefiere quedarse en Free Plan: poner un recordatorio de calendario ~2 semanas **antes** de la fecha de expiración ("upgrade a Paid o AWS pausa la cuenta"). El Budget alert **no** cubre esto — avisa de gasto, no de expiración del plan. + +Variables base para el resto de fases: + +```powershell +$env:AWS_PROFILE = "micopay"; $env:AWS_DEFAULT_REGION = "us-east-1" +$Acct = (aws sts get-caller-identity --query Account --output text) +$Vpc = (aws ec2 describe-vpcs --filters Name=isDefault,Values=true --query "Vpcs[0].VpcId" --output text) +$Subnets = (aws ec2 describe-subnets --filters Name=vpc-id,Values=$Vpc --query "Subnets[].SubnetId" --output text) -split "\s+" +$SubnetA = $Subnets[0]; $SubnetB = $Subnets[1] +"$Acct / $Vpc / $SubnetA,$SubnetB" +``` + +--- + +### Fase 2 — Red y base de datos · ~1 h (+15 min de espera de RDS) + +**2.1 — Security groups** + +```powershell +$AlbSg = (aws ec2 create-security-group --group-name micopay-alb --description "ALB publico" --vpc-id $Vpc --query GroupId --output text) +$AppSg = (aws ec2 create-security-group --group-name micopay-app --description "Tarea Fargate" --vpc-id $Vpc --query GroupId --output text) +$DbSg = (aws ec2 create-security-group --group-name micopay-db --description "RDS privado" --vpc-id $Vpc --query GroupId --output text) + +aws ec2 authorize-security-group-ingress --group-id $AlbSg --protocol tcp --port 443 --cidr 0.0.0.0/0 +aws ec2 authorize-security-group-ingress --group-id $AlbSg --protocol tcp --port 80 --cidr 0.0.0.0/0 +aws ec2 authorize-security-group-ingress --group-id $AppSg --protocol tcp --port 3000 --source-group $AlbSg +aws ec2 authorize-security-group-ingress --group-id $DbSg --protocol tcp --port 5432 --source-group $AppSg +``` + +**2.2 — RDS PostgreSQL 16** + +```powershell +aws rds create-db-subnet-group --db-subnet-group-name micopay-db-subnets --db-subnet-group-description "MicoPay" --subnet-ids $SubnetA $SubnetB + +# Password sin caracteres que rompan el URL (hex puro) +$b = New-Object byte[] 24; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($b) +$DbPass = ($b | ForEach-Object { '{0:x2}' -f $_ }) -join '' + +# Elegir la última minor de PG16 disponible hoy +aws rds describe-db-engine-versions --engine postgres --query "DBEngineVersions[?starts_with(EngineVersion,'16.')].EngineVersion" --output text + +aws rds create-db-instance --db-instance-identifier micopay-prod --engine postgres --engine-version 16.9 --db-instance-class db.t4g.micro --allocated-storage 20 --storage-type gp3 --storage-encrypted --master-username micopay --master-user-password $DbPass --db-name micopay --db-subnet-group-name micopay-db-subnets --vpc-security-group-ids $DbSg --backup-retention-period 7 --no-publicly-accessible --no-multi-az --auto-minor-version-upgrade --deletion-protection + +aws rds wait db-instance-available --db-instance-identifier micopay-prod +$DbHost = (aws rds describe-db-instances --db-instance-identifier micopay-prod --query "DBInstances[0].Endpoint.Address" --output text) +$DbUrl = "postgresql://micopay:$DbPass@${DbHost}:5432/micopay?sslmode=require" # sslmode=require es obligatorio (A5) +``` + +> Sin acceso público, para correr SQL a mano hace falta entrar desde dentro de la VPC (`aws ecs execute-command` a la tarea, una vez exista) o exponer temporalmente la instancia y revertir. No dejar `--publicly-accessible` puesto. + +**2.3 — Secretos en SSM** + +```powershell +function New-Hex($n) { $x = New-Object byte[] $n; [System.Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($x); ($x | ForEach-Object { '{0:x2}' -f $_ }) -join '' } +$Jwt = New-Hex 32 # >= 32 chars, exigido por validateConfig() +$Admin = New-Hex 24 +$Enc = New-Hex 32 # EXACTAMENTE 64 hex chars (AES-256-GCM) — ver A15 antes de regenerar + +# Los secretos "normales" van sobre la llave gestionada por defecto (aws/ssm): +aws ssm put-parameter --name /micopay/prod/DATABASE_URL --type SecureString --value $DbUrl --overwrite +aws ssm put-parameter --name /micopay/prod/JWT_SECRET --type SecureString --value $Jwt --overwrite +aws ssm put-parameter --name /micopay/prod/ADMIN_API_KEY --type SecureString --value $Admin --overwrite +aws ssm put-parameter --name /micopay/prod/SECRET_ENCRYPTION_KEY --type SecureString --value $Enc --overwrite +aws ssm put-parameter --name /micopay/prod/ETHERFUSE_API_KEY --type SecureString --value "..." --overwrite # de Render +aws ssm put-parameter --name /micopay/prod/FIREBASE_PRIVATE_KEY --type SecureString --value "-----BEGIN PRIVATE KEY-----\n..." --overwrite + +# La hot wallet va sobre su PROPIA llave KMS (CMK), no la default — ver A19. +# Esto separa el permiso de descifrado de la hot wallet del resto de secretos. +$HotKeyId = (aws kms create-key --description "micopay hot wallet (PLATFORM_SECRET_KEY)" --query "KeyMetadata.KeyId" --output text) +aws kms create-alias --alias-name alias/micopay-hotwallet --target-key-id $HotKeyId +aws ssm put-parameter --name /micopay/prod/PLATFORM_SECRET_KEY --type SecureString --key-id $HotKeyId --value "S..." --overwrite # de Render +# Los dos ETHERFUSE_WEBHOOK_SECRET_* se cargan en la Fase 7, tras re-registrar las suscripciones. +``` + +--- + +### Fase 3 — Imagen en ECR · ~30 min + +```powershell +aws ecr create-repository --repository-name micopay-backend --image-scanning-configuration scanOnPush=true +$Registry = "$Acct.dkr.ecr.us-east-1.amazonaws.com" +aws ecr get-login-password | docker login --username AWS --password-stdin $Registry +# Si PowerShell rompe el pipe por el BOM: +# docker login -u AWS -p (aws ecr get-login-password) $Registry + +docker build --platform linux/amd64 -f micopay/backend/Dockerfile -t "$Registry/micopay-backend:v1" -t "$Registry/micopay-backend:latest" micopay +docker push "$Registry/micopay-backend:v1" +docker push "$Registry/micopay-backend:latest" +``` + +`--platform linux/amd64` importa si alguien construye desde un Mac Apple Silicon; el task definition se declara `X86_64`. + +--- + +### Fase 4 — Roles IAM · ~30 min + +**4.1 — Execution role** (ECS lo usa para bajar la imagen, escribir logs y **resolver los secretos de SSM** — este último permiso es el que se olvida y produce `ResourceInitializationError`): + +```powershell +'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ecs-tasks.amazonaws.com"},"Action":"sts:AssumeRole"}]}' | Out-File -Encoding ascii trust-ecs.json +aws iam create-role --role-name micopayEcsExecutionRole --assume-role-policy-document file://trust-ecs.json +aws iam attach-role-policy --role-name micopayEcsExecutionRole --policy-arn arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy + +$HotKeyArn = (aws kms describe-key --key-id alias/micopay-hotwallet --query "KeyMetadata.Arn" --output text) +@" +{"Version":"2012-10-17","Statement":[ + {"Effect":"Allow","Action":["ssm:GetParameters"],"Resource":"arn:aws:ssm:us-east-1:$Acct`:parameter/micopay/prod/*"}, + {"Effect":"Allow","Action":["kms:Decrypt"],"Resource":"$HotKeyArn"} +]} +"@ | Out-File -Encoding ascii ssm-read.json +aws iam put-role-policy --role-name micopayEcsExecutionRole --policy-name ssm-read --policy-document file://ssm-read.json +``` + +> **Sobre `kms:Decrypt` (A19).** Los secretos sobre la llave gestionada `aws/ssm` **no** necesitan `kms:Decrypt` explícito: su key policy ya lo concede a la cuenta cuando la llamada pasa por SSM. Por eso el statement de arriba solo referencia la CMK de la hot wallet — que **sí** lo exige, y ese es justo el punto: el descifrado de `PLATFORM_SECRET_KEY` queda gobernado por una llave separada, auditable de forma independiente en CloudTrail, y accesible **solo** por este execution role. Verificable con `aws kms get-key-policy` (nadie más debe tener `Decrypt` sobre `alias/micopay-hotwallet`) — el task role, que corre tu código, no puede descifrarla. + +**4.2 — Task role** (permisos del proceso en runtime). Hoy el backend no llama a ninguna API de AWS, así que basta un rol vacío; se crea igual para poder añadir `ssmmessages:*` y usar `aws ecs execute-command`: + +```powershell +aws iam create-role --role-name micopayEcsTaskRole --assume-role-policy-document file://trust-ecs.json +aws iam attach-role-policy --role-name micopayEcsTaskRole --policy-arn arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore +``` + +--- + +### Fase 5 — Certificado, ALB y DNS · ~1 h + +```powershell +$CertArn = (aws acm request-certificate --domain-name api.micopay.app --validation-method DNS --query CertificateArn --output text) +aws acm describe-certificate --certificate-arn $CertArn --query "Certificate.DomainValidationOptions[0].ResourceRecord" +``` + +Crear ese CNAME en el DNS del dominio (si `micopay.app` aún no está en Route53: `aws route53 create-hosted-zone --name micopay.app --caller-reference (Get-Date -Format o)` y apuntar los NS en el registrador). Después: + +```powershell +aws acm wait certificate-validated --certificate-arn $CertArn + +$AlbArn = (aws elbv2 create-load-balancer --name micopay-alb --type application --scheme internet-facing --subnets $SubnetA $SubnetB --security-groups $AlbSg --query "LoadBalancers[0].LoadBalancerArn" --output text) + +$TgArn = (aws elbv2 create-target-group --name micopay-tg --protocol HTTP --port 3000 --vpc-id $Vpc --target-type ip --health-check-path /health --health-check-interval-seconds 15 --health-check-timeout-seconds 5 --healthy-threshold-count 2 --unhealthy-threshold-count 3 --matcher HttpCode=200 --query "TargetGroups[0].TargetGroupArn" --output text) + +aws elbv2 create-listener --load-balancer-arn $AlbArn --protocol HTTPS --port 443 --certificates CertificateArn=$CertArn --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 --default-actions Type=forward,TargetGroupArn=$TgArn +aws elbv2 create-listener --load-balancer-arn $AlbArn --protocol HTTP --port 80 --default-actions "Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}" +``` + +Registro alias `api.micopay.app` → ALB: + +```powershell +$AlbDns = (aws elbv2 describe-load-balancers --load-balancer-arns $AlbArn --query "LoadBalancers[0].DNSName" --output text) +$AlbZone = (aws elbv2 describe-load-balancers --load-balancer-arns $AlbArn --query "LoadBalancers[0].CanonicalHostedZoneId" --output text) +$Zone = (aws route53 list-hosted-zones-by-name --dns-name micopay.app --query "HostedZones[0].Id" --output text).Split('/')[-1] +@" +{"Changes":[{"Action":"UPSERT","ResourceRecordSet":{"Name":"api.micopay.app","Type":"A","AliasTarget":{"HostedZoneId":"$AlbZone","DNSName":"$AlbDns","EvaluateTargetHealth":true}}}]} +"@ | Out-File -Encoding ascii dns.json +aws route53 change-resource-record-sets --hosted-zone-id $Zone --change-batch file://dns.json +``` + +> El health check del target group (`/health`) devuelve 503 si la BD está caída (`index.ts:185`). Eso es lo correcto: la tarea sale de rotación en vez de servir errores. + +--- + +### Fase 6 — Servicio ECS · ~1 h + +```powershell +aws ecs create-cluster --cluster-name micopay +aws logs create-log-group --log-group-name /ecs/micopay-backend +aws logs put-retention-policy --log-group-name /ecs/micopay-backend --retention-in-days 30 +``` + +`taskdef.json` (sustituir ``; los `secrets` referencian ARNs de SSM, los `environment` van en claro): + +```json +{ + "family": "micopay-backend", + "requiresCompatibilities": ["FARGATE"], + "networkMode": "awsvpc", + "cpu": "256", + "memory": "512", + "runtimePlatform": { "cpuArchitecture": "X86_64", "operatingSystemFamily": "LINUX" }, + "executionRoleArn": "arn:aws:iam:::role/micopayEcsExecutionRole", + "taskRoleArn": "arn:aws:iam:::role/micopayEcsTaskRole", + "containerDefinitions": [ + { + "name": "api", + "image": ".dkr.ecr.us-east-1.amazonaws.com/micopay-backend:v1", + "essential": true, + "portMappings": [{ "containerPort": 3000, "protocol": "tcp" }], + "environment": [ + { "name": "NODE_ENV", "value": "production" }, + { "name": "PORT", "value": "3000" }, + { "name": "CORS_ALLOWED_ORIGINS", "value": "https://localhost,capacitor://localhost,http://localhost" }, + { "name": "STELLAR_RPC_URL", "value": "https://soroban-testnet.stellar.org" }, + { "name": "STELLAR_NETWORK", "value": "TESTNET" }, + { "name": "MOCK_STELLAR", "value": "false" }, + { "name": "ESCROW_CONTRACT_ID", "value": "CB4M5777YFQWKGDUULCX5W6PXEDJSJARDTMH4VV6FXC4W4UPANALO3HZ" }, + { "name": "MXNE_CONTRACT_ID", "value": "CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC" }, + { "name": "MXNE_ISSUER_ADDRESS", "value": "GBZXN7PIRZGNMHGA7MUUUF4GWMTISGNQ5E72TFL6GDWPE6K4RCAVOALV" }, + { "name": "ETHERFUSE_API_URL", "value": "https://api.sand.etherfuse.com" }, + { "name": "EVENT_LISTENER_ENABLED", "value": "false" }, + { "name": "KYC_GATE_ENABLED", "value": "false" }, + { "name": "JWT_EXPIRY", "value": "24h" }, + { "name": "FIREBASE_PROJECT_ID", "value": "<...>" }, + { "name": "FIREBASE_CLIENT_EMAIL", "value": "<...>" } + ], + "secrets": [ + { "name": "DATABASE_URL", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/DATABASE_URL" }, + { "name": "JWT_SECRET", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/JWT_SECRET" }, + { "name": "SECRET_ENCRYPTION_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/SECRET_ENCRYPTION_KEY" }, + { "name": "PLATFORM_SECRET_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/PLATFORM_SECRET_KEY" }, + { "name": "ADMIN_API_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/ADMIN_API_KEY" }, + { "name": "ETHERFUSE_API_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/ETHERFUSE_API_KEY" }, + { "name": "ETHERFUSE_WEBHOOK_SECRET_ORDER", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/ETHERFUSE_WEBHOOK_SECRET_ORDER" }, + { "name": "ETHERFUSE_WEBHOOK_SECRET_KYC", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/ETHERFUSE_WEBHOOK_SECRET_KYC" }, + { "name": "FIREBASE_PRIVATE_KEY", "valueFrom": "arn:aws:ssm:us-east-1::parameter/micopay/prod/FIREBASE_PRIVATE_KEY" } + ], + "logConfiguration": { + "logDriver": "awslogs", + "options": { + "awslogs-group": "/ecs/micopay-backend", + "awslogs-region": "us-east-1", + "awslogs-stream-prefix": "api" + } + } + } + ] +} +``` + +> Ni `ALLOW_IN_MEMORY_DB` ni `SEED_DEMO_DATA` aparecen: su ausencia es intencional (§5). + +```powershell +aws ecs register-task-definition --cli-input-json file://taskdef.json + +aws ecs create-service --cluster micopay --service-name micopay-backend --task-definition micopay-backend --desired-count 1 --launch-type FARGATE --platform-version LATEST --network-configuration "awsvpcConfiguration={subnets=[$SubnetA,$SubnetB],securityGroups=[$AppSg],assignPublicIp=ENABLED}" --load-balancers "targetGroupArn=$TgArn,containerName=api,containerPort=3000" --health-check-grace-period-seconds 180 --deployment-configuration "maximumPercent=100,minimumHealthyPercent=0" --enable-execute-command +``` + +Las dos decisiones no obvias de ese comando: +- `assignPublicIp=ENABLED` en subred pública → salida a internet por el IGW **sin NAT Gateway** (evita A2 y ~$32/mes). +- `maximumPercent=100, minimumHealthyPercent=0` → nunca hay dos tareas vivas a la vez (A10). Cuesta ~40 s de corte por deploy. + +**Verificación:** + +```powershell +aws ecs wait services-stable --cluster micopay --services micopay-backend +aws elbv2 describe-target-health --target-group-arn $TgArn --query "TargetHealthDescriptions[].TargetHealth.State" +curl https://api.micopay.app/health +curl https://api.micopay.app/.well-known/assetlinks.json +aws logs tail /ecs/micopay-backend --since 10m +``` + +Criterios de salida: `/health` → 200 con `dbConnected: true`, `mockStellar: false`, `configCheck` todo en `true`; en logs, `✅ apply` de las migraciones y `🍄 Micopay MVP Backend running`; `eventListenerState` = `"disabled"` (o `"healthy"` si se habilitó, A9); `assetlinks.json` en 200 con `Content-Type: application/json`. + +Prueba funcional: `cd micopay/backend; $env:API_URL="https://api.micopay.app"; npm run e2e`. + +--- + +### Fase 7 — Etherfuse, APK y apagado de Render · ~½ día + +**7.1 — Re-registrar los webhooks** contra las URLs reales (A8): +`https://api.micopay.app/defi/ramp/webhook/order` y `https://api.micopay.app/defi/ramp/webhook/kyc`. +Cada suscripción entrega su secreto **una sola vez**: capturarlo al crearla y cargarlo antes de nada. + +```powershell +aws ssm put-parameter --name /micopay/prod/ETHERFUSE_WEBHOOK_SECRET_ORDER --type SecureString --value "" --overwrite +aws ssm put-parameter --name /micopay/prod/ETHERFUSE_WEBHOOK_SECRET_KYC --type SecureString --value "" --overwrite +aws ecs update-service --cluster micopay --service micopay-backend --force-new-deployment +``` + +Después, disparar una orden en el sandbox de Etherfuse y confirmar en logs que la firma valida y la orden se procesa e2e — esto además cierra la verificación pendiente del flujo order/webhook. + +**7.2 — Recompilar el APK.** En `micopay/frontend/.env.mainnet` y `.env.testnet`: `VITE_API_URL=https://api.micopay.app`. Borrar o corregir `.env.production.local` (A14). Luego `npm run build:testnet && npx cap sync android` y build del APK. Instalar y probar login → trade → QR. + +**7.3 — Datos (opcional).** Si conviene conservar el contenido de la BD de Render: + +```bash +pg_dump --no-owner --no-acl "" | psql "" +``` + +Requiere acceso de red a RDS (ver nota de la Fase 2.2) y que `SECRET_ENCRYPTION_KEY` sea el mismo valor que en Render (A15). Sin usuarios reales, arrancar limpio suele ser la mejor opción. + +**7.4 — Apagar Render.** Antes de borrar nada: exportar los env vars del dashboard y diffear contra §5. Luego borrar servicio y BD, eliminar el bloque `micopay-backend` de `render.yaml` (o el archivo entero, ya que `micopay-api` tampoco está desplegado) y actualizar las notas de docs/memoria sobre Render. + +--- + +### Fase 8 — CI/CD con OIDC · ~2 h + +```powershell +aws iam create-open-id-connect-provider --url https://token.actions.githubusercontent.com --client-id-list sts.amazonaws.com +``` + +Rol `micopayGithubDeploy` con trust en `repo:Micopay/micopay-protocol:ref:refs/heads/main` y permisos de `ecr:*` sobre el repositorio, `ecs:RegisterTaskDefinition`, `ecs:UpdateService`, `ecs:DescribeServices` e `iam:PassRole` sobre los dos roles de tarea. Workflow `.github/workflows/deploy.yml`: + +```yaml +name: deploy +on: + push: + branches: [main] +permissions: { id-token: write, contents: read } +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: arn:aws:iam:::role/micopayGithubDeploy + aws-region: us-east-1 + - uses: aws-actions/amazon-ecr-login@v2 + id: ecr + - name: build & push + run: | + IMG=${{ steps.ecr.outputs.registry }}/micopay-backend:${{ github.sha }} + docker build --platform linux/amd64 -f micopay/backend/Dockerfile -t $IMG micopay + docker push $IMG + echo "IMAGE=$IMG" >> $GITHUB_ENV + - uses: aws-actions/amazon-ecs-render-task-definition@v1 + id: td + with: + task-definition: taskdef.json + container-name: api + image: ${{ env.IMAGE }} + - uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + with: + task-definition: ${{ steps.td.outputs.task-definition }} + service: micopay-backend + cluster: micopay + wait-for-service-stability: true +``` + +Versionar `taskdef.json` en el repo (sin secretos: solo ARNs de SSM). + +--- + +### Fase 9 — Endurecimiento post-deploy · ~1 semana de soak + +- **Alarmas CloudWatch:** `UnHealthyHostCount > 0` en el target group, `HTTPCode_Target_5XX_Count`, CPU/memoria de ECS, `FreeStorageSpace` y `CPUUtilization` de RDS. Todas a un SNS topic con tu email. +- **Cortar dev de la BD de prod.** `DATABASE_URL` de desarrollo → Postgres local (`docker run -p 5432:5432 -e POSTGRES_PASSWORD=dev postgres:16`) + `npm run migrate`. El endpoint de RDS no debe existir en máquinas de desarrollo. +- **Rotar** `JWT_SECRET` y `ADMIN_API_KEY` si estuvieron alguna vez en el dashboard de Render. Sobre `SECRET_ENCRYPTION_KEY` y `PLATFORM_SECRET_KEY`, leer A15 antes de tocar nada. +- **TLS estricto — gatillo: antes de fondos reales / mainnet (A5, Raúl).** Hasta aquí `DATABASE_URL` usa `sslmode=require` (cifra, no autentica al servidor). Pasar a `verify-full`: descargar el **CA bundle global** de RDS (`https://truststore.pki.rds.amazonaws.com/global/global-bundle.pem`), embarcarlo en la imagen y usar `sslmode=verify-full&sslrootcert=/app/rds-global-bundle.pem`. El bundle global cubre la rotación automática de la CA moderna, así que es una sola vez. Si algún día se conecta por un CNAME propio en vez del endpoint de RDS, ese hostname tiene que coincidir con el cert o `verify-full` rompe. +- **Escalar >1 instancia** requiere primero `pg_advisory_lock` en `startRefundSweep()`, el event listener y `runMigrations()`. Hasta entonces, `desiredCount` se queda en 1. +- **Backups — hacer un drill de restauración de verdad (Raúl, 2026-07-23).** Salimos de Render precisamente por perder datos; un backup nunca restaurado no sabes si sirve. Ejecutar el runbook de §11 al menos una vez y cronometrarlo. Punto clave que cambia la expectativa: **restaurar en RDS crea una instancia NUEVA con endpoint NUEVO** — no es un botón de "revertir", es "levantar instancia + repuntar la app" (por eso el runbook toca RDS → SSM → ECS, no solo RDS). Opcional: AWS Backup → Restore Testing lo automatiza de forma recurrente. +- Actualizar `README`, `docs/AUDIT_MOBILE_MAINNET.md` §hosting y este documento a estado "ejecutado". + +--- + +## 7. Costos estimados (us-east-1, mensual) + +| Recurso | Config | Costo aprox. | +|---|---|---| +| ECS Fargate | 0.25 vCPU / 0.5 GB, 1 tarea 24/7 | ~$9 | +| ALB | 1 ALB + LCU mínimo | ~$17 | +| RDS PostgreSQL | db.t4g.micro single-AZ + 20 GB gp3 + backups | ~$14 | +| Route53 | hosted zone + queries | ~$1 | +| ECR, CloudWatch, transferencia | volúmenes de este tamaño | ~$2 | +| SSM Parameter Store | estándar | $0 | +| KMS CMK (hot wallet, A19) | 1 llave | ~$1 | +| **Total** | | **~$44/mes** | + +Comparación con lo que proponía el v1: App Runner (~$14) + NAT Gateway obligatorio (~$32) + RDS (~$15) = **~$61/mes** *y* con los jobs en riesgo (A3). Fargate + ALB sale más barato y correcto. + +Sobre free tier: no asumir 12 meses gratis (A18). Con ~$44/mes, los créditos dan **~2.3 meses** (solo el crédito base de $100) a **~4.5 meses** (los $200 completos, si se hacen las actividades). Y ojo: al terminar el Free Plan la cuenta **se pausa** si no se hizo upgrade a Paid — no es una factura, es una caída. Decidir el plan el día 1 (Fase 1). + +## 8. Qué NO cambia + +- Contratos Stellar, RPC endpoints, flujo Etherfuse (una vez re-registrados los webhooks). +- El frontend solo cambia `VITE_API_URL`. +- Código de aplicación: **un solo cambio obligatorio**, `trustProxy: 1` (A6, 1 línea). El Dockerfile y el workflow son aditivos. Las migraciones ya corren en boot. + +## 9. Variante App Runner (si se prefiere sobre Fargate) + +Es viable, pero deja de ser "cero código". Deltas respecto de §6: + +1. **No** usar VPC connector; en su lugar, poner RDS `--publicly-accessible` con SG restringido — **inaceptable** para la BD de una hot wallet. La alternativa correcta es VPC connector + NAT Gateway (+$32/mes). +2. Sacar el refund sweep del proceso: exponerlo como ruta admin (reutilizando `assertAdmin` de `src/routes/admin.ts:8`) y dispararla con EventBridge Scheduler cada 5 min. `sweepPendingRefunds()` ya filtra por `release_tx_hash IS NULL`, así que es casi idempotente; añadir `pg_advisory_lock` para evitar solapes. +3. Lo mismo para el event listener si se habilita: su cursor ya se persiste en BD (`src/db/event-cursor.model.ts`), así que un "tick" programado funciona; hay que añadirle un modo de una sola pasada. +4. App Runner despliega por tag fijo: el CI tiene que pushear `:latest` además de `:sha`. + +Solo tiene sentido si se prioriza no operar un ALB por encima de mantener los jobs tal como están. + +## 10. Preguntas abiertas + +1. ¿`micopay.app` está registrado y quién controla los NS? Todo cuelga de esto (Fase 0.6). +2. ¿`CORS_ALLOWED_ORIGINS` está seteado a mano en el dashboard de Render? Determina si A4 es un bug latente o algo que ya estaba resuelto fuera del repo. +3. ¿Contra qué URL están registradas hoy las suscripciones de webhook de Etherfuse? +4. ¿`EVENT_LISTENER_ENABLED` debe quedar en `false` (paridad con Render) o se aprovecha la migración para habilitarlo? +5. ¿Se conservan los datos de Render o se arranca limpio? (define si `SECRET_ENCRYPTION_KEY` se regenera o se copia — A15). +6. **Gatillo de `verify-full` (A5, Raúl):** ¿lo atamos a "antes de la primera operación con fondos reales / mainnet", o a una fecha? Hasta entonces `require` queda como interino consciente. +7. **Free Plan (A18, Raúl):** ¿upgrade a Paid Plan el día 1 (sin precipicio) o quedarse en Free con recordatorio de expiración? + +--- + +## 11. Runbook de restauración de la BD (drill + emergencia) + +Adoptado de la propuesta de Raúl (2026-07-23). **Cuándo:** pérdida o corrupción de datos, o recuperar a un punto en el tiempo. **Premisa que cambia todo:** un restore de RDS **no reusa el endpoint viejo** — crea una instancia nueva. Por eso los pasos 5–6 (repuntar la app) son obligatorios, no opcionales. La secuencia toca **RDS → SSM → ECS**. + +1. **Elegir el punto de recuperación.** Un snapshot concreto, o un timestamp para PITR (point-in-time recovery, dentro de la retención de 7 días). +2. **Restaurar → instancia NUEVA.** Consola RDS → *Restore to point in time* o *Restore snapshot*. Colocarla en la **misma VPC, el mismo `micopay-db-subnets` y el SG `micopay-db`**, para que la app la alcance sin cambiar firewalls. + ```powershell + # PITR por CLI (ejemplo); la consola es más cómoda para el primer drill: + aws rds restore-db-instance-to-point-in-time --source-db-instance-identifier micopay-prod --target-db-instance-identifier micopay-restored --restore-time 2026-07-23T04:00:00Z --db-subnet-group-name micopay-db-subnets --vpc-security-group-ids $DbSg --no-publicly-accessible + aws rds wait db-instance-available --db-instance-identifier micopay-restored + ``` +3. **Esperar `available` y anotar el endpoint nuevo.** + ```powershell + $NewHost = (aws rds describe-db-instances --db-instance-identifier micopay-restored --query "DBInstances[0].Endpoint.Address" --output text) + ``` +4. **Verificar los datos ANTES de repuntar nada.** Conectar a la instancia restaurada (desde la tarea, con `aws ecs execute-command`) y revisar conteos de tablas clave: `users`, `trades`, `wallets`, `ramp_orders`, `schema_migrations`. Si los datos no están, parar aquí — la instancia vieja sigue intacta. +5. **Actualizar `DATABASE_URL` en SSM** → apuntar al endpoint nuevo, **conservando `?sslmode=…`**: + ```powershell + aws ssm put-parameter --name /micopay/prod/DATABASE_URL --type SecureString --value "postgresql://micopay:$DbPass@${NewHost}:5432/micopay?sslmode=require" --overwrite + ``` +6. **Forzar redeploy en ECS** para que la app tome el endpoint nuevo (los secretos se leen al arrancar, §7 de la guía). ~40 s de corte (el conocido): + ```powershell + aws ecs update-service --cluster micopay --service micopay-backend --force-new-deployment + aws ecs wait services-stable --cluster micopay --services micopay-backend + ``` +7. **Verificar la app:** `/health` en 200 con `dbConnected: true` + una prueba rápida de un endpoint real (p. ej. listar merchants). +8. **Dar de baja la instancia vieja/rota SOLO tras confirmar** que la nueva sirve. Recordar `--no-deletion-protection` antes de borrar (A17). + +**Notas:** +- Si algún día se conecta por un alias/CNAME propio en vez del endpoint de RDS, revisar que el repunte no rompa — especialmente relevante el día que se pase a `verify-full` (el hostname tiene que coincidir con el cert). +- **Tiempo estimado del restore:** de minutos a decenas de minutos según tamaño. Cronometrarlo en el primer drill y anotarlo aquí. +- Alternativa gestionada: **AWS Backup → Restore Testing** automatiza este drill de forma recurrente y valida que el backup es restaurable sin intervención manual. diff --git a/docs/CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md b/docs/CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md new file mode 100644 index 00000000..df24133a --- /dev/null +++ b/docs/CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md @@ -0,0 +1,217 @@ +# Camino a mainnet — estado de cumplimiento PLD y plan de avance + +> **Fecha:** 2026-07-21 · **Para:** todo el equipo MicoPay (Eric, Jose, Anna) · **Autor:** sesión de trabajo con Claude +> **Propósito:** que todo el equipo tenga la misma foto completa de dónde estamos con el tema regulatorio, +> qué sabemos con certeza, qué no, y cómo avanzamos a mainnet **sin quedar bloqueados** por lo que no podemos +> resolver hoy. +> +> **Esto NO es asesoría legal.** Es investigación de fuentes públicas oficiales (SAT, DOF, INEGI, Banxico) + +> decisiones de producto/ingeniería. La pregunta legal de fondo sigue abierta (ver §3) — la decisión del equipo, +> documentada aquí, es avanzar con las certezas que sí tenemos. + +--- + +## 0. La decisión que tomamos (para que quede por escrito) + +**No tenemos recursos ahora para pagar un dictamen a un despacho ($50k–150k MXN).** El equipo decidió que +**eso no bloquea seguir construyendo hacia mainnet**, siempre que: + +1. Construyamos sobre las **certezas verificadas** (los umbrales oficiales del SAT), no sobre suposiciones. +2. Todo quede **documentado** (este doc + los tres de soporte) para no perder el contexto ni el riesgo asumido. +3. Reconozcamos explícitamente qué queda **sin resolver** y por qué es un riesgo aceptado, no ignorado. + +Este documento cumple el punto 2 y 3. + +--- + +## 1. Qué es esto en una frase + +MicoPay facilita el intercambio habitual de efectivo↔cripto entre particulares. Bajo la ley mexicana eso es +una **"actividad vulnerable"** (Art. 17 fracción XVI de la LFPIORPI, la ley antilavado), y **eso trae +obligaciones concretas** de identificar usuarios, avisar al SAT y guardar registros. No es opcional ni futuro. + +Ya construimos la maquinaria técnica para cumplir (el "gate" de KYC, ver §4). Lo que falta es afinarla con los +datos correctos, registrarnos ante el SAT, y — cuando haya recursos — validar con un abogado la única pregunta +que no podemos resolver solos. + +--- + +## 2. Lo que sabemos CON CERTEZA (fuentes oficiales, verificado 2026-07-21) + +Esto viene directo del portal del SAT, el DOF e INEGI — no de blogs. Se puede tomar como dato duro y construir +sobre ello **hoy**. + +| Obligación | Regla | Qué significa para nosotros | +|---|---|---| +| **Identificar al usuario** | **Siempre, desde el primer peso** (sin umbral) | Cualquier trade cash↔cripto real exige identidad. Un "Nivel 0" que permita operar sin identificarse **no es legal**. | +| **Aviso al SAT por monto** | Operación ≥ **210 UMA = $24,635.10 MXN** | Arriba de eso, se reporta la operación. | +| **Aviso al SAT por comisión** | Comisión cobrada ≥ **4 UMA = $469.24 MXN** | ⚠️ **Ojo:** esto pega a NUESTRO fee, no al monto del trade. Si el fee del protocolo cruza ~$469, genera aviso. Hay que modelarlo al fijar precios. | +| **Cómo registrarse** | Alta en el padrón **SPPLD del SAT** | Trámite en línea, requiere RFC + e.firma. **No requiere abogado** (ver §5). | +| **Cuándo avisar** | Mensual, **día 17** del mes siguiente; "informes en ceros" si no hubo nada reportable | El motor de reporting (#317) automatiza esto. | +| **Beneficiario controlador** | Identificar a quien tenga **≥ 25%** de la empresa | Relevante al constituir la sociedad. | +| **Guardar expedientes** | **10 años** | ⚠️ Ojo con confundir dos cosas: nuestra **bitácora de decisiones** del gate sí es append-only y es nuestra, pero el **expediente de identificación** (INE, selfie, CURP) lo aloja el proveedor (Didit), no nosotros. La obligación pide lo segundo. Ver §3, pregunta B. | +| **UMA 2026** | **$117.31** diarios (vigente feb-2026 → ene-2027) | Es la unidad con la que se calculan todos los umbrales de arriba. | + +**Consecuencia de diseño que se sostiene:** como identificar es obligatorio desde el primer peso, nuestra +decisión de que **Nivel 0 = no puede hacer trades cash↔cripto** es la correcta. No hay que rediseñarla. + +--- + +## 3. Lo que NO sabemos (y por qué no nos frena) + +Hay **dos** preguntas de fondo que la investigación no puede resolver, porque no son buscar un dato: son aplicar +la ley a nuestro caso específico, que es exactamente lo que hace un abogado. + +> **Pregunta A — ¿Quién es el "sujeto obligado": MicoPay como plataforma, o cada comerciante que opera como nodo?** + +> **Pregunta B — Si el expediente de identificación lo aloja un tercero (Didit), ¿cumplimos?** Nosotros no +> guardamos INE, selfie ni CURP: solo un veredicto (`kyc_level`) y un `session_id`. El expediente vive íntegro +> en Didit. ¿Basta con ser *responsable* y que Didit sea *encargado*, y qué debe decir ese contrato como mínimo +> para sostener los 10 años? ¿Y puede el anchor que usamos para la rampa SPEI apoyarse en nuestro expediente +> bajo dependencia de terceros, o cada uno identifica por separado? + +Y una relacionada: **¿nuestro diseño no-custodial nos ayuda?** La investigación aclaró que: +- El texto de la ley se activa por **operar una plataforma que facilite** la compraventa. La custodia es un + supuesto **alternativo**, no un requisito. → Ser no-custodial es un argumento **débil** para quedar fuera de + la obligación, pero **fuerte** para no necesitar una licencia bancaria (IFPE). + +**Cómo avanzamos sin resolverla:** asumimos la lectura **más estricta y conservadora** — que **MicoPay es el +sujeto obligado**. Es lo que ya estamos construyendo, y si resulta que no lo somos, no perdimos nada: absorber +el cumplimiento por los tenderos (que individualmente no podrían) **es parte de nuestra propuesta de valor**, +no un costo tirado. + +**El riesgo que esto deja vivo (hay que tenerlo claro, no esconderlo):** registrarnos y construir el gate nos +pone en cumplimiento *operativo*, pero **no nos da cobertura legal formal** sobre esa pregunta estructural. +Como la obligación está vigente desde ~2019 (ver §6) y el SAT ya la aplica, esa incertidumbre sigue ahí hasta +que un despacho la cierre. **Es un riesgo aceptado a conciencia por falta de recursos, no un descuido.** + +--- + +## 4. Lo que YA construimos (ingeniería) + +El motor de cumplimiento ya está en `main` (issue #314, mergeado). Qué hace: + +- **Motor de niveles KYC (0/1/2):** cada operación (trade P2P, cash-in, cash-out, compra de CETES) se revisa + contra el nivel del usuario antes de ejecutarse. +- **Bitácora de auditoría inmutable:** cada decisión del gate (permitir/bloquear) se registra y no se puede + borrar ni editar — es la base de los reportes al SAT. +- **Umbrales configurables:** los límites viven en configuración, no en el código, así que se ajustan sin + reprogramar (importante porque pueden cambiar con el dictamen o con la UMA anual). +- **Está en modo "solo auditoría" por ahora** (`KYC_GATE_ENABLED = false`): registra todo pero **no bloquea + nada todavía**. Se prende cuando confirmemos que los umbrales son correctos. + +### Issues que completaban el sistema — ✅ los cuatro mergeados (actualizado 2026-08-03) +- **#315 (Didit):** ✅ mergeado. Proveedor real de verificación integrado (`didit.service.ts`), sesión hospedada + + webhook firmado. Los usuarios ya pueden *alcanzar* Nivel 1/2. +- **#316 (topes mensuales):** ✅ mergeado (PR #322). Techo acumulado por mes con lock por usuario para que dos + operaciones simultáneas no rebasen juntas el tope. +- **#317 (reporting SAT/UIF):** ✅ mergeado (`compliance.service.ts`). Avisos del día 17, informes en ceros y + job mensual automático. +- **#318 (phone_hash):** ✅ mergeado (PR #319). El registro ya pide teléfono y lo hashea en el dispositivo. + +### ✅ El bug de configuración ya está corregido +Los defaults permitían hasta $3,000 MXN en Nivel 0, contradiciendo la regla del primer peso. **Ya se corrigió** +(`config.ts`, marcado `CORRECTED 2026-07-21`): hoy las cuatro operaciones —`p2p_transfer`, `cash_in`, +`cash_out`, `cetes_purchase`— arrancan en `requiredLevel: 1`, así que **ningún trade cash↔cripto puede correr +en Nivel 0**. No queda acción pendiente aquí. + +### ⚠️ Lo que sí quedó abierto y no estaba en esta lista +- **`cetes_purchase` es config muerta.** Está en la tabla de umbrales pero ningún punto del código la usa: la + compra de CETES entra por la rampa como `cash_in`. O se cablea o se quita, pero hoy engaña al leer el config. +- **Solo Didit sube el `kyc_level`.** El único `UPDATE users SET kyc_level` trae `kyc_provider = 'didit'` fijo. + Es correcto —el KYC de Etherfuse es requisito **del anchor**, no de nuestro gate— pero conviene tenerlo + explícito: al prender el gate, la rampa SPEI/CETES pasa por **los dos** filtros (ver §3, pregunta B). +- **No guardamos rastro probatorio de qué se verificó.** `kyc_didit_sessions` guarda veredicto y `session_id`, + pero no el tipo de documento, ni el hash del payload de decisión, ni el `workflow_id` usado. En 10 años habrá + que poder explicar *qué* se verificó exactamente; hoy no se puede sin depender de Didit para todo. + +--- + +## 5. El desbloqueo barato: registro en SPPLD (NO necesita abogado) + +Este es el hallazgo más útil para avanzar sin gastar: + +**El alta en el padrón del SAT es un trámite en línea, no requiere despacho.** Hay uno específico: +*"Registra tu actividad de activos virtuales"* (trámite SAT 70111). + +- **Requisitos:** RFC + e.firma vigente de la sociedad. Nada más. +- Para persona moral se designa un representante que acepta el cargo con su propia e.firma — al inicio puede + ser Eric o Jose (no vimos requisito de certificación externa; **confirmar**, pero no parece exigir el oficial + de cumplimiento outsourced de $15–40k/mes desde el día uno). +- Como ya estamos constituyendo la empresa, el costo incremental de esto es **~cero**. + +Es la obligación más concreta, más barata y más verificable de todas. Debería ir primero. + +--- + +## 6. Un mito que hay que enterrar (importante para no confiarse) + +Circula MUCHO — en blogs de vendors de software PLD y en resúmenes generados por IA — la idea de que *"la +obligación para cripto entra en vigor 18 meses después de la reforma de 2025, o sea hasta enero de 2027"*. + +**Es falso.** Confunde dos leyes distintas: +- La obligación para activos virtuales **se creó en 2018** (paquete Ley Fintech) y está **vigente desde + ~septiembre de 2019**. Ese "18 meses" era de *esa* ley. +- La reforma de **julio 2025** solo la **endureció** (bajó el umbral de aviso, subió sanciones). + +**En la práctica ya se aplica:** en marzo de 2026 el SAT ya está requiriendo a plataformas cripto el alta en +SPPLD, identificación de usuarios e historial a 10 años. + +**Por qué importa para el equipo:** no estamos "adelantándonos" a una regla futura. Si operamos en mainnet de +forma habitual, **ya caemos en el supuesto hoy**. Hoy no nos afecta solo porque estamos en **testnet, sin dinero +real de usuarios**. El momento en que esto empieza a correr en serio es **al tocar mainnet con valor real** — por +eso el orden correcto es registrarnos y tener el gate funcionando *antes* de ese salto, no después. + +--- + +## 7. Plan de avance — qué hacemos ahora vs. después + +### AHORA (sin costo, desbloquea mainnet técnicamente) +1. ~~**Corregir los defaults del config**~~ → ✅ hecho (`CORRECTED 2026-07-21`). Sigue pendiente **modelar el + umbral de comisión de 4 UMA** al fijar el fee (ver punto 6). +2. **Alta en SPPLD** (RFC + e.firma) — trámite 70111. Dueño: Eric/Jose. +3. ~~**Terminar #315 (Didit) + #316 (topes)**~~ → ✅ los dos mergeados. +4. ~~**Terminar #317**~~ → ✅ mergeado. +5. **Probar `KYC_GATE_ENABLED = true` en testnet** — validar que el gate bloquea correctamente antes de mainnet. + **Ahora es el siguiente paso real de ingeniería**, ya que los tres issues que lo alimentaban están cerrados. + Al probarlo, verificar explícitamente el camino de la rampa SPEI/CETES, que pasa por dos filtros distintos + (el del anchor y el nuestro). +6. **Definir el fee del protocolo con conciencia del umbral de 4 UMA** ($469) para no disparar avisos sin querer. +7. **Persistir el rastro probatorio del KYC** — agregar a `kyc_didit_sessions` el tipo de documento verificado, + el hash del payload de decisión, el `workflow_id` y el timestamp firmado del webhook. Cambio chico, sin costo, + y es lo único de la pregunta B (§3) que depende solo de nosotros. +8. **Resolver `cetes_purchase`** — o se cablea como tipo de operación propio, o se quita de la tabla de umbrales. + +### DESPUÉS (cuando haya recursos / ingresos) +7. **Dictamen legal** ($50–150k una vez) — cierra **las dos** preguntas del §3 (sujeto obligado, y expediente + en manos de un tercero). Shortlist de despachos ya investigado en `FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md` + (Legal Paradox primero). ✅ La pregunta B ya está incorporada al brief como **punto 8**, con sus cuatro + sub-preguntas (conservación por tercero, contenido mínimo del contrato, transferencia internacional y + dependencia de terceros con el anchor). El brief está listo para enviar. +8. **Oficial de cumplimiento** outsourced (~$15–40k/mes) — cuando el volumen lo justifique. +9. **Nivel M / KYB de comercios** — verificación de los nodos como personas morales. Depende del dictamen. + +### El orden no negociable para mainnet +> **corregir config → alta SPPLD → gate funcionando y probado → recién entonces mainnet con valor real.** +> Nunca al revés. + +--- + +## 8. Documentos de soporte (para quien quiera el detalle) + +- **`HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md`** — el detalle técnico de cada hallazgo de la verificación, + con evidencia y fuentes. (Memo interno.) +- **`FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md`** — el brief listo para el despacho + shortlist, para cuando haya + recursos. +- **`KYC_COMPLIANCE_PLAN_2026-07.md`** — el plan de cumplimiento completo (mapa regulatorio, proveedores, costos). +- **`GRANTFOX_KYC_QUEUE_2026-07.md`** — la cola de issues de ingeniería y sus dependencias. + +## 9. Fuentes oficiales (por si alguien del equipo quiere verificarlo) + +- [Portal SPPLD del SAT — umbrales de actividades vulnerables](https://sppld.sat.gob.mx/pld/interiores/umbrales.html) +- [SAT — Registra tu actividad de activos virtuales (trámite 70111)](https://wwwmat.sat.gob.mx/tramites/70111/registra-tu-actividad-de-activos-virtuales) +- [SAT — Date de alta en el Portal de Prevención de Lavado de Dinero](https://www.sat.gob.mx/tramites/85869/date-de-alta-en-el-portal-de-lavado-de-dinero) +- [DOF — Decreto de reforma a la LFPIORPI, 16-jul-2025](https://www.diputados.gob.mx/LeyesBiblio/legis/reflxvi/decreto_05_16jul25.pdf) +- [DOF — Valor de la UMA 2026 (INEGI)](https://www.dof.gob.mx/nota_detalle.php?codigo=5778072&fecha=09%2F01%2F2026) +- [Banxico — Circular 4/2019](https://www.dof.gob.mx/nota_detalle.php?codigo=5552303&fecha=08/03/2019) +- [Expansión — SAT pide historial de operaciones cripto (mar-2026)](https://expansion.mx/finanzas-personales/2026/03/18/sat-criptomonedas-actividad-vulnerable) diff --git a/docs/DISTRIBUCION_BETA_TESTNET_2026-08.md b/docs/DISTRIBUCION_BETA_TESTNET_2026-08.md new file mode 100644 index 00000000..8b63a04f --- /dev/null +++ b/docs/DISTRIBUCION_BETA_TESTNET_2026-08.md @@ -0,0 +1,254 @@ +# Distribución de la beta en testnet — qué obligaciones aplican y por dónde publicar + +**Fecha:** 2026-08-03 +**Pregunta que responde:** operando en testnet, ¿quedamos fuera de los requisitos regulatorios? ¿Y qué tan viable es subir la app a Play Store para conseguir testers e iterar? + +**Documentos relacionados:** `CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md` (marco PLD), `AUDITORIA_APK_PILOTO_2026-08.md` (estado del APK). + +--- + +## Resumen + +| Pregunta | Respuesta corta | +|---|---| +| ¿Testnet nos libra de PLD/LFPIORPI? | **Sí.** Sin valor real no hay operación que identificar ni reportar. | +| ¿Nos libra de protección de datos? | **No.** Los testers son personas reales dando datos reales. | +| ¿Subimos a Play Store producción? | **No todavía.** Trae escrutinio de servicios financieros que hoy no podemos satisfacer. | +| ¿Entonces cómo conseguimos testers? | **Pista de prueba interna** (hasta 100, sin revisión) o APK firmado directo. | +| ¿Y si los reclutamos desde el sitio web? | **Se puede, y el mecanismo ya existe** — `micopay.com.mx` ya tiene lista de espera y avisos públicos. Faltan dos cosas: un aviso que cubra **la app** (el actual solo cubre el sitio) y decir en algún lado que es **red de prueba**. Ver §4. | + +--- + +## 1. Qué nos libra testnet y qué no + +### Sí nos libra: PLD / LFPIORPI + +En testnet no se mueve valor real. Los tokens de prueba no tienen valor económico y no son intercambiables por pesos. Sin una operación con valor: + +- no hay umbral de 210 UMA que cruzar, +- no hay aviso mensual que presentar, +- no hay expediente de identificación que integrar por una operación que no existe. + +Es la posición que ya sostiene `CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md` y se sostiene bien. La obligación empieza a correr **al tocar mainnet con valor real**, no antes. + +### No nos libra: protección de datos personales (LFPDPPP) + +Este es el punto que se estaba pasando por alto. **El dinero es de prueba, pero los testers son personas reales entregando datos reales.** La LFPDPPP aplica desde el primer dato personal, sin importar si la transacción tiene valor. + +Lo que la app recaba hoy: + +| Dato | De dónde | Nota | +|---|---|---| +| Teléfono | Registro | Se hashea en el dispositivo con SHA-256; el número crudo nunca sale. **Buena minimización.** | +| Ubicación | `ACCESS_FINE_LOCATION` / `ACCESS_COARSE_LOCATION` | Para el mapa de agentes. | +| Cámara | `CAMERA` | Escaneo de QR. | +| Identidad completa | Flujo KYC de Etherfuse o Didit | INE, selfie, CURP — de una persona real, alojado por un tercero. | + +Lo que eso implica y no depende de estar en testnet: aviso de privacidad válido y accesible, base legítima de tratamiento, mecanismo de derechos ARCO, y —si el proveedor está fuera de México— transferencia internacional de datos con su propia base legal. + +> Esto es un mapa de qué marco aplica, no un criterio legal. La pregunta B del §3 de `CAMINO_A_MAINNET_CUMPLIMIENTO_2026-07.md` (expediente en manos de un tercero) sigue siendo materia del dictamen. + +--- + +## 2. Estado de los avisos de privacidad + +Hay **dos** avisos y cubren cosas distintas. Verificado 2026-08-03. + +### El del sitio — existe, es público y está bien hecho + +`micopay.com.mx/privacy` responde 200 sin login. Cubre LFPDPPP, tabla de datos, finalidades primarias/secundarias con opt-out, terceros nombrados (Cloudflare, Mailgun/Sinch), transferencia internacional por Art. 37, ARCO con plazos, retención 24 meses e INAI. + +**Pero está acotado al formulario de lista de espera del sitio, no a la app** — ver §4.2, que es donde está el hueco real. + +### El de la app — existe pero no es alcanzable ni completo + +`frontend/src/pages/Privacy.tsx` y `Terms.tsx` (99 líneas el primero). Dos problemas en código: + +1. **Están detrás del login.** Ambas rutas van envueltas en `` (`App.tsx:1101-1102`), así que nadie puede leer el aviso **antes** de registrarse — que es exactamente cuando debería leerlo. +2. **No mencionan a los terceros ni los permisos sensibles.** Cero coincidencias de "Didit", "Etherfuse", "ubicación" ni "cámara". + +**Lo que Play necesita** es un aviso que describa lo que recaba **la app**, en URL pública, y que coincida con el formulario de Data Safety. Hoy ninguno de los dos lo cumple: el del sitio es público pero habla de otra cosa, y el de la app habla de lo correcto pero está tras el login e incompleto. + +--- + +## 3. Por dónde publicar + +### Por qué producción en Play Store no es el camino hoy + +- **Política de servicios financieros de Google.** Las apps de intercambio cripto requieren declaración y, según el país objetivo, evidencia de registro o licencia. Ser no custodial ayuda —es el mismo argumento que usamos para no necesitar IFPE— pero la app *facilita el intercambio de cripto por efectivo*, que ante un revisor se parece bastante a un exchange. Sin la sociedad constituida ni el alta en SPPLD, no conviene abrir esa conversación. +- **Requisitos de la cuenta de Play Console.** Publicar como organización pide D-U-N-S. Y en cuentas personales recientes, Google exige de todos modos **12 testers durante 14 días en prueba cerrada** antes de habilitar producción — es decir, hay que pasar por testing igual. +- **Formulario de Data Safety.** Obliga a declarar ubicación, cámara y demás datos recabados; es donde el aviso incompleto del §2 se vuelve un problema formal de revisión. + +### Opciones comparadas + +| Vía | Testers | Revisión | Cuándo usarla | +|---|---|---|---| +| **Prueba interna (internal testing)** | hasta 100 | ninguna, publica casi al instante | **Recomendada ahora** | +| Prueba cerrada (closed testing) | más de 100 | ligera | Cuando se pase de 100 | +| APK firmado directo / Firebase App Distribution | sin límite | ninguna política de Google | Si son conocidos y se quiere iterar aún más rápido | +| Producción | público | completa, con escrutinio financiero | Después del dictamen y el alta en SPPLD | + +**Recomendación: prueba interna**, o APK firmado directo si el grupo es chico y de confianza. Ambas permiten iterar en horas en vez de días, y ninguna obliga a la declaración de servicios financieros. + +> ⚠️ La **prueba abierta** (open testing, con liga pública) sí pasa por revisión y ahí es donde se topa con la política de servicios financieros. Si se quiere reclutar en abierto, la vía es prueba **cerrada** con lista de correos — ver §4. + +--- + +## 4. Si reclutamos testers desde el sitio web + +Reclutar en público —no repartir el APK entre conocidos— cambia tres cosas. + +**El sitio ya existe: `micopay.com.mx`** (Astro, servido por Cloudflare). Verificado 2026-08-03, incluye: + +- Formulario de **lista de espera** ya funcionando (nombre, correo, ciudad, tipo de interés), con Turnstile anti-bots. +- **`/privacy` y `/terms` públicos**, accesibles sin login ni instalación. +- Un aviso de privacidad bien hecho: cita la LFPDPPP, tabla de datos recabados, finalidades primarias y secundarias con opt-out, terceros nombrados (Cloudflare y Mailgun/Sinch) con lo que recibe cada uno, transferencia internacional fundada en el Art. 37, derechos ARCO con plazos de 20/15 días hábiles, retención de 24 meses y referencia al INAI. + +Es decir: **el mecanismo de reclutamiento que este documento recomienda ya está construido.** Lo que falta es acotado y está en §4.2 y §4.1. + +### 4.1 Protección al consumidor (LFPC / PROFECO) — el riesgo aparece al distribuir la beta + +Un sitio público hace **representaciones comerciales** y el riesgo es **publicidad engañosa**. + +**Hoy el riesgo es bajo**, y hay que decirlo con justicia: el sitio está planteado como **lista de espera de un producto que aún no abre** ("avisarte cuando MicoPay esté disponible en tu ciudad"), marca CETES/DeFi como "Próximamente" y aclara que el mapa de proveedores es un "ejemplo ilustrativo". Ese encuadre de pre-lanzamiento es normal y defendible. + +**El riesgo aparece cuando se empiece a repartir la beta desde ahí.** Verificado 2026-08-03: el sitio **no menciona testnet ni red de prueba en ningún lado** (cero coincidencias de "testnet", "red de prueba", "dinero real"), mientras el copy está en presente —"Cambia USDC por pesos en efectivo", "Llegas, muestras el QR y recibes tus pesos"— y hay una **calculadora de ganancias para comercios** ("Calcula tu ganancia", 100 operaciones/mes, $1,200 MXN promedio). + +Mientras es solo lista de espera, eso es marketing de pre-lanzamiento. En el momento en que alguien descarga una app funcional desde ese mismo sitio, asumirá razonablemente que mueve dinero real — y la calculadora de ganancias proyecta ingresos que hoy no se pueden generar. + +**Mitigación, visible y no en letra chica, antes de publicar la beta:** + +> Beta técnica en **red de prueba**. No se mueve dinero real, los saldos son simulados y los tokens no tienen valor. + +Y acotar la calculadora de ganancias como proyección ilustrativa sujeta al lanzamiento real, del mismo modo que ya se hace con el mapa de proveedores. + +### 4.2 Datos personales: el aviso del sitio **no cubre la app** + +Este es el hueco real, y es más fino que "falta un aviso de privacidad". + +El aviso de `micopay.com.mx/privacy` está **acotado deliberadamente al formulario de lista de espera del sitio**. Dice, textual: + +> "No recabamos datos personales sensibles […], ni datos financieros o patrimoniales, ni **documentos de identificación oficial**." + +Eso es correcto para el sitio. Pero **la app sí recaba todo eso**: ubicación, cámara, teléfono (hasheado) y, al entrar al flujo de KYC, INE, selfie y CURP vía Didit o Etherfuse. Ninguno de esos terceros aparece en el aviso, y la frase de arriba lo contradice de frente. + +Consecuencia práctica: **no se puede apuntar la ficha de Play a esa URL tal cual.** Play exige que el aviso describa lo que recaba **la app**, y el formulario de Data Safety tiene que coincidir con él. Un aviso que dice "no recabamos documentos de identificación" junto a una app que manda al usuario a subir su INE es una inconsistencia que un revisor puede detectar — y, peor, es inexacto frente al usuario. + +**Lo que falta:** + +- [ ] Un aviso **para la app** —sección aparte o documento propio— que nombre a Didit y Etherfuse, la ubicación, la cámara y el teléfono, y explique que el expediente de identidad lo aloja el proveedor (ver pregunta B del dictamen). +- [ ] Rellenar los marcadores `[RAZÓN SOCIAL]` y `[DOMICILIO FISCAL COMPLETO]`, hoy pendientes de la constitución de la sociedad. Play exige un responsable identificable. +- [ ] Sacar `Privacy`/`Terms` de detrás del `ProtectedRoute` en la app (§2), para que dentro del producto también se lean antes de registrarse. + +Nota menor: con desconocidos en vez de conocidos sube la probabilidad de solicitudes ARCO reales, y el aviso ya compromete plazos de 20/15 días hábiles. Conviene que alguien sea dueño de ese buzón. + +### 4.3 PLD no cambia, pero sí importa cómo se describe + +El gatillo del Art. 17 fr. XVI es el intercambio **real con valor**. Reclutar testers para testnet no crea actividad vulnerable: seguimos fuera. + +El matiz a tener presente: un sitio público que se ofrece como servicio de cambio efectivo↔cripto deja **registro público de que nos ostentamos como tal**. El brief de Fase 4 ya pregunta por exposición retroactiva (punto 7) y el SAT aplica activamente desde marzo 2026. La diferencia entre *"beta técnica en red de prueba"* y *"cambia tu efectivo por cripto"* no es cosmética — conviene que el sitio diga lo primero. + +### 4.4 No mandar desconocidos a instalar por sideload + +Poner el APK a descargar del sitio y pedir que activen "orígenes desconocidos" es un antipatrón de seguridad: es precisamente el vector de los troyanos bancarios en México. Para una app financiera, entrenar ese hábito trabaja en contra del producto, y además deja sin canal de actualización. + +**El flujo recomendado:** + +``` +Sitio: formulario de lista de espera (solo correo) + ↓ +Agregar esos correos a la lista de testers de prueba CERRADA en Play + ↓ +Instalan desde Play con su cuenta de Google +``` + +Sin sideload, con actualizaciones automáticas, firmado, y sin pasar por revisión de producción. La prueba cerrada acepta listas de correo o grupos de Google justo para este caso. + +--- + +## 5. Requisitos previos (aplican a cualquier vía) + +- [ ] **Compilar un release firmado.** No repartir el build de debug — trae `debuggable=true`, permite cleartext y confía en CAs de usuario, lo que deja la llave privada Stellar extraíble con `adb`. Detalle en `AUDITORIA_APK_PILOTO_2026-08.md`. +- [ ] **Aviso de privacidad de la app**, publicado en `micopay.com.mx` junto al del sitio (p. ej. `/privacy-app`), nombrando a Didit, Etherfuse, ubicación, cámara y teléfono. El aviso actual del sitio **no sirve** para esto: dice que no se recaban documentos de identificación, lo cual es falso para la app (§4.2). Además, sacar `Privacy`/`Terms` de detrás de `ProtectedRoute`. +- [ ] **Etiquetar visiblemente que es una beta en red de prueba**, para que ningún tester crea que mueve dinero real. +- [ ] **Resolver el dominio de deep links** — hoy el manifiesto apunta a `app.micopay.xyz`, que no resuelve. + +--- + +## 6. Checklist operativo para publicar en Play + +### 6.0 Lo que ya cumple (verificado en el repo, no requiere acción) + +| Requisito | Estado | +|---|---| +| Target API level reciente | ✅ `targetSdkVersion = 36`, `compileSdkVersion = 36` | +| minSdk soportado | ✅ `24` | +| Llave de firma creada y fuera de git | ✅ `micopay-release.jks` + `keystore.properties`, ambos gitignored | +| Config de release endurecida | ✅ `minifyEnabled`, `shrinkResources`, `debuggable=false`, solo CAs del sistema | + +Lo que falta es sobre todo cuenta y contenido, no código. + +### 6.1 Cuenta de Play Console + +- [ ] Pagar la cuota de desarrollador: **$25 USD**, pago único de por vida. +- [ ] **Decidir personal vs. organización.** Si la sociedad ya se está constituyendo, conviene **organización** (la cuenta queda a nombre de la empresa, no de una persona), pero exige **D-U-N-S** — gratis, vía Dun & Bradstreet, no inmediato: hay que pedirlo con holgura. +- [ ] Completar la **verificación de identidad del desarrollador** que pide Google. + +> ⚠️ En cuentas **personales** nuevas, Google exige **12 testers durante 14 días** en prueba cerrada antes de habilitar producción. En cuentas de organización no aplica. Es otro argumento para ir por organización si la sociedad ya viene en camino. + +### 6.2 Preparar el binario + +- [ ] **Subir el `versionCode`** — hoy está en `1` (`app/build.gradle`); cada subida a Play necesita uno nuevo y no se puede reutilizar. +- [ ] **Compilar un AAB, no un APK.** Play exige App Bundle para apps nuevas: + ```bash + npm run build:testnet + npx cap sync android + cd android && ./gradlew bundleRelease + ``` +- [ ] **Activar Play App Signing.** Google custodia la llave de firma real y nosotros subimos una llave de *upload*. Importante: **sin esto, perder `micopay-release.jks` significa no poder actualizar la app nunca más**. + +### 6.3 Contenido de la ficha (es lo que más tiempo consume) + +- [ ] **Aviso de privacidad *de la app* en URL pública** — el del sitio ya es público pero cubre solo la lista de espera (§4.2). Hay que publicar uno que describa la app. +- [ ] **Formulario Data Safety**: declarar ubicación, cámara, teléfono y los terceros (Didit, Etherfuse) +- [ ] **Declaración de servicios financieros** ← el punto delicado por tratarse de cripto +- [ ] Ícono 512×512, gráfico destacado 1024×500, mínimo 2 capturas +- [ ] Clasificación de contenido, países objetivo y descripción + +### 6.4 Qué necesita cada pista + +| Pista | Requiere | Cuándo | +|---|---|---| +| **Prueba interna** | 6.1 + 6.2 + aviso público | **Empezar aquí** | +| Prueba cerrada | lo anterior + lista de correos | Para reclutar desde el sitio (§4.4) | +| Producción | todo + declaración financiera + dictamen + SPPLD | Después | + +### 6.5 Los tres bloqueadores reales + +1. **Aviso de privacidad público** — único que bloquea *todas* las pistas, incluida la interna. Es trabajo de código y hosting. +2. **Declaración de servicios financieros** — solo pega en producción; prueba interna y cerrada no la piden. Es justamente la conversación que no conviene abrir sin sociedad ni alta en SPPLD. +3. **D-U-N-S** — solo si se va por cuenta de organización. + +**Ruta recomendada:** prueba interna primero. Con 6.1 + 6.2 + el aviso público ya hay gente instalando desde Play, sin revisión y sin tocar la declaración financiera. + +--- + +## 7. Acciones en orden + +1. Sacar `Privacy` y `Terms` de detrás del login y completarlos con terceros y permisos. +2. Publicar un aviso **de la app** en `micopay.com.mx` (el sitio ya existe y ya sirve `/privacy` y `/terms`, así que es agregar una página más), y rellenar los marcadores `[RAZÓN SOCIAL]` / `[DOMICILIO FISCAL COMPLETO]` cuando exista la sociedad. +3. Compilar y firmar el binario de release — `assembleRelease` si es APK directo, `bundleRelease` si va a Play (§6.2). +4. Elegir vía según el alcance: + - grupo chico y conocido → prueba interna, o APK directo; + - reclutamiento abierto desde el sitio → lista de espera + prueba **cerrada** (§4.4). +5. Etiquetar la app como beta de red de prueba — en la app **y** en el sitio (§4.1). +6. Si hay sitio: redactar el aviso de la lista de espera y cuidar que el copy describa una beta técnica, no un servicio de cambio activo (§4.3). +7. Si se va por Play: abrir la cuenta de Console y preparar la ficha siguiendo el checklist del §6. +8. (Paralelo, sin bloquear) seguir con el alta en SPPLD y el dictamen — son los que habilitan mainnet y, eventualmente, producción en Play. + +--- + +## Notas de calibración + +- **Las políticas de Google Play cambian con frecuencia** y la información de esta nota tiene corte a enero de 2026. Verificar los requisitos vigentes directamente en Play Console antes de comprometer fechas — en particular el umbral de testers para cuentas personales, el trámite y tiempos del D-U-N-S, el target API level mínimo exigido y la política de servicios financieros. Los datos del §6.0 sí están verificados contra el repo; los del §6.1–6.3 son requisitos de plataforma y hay que reconfirmarlos. +- **Nada de este documento es asesoría legal.** Es el mapa de qué marcos aplican y qué preguntas hay que hacer. Los criterios los fija el dictamen de Fase 4. diff --git a/docs/FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md b/docs/FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md new file mode 100644 index 00000000..e3a8de06 --- /dev/null +++ b/docs/FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md @@ -0,0 +1,161 @@ +# Fase 4 — Brief para solicitar el dictamen legal (listo para enviar) + +> **Qué es esto:** un brief que puedes copiar/pegar (o adjuntar) en el primer contacto con un despacho +> fintech mexicano para arrancar el dictamen que bloquea todo lo demás en Fase 4 (SPPLD, oficial de +> cumplimiento, y la activación real de `KYC_GATE_ENABLED`). Ajusta el tono/detalles antes de enviarlo — +> esto es un punto de partida, no el mensaje final. Contexto completo en `docs/KYC_COMPLIANCE_PLAN_2026-07.md`. +> +> **No es asesoría legal.** Todo lo de abajo es investigación de fuentes públicas para llegar preparado +> a la conversación con el despacho, no para sustituirla. + +--- + +## ⚠️ Verificación de hechos (2026-07-21) — leer antes de enviar + +Se verificaron los datos regulatorios de este brief contra fuentes primarias (SAT/SPPLD, DOF, INEGI, Banxico) +porque un error factual en el primer contacto cuesta credibilidad. + +> **Versión larga:** [`HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md`](./HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md) — memo interno con el detalle de cada hallazgo, su impacto y las acciones que se derivan. Lo de abajo es el resumen operativo. + +### Lo que se confirmó como correcto ✅ + +| Dato | Estado | Fuente | +|---|---|---| +| UMA 2026 = **$117.31** diarios (vigente 1-feb-2026 → 31-ene-2027) | ✅ | INEGI / DOF 9-ene-2026 | +| Umbral de **aviso** activos virtuales = **210 UMA = $24,635.10 MXN** | ✅ exacto | Portal SPPLD del SAT | +| Umbral de **identificación** = **"Siempre"** (desde el primer peso, sin umbral) | ✅ | Portal SPPLD del SAT | +| Aviso adicional por **comisiones ≥ 4 UMA** ($469.24 MXN) | ✅ (dato nuevo, no estaba en el plan) | SPPLD / Expansión mar-2026 | +| Beneficiario controlador: umbral bajó de 50% → **25%** | ✅ | Reforma DOF 16-jul-2025 | +| Retención de expedientes: 5 → **10 años** | ✅ | Reforma DOF 16-jul-2025 | +| Reforma publicada DOF **16-jul-2025**, en vigor **17-jul-2025** | ✅ | DOF | +| Presentación de avisos: **día 17** del mes siguiente | ✅ | SAT | + +### Corrección importante ❌ → ✅ : la obligación NO es futura, lleva años vigente + +Circula ampliamente (en blogs SEO y resúmenes generados por IA) la afirmación de que *"la adición de la +fracción XVI entra en vigor 18 meses después del decreto"*, lo que llevaría a concluir que las +obligaciones para activos virtuales arrancan **hasta enero de 2027**. **Eso es falso**, y es una confusión +de dos decretos distintos: + +- La fracción XVI **se añadió en el decreto del 9-mar-2018** (paquete de la Ley Fintech). *Ese* decreto + traía el transitorio de 18 meses → la actividad vulnerable entró en vigor **~septiembre de 2019**. +- El decreto del **16-jul-2025 no creó la fracción XVI: la reformó** — bajó el umbral de aviso de 645 a + 210 UMA (−67%) y la extendió expresamente a operaciones hechas **desde otra jurisdicción con mexicanos**. + +**Implicación práctica:** MicoPay no se está preparando para una obligación futura con margen de maniobra. +Si opera en mainnet de forma habitual y profesional, **ya está dentro del supuesto hoy**. Esto sube la +urgencia de Fase 4, no la baja. (Referencia de que ya se aplica en la práctica: en marzo de 2026 el SAT +ya requiere a plataformas cripto alta en SPPLD, identificación plena e historial a 10 años.) + +### Debilidad en el argumento "somos no custodiales" ⚠️ + +El texto de la fracción XVI describe el supuesto como el ofrecimiento habitual y profesional de intercambio +de activos virtuales a través de plataformas que se **administren u operen**, *"facilitando o realizando"* +operaciones de compra o venta, **o** proveyendo medios para custodiar/almacenar/transferir. + +La custodia aparece como **supuesto alternativo, no como requisito**. El gatillo principal es *operar una +plataforma que facilite* la compraventa — que es exactamente lo que hace MicoPay, con o sin custodia. +Por eso el brief **no debe** plantear "somos no custodiales" como si fuera una defensa que exenta de la +LFPIORPI; conviene plantearlo como lo que probablemente sí es: un argumento fuerte para **no requerir +licencia** (IFPE), pero débil para quedar fuera de la actividad vulnerable. Las preguntas de abajo ya +están reformuladas con esa distinción. + +### Precisión sobre la Circular 4/2019 ⚠️ + +La Circular 4/2019 de Banxico va dirigida a **Instituciones de Crédito e ITF**, y les *prohíbe* ofrecer al +público operaciones con activos virtuales (solo pueden usarlos en "Operaciones Internas"). **No es la fuente +de un requisito de licencia IFPE.** De hecho apunta en sentido contrario: ser ITF/IFPE **impediría** ofrecer +cripto al público. Eso refuerza que la ruta de sociedad mercantil no financiera bajo LFPIORPI no solo es +defendible, sino posiblemente la única viable — vale la pena que el dictamen lo confirme explícitamente. + +--- + +## Asunto sugerido + +Solicitud de dictamen — cumplimiento LFPIORPI para plataforma de intercambio cash↔cripto (P2P, no custodial) + +## Cuerpo del mensaje + +Estamos desarrollando **MicoPay**, una red de liquidez P2P que permite el intercambio entre efectivo (pesos mexicanos) y stablecoins/cripto entre particulares, usando comercios locales como nodos de intercambio. El protocolo es **no custodial**: los fondos se bloquean on-chain (contrato HTLC en Stellar/Soroban) bajo el hash de un secreto, y se liberan automáticamente cuando se revela ese secreto — MicoPay nunca tiene control de los fondos del usuario en ningún momento. + +Buscamos un **dictamen legal** que resuelva, antes de lanzar en mainnet con volumen real: + +1. **¿Quién es el sujeto obligado bajo el Art. 17 fracción XVI de la LFPIORPI ("intercambio de activos virtuales", vigente desde 2019 y reformado en julio de 2025)?** ¿MicoPay como plataforma que administra y facilita las operaciones, cada comercio/nodo que intercambia habitualmente, o ambos bajo distintas figuras? Entendemos que el supuesto se activa por *operar la plataforma que facilita* la compraventa, con independencia de la custodia — nos interesa que confirmen o corrijan esa lectura. +2. **¿Qué efecto tiene el diseño no custodial (escrow HTLC en cadena, llaves en el dispositivo del usuario, MicoPay nunca controla los fondos) en dos preguntas separadas?** + a) Para efectos de **LFPIORPI**: ¿reduce el alcance de las obligaciones o es irrelevante porque el gatillo es la facilitación y no la custodia? + b) Para efectos de **licenciamiento**: ¿confirma que NO se requiere autorización como ITF/IFPE bajo la Ley Fintech? Notamos que la Circular 4/2019 de Banxico prohíbe a Instituciones de Crédito e ITF ofrecer activos virtuales al público, lo que sugeriría que constituirse como ITF sería contraproducente y que la ruta correcta es sociedad mercantil no financiera sujeta a LFPIORPI. ¿Es correcto? +3. **Estructura societaria recomendada** para que MicoPay pueda asumir el rol de sujeto obligado (si aplica) y absorber esa carga de cumplimiento en nombre de los comercios/nodos, en vez de que cada uno individualmente tuviera que registrarse (inviable para comercios pequeños). +4. **Ruta y requisitos para el alta en el padrón SPPLD del SAT** (RFC + e.firma de la sociedad) — qué necesitamos preparar y en qué orden. +5. **Validación de nuestra propuesta técnica de niveles KYC** (ya construida en el motor de acceso escalonado, actualmente en modo solo-auditoría): + + | Nivel | Requisitos propuestos | Límites propuestos | + |---|---|---| + | 0 | Solo cuenta + llave | Sin trades cash↔cripto | + | 1 | INE/pasaporte + selfie liveness + CURP validada | ~$3,000 MXN/operación, ~$10,000 MXN/mes | + | 2 | + comprobante de domicilio | Hasta 210 UMA/operación (~$24,600 MXN) | + | M (comercio/nodo) | Nivel 2 + KYB si persona moral + beneficiario controlador (25%) | Operar como nodo de liquidez | + + Entendemos que para esta actividad la identificación es obligatoria **desde el primer peso** (umbral "siempre" en el portal SPPLD), que el aviso se activa en **210 UMA** por operación (~$24,635 MXN con la UMA 2026 de $117.31) y que también hay aviso por **comisiones ≥ 4 UMA** (~$469 MXN). ¿Estos niveles y límites son razonables bajo ese marco? ¿Qué ajustarían? +6. **Oficial de cumplimiento**: ¿interno o outsourced para arrancar? ¿Qué perfil/certificación se requiere? +7. **Exposición retroactiva**: dado que la fracción XVI está vigente desde ~2019 y hoy operamos únicamente en **testnet** (sin dinero real de usuarios), ¿existe alguna exposición por el periodo previo al alta en SPPLD, o el riesgo empieza a correr al momento de operar con valor real en mainnet? Esto define qué tan atrás debemos mirar y qué tan rápido debemos registrarnos. +8. **Expediente de identificación alojado por un tercero.** La verificación de identidad la ejecuta un proveedor externo (tipo Didit), que es quien recaba y almacena INE, selfie y CURP. Nosotros **no alojamos ninguno de esos datos**: en nuestra base guardamos únicamente un veredicto (nivel KYC alcanzado, fecha de verificación, proveedor) y el identificador de la sesión con el proveedor. + a) **¿Cumplimos así la obligación de integrar y conservar el expediente de identificación por 10 años**, siendo nosotros responsables y el proveedor encargado del tratamiento? ¿O la autoridad exigiría que el expediente esté materialmente en nuestro poder? + b) **¿Qué debe estipular como mínimo ese contrato** para que la obligación se sostenga: derecho a recuperar el expediente completo a solicitud de la autoridad, plazo de conservación garantizado, residencia de los datos, y qué pasa con los expedientes si el proveedor desaparece o se termina la relación? + c) Si el proveedor está **fuera de México**, ¿qué requisitos adicionales impone la LFPDPPP por transferencia internacional? + d) **Dependencia de terceros entre sujetos obligados:** usamos además un *anchor* externo (proveedor de rampa SPEI/CETES) que hace su propio KYC a los mismos usuarios. ¿Puede uno apoyarse en el expediente del otro bajo alguna figura de dependencia de terceros, o cada sujeto obligado debe identificar por separado aunque se trate del mismo cliente y la misma operación? Hoy, si activáramos nuestro gate, el usuario tendría que verificarse dos veces para la misma operación. + +## Lo que ya tenemos construido (para que dimensionen el alcance) + +- Motor de niveles KYC configurable + bitácora de auditoría inmutable de cada decisión de acceso (código ya en producción, actualmente en modo "solo auditoría" — no bloquea nada hasta activarlo). +- **Dos** integraciones de verificación de identidad hospedada ya funcionando: un proveedor para el flujo de rampa SPEI/CETES (el *anchor* del punto 8.d) y **Didit** para el gate de niveles propio, con sesión hospedada y webhook firmado. Los usuarios ya pueden alcanzar Nivel 1/2. +- Topes de volumen mensual acumulado por nivel, además del límite por operación. +- Motor de reporting SAT/UIF: agregación mensual, avisos del día 17 e informes en ceros. +- Contrato de escrow no custodial ya desplegado y operando en testnet. + +**Precisión relevante para el punto 8:** de todo lo anterior, lo único que guardamos nosotros son *decisiones y veredictos* (qué nivel tiene cada usuario, qué operaciones se permitieron o bloquearon y cuándo). Los **documentos de identidad en sí nunca tocan nuestra infraestructura** — viven íntegros con los proveedores. + +## Lo que pedimos como entregable + +- Dictamen escrito sobre los 8 puntos arriba. +- Cotización y tiempo estimado. +- Si aplica, una lista de siguientes pasos priorizados (qué bloquea qué). + +--- + +## A quién enviarlo — shortlist (investigado 2026-07-21, rankings Chambers FinTech México) + +No hay relación previa con ninguno; esto es punto de partida para cotizar, no una recomendación cerrada. +Sugerencia: pedir cotización a **un boutique especialista + un despacho grande** para contrastar precio y enfoque. + +| Despacho | Por qué está en la lista | Consideración | +|---|---|---| +| **Legal Paradox** (Carlos Valderrama) | Boutique mexicano especializado **específicamente en blockchain/activos virtuales** desde 2017; Chambers Band 2 FinTech. Es el perfil más cercano al caso de uso exacto. | Probablemente el mejor fit técnico y más accesible en precio que un Big Law. **Primera llamada sugerida.** | +| **Nader Hayaux & Goebel** | Fuerte en medios de pago, e-wallets y proyectos de criptomonedas; asesoría regulatoria. | Despacho grande mexicano, buen balance entre especialidad y peso institucional. | +| **White & Case México** | **Band 1** Chambers FinTech México. | El más caro casi con seguridad; útil si el dictamen se va a usar frente a inversionistas o un banco. | +| **Hogan Lovells México** | Licencias fintech + experiencia explícita en **PLD/AML** y protección de datos. | Buena opción si se quiere el paquete dictamen + programa PLD completo. | +| **Bello, Gallardo, Bonequi y García** | Especializados en obtener autorizaciones para wallets/pagos/transmisión de dinero. | Relevante si el dictamen concluye que sí hace falta alguna autorización. | + +## Checklist de seguimiento (Fase 4, dueño: Eric/Jose — fuera de GrantFox) + +- [ ] Enviar este brief a 2–3 despachos de la lista de arriba (cotizar en paralelo, no secuencial) +- [ ] Recibir dictamen escrito → resuelve: sujeto obligado, necesidad de licencia IFPE, estructura societaria +- [ ] Con el dictamen en mano: iniciar alta en padrón SPPLD (RFC + e.firma de la sociedad) +- [ ] Definir oficial de cumplimiento (interno vs. outsourced ~$15–40k MXN/mes) +- [ ] Con el dictamen validando (o ajustando) los umbrales de la tabla: actualizar `KYC_OPERATION_THRESHOLDS_JSON` en producción y solo entonces considerar `KYC_GATE_ENABLED=true` +- [ ] Revisar si procede agregar el nivel M (merchant/KYB) al motor — hoy deliberadamente fuera de #314 hasta que esto se resuelva + +**Por qué importa ahora:** #315 (4b, Didit), #316 (4c, topes mensuales) y #317 (5a, reporting SAT/UIF) ya están asignados y en construcción — pero aunque los tres mergeen, `KYC_GATE_ENABLED` seguirá apagado y el sistema seguirá en modo "solo auditoría" hasta que el dictamen confirme los umbrales y la estructura. El trabajo de ingeniería puede terminar antes que esto — vale la pena arrancarlo ya, no cuando el código esté listo. + +**Y con la verificación de arriba, más todavía:** la obligación de la fracción XVI **no arranca en 2027 — lleva vigente desde ~2019**, y el SAT ya la está aplicando activamente a plataformas cripto (marzo 2026). Hoy eso no muerde porque MicoPay opera en testnet sin dinero real, pero **el dictamen deja de ser un requisito de "antes de escalar" y pasa a ser un requisito de "antes de tocar mainnet"**. El orden correcto es: dictamen → alta SPPLD → oficial de cumplimiento → mainnet, y no al revés. + +## Fuentes consultadas (2026-07-21) + +- [Portal SPPLD del SAT — umbrales de actividades vulnerables](https://sppld.sat.gob.mx/pld/interiores/umbrales.html) (fuente autoritativa de los umbrales de identificación y aviso) +- [DOF — Decreto de reforma a la LFPIORPI, 16-jul-2025](https://www.diputados.gob.mx/LeyesBiblio/legis/reflxvi/decreto_05_16jul25.pdf) +- [DOF — Valor de la UMA 2026 (INEGI)](https://www.dof.gob.mx/nota_detalle.php?codigo=5778072&fecha=09%2F01%2F2026) +- [UIF — Criterio general para la aplicación de la fracción XVI del Art. 17 LFPIORPI](https://www.gob.mx/uif/prensa/comunicado-040-la-uif-emite-criterio-general-para-la-aplicacion-de-fraccion-xvi-del-articulo-17-de-la-lfpiorpi?idiom=es) +- [Banxico — Circular 4/2019 (DOF 8-mar-2019)](https://www.dof.gob.mx/nota_detalle.php?codigo=5552303&fecha=08/03/2019) +- [EY México — Reforma a la Ley Antilavado 2025](https://www.ey.com/es_mx/technical/tax/boletines-fiscales/reforma-ley-antilavado-2025-nuevas-obligaciones) +- [KPMG México — Flash: Decreto que reforma la LFPIORPI](https://kpmg.com/mx/es/tendencias/2025/07/flash-decreto-que-reforma-y-adiciona-disposiciones-a-la-lfpiorpi.html) +- [Expansión — SAT pide nombres e historial de operaciones cripto (18-mar-2026)](https://expansion.mx/finanzas-personales/2026/03/18/sat-criptomonedas-actividad-vulnerable) +- [Chambers — FinTech Legal México (rankings)](https://chambers.com/legal-rankings/fintech-legal-mexico-49:2744:144:1) diff --git a/docs/HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md b/docs/HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md new file mode 100644 index 00000000..f5d675ed --- /dev/null +++ b/docs/HALLAZGOS_VERIFICACION_REGULATORIA_2026-07.md @@ -0,0 +1,187 @@ +# Memo interno — Verificación regulatoria PLD/cripto (julio 2026) + +> **Fecha:** 2026-07-21 · **Audiencia:** Eric, Jose (interno — no es el documento que sale al despacho) +> **Qué es:** los hallazgos de verificar contra fuentes primarias los supuestos regulatorios sobre los que +> estábamos construyendo el gate de KYC. Tres de ellos cambian decisiones, no solo datos. +> +> **No es asesoría legal.** Es investigación de fuentes públicas para llegar preparados al dictamen, no +> para sustituirlo. +> +> **Documentos relacionados:** +> - [`FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md`](./FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md) — el brief que **sí** se manda al despacho (ya incorpora estas correcciones) +> - [`KYC_COMPLIANCE_PLAN_2026-07.md`](./KYC_COMPLIANCE_PLAN_2026-07.md) — el plan de cumplimiento completo (ya actualizado) +> - [`GRANTFOX_KYC_QUEUE_2026-07.md`](./GRANTFOX_KYC_QUEUE_2026-07.md) — la cola de issues de ingeniería + +--- + +## TL;DR — lo que cambia + +| # | Hallazgo | Tipo | Qué cambia | +|---|---|---|---| +| 1 | La obligación **lleva vigente desde ~sept 2019**, no arranca en 2027 | ❌ Error corregido | El dictamen pasa de "antes de escalar" a **"antes de tocar mainnet"** | +| 2 | "Somos no custodiales" es **débil** para LFPIORPI | ⚠️ Argumento reformulado | No anclar al despacho en una defensa que no sostiene | +| 3 | Circular 4/2019 estaba **mal citada** | ⚠️ Precisión | Refuerza (no debilita) la ruta de sociedad no financiera | +| 4 | Aviso también por **comisión ≥ 4 UMA** | ➕ Dato nuevo | Pega directo al **fee del protocolo**, no solo al monto de la operación | +| 5 | Shortlist de despachos con ranking | ➕ Accionable | El checklist decía "manda a 2-3" sin nombrar ninguno | + +**El resto del marco regulatorio que ya teníamos documentado resultó correcto** (ver §6). + +--- + +## 1. ❌ La obligación no es futura: lleva ~7 años vigente + +### Qué creíamos +Que el Art. 17 fracción XVI (activos virtuales) era producto de la reforma de julio 2025 y que, por un +transitorio de 18 meses, las obligaciones arrancaban **hasta enero de 2027** — es decir, con margen cómodo. + +### Qué es realmente +Son **dos decretos distintos** y la afirmación de los 18 meses los confunde: + +| Decreto | Qué hizo | Vigencia | +|---|---|---| +| **9-mar-2018** (paquete Ley Fintech) | **Añadió** la fracción XVI | Transitorio de 18 meses → en vigor **~septiembre 2019** | +| **16-jul-2025** | **Reformó** la fracción XVI: bajó el umbral de aviso 645→210 UMA (−67%) y la extendió a operaciones desde otra jurisdicción con mexicanos | En vigor **17-jul-2025** | + +El transitorio de 18 meses pertenece al decreto de **2018**, no al de 2025. + +### Por qué nos lo tragamos +La afirmación errónea aparece repetida en blogs SEO de vendors de software PLD y en resúmenes generados +por IA — que es exactamente lo que devuelven las primeras páginas de búsqueda. Fue necesario ir al portal +del SAT y a los decretos para desarmarlo. + +> **Lección operativa:** en temas regulatorios, los primeros resultados de búsqueda son contenido de +> vendors optimizado para SEO. Solo cuentan SAT/SPPLD, DOF, UIF, Banxico, y en segundo lugar Big Four. + +### Evidencia de que ya se aplica en la práctica +En **marzo de 2026** el SAT ya requiere a plataformas cripto: alta en SPPLD, identificación plena de +usuarios, historial de operaciones conservado **10 años**, y avisos el día 17. No es letra muerta. + +### Impacto — esto sí cambia el plan +- MicoPay **no se está preparando para una obligación futura**: si opera de forma habitual y profesional + en mainnet, **ya cae en el supuesto hoy**. +- Hoy no muerde porque estamos en **testnet sin dinero real de usuarios**. +- **El orden correcto es: dictamen → alta SPPLD → oficial de cumplimiento → mainnet.** No al revés. +- Se agregó al brief una pregunta nueva (la 7) sobre **exposición retroactiva**: dado que la fracción está + vigente desde 2019, ¿corre algún riesgo por el periodo previo al registro, o el reloj empieza al operar + con valor real? Eso define qué tan rápido hay que registrarse. + +--- + +## 2. ⚠️ "Somos no custodiales" no nos saca de la actividad vulnerable + +### Qué asumía el brief original +Planteaba la pregunta como *"¿la naturaleza no custodial cambia el análisis?"* — redactada esperando un sí, +como si el diseño HTLC fuera una defensa que nos deja fuera del supuesto. + +### Qué dice el texto de la fracción XVI +Describe el supuesto como el ofrecimiento habitual y profesional de intercambio de activos virtuales a +través de plataformas que se **administren u operen**, *"facilitando o realizando"* operaciones de compra o +venta, **o** proveyendo medios para custodiar, almacenar o transferir. + +La custodia entra como **supuesto alternativo** (esa "o"), **no como requisito**. El gatillo principal es +*operar una plataforma que facilite la compraventa* — que es literalmente lo que hace MicoPay, con o sin +custodia de por medio. + +### Impacto +Hay que separar dos preguntas que veníamos mezclando: + +| Pregunta | ¿Ayuda ser no custodial? | +|---|---| +| ¿Somos **sujeto obligado** bajo LFPIORPI? | **Probablemente no ayuda** — el gatillo es facilitar | +| ¿Necesitamos **licencia** (ITF/IFPE)? | **Sí, ayuda mucho** — no captamos ni custodiamos fondos | + +El brief ya está reformulado para preguntar ambas por separado, en vez de anclar al despacho en una +defensa que puede no sostenerse. Nota: el `KYC_COMPLIANCE_PLAN` ya era más cuidadoso que el brief en este +punto — decía explícitamente "aunque el escrow sea non-custodial" cae en el supuesto. + +--- + +## 3. ⚠️ Circular 4/2019 estaba mal citada (y en realidad juega a favor) + +### Cómo la citaba el brief +Como si fuera la fuente de un posible requisito de **licencia IFPE**: *"¿Se requiere licencia IFPE +(Circular 4/2019 CNBV/Banxico)?"*. + +### Qué es realmente +Es regulación de **Banxico dirigida a Instituciones de Crédito e ITF** (DOF 8-mar-2019). Les **prohíbe +ofrecer al público** operaciones con activos virtuales; solo pueden usarlos en "Operaciones Internas". +No crea ningún requisito de licencia para terceros. + +### Impacto — apunta en sentido contrario al que asumíamos +Ser ITF/IFPE **nos impediría** ofrecer cripto al público. Entonces la ruta de **sociedad mercantil no +financiera sujeta a LFPIORPI** no solo es defendible: posiblemente es **la única viable**. Vale la pena que +el dictamen lo confirme explícitamente, porque convierte una duda ("¿necesitamos licencia?") en un +argumento estructural ("la licencia sería contraproducente"). + +--- + +## 4. ➕ Dato nuevo: el aviso también se dispara por la comisión (≥ 4 UMA) + +No lo teníamos en el plan. Además del umbral por monto de operación, se activa obligación de aviso cuando +**la comisión cobrada ≥ 4 UMA = $469.24 MXN** (UMA 2026). + +**Por qué importa:** hasta ahora razonábamos los umbrales sobre el **monto del trade**. Este umbral pega +sobre el **fee del protocolo**, que es otra variable y la controlamos nosotros vía configuración de +`platform_fee`. Hay que modelarlo explícitamente cuando se definan los parámetros económicos de mainnet — +no es lo mismo un fee que cruza los $469 que uno que no. + +Ya agregado como fila propia en la tabla regulatoria del `KYC_COMPLIANCE_PLAN`. + +--- + +## 5. ➕ Shortlist de despachos (rankings Chambers FinTech México) + +El checklist de Fase 4 decía "enviar a 2–3 despachos" sin nombrar ninguno. Sin relación previa con +ninguno de estos; es punto de partida para cotizar, no recomendación cerrada. + +| Despacho | Perfil | Nota | +|---|---|---| +| **Legal Paradox** (Carlos Valderrama) | Boutique mexicano especializado **específicamente en blockchain/activos virtuales** desde 2017; Chambers Band 2 | El fit más cercano al caso de uso. Probablemente más accesible que Big Law. **Primera llamada sugerida.** | +| **Nader Hayaux & Goebel** | Medios de pago, e-wallets, proyectos de criptomonedas | Despacho grande mexicano; balance especialidad/peso institucional | +| **White & Case México** | **Band 1** Chambers FinTech México | El más caro casi con seguridad. Útil si el dictamen se usará frente a inversionistas o un banco | +| **Hogan Lovells México** | Licencias fintech + **PLD/AML** explícito | Opción si se quiere dictamen + programa PLD completo | +| **Bello, Gallardo, Bonequi y García** | Autorizaciones para wallets/pagos/transmisión de dinero | Relevante solo si el dictamen concluye que sí hace falta autorización | + +**Estrategia sugerida:** cotizar en paralelo con **un boutique especialista + un despacho grande** para +contrastar precio y enfoque. No secuencial — el tiempo aquí es el recurso escaso. + +--- + +## 6. ✅ Lo que resultó correcto (no tocar, ya está verificado) + +| Dato | Fuente | +|---|---| +| UMA 2026 = **$117.31** diarios (vigente 1-feb-2026 → 31-ene-2027) | INEGI / DOF 9-ene-2026 | +| Umbral de **aviso** = **210 UMA = $24,635.10 MXN** (exacto) | Portal SPPLD del SAT | +| Umbral de **identificación** = **"Siempre"**, desde el primer peso | Portal SPPLD del SAT | +| Beneficiario controlador: 50% → **25%** | Reforma DOF 16-jul-2025 | +| Retención de expedientes: 5 → **10 años** | Reforma DOF 16-jul-2025 | +| Avisos mensuales, **día 17** del mes siguiente | SAT | +| Reforma publicada **16-jul-2025**, en vigor **17-jul-2025** | DOF | + +**Consecuencia de diseño que se sostiene:** como la identificación es obligatoria desde el primer peso, +el **Nivel 0 = sin trades cash↔cripto** del motor de tiers es la decisión correcta y no hay que rediseñarla. + +--- + +## 7. Acciones que se derivan + +- [ ] **Mandar el brief** a 2–3 despachos de §5 (única acción bloqueante real; nadie más puede hacerla) +- [ ] En el dictamen, pedir explícitamente respuesta a la **pregunta 7** (exposición retroactiva desde 2019) +- [ ] Modelar el umbral de **comisión ≥ 4 UMA** al fijar los parámetros económicos de mainnet +- [ ] No mover mainnet hasta: dictamen → alta SPPLD → oficial de cumplimiento +- [ ] Mantener `KYC_GATE_ENABLED=false` hasta que el dictamen valide o corrija los umbrales + +--- + +## Fuentes primarias consultadas (2026-07-21) + +- [Portal SPPLD del SAT — umbrales de actividades vulnerables](https://sppld.sat.gob.mx/pld/interiores/umbrales.html) — **la fuente autoritativa** de los umbrales de identificación y aviso +- [DOF — Decreto de reforma a la LFPIORPI, 16-jul-2025](https://www.diputados.gob.mx/LeyesBiblio/legis/reflxvi/decreto_05_16jul25.pdf) +- [DOF — Valor de la UMA 2026 (INEGI)](https://www.dof.gob.mx/nota_detalle.php?codigo=5778072&fecha=09%2F01%2F2026) +- [UIF — Criterio general para la aplicación de la fracción XVI del Art. 17 LFPIORPI](https://www.gob.mx/uif/prensa/comunicado-040-la-uif-emite-criterio-general-para-la-aplicacion-de-fraccion-xvi-del-articulo-17-de-la-lfpiorpi?idiom=es) +- [Banxico — Circular 4/2019 (DOF 8-mar-2019)](https://www.dof.gob.mx/nota_detalle.php?codigo=5552303&fecha=08/03/2019) +- [EY México — Reforma a la Ley Antilavado 2025](https://www.ey.com/es_mx/technical/tax/boletines-fiscales/reforma-ley-antilavado-2025-nuevas-obligaciones) +- [KPMG México — Flash: Decreto que reforma la LFPIORPI](https://kpmg.com/mx/es/tendencias/2025/07/flash-decreto-que-reforma-y-adiciona-disposiciones-a-la-lfpiorpi.html) +- [Expansión — SAT pide nombres e historial de operaciones cripto (18-mar-2026)](https://expansion.mx/finanzas-personales/2026/03/18/sat-criptomonedas-actividad-vulnerable) +- [Chambers — FinTech Legal México (rankings)](https://chambers.com/legal-rankings/fintech-legal-mexico-49:2744:144:1) diff --git a/docs/KYC_COMPLIANCE_PLAN_2026-07.md b/docs/KYC_COMPLIANCE_PLAN_2026-07.md new file mode 100644 index 00000000..544f985e --- /dev/null +++ b/docs/KYC_COMPLIANCE_PLAN_2026-07.md @@ -0,0 +1,111 @@ +# Plan KYC / Cumplimiento PLD — Mercado Mexicano (julio 2026) + +> Investigación al 2026-07-16. **No es asesoría legal** — el paso 0 de cualquier opción es un dictamen de un despacho fintech mexicano. Este doc fija el mapa regulatorio, las opciones y el plan técnico mapeado al código actual. + +--- + +## 1. Mapa regulatorio (post-reforma LFPIORPI, DOF 16-jul-2025) + +MicoPay facilita intercambio habitual efectivo↔USDC entre particulares. Eso cae en **Art. 17 fracción XVI LFPIORPI** ("intercambio de activos virtuales") = **actividad vulnerable**, aunque no exista licencia VASP en México y aunque el escrow sea non-custodial. La reforma de julio 2025 endureció todo: + +> ⚠️ **Vigencia (verificado 2026-07-21): la fracción XVI no es nueva ni entra en vigor en 2027.** Se añadió en el decreto del 9-mar-2018 (Ley Fintech), cuyo transitorio de 18 meses la puso en vigor **~septiembre de 2019**; el decreto del 16-jul-2025 solo la **reformó** (bajó el umbral de aviso 645→210 UMA y la extendió a operaciones desde el extranjero con mexicanos). Circula en blogs SEO/resúmenes de IA la idea de que arranca 18 meses después de la reforma de 2025 (≈ enero 2027) — **es falso**, confunde los dos decretos. En marzo de 2026 el SAT ya la aplica activamente a plataformas cripto. Detalle y fuentes en [`FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md`](./FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md). + +| Obligación | Regla post-reforma | +|---|---| +| Identificación del cliente | **Siempre — desde el primer peso** (antes había umbral) | +| Aviso al SAT/UIF | Operaciones ≥ **210 UMA = $24,635.10 MXN** (2026, UMA $117.31; antes 645 UMA — bajó 67%) | +| Aviso por comisión | También se activa aviso cuando la **comisión cobrada ≥ 4 UMA = $469.24 MXN** ⚠️ relevante para el fee del protocolo | +| Registro | Padrón **SPPLD del SAT** (requiere RFC + e.firma de la sociedad) | +| Avisos | Mensuales (día 17), **informes en ceros** si no hubo reportables | +| Operaciones inusuales | Aviso a UIF en **24 horas**; requiere **monitoreo automatizado** (Art. 18 X — nuevo) | +| Beneficiario controlador | Identificar desde **25%** de participación (antes 50%) | +| Retención de expedientes | **10 años** (antes 5) | +| Alcance | Explícitamente incluye operar **desde otra jurisdicción con mexicanos** | +| Sanciones | Hasta 65,000 UMA (~$7.6M MXN) o 10–100% del valor de la operación; clausura | + +Contexto CNBV/Banxico: bancos e ITFs tienen prohibido ofrecer cripto a clientes (Circular 4/2019), pero una sociedad mercantil no-financiera opera legalmente bajo LFPIORPI sin licencia. Al ser **non-custodial** (HTLC en Soroban, firmas en dispositivo), MicoPay no capta ni custodia fondos → argumento fuerte de que **no** requiere licencia IFPE. **Watch:** la industria empuja "Fintech Law 2.0" en 2026 (nueva dirección CNBV) — podría crear licencia VASP por niveles. + +**Nota clave sobre los merchants:** si MicoPay no es el sujeto obligado, cada tendero que intercambia habitualmente lo sería individualmente — inviable para ellos. Que MicoPay asuma el rol de sujeto obligado y les resuelva el cumplimiento **es parte de la propuesta de valor**, no solo un costo. + +## 2. Qué pide el mercado (estándar de facto) + +Flujo estándar mexicano (Bitso, exchanges CNBV-adjacentes, fintechs): +- **INE o pasaporte** + **selfie con liveness** (prueba de vida) +- **CURP** validada contra RENAPO; INE validada contra lista nominal +- **Comprobante de domicilio** y/o **RFC** para límites altos +- **Modelo de niveles** con límites crecientes (patrón Bitso: 3 niveles) + +## 3. Proveedores evaluados + +| Proveedor | Fuerte | Débil | Fit | +|---|---|---|---| +| **Incode** (absorbió MetaMap 2024) | El mejor acceso a fuentes gubernamentales MX (CURP/RENAPO, INE, RFC); biometría top (NIST); estándar bancario MX | Pricing enterprise, ciclo de ventas | Escala / mainnet serio | +| **Truora** | Especialista LATAM, checks contra fuentes oficiales, flujos por WhatsApp, más barato | Menos profundidad biométrica | Arranque con costo bajo | +| **Sumsub** | Global, muy fuerte en crypto (Travel Rule, wallets screening) | Caro, sin foco en fuentes MX | Si hay expansión multi-país | +| **Didit** | Free tier / muy barato | Menos validación de fuentes MX | Piloto / demo | +| **Etherfuse hosted** (ya integrado) | Cero costo, ya funciona | Solo cubre SU servicio (CETES); no es delegable para el P2P core de MicoPay | Se queda para el ramp CETES | + +## 4. Opciones + +### Opción A — Statu quo plus (piloto/testnet) +Solo KYC de Etherfuse para CETES; P2P sin identificación, con límites bajos hardcodeados. +- ✅ Costo cero, nada que construir. +- ❌ Post-reforma es **insostenible en mainnet**: la identificación es obligatoria desde el primer peso para intercambio de AV. Solo defendible mientras todo sea testnet sin dinero real. +- **Veredicto: es la fase actual, no una opción de destino.** + +### Opción B — KYC por niveles con proveedor (RECOMENDADA para mainnet) +Modelo tipo Bitso, adaptado: + +| Nivel | Requisitos | Permisos | +|---|---|---| +| 0 — Explorar | Solo cuenta + keypair | Ver mapa, recibir pagos directos pequeños; **sin** trades cash↔crypto | +| 1 — Identificado | INE/pasaporte + selfie liveness + CURP validada | Trades hasta ~$3,000 MXN/op, techo mensual ~$10,000 MXN | +| 2 — Verificado | + comprobante domicilio (+ RFC opcional) | Hasta <210 UMA/op ($24.6k); operaciones mayores generan aviso automático | +| M — Merchant/Agente | Nivel 2 + KYB si persona moral + beneficiario controlador (25%) + domicilio del negocio | Operación como nodo de liquidez | + +- ✅ Cumple identificación universal; los límites del Nivel 1 mantienen fricción mínima para el usuario de a pie (el mercado objetivo: no bancarizados, tickets chicos). +- ✅ Los umbrales en UMA viven en config (cambian cada año). +- Proveedor: **Truora o Didit para arrancar barato → Incode al escalar** (o Incode directo si el pricing inicial lo permite). + +### Opción C — Programa PLD completo (obligatorio antes de escalar, complementa B) +No es alternativa a B: es la capa institucional que la ley exige al sujeto obligado: +1. Constitución/estructura societaria clara + RFC + e.firma → **alta en padrón SPPLD**. +2. **Oficial de cumplimiento** (outsourced al inicio, ~$15–40k MXN/mes) + manual PLD + matriz de riesgo. +3. **Motor de avisos**: agregación mensual por cliente, XML SAT, informes en ceros, aviso 24h. +4. **Monitoreo automatizado** (Art. 18 X): reglas (estructuración/pitufeo bajo umbral, velocidad, geografía) + screening de listas (UIF, OFAC, PEPs) en onboarding y recurrente. +5. Retención cifrada 10 años de expedientes y avisos. + +### Secuencia recomendada: **A (hoy, testnet) → B (gate de mainnet) → C (antes de volumen real)** +El lanzamiento mainnet **no debe ocurrir sin B funcionando y el registro SPPLD de C iniciado.** + +## 5. Plan técnico (mapeado al código actual) + +Lo ya construido que se reutiliza: `users.kyc_status`, `KYCScreen.tsx` (patrón hosted-flow: `startKYC` → browser del sistema → polling de status), auth challenge-response Ed25519, `trade.service.ts` como choke-point de todas las operaciones. + +**Fase 1 — Esquema y motor de límites (independiente del proveedor):** +- Migración: `users.kyc_level` (0/1/2/M), `kyc_provider`, `kyc_verified_at`; tabla `kyc_events` (audit); tabla `user_monthly_volume` o agregación sobre trades. +- Middleware en `trade.service.ts`: valida nivel + límite por operación + acumulado mensual **antes** de crear/lockear cualquier trade. Límites en UMA en config, no hardcodeados. +- Feature-flag por ambiente (testnet laxo, mainnet estricto). + +**Fase 2 — Integración del proveedor:** +- Generalizar el patrón KYCScreen actual a multi-provider: `POST /kyc/start?provider=` → URL hosted del proveedor → webhook firma resultado → actualiza `kyc_level`. +- El KYC de Etherfuse queda como requisito **adicional** solo para el ramp CETES (ellos siguen siendo sujeto obligado de su servicio). + +**Fase 3 — Cumplimiento operativo (C):** +- Job mensual de agregación → candidatos a aviso → XML SAT; screening de listas en onboarding + batch recurrente; reglas de inusualidad con cola de revisión y timer de 24h. + +**Fase 4 — Societario/legal (en paralelo desde ya):** +- Dictamen legal (¿sujeto obligado MicoPay o los merchants? ¿estructura societaria?), alta SPPLD, oficial de cumplimiento. +- Brief listo para enviar a despachos + checklist de seguimiento: `docs/FASE4_LEGAL_DICTAMEN_BRIEF_2026-07.md`. Dueño: Eric/Jose, fuera de GrantFox — no bloquea el trabajo de ingeniería (#315/#316/#317) pero sí bloquea activar `KYC_GATE_ENABLED` en producción. + +## 6. Costos estimados (orden de magnitud) +- Verificación: ~$0.5–2 USD por check (Truora/Didit abajo, Incode arriba) → a 1,000 onboardings/mes: $500–2,000 USD/mes. +- Oficial de cumplimiento outsourced: $15–40k MXN/mes. +- Dictamen legal inicial: $50–150k MXN una vez. +- Desarrollo Fases 1–2: ~2–4 semanas de trabajo interno; Fase 3: ~3–4 semanas. + +## 7. Fuentes +- Reforma LFPIORPI y umbrales: kyc-systems.com/blog/lfpiorpi · kimgomezfranco.com (actualización umbrales 2025) · ey.com/es_mx (reforma antilavado 2025) · hoganlovells.com (modificaciones reglamento) +- Marco general cripto MX: globallegalinsights.com (Blockchain & Crypto Laws Mexico 2026) · cms.law (crypto regulation Mexico) · license.aiying.cc (Fintech Law 2.0 push 2026) +- Proveedores: signzy.com (KYC platforms Mexico 2026) · sacra.com (MetaMap→Incode) · didit.me (pricing comparison) +- Modelo de niveles: soporte y blog de Bitso diff --git a/docs/MULTI_ASSET_ESCROW_ONBOARDING_PLAN_2026-07.md b/docs/MULTI_ASSET_ESCROW_ONBOARDING_PLAN_2026-07.md new file mode 100644 index 00000000..b7029a01 --- /dev/null +++ b/docs/MULTI_ASSET_ESCROW_ONBOARDING_PLAN_2026-07.md @@ -0,0 +1,317 @@ +# Plan de implementación — Onboarding de trustlines + escrow multi-asset (UX en pesos) + +> **Fecha:** 2026-07-02 · **Origen:** discusión de diseño (Eric + Fable) tras detectar que una cuenta +> nueva de MicoPay nace sin fondos ni trustlines y hubo que fondearla a mano. +> **Ejecutor: Sonnet.** Las decisiones de diseño ya están tomadas y revisadas — tu trabajo es +> implementar, no rediseñar. Reglas de ejecución: +> 1. **Un WP por vez, en orden.** Cada WP termina con su bloque **Verify** en verde y un commit +> propio (`feat(escrow-multiasset): WPn — `). No mezclar WPs en un commit. +> 2. **No refactorices nada fuera del alcance del WP**, aunque veas código mejorable. Si encuentras +> un bug fuera de alcance, anótalo al final del PR/reporte, no lo arregles. +> 3. **Verifica antes de asumir:** las referencias `archivo:línea` de este doc eran correctas el +> 2026-07-02 pero el código puede haberse movido — confirma con grep antes de editar. +> 4. **Condiciones de PARO (detente y reporta a Eric, no continúes):** +> - WP0 revela que la instancia actual está en USDC (ver HALLAZGO-1). +> - Una migración SQL falla o el resultado difiere de lo esperado — **la DB es la de producción +> en Render y el backend aplica migraciones al deployar** (migrate.ts como preDeploy): mergear +> una migración = aplicarla en prod. Nunca pruebes una migración directo en esa DB; usa una DB +> local o transacción con ROLLBACK primero. +> - Necesitas una llave secreta que no está en los `.env` locales, o fondos que no existen. +> - Un test preexistente que pasaba se rompe y la causa no es obvia en <30 min. +> 5. Comandos de verificación estándar: backend `cd micopay/backend && npx tsc --noEmit && npm test`; +> frontend `cd micopay/frontend && npx tsc --noEmit && npm run build`. Córrelos al cierre de CADA WP. +> 6. Secretos: nunca imprimas valores de `.env` en logs/salida; los `.env` locales ya tienen +> `PLATFORM_SECRET_KEY` de testnet — úsalo leyéndolo del archivo, no lo copies a ningún lado. +> **Decisiones ya tomadas en la discusión** (este doc las implementa, no las reabre): +> 1. Trustlines se abstraen **al fondear**, no al registrar (antes de fondear la cuenta no existe +> on-chain y cada trustline exige ~0.5 XLM de reserva). +> 2. Escrow multi-asset por **instancia por asset** (opción A), no contrato multi-token. +> 3. La selección de activo vive en la **creación de la oferta, lado vendedor** — el comprador solo +> ve pesos y un badge del asset. +> 4. Default **MXNe** (es pesos, 1:1); USDC como opción con tasa; **CETES fuera del escrow** por ahora +> (instrumento de inversión, gated por `VITE_ENABLE_DEFI_TRADING`, audit B2: no mueve fondos reales). +> 5. Mainnet usa **sponsored reserves** (el usuario nunca necesita XLM); testnet usa friendbot. + +--- + +## 0. Punto de partida (verificado contra el código, 2026-07-02) + +**Lo que existe hoy:** +- Registro (`micopay/frontend/src/App.tsx:824-828`): genera keypair en el dispositivo y registra al + usuario. **No** llama a friendbot ni crea trustlines — la cuenta no existe on-chain hasta que + alguien la fondea a mano. +- Trustlines lazy: `ensureTrustline()` (`frontend/src/services/payment.ts:71`) solo se invoca en + `TradeDetail.tsx:773` y `QRReveal.tsx:51`, ya dentro del trade, y lanza `UNDERFUNDED` si la cuenta + no tiene ~0.5 XLM. +- El asset del escrow es una env var global: `VITE_ESCROW_ASSET_CODE || 'USDC'`. +- Contrato escrow (`micopay/contracts/escrow/src/lib.rs:37-47`): **un solo token por instancia**, + fijado en `initialize` (instance storage). Instancia testnet actual: + `CB4M5777YFQWKGDUULCX5W6PXEDJSJARDTMH4VV6FXC4W4UPANALO3HZ`. +- La tabla `trades` YA es peso-first (`micopay/sql/init.sql:49-50`): `amount_mxn INTEGER` + + `amount_stroops BIGINT`. Falta: asset, tasa, fuente de tasa. +- Rates: `backend/src/routes/rate.ts` ya tiene XLM→MXN y USDC→MXN multi-fuente (coinbase/kraken/ + coingecko/binance × er-api) con fallbacks — la infraestructura de cotización existe. +- Cuenta plataforma testnet `GDKK…BJJK`: 17,905 XLM, 1.52 USDC. + +**⚠️ HALLAZGO-1 (pre-requisito de todo lo demás):** `trade.service.ts:59,210` convierte con constante +fija `STROOPS_PER_MXN = 10_000_000` → `amountStroops = amountMxn × 10^7`, o sea **asume que el asset +del escrow vale exactamente 1 MXN** (MXNe). Pero el frontend defaultea la trustline a **USDC**. Si la +instancia deployada fue inicializada con USDC, un trade de 100 MXN bloquea **100 USDC (~1,750 MXN)**; +si fue inicializada con MXNe, el bug es el default `'USDC'` del frontend (trustline del asset +equivocado). En cualquier caso hay una inconsistencia real hoy. **Paso 0 obligatorio:** leer el +`token_id` del instance storage del contrato `CB4M…` en testnet (via `stellar contract read` o RPC +`getLedgerEntries`) y documentar cuál de las dos patas está mal antes de tocar nada. + +--- + +## 1. Work packages + +### WP0 — Diagnóstico del HALLAZGO-1 · ~30 min · bloqueante + +> **✅ RESUELTO (Fable, 2026-07-02, verificado on-chain):** el instance storage de `CB4M…` dice +> `TokenId = CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` → **la instancia actual es +> MXNe** (admin y plataforma: `GDKK…BJJK`). La conversión 1:1 del backend es CORRECTA; el bug es el +> default `'USDC'` del frontend: `ensureTrustline('USDC')` crea la trustline del asset equivocado, +> así que a un comprador nuevo sin trustline MXNe el release del escrow le puede fallar. +> **Para el ejecutor:** el paso 1 ya no es necesario; aplica directamente la rama "token es MXNe" +> del paso 2 (fix de una línea + env var) como primer commit. En WP3, la instancia NUEVA a deployar +> es la de **USDC**. +1. Leer `token_id` del instance storage de la instancia `CB4M…` en testnet. El contrato NO expone un + getter (`lib.rs` solo exporta initialize/lock/release/refund/get_trade), así que se lee el ledger + entry directamente. Script listo (correr con `node` desde `micopay/backend`, que ya tiene + `@stellar/stellar-sdk`): + ```js + // wp0-check-token.mjs — leer TokenId del instance storage del escrow + import { rpc, xdr, Address, scValToNative } from '@stellar/stellar-sdk'; + const s = new rpc.Server('https://soroban-testnet.stellar.org'); + const CONTRACT = 'CB4M5777YFQWKGDUULCX5W6PXEDJSJARDTMH4VV6FXC4W4UPANALO3HZ'; + const entry = await s.getContractData( + CONTRACT, xdr.ScVal.scvLedgerKeyContractInstance(), rpc.Durability.Persistent); + const storage = entry.val.contractData().val().instance().storage(); + for (const item of storage ?? []) { + const key = scValToNative(item.key()); + console.log(key, '→', (() => { try { return scValToNative(item.val()); } catch { return item.val().switch().name; } })()); + } + ``` + El valor de la clave `TokenId` es un contract address `C…`. Compararlo: + - MXNe SAC = `CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC` (TESTNET.md:11) + - Si no es ese, resolver a qué asset corresponde (probable USDC SAC del issuer `GBBD…FLA5`). + Nota: pistas circunstanciales apuntan a MXNe (TESTNET.md documenta MXNe junto al escrow, y la + conversión 1:1 del backend solo tiene sentido con MXNe) — pero se confirma on-chain, no se asume. +2. Documentar el resultado **en esta sección del doc** (editar aquí mismo) y aplicar la corrección: + - Si el token es MXNe → cambiar el default de `TradeDetail.tsx:772` / `QRReveal.tsx:50` a + `'MXNE'` y setear `VITE_ESCROW_ASSET_CODE=MXNE` en `micopay/frontend/.env.testnet` (fix de una + línea). Continuar con WP1. + - Si el token es USDC → **PARO: reportar a Eric antes de continuar** (los montos de los trades + reales están mal denominados; WP2 pasa de mejora a fix y hay que decidir qué hacer con los + trades históricos). + +**Verify:** el asset de la trustline que crea el flujo de trade coincide con el token del contrato +donde se bloquea; el resultado quedó escrito en este doc. + +--- + +### WP1 — Onboarding testnet: friendbot + trustlines al registrarse · ~0.5 día · bajo riesgo +**Archivos:** `frontend/src/App.tsx` (flujo de registro), nuevo `frontend/src/services/onboarding.ts`, +reusa `toAsset`/`hasTrustline` de `payment.ts`, `.env.testnet`. + +Desbloquea las pruebas del equipo esta semana (hoy cada cuenta nueva requiere fondeo manual). + +1. Crear `frontend/src/services/onboarding.ts` con una única función exportada + `setupTestnetAccount(): Promise` y llamarla (sin `await` bloqueante del flujo de registro — + fire-and-forget con manejo de error propio) tras registro exitoso (`App.tsx` ~línea 828, después + de `getPublicKey()`), solo si `import.meta.env.VITE_STELLAR_NETWORK === 'TESTNET'`: + 1. `fetch('https://friendbot.stellar.org/?addr=' + pubKey)` — idempotente: si la cuenta ya existe + friendbot responde 400 (`op_already_exists` en el detalle), tratar como éxito. + 2. **Una sola transacción** con `changeTrust` × [USDC, MXNe] firmada con la llave del dispositivo + (un fee, un round trip). Reusar `toAsset`/`hasTrustline`/el patrón de submit de `payment.ts` + (no duplicar la lógica de red/passphrase). Idempotente: filtrar por `hasTrustline` antes de + agregar cada op; si ambas existen, no someter nada. +2. UI: estado "Preparando tu cuenta…" no bloqueante. Si friendbot da 429/timeout, el registro + **completa igual** — el lazy `ensureTrustline` existente queda como self-heal (no se elimina). +3. CETES **no** se incluye: su trustline se crea al entrar a `CETESScreen`/flujo de inversión + (cada trustline son 0.5 XLM de reserva; no regalarla para un asset que la mayoría no usará). +4. Fondeo de assets de prueba para el equipo (operativo, no código): la plataforma solo tiene 1.52 + USDC — conseguir USDC/MXNe de prueba de sus emisores testnet (`GBBD…FLA5` / `GBZXN…OALV`) o + emitir, y documentar en el README interno cómo pedir saldo de prueba. + +**Verify:** cuenta recién registrada en build testnet → Horizon muestra XLM + trustlines USDC y MXNe +sin intervención manual; registro sobrevive a friendbot caído; correr dos veces no duplica nada. + +--- + +### WP2 — Conversión por asset + tasa congelada por trade · ~1 día · CORE +**Archivos:** `backend/src/services/trade.service.ts`, `backend/src/routes/rate.ts` (extraer a +servicio), migración nueva en `micopay/sql/migrations/` (patrón `YYYYMMDDHHMMSS_*.up/.down.sql`), +`backend/src/index.ts` (queries de trades), `frontend/src/services/api.ts`. + +Es la pieza que hace real el "UX en pesos con activos por detrás": el peso es la denominación +primaria (ya lo es: `amount_mxn`), el asset y la tasa son metadata del trade. + +1. **Migración** en `micopay/sql/migrations/` con el patrón de nombre timestamped y su `.down.sql` + (espejo de `20260702090000_ramp_order_ownership.{up,down}.sql`, el más reciente): + ```sql + -- 2026MMDDHHMMSS_trade_asset_rate.up.sql + ALTER TABLE trades + ADD COLUMN asset_code VARCHAR(12) NOT NULL DEFAULT 'MXNE', -- confirmado en WP0 + ADD COLUMN rate_mxn NUMERIC(18,7) NOT NULL DEFAULT 1, -- MXN por 1 unidad del asset + ADD COLUMN rate_source VARCHAR(32), + ADD COLUMN rate_locked_at TIMESTAMPTZ; + ``` + El DEFAULT preserva la semántica de los trades históricos según lo que diga WP0. Recordatorio de + la regla 4 del encabezado: esta migración se aplica a la DB de producción al deployar — probarla + antes en local o dentro de una transacción con ROLLBACK. +2. `createTrade` recibe `asset_code` (default MXNe) y reemplaza la constante. Crear + `backend/src/services/assetRate.service.ts` con UNA función de conversión + `mxnToAssetStroops(amountMxn: number, assetCode: string): Promise<{ stroops: bigint; rateMxn: string; rateSource: string }>` + — toda conversión del sistema pasa por ahí, nadie más multiplica: + - MXNe → `rate = 1` exacto, `amount_stroops = amount_mxn × 10^7` (comportamiento actual). + - USDC → tasa viva de `rate.ts` (extraer la lógica de fetch a este servicio o importarla — no + duplicar las fuentes) **congelada y persistida** en el row: + `amount_stroops = round(amount_mxn × 10^7 / rate_mxn)`. Aritmética con `BigInt`/enteros: la + tasa se maneja como entero escalado (p.ej. milésimas de centavo), el redondeo se define UNA vez + (round half-up, documentado en el JSDoc de la función) y se testea en los bordes; nunca floats + encadenados. + - Asset no soportado → 400 con código de error del taxonomy existente + (`backend/src/utils/errors.ts`). +3. **Ventana de tasa:** la tasa congelada al crear vale hasta el `lock`. Si el vendedor bloquea + > 10 min después de creado el trade con asset ≠ MXNe, el backend recotiza y actualiza + `rate_mxn/amount_stroops` **antes** de armar la tx de lock (el monto MXN nunca cambia — es el + ancla del acuerdo P2P). Para MXNe la ventana es irrelevante (1:1). +4. Respuestas de la API de trades incluyen `asset_code`, `rate_mxn`, `rate_source` — el frontend + puede mostrar "≈ 5.71 USDC" como secundario del monto en pesos, y una disputa de "acordamos X + pesos" tiene la tasa y fuente persistidas como evidencia. + +**Verify:** unit tests de conversión (bordes de redondeo, montos mínimos, rate con 7 decimales); +trade MXNe reproduce byte a byte los montos actuales; trade USDC de 100 MXN a rate 17.5 bloquea +exactamente 57,142,857 stroops (o el redondeo documentado); recotización al lock tardío. + +--- + +### WP3 — Escrow multi-asset por instancias · ~0.5 día + deploy · bajo riesgo (cero Rust nuevo) +**Archivos:** deploy (CLI), `backend/src/config.ts`, `backend/src/services/stellar.service.ts`, +`backend/src/services/trade.service.ts`, `frontend/src/pages/TradeDetail.tsx`, `QRReveal.tsx`, +`frontend/src/services/api.ts`. + +1. **Deploy** de una segunda instancia del **mismo WASM ya auditado** del escrow, `initialize` con el + SAC del asset faltante según WP0 (si la instancia actual es MXNe, la nueva es USDC — SAC del + issuer `GBBD…FLA5`; se obtiene con `stellar contract asset id --asset USDC:GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5 --network testnet`, + y si el SAC no está deployado, `stellar contract asset deploy` con los mismos args). Seguir el + procedimiento documentado en `micopay/contracts/TESTNET.md` (build, deploy, initialize) — **leer + la firma real de `initialize` en `lib.rs:37` para los argumentos**, no inventarla; usar el mismo + admin/plataforma que la instancia actual. Registrar el contract id nuevo en TESTNET.md. El + allowlist de assets ES el conjunto de instancias deployadas — nadie puede meter un token basura, + y no se reabre el contrato auditado. +2. Backend: mapa `asset → contract_id` en `config.ts` (`ESCROW_CONTRACT_USDC`, + `ESCROW_CONTRACT_MXNE`; la var vieja `ESCROW_CONTRACT_ID` se mapea al asset que diga WP0 para no + romper deploys existentes). `stellar.service.ts` (`lock`/`release`/`refund`, ~líneas 147-235) + recibe el `contract_id` del trade en vez de leer el global. +3. API: los responses de trade incluyen `escrow_contract_id` junto a `asset_code`. +4. Frontend: `TradeDetail.tsx` y `QRReveal.tsx` leen `trade.asset_code` y `trade.escrow_contract_id` + del trade — `VITE_ESCROW_ASSET_CODE` y `VITE_ESCROW_CONTRACT_ID` quedan solo como fallback una + release y luego se retiran. + +**Verify:** e2e en testnet por cada asset: trade MXNe y trade USDC completos +(lock → reveal → release) + camino de refund en ambos; un trade creado antes de la migración sigue +funcionando con la instancia vieja. + +--- + +### WP4 — Pantalla de selección de activo (lado vendedor) · ~1 día · UX +**Archivos:** flujo de creación de oferta (entra por `App.tsx:916` → `createTrade`; +`TradeConfirmation.tsx` es el pre-flight summary existente), `frontend/src/services/api.ts`, +`frontend/src/i18n/{es,en}.json`. + +1. Selector **antes del pre-flight de `TradeConfirmation`**, solo para quien bloquea fondos + (vendedor). Framing peso-first — la pregunta no es "elige tu activo": + - Título: **"¿En qué guardas tu dinero?"** — el monto en MXN siempre como número principal. + - Opción default (preseleccionada): **Pesos digitales (MXNe)** — "sin tipo de cambio". + - Opción: **Dólares (USDC)** — muestra equivalente y tasa viva: "≈ 5.71 USDC · $17.50/USD". + - CETES no aparece (decisión de diseño, ver encabezado). +2. Al seleccionar un asset sin trustline → `ensureTrustline(asset)` ahí mismo con estado + "Preparando tu cuenta…" (aquí convergen WP1 y WP4: en testnet ya existirá por onboarding; en + cuentas viejas se crea en este momento, que es el natural). +3. `api.ts createTrade` envía `asset_code`; el comprador ve el monto en pesos + badge discreto del + asset que lo respalda ("respaldado en USDC"), nunca una decisión. +4. i18n completo es/en desde el primer commit (lección de los PRs de i18n recientes). + +**Verify:** crear oferta MXNe y USDC desde la UI y completar ambos trades; el comprador nunca elige +asset; con trustline faltante el selector la crea y continúa; textos en ambos idiomas. + +--- + +### WP5 — Mainnet: sponsored reserves (el usuario nunca toca XLM) · ~2–3 días · **NO IMPLEMENTAR EN ESTE CICLO** + +> **Para el ejecutor:** este WP es diseño de referencia para el track mainnet. Tu alcance termina en +> WP4. No crees `wallet.ts` ni el servicio de co-firma ahora. +**Archivos:** nuevo `backend/src/routes/wallet.ts` (`POST /wallet/sponsor-setup`), nuevo servicio de +co-firma, `frontend/src/services/onboarding.ts` (rama mainnet). + +No bloquea WP1–WP4. Es la versión mainnet del mismo concepto de WP1 y se secuencia con los +blockers del audit mainnet, no antes. + +1. Flujo sandwich estándar de Stellar: la app construye la tx + `beginSponsoringFutureReserves(plataforma)` → [`createAccount` si no existe] → `changeTrust` + (source: usuario) → `endSponsoringFutureReserves`, firma con la llave del dispositivo y manda el + XDR al backend; la plataforma co-firma con `PLATFORM_SECRET_KEY` y somete. La llave del usuario + nunca sale del dispositivo; la plataforma solo paga reservas. +2. **El backend NUNCA co-firma a ciegas** (misma lección que la Fase 0 del ZKaaS): valida el XDR + recibido — exactamente las operaciones esperadas, sponsoring source = plataforma, `changeTrust` + solo de assets del allowlist (USDC/MXNe con issuers pinneados), fee acotado, nada más en la tx. + Cualquier op extra → rechazo. +3. Gancho de activación: **primer ramp-in de Etherfuse** (webhook de orden SPEI completada) — la + cuenta se crea patrocinada y con trustlines de forma invisible, que es el momento "al fondear" + de la decisión original. +4. Anti-farming: patrocinar reservas cuesta XLM real → rate limit por usuario/dispositivo y gated a + usuarios con ramp-in real (u orden KYC), no a cualquier registro. + +**Verify:** en testnet con `STELLAR_NETWORK=MAINNET` simulado no aplica — probar el sandwich completo +en testnet con la rama mainnet forzada; XDR adulterado (op extra, asset fuera de allowlist, otro +sponsor) → rechazado; cuenta nueva queda operable con 0 XLM propios. + +--- + +## 2. Orden de ejecución + +``` +WP0 (30 min, hoy) → WP1 (desbloquea al equipo) → WP2 → WP3 → WP4 + WP5 (track mainnet, tras blockers del audit) +``` + +- **WP0 primero y solo:** hasta no saber qué token tiene la instancia actual, cualquier otro cambio + puede estar construyendo sobre montos mal denominados. +- WP2 antes que WP3: la migración y la conversión definen el contrato de datos que WP3 y WP4 leen. +- WP4 al final del camino testnet: es la capa visible; sin WP2/WP3 no tiene qué seleccionar. + +--- + +## 3. Definición de "hecho" + +- [ ] WP0: token de la instancia `CB4M…` documentado y la inconsistencia HALLAZGO-1 corregida. +- [ ] Cuenta nueva en build testnet queda fondeada (friendbot) y con trustlines USDC+MXNe sin + intervención manual; el registro no se rompe si friendbot falla. +- [ ] `trades` tiene `asset_code`, `rate_mxn`, `rate_source`, `rate_locked_at`; la tasa se congela al + crear y se recotiza en lock tardío; conversión con enteros/BigInt y tests de borde verdes. +- [ ] Dos instancias de escrow (USDC, MXNe) del mismo WASM auditado; backend y frontend resuelven + contrato y asset **por trade**, no por env var global. +- [ ] El vendedor elige asset en una pantalla peso-first (default MXNe); el comprador solo ve pesos; + trades e2e completos en ambos assets (lock → reveal → release y refund). +- [ ] i18n es/en completo; `tsc --noEmit` y `npm test` verdes en frontend y backend; el APK testnet + existente no se rompe (fallback de env vars una release). +- [ ] WP5 diseñado aquí queda explícitamente **fuera** de este ciclo — se ejecuta con el track + mainnet. + +--- + +## 4. Riesgos + +| Riesgo | Mitigación | +|---|---| +| ~~HALLAZGO-1: montos mal denominados si la instancia es USDC~~ **RESUELTO**: instancia es MXNe; el bug real era el default `'USDC'` del frontend (trustline equivocada → release puede fallar a compradores nuevos) | Fix de una línea en WP0 paso 2; queda cubierto además por el onboarding de WP1 (crea ambas trustlines) | +| Redondeo MXN↔asset (7 decimales) | Una sola función de conversión, enteros/BigInt, regla de redondeo documentada, tests de borde (WP2) | +| Friendbot rate-limita en registros masivos de prueba | Registro nunca depende de friendbot; retry lazy + `ensureTrustline` self-heal (WP1) | +| Plataforma casi sin USDC/MXNe de prueba (1.52 USDC) | Punto operativo WP1.4 — conseguir saldo de emisores testnet antes de las pruebas del equipo | +| Tasa USDC se mueve entre crear y lock | Ventana de 10 min + recotización en lock; el monto MXN nunca cambia (WP2.3) | +| Env vars viejas (`VITE_ESCROW_*`, `ESCROW_CONTRACT_ID`) en builds/deploys existentes | Fallback mapeado una release, retiro después (WP3) | +| Farming de reservas patrocinadas en mainnet | WP5.4: gated a ramp-in real + rate limit; no patrocinar por registro | +| Co-firma de XDR del cliente en WP5 | Validación estricta de ops/allowlist antes de firmar — nunca firmar a ciegas | diff --git a/docs/PLAN_MAPA_REAL_2026-07.md b/docs/PLAN_MAPA_REAL_2026-07.md new file mode 100644 index 00000000..10019959 --- /dev/null +++ b/docs/PLAN_MAPA_REAL_2026-07.md @@ -0,0 +1,174 @@ +# Plan de implementación — Mapa real + brechas del APK + +**Fecha:** 2026-07-25 · **Origen:** `docs/AUDIT_APK_MAPA_2026-07.md` (leerlo primero: contiene el diagnóstico completo) +**Ejecutor previsto:** sesión de agente (Sonnet) sin contexto previo. Este doc es autocontenido. +**Estado del repo esperado:** rama `main` con los merges #320/#321 y los fixes AWS (`1db550a`, `02232b4`). + +## Contexto mínimo (no asumir nada más) + +- El APK es React + Vite + Capacitor en `micopay/frontend`; backend Fastify en `micopay/backend`. +- El backend de producción vive en `https://api.micopay.app` (AWS ECS). **Este plan NO toca infraestructura AWS** — solo código del repo. Cualquier cambio de env vars de producción se anota en §7 como "handoff a humano". +- El pipeline de datos geoespaciales ya es real: GPS del usuario (`useGeolocation.ts`, `useMerchantsAvailable.ts`) → `GET /merchants/available` (Haversine en SQL, `merchant.service.ts:160`) → lista ordenada por distancia. +- Lo simulado es el **render** (`MapSim.tsx` = PNG estático) y falta el **flujo de captura de ubicación del comercio** (endpoint `PATCH /merchants/me/location` existe en `micopay/backend/src/routes/merchants.ts:113` pero el frontend jamás lo llama). + +## Reglas de trabajo + +- Una rama por paquete de trabajo: `feat/map-real-wp1`, `feat/map-real-wp2`, etc. Commits convencionales (`feat(map): …`, `fix(privacy): …`). +- Después de cada WP: `npm run build` en `micopay/frontend` (y en `micopay/backend` si se tocó) debe pasar. El CI ya bloquea builds rotos. +- No modificar: `Dockerfile`, `.github/workflows/ci.yml`, nada bajo `micopay/sql/migrations/` existente (solo se permite **añadir** migraciones nuevas). +- Textos de UI: siempre vía i18n (`src/i18n/es.json` + `en.json`), nunca hardcodeados. El español es el idioma primario. +- Estilo visual: conservar el design system existente (clases `surface-*`, `primary`, `font-headline`, bordes `rounded-[24px]/[32px]`) y los pins de hongo (`public/mushroom_*.png`). + +--- + +## WP1 — Componente de mapa real (MapLibre GL) + +**Objetivo:** reemplazar el PNG simulado por un mapa real con tiles, pan/zoom, centrado en el GPS real del usuario. Interfaz de props compatible con `MapSim` para swap 1:1. + +**Dependencia nueva:** `npm i maplibre-gl` en `micopay/frontend` (≈250 KB gz; el bundle actual es 1.7 MB — aceptable, no intentar code-splitting en este WP). + +**Tiles:** usar el estilo demo de MapLibre (`https://demotiles.maplibre.org/style.json`) SOLO como fallback de desarrollo. El estilo de producción se lee de `VITE_MAP_STYLE_URL` (env). En `.env.testnet` y `.env.mainnet` añadir la variable con un estilo de MapTiler free tier — **la key de MapTiler la provee el humano** (handoff §7); mientras no exista, el componente usa el fallback demo y muestra un aviso pequeño "mapa de desarrollo". + +**Archivos:** + +1. **Crear `src/components/MapReal.tsx`** con esta interfaz (superset de la de `MapSim`): + ```ts + interface MapRealProps { + type?: 'cashout' | 'deposit'; + merchants?: AvailableMerchant[]; + selectedMerchantId?: string | null; + onSelectMerchant?: (merchantId: string) => void; + /** Posición real del usuario; si null, fit-bounds solo sobre merchants */ + userPosition?: { lat: number; lng: number } | null; + } + ``` + Implementación: + - `maplibregl.Map` en un `div` contenedor con la misma altura/borde que MapSim (`h-64 rounded-[32px] overflow-hidden`). + - Markers de comercios: `maplibregl.Marker({ element })` con un `` de hongo (verde para `deposit`, rotación roja/verde/dorada para `cashout`, igual que hoy) + label con `username`. Click → `onSelectMerchant`. Selected → escala 1.25 + ring (clases existentes). + - Marker del usuario: punto azul/primary con pulso (reusar el estilo del pulso actual como elemento HTML custom). + - `map.fitBounds()` sobre usuario + merchants con `padding: 48, maxZoom: 16`. Si solo hay usuario, `setCenter` + zoom 14. + - Cleanup correcto en unmount (`map.remove()`). + - **No** incluir: el label "CDMX · ZONA CENTRO" ni "Agentes reales cercanos" (brecha G3 del audit). En su lugar, un chip discreto con `{merchants.length} agentes cerca` derivado de datos (i18n: `map.agentsNearby`). + - Importar el CSS: `import 'maplibre-gl/dist/maplibre-gl.css'` (una sola vez, en el componente). + +2. **Modificar `src/hooks/useMerchantsAvailable.ts`:** hoy obtiene `lat/lng` y los descarta tras el fetch. Exponerlos: al estado `success` añadir `userPosition: { lat, lng }`. Actualizar el tipo `MerchantsState`. (Cambio aditivo; no romper consumidores existentes.) + +3. **Swap en consumidores:** + - `src/pages/ExploreMap.tsx:203` — `` → ``. + - `src/pages/DepositMap.tsx:460` — igual (`type="deposit"`, sin selección, como hoy). + - **No borrar `MapSim.tsx` todavía** (lo referencian tests/snapshots potenciales); marcarlo `@deprecated` en JSDoc. Borrado real en WP5. + +4. **i18n:** añadir `map.agentsNearby` y `map.devMapNotice` a `es.json`/`en.json`. + +**Criterios de aceptación WP1:** +- `npm run build:testnet` pasa. +- En emulador/dispositivo: el mapa muestra tiles reales, el centro corresponde al GPS real, los pins están en sus coordenadas reales, tap en pin selecciona la oferta (scroll a card, igual que hoy). +- Sin conexión a tiles, el componente no crashea (MapLibre degrada solo; verificar que la pantalla sigue usable). +- `grep -rn "ZONA CENTRO\|Agentes reales" src/` → 0 resultados fuera de `MapSim.tsx` deprecado. + +--- + +## WP2 — Captura de ubicación del comercio (el unlock real) + +**Objetivo:** que un comercio real pueda fijar su ubicación desde el APK. Sin esto, ningún comercio real aparece jamás en el mapa (causa raíz §3.3 del audit). + +**Archivos:** + +1. **`src/services/api.ts`** — añadir (seguir el patrón de `patchMerchantAvailability`, línea 119): + ```ts + 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 // PATCH /merchants/me/location + ``` + Nota: el backend ya valida rangos (schema en `routes/merchants.ts:113-146`); no duplicar validación más allá de lo básico. + +2. **`src/services/api.ts`** — extender el tipo `MerchantConfig` (ya existe, lo usa `getMerchantConfig`) con `latitude/longitude/address_text` si aún no los expone — el backend YA los devuelve en `GET /merchants/me/config` (ver `merchant.service.ts:104`), solo falta tiparlos. + +3. **`src/pages/MerchantSettings.tsx`** — nueva sección "Mi ubicación" debajo de la sección de configuración existente (patrón visual: misma `
` de la línea 115): + - Estado: sin ubicación → texto "Aún no has fijado tu ubicación. Los clientes no pueden encontrarte en el mapa." + CTA primario **"Usar mi ubicación actual"**. + - CTA usa `useGeolocation` (hook existente, con su flujo de permisos) → al obtener coords, mostrar `MapReal` en modo picker: un solo marker **arrastrable** (`Marker({ draggable: true })` — añadir prop opcional `pickerMode` a `MapReal` en este WP) centrado en las coords, con texto "arrastra el pin para ajustar". + - Campo opcional `address_text` (input de texto, `maxLength 200`). + - Guardar → `updateMerchantLocation(...)` → mensaje de éxito (patrón `message/messageType` ya presente en el archivo). + - Con ubicación ya fijada → mostrar el mini-mapa con el pin actual + dirección + botón "Cambiar ubicación". +4. **Gate suave en `src/components/MerchantAvailabilityToggle.tsx`:** si el comercio activa disponibilidad y su config no tiene `latitude` (leer del `getMerchantConfig` que ya carga MerchantSettings, o fetch ligero), mostrar aviso no-bloqueante (banner warning): "Sin ubicación fijada no apareces en el mapa" con link a ajustes. **No bloquear** la activación (decisión: fricción mínima). +5. **i18n:** claves nuevas bajo `merchantSettings.location.*`. + +**Criterios de aceptación WP2:** +- Flujo completo en dispositivo: registrar usuario → MerchantSettings → fijar ubicación (permiso GPS → pin → ajustar → guardar) → activar disponibilidad → desde OTRO usuario/dispositivo (o web) buscar en `ExploreMap` con un monto dentro del rango → el comercio aparece en el mapa real. +- `PATCH` con token inválido → error manejado con banner, no crash. +- Backend no requiere cambios en este WP (el endpoint ya existe y está validado). + +--- + +## WP3 — Privacidad y abuso del endpoint público (G1) + +**Objetivo:** `/merchants/available` es público, sin rate limit, y devuelve lat/lng exactos — permite scrapear el censo de ubicaciones de comercios. Cerrar antes de tener comercios reales. + +**Archivos (todos backend):** + +1. **`src/routes/merchants.ts`** — rate limit al endpoint público: + ```ts + import { createRateLimiter } from '../middleware/rateLimit.middleware.js'; + const discoveryRateLimit = createRateLimiter({ windowMs: 60_000, max: 30 }); // por IP + app.get('/merchants/available', { preHandler: [discoveryRateLimit], schema: {…} }, …) + ``` + (30 req/min por IP es holgado para uso legítimo — la app hace 1 request por búsqueda.) + +2. **`src/services/merchant.service.ts`** — redondeo de coordenadas públicas en `getAvailableMerchants`: devolver `latitude/longitude` redondeados a **3 decimales** (~110 m) usando `Math.round(x * 1000) / 1000`. La `distance_km` se sigue calculando con las coordenadas exactas (el redondeo es solo de salida). Añadir comentario de una línea explicando el porqué (privacidad). + - **Decisión consciente:** la ubicación exacta del comercio se revela solo dentro de un trade aceptado (flujo de chat/dirección existente); si algún flujo actual depende del lat/lng exacto de discovery, ajustarlo para usar `address_text` o posponer y documentar. + +3. **Tests:** en `src/tests/`, test nuevo `merchant.discovery.test.ts` (patrón de los `test:*` existentes en `package.json`, estilo standalone con `ALLOW_IN_MEMORY_DB=true`): verifica (a) redondeo a 3 decimales en la salida, (b) el rate limiter dispara 429 tras exceder `max`. Añadir script `test:discovery` a `package.json`. + +**Criterios de aceptación WP3:** build backend pasa; test nuevo verde; `curl` repetido >30/min devuelve 429 con `Retry-After`. + +--- + +## WP4 — Señales falsas en la UI de ofertas (G2) + +**Objetivo:** eliminar el `online: true` hardcodeado. + +1. **`src/pages/ExploreMap.tsx:62`** — `merchantToOffer` pone `online: true` fijo. El backend ya filtra por `merchant_available = true` en la query, así que todo merchant devuelto está disponible **por definición**: eliminar el campo `online` del tipo `Offer` y de `OfferConfirmData`, y quitar sus usos (`(offer as any).online ?? true` en las líneas ~307 y ~386 — ese cast ya es un code smell). Si `TradeConfirmation` u otra pantalla consume `online`, quitar el badge correspondiente o derivarlo de `merchant_available` real si el dato viaja. +2. Buscar otros consumidores: `grep -rn "\.online" src/` y limpiar. + +**Criterios:** build + `grep -rn "online: true" src/` → 0 resultados; ninguna pantalla muestra "en línea" como dato inventado. + +--- + +## WP5 — Limpieza (G3 remanente, G6) + +1. Borrar `src/components/MapSim.tsx` y assets exclusivos (`public/map_bg.png` — verificar que nada más lo referencia con `grep -rn "map_bg" src/ index.html`). Los `mushroom_*.png` se quedan (los usa `MapReal`). +2. Borrar `micopay/backend/src/seed.ts` (seed viejo inconsistente; el real es `seedDemoMerchants()` en `index.ts:290`). Verificar que ningún script de `package.json` lo referencia. +3. Actualizar `docs/AUDIT_APK_MAPA_2026-07.md`: marcar G1–G3, G6 y §3 como resueltos con referencia a los commits. + +--- + +## 6. Orden de ejecución y dependencias + +``` +WP1 (MapReal) ──> WP2 (ubicación comercio, usa MapReal picker) ──> WP5 (borrar MapSim) +WP3 (privacidad backend) — independiente, puede ir en paralelo +WP4 (online hardcode) — independiente, trivial, puede ir primero si se quiere un win rápido +``` + +No mezclar WPs en una misma rama/PR. WP1+WP2 son el corazón; WP3 es obligatorio **antes** de promover comercios reales en producción. + +## 7. Handoffs a humano (no ejecutables por el agente) + +| Qué | Quién/cómo | +|---|---| +| Cuenta MapTiler free + key para `VITE_MAP_STYLE_URL` | Eric — cloud.maptiler.com, plan free (100k tiles/mes); pegar el style URL en `.env.testnet`/`.env.mainnet` | +| Seed demo en AWS para demos (`SEED_DEMO_DATA=true` + `SEED_ORIGIN_LAT/LNG` en task def + redeploy) | Sesión con acceso AWS (`--profile micopay-admin`) — opcional, solo para demos; ver G7 del audit | +| Recompilar APK tras WP1/WP2 (`npm run build:testnet && npx cap sync android && gradlew assembleDebug` con `JAVA_HOME` = JBR de Android Studio) | Cualquier sesión en la máquina de Eric — receta verificada 2026-07-25 | +| Decidir política de revelado de ubicación exacta post-trade (WP3, decisión de producto) | Eric/Jose | + +## 8. Fuera de alcance (explícito) + +- PostGIS/geohash (solo si >10k comercios; hoy no). +- Geocodificación inversa para autollenar dirección (nice-to-have posterior). +- Rutas caminables reales (G8 — el estimado lineal se queda). +- Cualquier cambio de infra AWS, Dockerfile o CI. +- SEC-16 y TEST-01 (memory leaks, tests en CI) — ya tienen issues de GrantFox propios; no duplicar aquí. diff --git a/docs/PLAN_REDISENO_VISUAL_APK_2026-08.md b/docs/PLAN_REDISENO_VISUAL_APK_2026-08.md new file mode 100644 index 00000000..d48549f9 --- /dev/null +++ b/docs/PLAN_REDISENO_VISUAL_APK_2026-08.md @@ -0,0 +1,482 @@ +# Plan de implementación — migrar el APK al sistema visual "Mercado / Rótulo" + +**Fecha:** 2026-08-04 +**Fuente de verdad:** `Micopay/micopay-landig` @ `ef5cbe4` ("Nueva dirección visual: mercado / rótulo"), leído desde `C:\Users\eric\Desktop\micopay-landig` (working tree limpio, verificado). +**Objetivo de este documento:** describir la migración. **No se implementa nada aquí.** +**Alcance:** solo capa de presentación de `micopay/frontend`. No se toca lógica de negocio, contratos, integraciones ni copy. + +--- + +## 0. Resumen ejecutivo + +La app y el sitio hoy no comparten un solo token. El sitio migró a papel cálido + tinta + canto vivo + sombra sólida; la app sigue en Material 3 azulado con radios de 20–32 px, `backdrop-blur`, gradientes y glows — exactamente el lenguaje que el commit `ef5cbe4` eliminó a propósito. + +Tres hallazgos condicionan el plan: + +1. **La capa de tokens de la app está rota, no solo desalineada.** El proyecto usa Tailwind v4 (`@import "tailwindcss"` + `@theme` en `src/index.css`), pero conserva un `tailwind.config.ts` estilo v3 que **v4 no lee** (no hay directiva `@config` en ningún lado — verificado). Consecuencia: 73 usos de clases `*-error`, más `bg-background`, `bg-accent`, `text-on-primary`, `text-secondary` **no generan CSS**. Confirmado compilando: en `dist/assets/index-CUcNQ-Kp.css` no existe ninguna regla `.bg-error` ni `.text-error`. La migración de tokens no es cosmética: arregla un defecto real. +2. **La regla de color del sistema (verde = digital, naranja = efectivo y acción) es aplicable casi tal cual al producto**, porque el producto *es* esa conversión. Es la parte del sistema con mayor retorno y menor riesgo. +3. **La firma del sistema (borde 2 px + sombra sólida 4 px) sobrevive en Android sin problema.** El ajuste de traducción que queda es táctil: las píldoras del sitio miden ~43 dp de alto, por debajo del mínimo de 48 dp. Se resuelve sin tocar la firma. + +> **Actualización 2026-08-04 — el sitio cambió mientras se escribía este plan.** Dos commits posteriores a `ef5cbe4` corrigen defectos de contraste que esta auditoría destapó: `410b6d9` (`--gris-2` deja de usarse para texto) y `c6b395f` (**el naranja pasa a ser direccional**). Las secciones §2.1, §3-F1, §3-F3, D-3, D-5 y §8 están alineadas con `c6b395f`. **Los hexes a heredar son los de `c6b395f`, no los de `ef5cbe4`.** + +Fases propuestas: **F0** arreglo de la capa de tokens · **F1** primitivas · **F2** chrome (nav, headers, estados vacíos/error) · **F3** superficies de dinero (Home, Historial, CETES) · **F4** mapa y descubrimiento · **F5** flujo crítico (QR, operación, KYC). + +--- + +## 1. Auditoría del estado actual + +### 1.1 Stack (verificado, no supuesto) + +| Pieza | Qué es | Evidencia | +|---|---|---| +| App shell | **Capacitor 8** → WebView Android, `appId: com.micopay.app` | `micopay/frontend/capacitor.config.ts` | +| UI | **React 19 + TypeScript**, `react-router-dom` 6 con **HashRouter** | `src/App.tsx:1073-1103`, `src/main.tsx` | +| Build | **Vite 6**, modos `development / testnet / mainnet / production` | `package.json` scripts | +| Estilos | **Tailwind CSS v4** vía `@tailwindcss/postcss` | `postcss.config.js`, `src/index.css:1` | +| Iconos | **Material Symbols Outlined** (fuente Google, CDN) — 241 usos | `index.html`, `src/index.css:26-35` | +| Tipografía | **Plus Jakarta Sans** (headline) + **Manrope** (body), Google Fonts **CDN** | `index.html:11` | +| Mapa | **MapLibre GL v5**, estilo `tiles.openfreemap.org/styles/liberty` | `src/components/MapReal.tsx:27,133` | +| Escáner QR | **`@capacitor-mlkit/barcode-scanning`** — UI nativa a pantalla completa | `src/hooks/useQRScanner.ts` | +| QR mostrado | `qrcode.react` (`QRCodeSVG`) | `src/pages/ClaimQR.tsx:154` | + +### 1.2 Dónde viven los estilos — hay **tres capas** que no se hablan + +**Capa A — `src/index.css` (`@theme`), la única viva.** Define 13 tokens de color y 2 de tipografía: +`--color-primary #00694C`, `--color-primary-container #C8E6C9`, `--color-on-primary-container #002114`, `--color-surface #FFFFFF`, `--color-on-surface #1A1C1E`, `--color-on-surface-variant #44474E`, `--color-outline #74777F`, `--color-outline-variant #C4C6CF`, `--color-surface-container-lowest/low/(container)/high/highest`, `--font-headline`, `--font-body`. + +**Capa B — `tailwind.config.ts`, muerta.** 58 colores Material 3 (`error`, `background`, `accent`, `secondary`, `tertiary`, `inverse-*`, `surface-dark`, …), `fontFamily` y `borderRadius`. **Tailwind v4 no la carga.** Es la fuente de los 73 usos de `*-error` que no pintan nada. Ver §1.5. + +**Capa C — valores sueltos en los `.tsx`.** 300+ literales hex y ~56 clases `rounded-[…]`: + +| Hex | Usos | Rol de facto | +|---|---|---| +| `#67808C` | 56 | gris secundario | +| `#0B1E26` | 44 | tinta azulada | +| `#1D9E75` | 39 | verde de éxito | +| `#00694C` | 38 | primario (duplica el token) | +| `#D7E3EA` | 33 | línea / borde | +| `#F4FAFF` | 16 | fondo frío | +| `#C62828` | 15 | error | +| `#5DCAA5` | 6 | acento con glow | + +Y una **cuarta capa parcial**: `src/pages/ClaimQR.tsx` (207 líneas) está escrita **entera con `style={{}}` inline**, sin Tailwind. No está en el router de `App.tsx`: se monta desde `src/main.tsx:36-40` cuando la URL es `/claim/:requestId`. Es la página que el comerciante abre desde un enlace externo — es decir, **la superficie del producto que más se parece a una landing** y la que más desentona hoy. + +### 1.3 Idiomas visuales que el nuevo sistema prohíbe, y cuántos hay + +Conteo sobre `src/**/*.tsx`: + +| Idioma prohibido | Usos | Ejemplo | +|---|---|---| +| `rounded-[…]` (radios grandes arbitrarios) | 56 | `rounded-[24px]`, `rounded-t-[32px]` | +| `rounded-2xl` / `rounded-xl` / `rounded-full` | 130 / 102 / 139 | tarjetas y tiles de ícono | +| `backdrop-blur*` | 30 | `BottomNav.tsx:29`, `KYCScreen.tsx:227` | +| `shadow-sm/md/lg/xl` (difuminadas) | 115 | `Home.tsx:255` `shadow-xl shadow-primary/20` | +| `shadow-[…]` arbitrarias | 17 | `shadow-[0_0_8px_#5DCAA5]` — glow puro | +| `bg-gradient-*` | 11 | `KYCScreen.tsx:245` | +| `animate-pulse` | 8 | punto "en vivo" del balance | +| Tile de ícono redondeado sobre encabezado | ~30 | `w-9 h-9 rounded-full` en `TradeStateBadge.tsx:153` | +| Tarjeta anidada en tarjeta | frecuente | `Home.tsx` lista de activos dentro de card | + +`BottomNav.tsx:29` concentra cinco prohibiciones en una línea: `rounded-t-[32px]`, `backdrop-blur-xl`, `bg-[#F4FAFF]/80`, `shadow-[0_-8px_32px_rgba(11,30,38,0.04)]` y píldoras `rounded-full`. + +### 1.4 Inventario de pantallas y componentes + +**33 pantallas** (`src/pages/`, rutas en `App.tsx:1073-1103`): + +| Grupo | Pantallas | Ruta | +|---|---|---| +| Acceso | `Login`, `Register` | `/login`, `/register` | +| Núcleo | `Home` (548 L), `History`, `Profile` | `/`, `/history`, `/profile` | +| Pago | `PayHub`, `SendPayment`, `ReceivePayment` | `/pay`, `/pay/send`, `/pay/receive` | +| Retiro (USDC→efectivo) | `Explore`, `ExploreMap` (522 L), `TradeConfirmation`, `ChatRoom`, `QRReveal`, `SuccessScreen`, `TradeDetail` (977 L), `TradeCancelled`, `CashoutRequest` | `/explore`, `/map`, `/confirm`, `/chat`, `/qr-reveal`, `/success`, `/trade/:id`, `/cashout` | +| Depósito (efectivo→USDC) | `DepositRequest`, `DepositMap` (501 L), `DepositChat`, `DepositQR` | `/deposit`, `/map-deposit`, `/chat-deposit`, `/qr-deposit` | +| Comercio | `MerchantInbox` (515 L), `MerchantSettings` | `/inbox`, `/merchant-settings` | +| Inversión / rampa | `CETESScreen` (850 L), `BlendScreen` | `/cetes`, `/blend` | +| Identidad | `KYCScreen` | `/kyc`, `/kyc-approved` | +| Legal | `Privacy`, `Terms` | `/privacy`, `/terms` | +| Fuera del router | `ClaimQR` | `/claim/:id` (montada en `main.tsx`) | + +**16 componentes** (`src/components/`): `BottomNav`, `Logo`, `MapReal` (280 L), `TradeStateBadge` (176 L), `ErrorBanner`, `ErrorBoundary`, `ConnectionBanner`, `MerchantUnavailableBanner`, `MerchantAvailabilityToggle`, `OfflineQueueStatus`, `PermissionGate`, `CancelTradeDialog`, `DeleteAccountModal`, `TradeConfirmation`, `SupportLink`, `DebugOverlay`. + +**No existe una capa de primitivas.** No hay `Button`, `Card`, `Pill`, `Input`, `Label`, `Sheet`. Cada pantalla repite el string de clases. Eso es lo que hace que hoy el cambio visual sea caro — y es lo primero que hay que arreglar (F1). + +**Assets propios:** `public/mushroom_green.png`, `mushroom_gold.png`, `mushroom_red.png` — usados como marcadores de proveedor por tier en `MapReal.tsx`. El sitio dibuja el hongo como SVG plano (`Conversor.jsx:62-67`, sombrero `#D9420B`, tallo `#F5F1E8`, trazo `#16130F` 2 px). **Los PNG de la app son de la paleta anterior**; ver §4. + +### 1.5 Defecto encontrado durante la auditoría (no es cosmético) + +`tailwind.config.ts` existe pero Tailwind v4 solo lo lee con una directiva `@config` explícita, que no está presente en `src/index.css` ni en ningún otro archivo (verificado con grep sobre `src/` e `index.html`). + +Compilé `vite build --mode testnet` y revisé el CSS resultante: + +``` +.bg-error → 0 reglas +.text-error → 0 reglas +.bg-background → 0 reglas +.bg-accent → 0 reglas +.text-on-primary → 0 reglas +.text-secondary → 0 reglas +.bg-primary → sí (viene de @theme) +.text-outline-variant→ sí (viene de @theme) +``` + +Usos afectados en `src/**/*.tsx`: **73** de `*-error`, 8 de `*-on-primary`, 7 de `*-secondary`, 2 de `*-accent`, 1 de `*-background`, 1 de `*-surface-variant`, 1 de `*-primary-fixed`. + +Impacto visible: los banners de error de `KYCScreen.tsx:48-52` y `Home.tsx:219-231`, y el badge de notificaciones de `Home.tsx:191`, **se renderizan sin color de error**. `TradeStateBadge.tsx:46-49` (`pending_cash`) usa `bg-secondary-container/30 border-secondary/20 text-secondary`, todos muertos: ese estado no tiene tono. El `` de `index.html` trae `bg-background`, que tampoco resuelve. + +**Recomendación:** F0 lo corrige por construcción, porque el nuevo `@theme` define todos los tokens que hoy faltan. **Borrar `tailwind.config.ts`** en lugar de migrarlo — mantenerlo garantiza que alguien vuelva a escribir clases muertas. + +### 1.6 Dependencias de red en un APK + +`index.html` carga Plus Jakarta Sans, Manrope y Material Symbols desde `fonts.googleapis.com`. En un APK offline-first (hay `offlineQueue`, `OfflineQueueStatus`, `ConnectionBanner` — la app asume conectividad intermitente), **la tipografía y los 241 iconos dependen de la red**. Sin conexión la app cae a `system-ui` y los iconos se ven como texto crudo ("home", "qr_code_scanner"). La migración a Archivo es la ocasión para **empaquetar las fuentes localmente**. Ver F0-4. + +--- + +## 2. Mapeo token a token + +### 2.1 Color + +| Token web (`ef5cbe4`) | Valor | Equivalente hoy en la app | Estado | +|---|---|---|---| +| `--verde` | `#0f4a33` | `--color-primary #00694C` + `#00694C` suelto (38) | **Reemplazo.** Más oscuro y más sucio. | +| `--verde-claro` | `#1a7a54` | `#1D9E75` suelto (39) | **Reemplazo.** El de la app es más saturado. | +| `--verde-brillo` (**sobre oscuro**) | `#4fb98a` | `#5DCAA5` / `accent` (config muerta) | **Reemplazo.** Es el verde del lado oscuro (**7.62:1 sobre `--tinta`**). Está definido pero **sin usar** en el sitio; la app sí lo necesita (§3-F3). Ojo: hoy el `#5DCAA5` va siempre con glow (`shadow-[0_0_8px_#5DCAA5]`) — el glow se va. | +| `--verde-suave` | `#e4ede6` | `--color-primary-container #C8E6C9`, `#E1F5EE`, `#E6F9F1`, `#E8F5EE`, `#e6f9f1`, `#F0FBF7` | **Consolida 6 valores en 1.** | +| `--verde-borde` | `#bfd3c4` | — | **Nuevo.** Hoy los bordes verdes se hacen con `border-primary/20`. | +| `--naranja` (**sobre claro**) | `#c53c0a` | — | **Nuevo. Es el cambio de mayor impacto.** Valor de `c6b395f`. **Heredar este hex, no `#d9420b`.** | +| `--naranja-claro` (**sobre oscuro**) | `#f2631f` | — | **Nuevo.** | +| `--naranja-suave` | `#fbe8dd` | `#F6E8DE` (1 uso, casual) | **Nuevo de facto.** | +| `--naranja-borde` | `#f0c3ab` | — | **Nuevo.** | +| `--tinta` | `#16130f` | `--color-on-surface #1A1C1E`, `#0B1E26` (44), `#1A2830`, `#1a1a2e` | **Reemplazo.** La app usa tinta **azulada**; la nueva es cálida. | +| `--tinta-2` | `#241f19` | `#1A2830` (3) | **Reemplazo.** | +| `--tinta-3` | `#3d352b` | — | **Nuevo.** | +| `--fondo` | `#f5f1e8` | `#F4FAFF` (16) + `--color-surface #FFFFFF` | **Reemplazo.** Azul frío → papel cálido. Cambia el 100% de las pantallas. | +| `--papel` | `#fffdf8` | `#FFFFFF`, `bg-white` (113) | **Reemplazo.** Blanco puro → blanco roto. | +| `--gris` | `#57514a` | `--color-on-surface-variant #44474E` | Reemplazo. | +| `--gris-2` | `#857d71` | `#67808C` (56), `--color-outline #74777F` | **Consolida.** | +| `--gris-3` | `#a89f92` | `#888`, `#999`, `#aaa`, `#bbb` (en `ClaimQR`) | Consolida. | +| `--linea` | `#ddd5c4` | `#D7E3EA` (33), `--color-outline-variant #C4C6CF` | **Reemplazo.** | +| `--linea-suave` | `#ebe4d6` | `#D4E4EC`, `#EFF6FA`, `#EEF1F4` | Consolida. | + +#### La regla direccional del color (heredada de `c6b395f` — no es opcional) + +Ni el naranja ni el verde son un color: son un **par direccional**. Cada miembro reprueba contraste en el fondo del otro, así que **no son intercambiables**. + +| | Sobre claro (`--papel` / `--fondo`) | Sobre oscuro (`--tinta` / `--tinta-2`) | +|---|---|---| +| Efectivo / acción | `--naranja` `#c53c0a` — **5.13** papel · **4.63** fondo | `--naranja-claro` `#f2631f` — **5.80** | +| Digital / saldo | `--verde` `#0f4a33` — **10.1** papel | `--verde-brillo` `#4fb98a` — **7.62** | + +Los cuatro pasan AA de texto normal. Usar el miembro equivocado reprueba: `#c53c0a` sobre tinta cae a **3.55** (solo sirve como gráfico) y `#f2631f` sobre papel cae a **2.4**. + +**Consecuencia para la app:** cualquier primitiva que exista en variante clara y oscura (``, ``, `
- + {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 && ( + + )} + +
+
+ )} +
+ )} + +