diff --git a/.gitignore b/.gitignore index a362b50..f50674a 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,7 @@ packages/*/src/*.js packages/*/src/*.js.map packages/*/src/*.d.ts packages/*/src/*.d.ts.map + +# Config local de Claude Code (launch.json para levantar los dev servers). +# Es herramienta de quien desarrolla, no del producto. +.claude/ diff --git a/apps/api/package.json b/apps/api/package.json index 9ff3958..4e1d554 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,10 +6,11 @@ "scripts": { "dev": "tsx watch src/index.ts", "start": "node dist/index.js", - "build": "tsc", + "build": "tsc && node -e \"require('fs').cpSync('src/db/migrations','dist/db/migrations',{recursive:true})\"", "typecheck": "tsc --noEmit", "test": "vitest run", "test:live": "tsx src/scripts/live-swap.ts", + "test:live:mainnet": "tsx src/scripts/live-swap-mainnet.ts", "test:recovery": "tsx src/scripts/live-recovery.ts", "test:concurrency": "tsx src/scripts/concurrency-check.ts", "seed": "tsx src/scripts/seed.ts", @@ -32,7 +33,6 @@ "@micopay/types": "*", "@micopaybridge/xrpl-bridge": "*", "@stellar/stellar-sdk": "^16.0.1", - "xrpl": "^5.0.0", "@types/bcryptjs": "^2.4.6", "@types/node": "^20.14.0", "@types/pg": "^8.11.6", @@ -42,7 +42,9 @@ "pg": "^8.13.0", "tsx": "^4.19.0", "typescript": "^5.7.3", - "viem": "^2.54.1" + "viem": "^2.54.1", + "xrpl": "^5.0.0", + "xumm-sdk": "^1.11.2" }, "devDependencies": { "fast-check": "^4.8.0", diff --git a/apps/api/src/__tests__/activationExpiry.test.ts b/apps/api/src/__tests__/activationExpiry.test.ts new file mode 100644 index 0000000..9abce5c --- /dev/null +++ b/apps/api/src/__tests__/activationExpiry.test.ts @@ -0,0 +1,44 @@ +/** + * La regresión que este test fija: el payload de Xaman vivía 24 h por + * defecto mientras `CancelAfter` se anclaba al momento de crearlo. Con el + * plazo por defecto de 1 h quedaban 23 h en las que el QR seguía siendo + * firmable pero la transacción ya nacía vencida — rippled la rechaza, el + * usuario quema la fee, no queda escrow, y su dirección no cuenta para las + * 300 de Make Waves. Y como sí firmó, cree que funcionó. + * + * La invariante, en una frase: el payload nunca puede seguir vivo después + * de su propio CancelAfter. + */ +import { describe, it, expect } from "vitest"; +import * as bt from "@micopaybridge/xrpl-bridge/bridge-translate"; +import { minutosDeFirma } from "../lib/xumm.js"; + +const AHORA = 1_800_000_000; +const enRipple = (segundosDesdeAhora: number) => bt.toRippleTime(AHORA + segundosDesdeAhora); + +describe("ventana de firma del payload de activación", () => { + it("nunca deja el payload vivo más allá del CancelAfter", () => { + // 600 es el mínimo que acepta la ruta; 86400 el máximo. + for (const plazo of [600, 3600, 7200, 86400]) { + const minutos = minutosDeFirma(enRipple(plazo), AHORA); + expect(minutos * 60, `plazo de ${plazo}s`).toBeLessThan(plazo); + } + }); + + it("con el plazo por defecto de 1 h da 55 min de firma, no 24 h", () => { + expect(minutosDeFirma(enRipple(3600), AHORA)).toBe(55); + }); + + it("deja margen para que la firma alcance a entrar en un ledger validado", () => { + // 3600 - 300 de margen = 3300 s = 55 min exactos. + expect(minutosDeFirma(enRipple(3600), AHORA) * 60).toBeLessThanOrEqual(3600 - 300); + }); + + it("señala con <= 0 los plazos que no dejan ventana, en vez de redondear a 1", () => { + // Redondear hacia arriba aquí es justo lo que rompía la invariante en el + // plazo mínimo: el payload acababa vivo exactamente hasta el CancelAfter. + for (const plazo of [0, 60, 300, -600]) { + expect(minutosDeFirma(enRipple(plazo), AHORA), `plazo de ${plazo}s`).toBeLessThanOrEqual(0); + } + }); +}); diff --git a/apps/api/src/__tests__/stellarNetwork.test.ts b/apps/api/src/__tests__/stellarNetwork.test.ts new file mode 100644 index 0000000..0efc719 --- /dev/null +++ b/apps/api/src/__tests__/stellarNetwork.test.ts @@ -0,0 +1,96 @@ +/** + * El swap y la capa de pago tienen que acabar en la MISMA red. + * + * No lo estaban: `lib/soroban.ts` se pasaba a mainnet con `STELLAR_NETWORK=PUBLIC` + * y `middleware/x402.ts` con `MAINNET`. Con el valor que pone el deploy + * (`PUBLIC`) el swap movía fondos reales mientras los pagos se verificaban + * contra Horizon de testnet: pagar con USDC de faucet y recibir un swap real. + * Con `MAINNET` se invertía y el swap se firmaba con el passphrase equivocado. + * + * Cada archivo era coherente consigo mismo, así que nada lo detectaba. Este + * test mira las dos mitades a la vez, que es donde vivía el fallo. + */ +import { describe, it, expect, afterEach, beforeEach } from "vitest"; +import { Networks } from "@stellar/stellar-sdk"; + +const ORIGINAL = process.env.STELLAR_NETWORK; + +/** Recarga el módulo con el entorno actual (se evalúa al importarse). */ +async function cargar(valor: string | undefined) { + if (valor === undefined) delete process.env.STELLAR_NETWORK; + else process.env.STELLAR_NETWORK = valor; + const vitest = await import("vitest"); + vitest.vi.resetModules(); + return import("../lib/stellarNetwork.js"); +} + +beforeEach(() => { + delete process.env.STELLAR_NETWORK; +}); + +afterEach(() => { + if (ORIGINAL === undefined) delete process.env.STELLAR_NETWORK; + else process.env.STELLAR_NETWORK = ORIGINAL; +}); + +describe("red de Stellar — una sola interpretación", () => { + it("las dos grafías de la red real llevan a mainnet", async () => { + for (const valor of ["PUBLIC", "MAINNET", "public", "mainnet", " Public "]) { + const net = await cargar(valor); + expect(net.IS_MAINNET, `valor ${JSON.stringify(valor)}`).toBe(true); + expect(net.NETWORK_PASSPHRASE).toBe(Networks.PUBLIC); + expect(net.HORIZON_URL).toBe("https://horizon.stellar.org"); + } + }); + + it("por defecto, y ante cualquier otra cosa, testnet", async () => { + for (const valor of [undefined, "TESTNET", "testnet", "", "produccion"]) { + const net = await cargar(valor); + expect(net.IS_MAINNET, `valor ${JSON.stringify(valor)}`).toBe(false); + expect(net.NETWORK_PASSPHRASE).toBe(Networks.TESTNET); + expect(net.HORIZON_URL).toBe("https://horizon-testnet.stellar.org"); + } + }); + + it("el nombre que se anuncia a los agentes no depende de cómo se escribió", async () => { + // Va en el reto 402. Si dependiera de la grafía, el mismo despliegue diría + // "public" o "mainnet" según quién configuró el entorno. + expect((await cargar("PUBLIC")).NETWORK_NAME).toBe("public"); + expect((await cargar("MAINNET")).NETWORK_NAME).toBe("public"); + expect((await cargar("TESTNET")).NETWORK_NAME).toBe("testnet"); + }); + + it("no queda ningún módulo interpretando STELLAR_NETWORK por su cuenta", async () => { + // La regresión de verdad: el fallo no fue un valor mal escrito, fue que dos + // archivos decidían la red cada uno por su lado. Que solo lo haga uno. + const { readFileSync, readdirSync, statSync } = await import("node:fs"); + const { join } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + // fileURLToPath y no .pathname: el segundo deja los espacios de la ruta + // como %20 y readdirSync no encuentra el directorio. + const raiz = fileURLToPath(new URL("../", import.meta.url)); + const culpables: string[] = []; + + const recorrer = (dir: string) => { + for (const entrada of readdirSync(dir)) { + const ruta = join(dir, entrada); + if (statSync(ruta).isDirectory()) { + if (entrada !== "node_modules" && entrada !== "__tests__") recorrer(ruta); + continue; + } + if (!entrada.endsWith(".ts")) continue; + if (ruta.endsWith(join("lib", "stellarNetwork.ts"))) continue; + const texto = readFileSync(ruta, "utf8"); + // Leer la variable para registrarla o mostrarla está bien; lo que no + // puede hacerse fuera del módulo es COMPARARLA para decidir la red. + if (/process\.env\.STELLAR_NETWORK\s*===/.test(texto)) { + culpables.push(ruta.slice(raiz.length)); + } + } + }; + recorrer(raiz); + + expect(culpables, `deben usar lib/stellarNetwork.ts: ${culpables.join(", ")}`).toEqual([]); + }); +}); diff --git a/apps/api/src/__tests__/x402.test.ts b/apps/api/src/__tests__/x402.test.ts index ef1a50f..a059ccc 100644 --- a/apps/api/src/__tests__/x402.test.ts +++ b/apps/api/src/__tests__/x402.test.ts @@ -71,6 +71,19 @@ describe("x402 Middleware", () => { }); describe("with mock payment but X402_MOCK_MODE unset (SEC-C2 regression)", () => { + const originalMockMode = process.env.X402_MOCK_MODE; + + // Borrarla explícitamente: un .env local con X402_MOCK_MODE=true hacía + // que este test pasara en CI y fallara en la máquina del desarrollador. + beforeAll(() => { + delete process.env.X402_MOCK_MODE; + }); + + afterAll(() => { + if (originalMockMode === undefined) delete process.env.X402_MOCK_MODE; + else process.env.X402_MOCK_MODE = originalMockMode; + }); + it("should reject the mock payment header", async () => { const response = await app.inject({ method: "GET", diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 57ce80e..bd57b71 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -16,6 +16,8 @@ import { agentRoutes } from "./routes/agent.js"; import { swapRoutes } from "./routes/swaps.js"; import { cashRoutes } from "./routes/cash.js"; import { fundRoutes } from "./routes/fund.js"; +import { activationRoutes } from "./routes/activation.js"; +import { startActivationSweeper } from "./lib/activationSweeper.js"; import { recoverInFlightSwaps, startRefundRetryLoop } from "./lib/recovery.js"; import { pendingRefunds, planStore } from "./lib/swapStore.js"; import { runMigrations } from "./db/migrator.js"; @@ -131,6 +133,7 @@ export async function createApp() { app.register(swapRoutes); app.register(cashRoutes); app.register(fundRoutes); + app.register(activationRoutes); app.register(reputationRoutes); app.register(serviceRoutes); app.register(demoRoutes); @@ -208,6 +211,7 @@ async function start() { const app = await createApp(); await arrancarMigraciones(); await arrancarRecuperacion(); + startActivationSweeper(); // Los planes vencidos no le sirven a nadie y el directorio es compartido: // sin esto crece para siempre (#17). diff --git a/apps/api/src/lib/activationSweeper.ts b/apps/api/src/lib/activationSweeper.ts new file mode 100644 index 0000000..ddd0e44 --- /dev/null +++ b/apps/api/src/lib/activationSweeper.ts @@ -0,0 +1,95 @@ +/** + * "Se regresa solo" solo es cierto si alguien manda el EscrowCancel — XRPL + * no ejecuta nada por su cuenta. Esto es ese "alguien": revisa cada + * escrow de activación pendiente y, pasado su CancelAfter, cancela. + * + * Cualquier cuenta puede mandar EscrowCancel y el XRP siempre vuelve al + * dueño original (regla de XRPL, no de este código) — así que el firmante + * de la cancelación no necesita ser el usuario. Aun así, sin + * XRPL_SWEEPER_SEED configurado y fondeado en la red correcta, esto no + * puede mandar nada: se queda avisando en el log, no revienta el server. + */ +import * as bt from "@micopaybridge/xrpl-bridge/bridge-translate"; +import { cancelXrplLeg } from "./xrpl-leg.js"; + +interface PendingCancel { + owner: string; + offerSequence: number; + cancelAfterUnix: number; + attempts: number; +} + +const MAX_ATTEMPTS = 20; +const CHECK_INTERVAL_MS = 20_000; + +const pending = new Map(); +let started = false; + +function key(owner: string, offerSequence: number): string { + return `${owner}:${offerSequence}`; +} + +/** Se llama en cuanto se sabe que el escrow quedó confirmado on-chain. */ +export function trackActivationEscrow(params: { + owner: string; + offerSequence: number; + cancelAfterRipple: number; +}): void { + const cancelAfterUnix = bt.fromRippleTime(params.cancelAfterRipple); + pending.set(key(params.owner, params.offerSequence), { + owner: params.owner, + offerSequence: params.offerSequence, + cancelAfterUnix, + attempts: 0, + }); +} + +async function sweepOnce(): Promise { + const seed = process.env.XRPL_SWEEPER_SEED; + if (!seed) { + if (pending.size > 0) { + console.warn( + `[activation-sweeper] XRPL_SWEEPER_SEED no configurado — ${pending.size} escrow(s) esperando cancelación manual`, + ); + } + return; + } + + const nowUnix = Math.floor(Date.now() / 1000); + for (const [k, entry] of pending.entries()) { + if (nowUnix < entry.cancelAfterUnix) continue; + + try { + const { hash } = await cancelXrplLeg({ + senderSeed: seed, + owner: entry.owner, + offerSequence: entry.offerSequence, + }); + console.log(`[activation-sweeper] cancelado owner=${entry.owner} seq=${entry.offerSequence} hash=${hash}`); + pending.delete(k); + } catch (err) { + entry.attempts += 1; + const msg = err instanceof Error ? err.message : String(err); + // tecNO_TARGET: ya no existe (alguien más lo canceló, o ya se resolvió) — no es un fallo. + if (msg.includes("tecNO_TARGET")) { + console.log(`[activation-sweeper] owner=${entry.owner} seq=${entry.offerSequence} ya no existe — nada que hacer`); + pending.delete(k); + continue; + } + console.warn(`[activation-sweeper] intento ${entry.attempts}/${MAX_ATTEMPTS} falló owner=${entry.owner} seq=${entry.offerSequence}: ${msg}`); + if (entry.attempts >= MAX_ATTEMPTS) { + console.error(`[activation-sweeper] owner=${entry.owner} seq=${entry.offerSequence} se rindió tras ${MAX_ATTEMPTS} intentos — necesita revisión manual`); + pending.delete(k); + } + } + } +} + +/** Un solo intervalo para todo el proceso — llamar una vez al arrancar. */ +export function startActivationSweeper(): void { + if (started) return; + started = true; + setInterval(() => { + sweepOnce().catch((err) => console.error("[activation-sweeper] sweep falló", err)); + }, CHECK_INTERVAL_MS); +} diff --git a/apps/api/src/lib/soroban.ts b/apps/api/src/lib/soroban.ts index b882906..0dbf572 100644 --- a/apps/api/src/lib/soroban.ts +++ b/apps/api/src/lib/soroban.ts @@ -17,9 +17,14 @@ import crypto from "crypto"; import * as bt from "@micopaybridge/xrpl-bridge/bridge-translate"; import { swapStore, type SwapState } from "./swapStore.js"; import { lockXrplLeg, revealOnXrpl, cancelXrplLeg, xrplAddressFromSeed } from "./xrpl-leg.js"; +import { NETWORK_PASSPHRASE } from "./stellarNetwork.js"; const RPC_URL = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NET = StellarSdk.Networks.TESTNET; +// La red sale de lib/stellarNetwork.ts, que es la única que interpreta +// STELLAR_NETWORK. Este archivo la resolvía por su cuenta aceptando solo +// "PUBLIC", y x402 solo "MAINNET": no había valor que pusiera a los dos en la +// red real. De NET dependen el passphrase de firma y los SAC de abajo. +const NET = NETWORK_PASSPHRASE; const USDC_ISSUER = process.env.USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; const USDC_SAC = new StellarSdk.Asset("USDC", USDC_ISSUER).contractId(NET); diff --git a/apps/api/src/lib/stellarNetwork.ts b/apps/api/src/lib/stellarNetwork.ts new file mode 100644 index 0000000..f270062 --- /dev/null +++ b/apps/api/src/lib/stellarNetwork.ts @@ -0,0 +1,46 @@ +/** + * Qué red de Stellar usa el proceso. Una sola fuente para todo el servicio. + * + * Antes cada módulo interpretaba `STELLAR_NETWORK` por su cuenta y **no + * coincidían**: `lib/soroban.ts` se pasaba a mainnet con el valor `"PUBLIC"` y + * `middleware/x402.ts` con `"MAINNET"`. No existía ningún valor que pusiera a + * los dos en la red real: + * + * STELLAR_NETWORK=PUBLIC → el swap movía fondos reales, pero los pagos se + * verificaban contra Horizon de TESTNET: se pagaba + * con USDC de juguete y se recibía un swap real. + * STELLAR_NETWORK=MAINNET → los pagos iban bien, pero el swap se firmaba con + * el passphrase de testnet y la red lo rechazaba. + * + * Cada archivo era coherente consigo mismo, así que ni el typecheck ni los + * tests lo veían: las suites corren con el valor por defecto, donde ambos + * coinciden en testnet. La contradicción solo aparecía con dinero real de por + * medio. + * + * Se aceptan las dos grafías a propósito — `PUBLIC` es como llama Stellar a su + * red real y es lo que ya usa el deploy; `MAINNET` es lo que esperaba x402 y lo + * que la gente escribe por costumbre. Rechazar una de las dos convertiría este + * arreglo en otra forma del mismo fallo. + */ +import { Networks } from "@stellar/stellar-sdk"; + +const CRUDO = (process.env.STELLAR_NETWORK ?? "TESTNET").trim().toUpperCase(); + +/** Grafías que significan "la red real". */ +const NOMBRES_MAINNET = new Set(["PUBLIC", "MAINNET"]); + +export const IS_MAINNET: boolean = NOMBRES_MAINNET.has(CRUDO); + +/** Passphrase con el que se firman las transacciones. */ +export const NETWORK_PASSPHRASE: string = IS_MAINNET ? Networks.PUBLIC : Networks.TESTNET; + +export const HORIZON_URL: string = IS_MAINNET + ? "https://horizon.stellar.org" + : "https://horizon-testnet.stellar.org"; + +/** + * Nombre canónico. Va en el reto 402 que leen los agentes, así que no puede + * depender de cómo se escribiera la variable: el mismo despliegue anunciaría + * "public" o "mainnet" según el humor de quien configuró el entorno. + */ +export const NETWORK_NAME: "public" | "testnet" = IS_MAINNET ? "public" : "testnet"; diff --git a/apps/api/src/lib/xrpl-leg.ts b/apps/api/src/lib/xrpl-leg.ts index a40966b..8f3d014 100644 --- a/apps/api/src/lib/xrpl-leg.ts +++ b/apps/api/src/lib/xrpl-leg.ts @@ -144,3 +144,109 @@ export async function cancelXrplLeg(params: { export function xrplAddressFromSeed(seed: string): string { return Wallet.fromSeed(seed).address; } + +/** + * ¿Esta dirección existe en el ledger? + * + * Que una dirección tenga formato válido no dice nada: una wallet recién + * creada en Xaman y nunca fondeada no existe para XRPL (`actNotFound`) y no + * puede firmar. Sin esta comprobación le entregábamos un QR a la persona + * que más probablemente lo escanee — alguien que acaba de instalar Xaman + * para esto — y el flujo moría sin explicación. + * + * Devuelve `null` si no se pudo saber (RPC caído, timeout). Quien llama debe + * dejar pasar ese caso: bloquear a todo el mundo porque el nodo no responde + * es peor que dejar entrar a alguien con una cuenta sin fondear. + */ +export async function accountExists(address: string): Promise { + try { + return await withClient(async (client) => { + try { + await client.request({ command: "account_info", account: address, ledger_index: "validated" }); + return true; + } catch (err) { + const code = (err as { data?: { error?: string } })?.data?.error; + if (code === "actNotFound" || String(err).includes("actNotFound")) return false; + throw err; + } + }); + } catch { + return null; + } +} + +/** + * Busca en el ledger el `Sequence` de una tx ya confirmada — es lo que hace + * falta como `OfferSequence` para cancelar el escrow que esa tx creó. No lo + * devuelve Xaman: solo da el hash, hay que ir a buscarlo. + */ +export async function fetchTxSequence(txHash: string): Promise<{ account: string; sequence: number } | null> { + return withClient(async (client) => { + const res = await client.request({ command: "tx", transaction: txHash }); + const txJson = res.result.tx_json; + if (!txJson || typeof txJson.Account !== "string" || typeof txJson.Sequence !== "number") return null; + return { account: txJson.Account, sequence: txJson.Sequence }; + }); +} + +/** + * Plantilla de EscrowCancel SIN firmar y SIN `Account` — reclamo manual del + * propio usuario, para cuando no hay sweeper fondeado (o solo por no + * esperarlo). `Account` no importa para saber a dónde va el XRP: XRPL + * siempre lo devuelve al `Owner` original sin importar quién la firme — + * puede ser el usuario mismo, con su propia wallet ya activada, sin pedirle + * fondear nada nuevo. + */ +export function activationCancelTxJson(params: { + owner: string; + offerSequence: number; +}): Omit { + return { + TransactionType: "EscrowCancel", + SourceTag: bt.SOURCE_TAG, + Owner: params.owner, + OfferSequence: params.offerSequence, + }; +} + +/** + * Plantilla de EscrowCreate SIN firmar y SIN `Account` — para Xaman, que + * rellena `Account` con la wallet que de verdad escanea el QR. Nunca ve una + * seed ni un Wallet. + * + * Es la mitad "armar" del par armar/firmar que exige el T&C de Make Waves: + * 300 cuentas activas cuentan solo si cada una firma con su propia llave — + * cualquier automatización de la firma es "scripted transactions", motivo + * de descalificación (§7). + * + * `CancelAfter` solo no basta — probado en vivo contra mainnet (temMALFORMED + * dos veces): la propia validación de xrpl.js lo confirma, EscrowCreate + * exige además `Condition` o `FinishAfter`. `FinishAfter` se descartó a + * propósito: con eso, CUALQUIERA puede mandar `EscrowFinish` pasado ese + * tiempo y el XRP se va al `Destination`, no de vuelta al dueño — rompe la + * garantía de que nadie más lo toca. En su lugar, `Condition` con una + * preimagen aleatoria que se genera aquí y se descarta sin persistir en + * ningún lado: sin la preimagen, `EscrowFinish` es imposible para + * cualquiera, ni siquiera para nosotros. La única salida que queda es + * `EscrowCancel`, que siempre regresa al dueño original. + */ +export function activationTxJson(params: { + destinationAddress: string; + amountXrp: string; + cancelAfterSeconds?: number; +}): Omit { + // 1 h, no 5 min: rippled rechaza el EscrowCreate si CancelAfter ya pasó + // cuando llega la firma, y el payload de Xaman vive más que eso. Con 300 s + // quien instalaba Xaman por primera vez firmaba una tx muerta: fee quemado + // y sin escrow. El plazo solo marca desde cuándo se puede reclamar. + const cancelAfterSec = params.cancelAfterSeconds ?? 3600; + const preimage = bt.generatePreimage(); // se usa una vez y se olvida — no se guarda + return { + TransactionType: "EscrowCreate", + SourceTag: bt.SOURCE_TAG, + Destination: params.destinationAddress, + Amount: xrpToDrops(params.amountXrp), + Condition: bt.xrplCondition(preimage), + CancelAfter: bt.toRippleTime(Math.floor(Date.now() / 1000) + cancelAfterSec), + }; +} diff --git a/apps/api/src/lib/xumm.ts b/apps/api/src/lib/xumm.ts new file mode 100644 index 0000000..d82a6e1 --- /dev/null +++ b/apps/api/src/lib/xumm.ts @@ -0,0 +1,182 @@ +/** + * Puente a Xaman (ex-Xumm) para la estrategia de 300 cuentas: crea un + * "payload" — una tx sin firmar que Xaman muestra como QR / deep link — y + * el usuario la firma con su propia wallet. El backend nunca ve una seed. + * + * Necesita XUMM_API_KEY + XUMM_API_SECRET de https://apps.xaman.dev — hay + * que crear una cuenta y un proyecto ahí primero (no está en env.example + * ni se puede inventar, ver docs/ESTRATEGIA_300_CUENTAS.md). + */ +import { XummSdk } from "xumm-sdk"; +import * as bt from "@micopaybridge/xrpl-bridge/bridge-translate"; +import { activationTxJson, activationCancelTxJson } from "./xrpl-leg.js"; + +let sdk: XummSdk | null = null; + +function getSdk(): XummSdk { + const apiKey = process.env.XUMM_API_KEY; + const apiSecret = process.env.XUMM_API_SECRET; + if (!apiKey || !apiSecret) { + throw new Error("XUMM_API_KEY/XUMM_API_SECRET no configurados — crea un proyecto en apps.xaman.dev"); + } + if (!sdk) sdk = new XummSdk(apiKey, apiSecret); + return sdk; +} + +export function xummConfigured(): boolean { + return Boolean(process.env.XUMM_API_KEY && process.env.XUMM_API_SECRET); +} + +export interface ActivationPayload { + uuid: string; + qrPng: string; + deepLink: string; + websocketUrl: string; +} + +/** + * `cancelAfter` por uuid — lo decidimos nosotros al crear el payload, pero + * Xaman no nos lo devuelve después. El sweeper lo necesita para saber cuándo + * ya puede cancelar. En memoria: si el proceso se reinicia a medio camino, + * el peor caso es un escrow que espera a que alguien lo cancele a mano — + * el dinero no se pierde, solo se tarda. + */ +const pendingCancelAfter = new Map(); + +/** + * Margen entre que el usuario aprieta "firmar" en Xaman y que la tx entra a + * un ledger validado. Cinco minutos es holgado: XRPL cierra ledger cada 3-5 s. + */ +const MARGEN_DE_FIRMA_SEG = 300; + +/** + * Minutos que Xaman debe mantener vivo el payload. + * + * El payload NUNCA puede sobrevivir a su propio `CancelAfter`. Por defecto + * Xaman lo deja 24 h, y `CancelAfter` se ancla al momento de crearlo: con + * una hora de plazo eso dejaba 23 h en las que el QR seguía firmándose pero + * la transacción ya nacía vencida — rippled la rechaza, el usuario quema la + * fee, no queda escrow y su dirección no cuenta para las 300. Peor aún, + * firmó: cree que funcionó. + * + * Atar la expiración al plazo real convierte ese fallo silencioso en un + * "código expirado, genera otro", que no cuesta nada y se entiende. + * + * Devuelve <= 0 cuando el plazo es tan corto que no queda ventana honesta. + * NO se redondea hacia arriba a 1 minuto: ese clamp volvía a romper la + * invariante justo en el plazo mínimo. Quien llama debe rechazar. + */ +export function minutosDeFirma(cancelAfterRipple: number, ahoraUnix: number): number { + const restante = bt.fromRippleTime(cancelAfterRipple) - ahoraUnix - MARGEN_DE_FIRMA_SEG; + return Math.floor(restante / 60); +} + +export async function createActivationPayload(params: { + accountAddress: string; + amountXrp: string; + cancelAfterSeconds?: number; +}): Promise { + // Destination === Account (self-escrow) — confirmado leyendo + // EscrowCreate.cpp de rippled: es un caso de primera clase ("If it's not + // a self-send..."), no está prohibido. Es la única dirección garantizada + // de existir en la red donde se firma (tecNO_DST exige que Destination + // ya esté activada — probado en vivo con una dirección de config que no + // lo estaba). Este SÍ es el diseño correcto, no una simplificación. + const txFields = activationTxJson({ + destinationAddress: params.accountAddress, + amountXrp: params.amountXrp, + cancelAfterSeconds: params.cancelAfterSeconds, + }); + const tx = { ...txFields, Account: params.accountAddress }; + + // xumm-sdk tipa txjson como Record & {TransactionType}; + // el EscrowCreate de xrpl.js es una interfaz normal sin index signature — + // no encajan estructuralmente aunque el shape en runtime es exactamente + // el que pide. any de frontera, no de descuido. + const expire = minutosDeFirma(Number(txFields.CancelAfter), Math.floor(Date.now() / 1000)); + if (expire <= 0) { + throw new Error( + "el plazo pedido no deja ventana para firmar — sube cancelAfterSeconds", + ); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const created = await getSdk().payload.create({ txjson: tx, options: { expire } } as any); + if (!created) throw new Error("Xaman no devolvió el payload"); + + // Number(): Omit pierde el tipo literal de + // CancelAfter en este cruce con xumm-sdk — en runtime siempre es number, + // lo pusimos dos líneas arriba. + pendingCancelAfter.set(created.uuid, Number(txFields.CancelAfter)); + + return { + uuid: created.uuid, + qrPng: created.refs.qr_png, + deepLink: created.next.always, + websocketUrl: created.refs.websocket_status, + }; +} + +/** + * Payload de reclamo manual: EscrowCancel para que el propio usuario se + * devuelva su XRP sin esperar al sweeper. No hace falta saber quién firma + * de antemano — XRPL manda el reembolso al Owner original sin importar + * quién mande la cancelación. + */ +export async function createCancelPayload(params: { + owner: string; + offerSequence: number; +}): Promise { + const tx = activationCancelTxJson(params); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const created = await getSdk().payload.create({ txjson: tx } as any); + if (!created) throw new Error("Xaman no devolvió el payload"); + + return { + uuid: created.uuid, + qrPng: created.refs.qr_png, + deepLink: created.next.always, + websocketUrl: created.refs.websocket_status, + }; +} + +export interface ActivationStatus { + resolved: boolean; + signed: boolean; + cancelled: boolean; + expired: boolean; + txid: string | null; + account: string | null; + /** + * tesSUCCESS/tec.../null — que Xaman diga signed:true solo significa que + * aprobaste en la app. Esto es lo que de verdad pasó al someterla a la + * red (ej. tecNO_PERMISSION si intentas cancelar antes de tiempo). + */ + dispatchedResult: string | null; +} + +export async function getActivationPayloadStatus(uuid: string): Promise { + const got = await getSdk().payload.get(uuid); + if (!got) throw new Error("payload no encontrado o expirado"); + + return { + resolved: got.meta.resolved, + signed: got.meta.signed, + cancelled: got.meta.cancelled, + expired: got.meta.expired, + txid: got.response.txid, + account: got.response.account, + dispatchedResult: got.response.dispatched_result ?? null, + }; +} + +/** El `CancelAfter` (hora Ripple) que se le puso a este payload al crearlo. */ +export function getPendingCancelAfter(uuid: string): number | undefined { + return pendingCancelAfter.get(uuid); +} + +/** Se llama una vez que ya se registró en el sweeper — no hace falta cargarlo en memoria dos veces. */ +export function clearPendingCancelAfter(uuid: string): void { + pendingCancelAfter.delete(uuid); +} diff --git a/apps/api/src/middleware/x402.ts b/apps/api/src/middleware/x402.ts index 34851a2..eab0f9b 100644 --- a/apps/api/src/middleware/x402.ts +++ b/apps/api/src/middleware/x402.ts @@ -10,6 +10,7 @@ import { reservePaymentKey, releaseReservedPayment, } from "../db/x402.js"; +import { NETWORK_PASSPHRASE, HORIZON_URL, NETWORK_NAME } from "../lib/stellarNetwork.js"; let x402Initialized = false; @@ -59,11 +60,10 @@ const USDC_ASSET_CODE = "USDC"; // SEC-A1: without pinning the issuer, `op.asset.code === "USDC"` accepts an // asset with that code minted by ANY account — a free, worthless lookalike. const USDC_ISSUER = process.env.USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; -const STELLAR_NETWORK = process.env.STELLAR_NETWORK ?? "TESTNET"; -const NETWORK_PASSPHRASE = - STELLAR_NETWORK === "MAINNET" ? Networks.PUBLIC : Networks.TESTNET; -const HORIZON_URL = - STELLAR_NETWORK === "MAINNET" ? "https://horizon.stellar.org" : "https://horizon-testnet.stellar.org"; +// Ver lib/stellarNetwork.ts: esto interpretaba STELLAR_NETWORK por su cuenta y +// solo reconocía "MAINNET", mientras lib/soroban.ts solo reconocía "PUBLIC". +// Con el valor del deploy ("PUBLIC") el swap movía fondos reales y los pagos se +// verificaban contra Horizon de testnet — se pagaba con USDC gratis. // ── Base (BASE_IMPLEMENTATION_PLAN_2026-07.md, WP2) ───────────────────────── const X402_ACCEPT_CHAINS = (process.env.X402_ACCEPT_CHAINS ?? "stellar") @@ -220,7 +220,7 @@ function build402Body(config: X402Config) { if (X402_ACCEPT_CHAINS.includes("stellar")) { accepts.push({ scheme: "stellar-usdc", - network: STELLAR_NETWORK.toLowerCase(), + network: NETWORK_NAME, maxAmountRequired: config.amount, resource: config.service, description: `MicoPay ${config.service}`, @@ -255,7 +255,7 @@ function build402Body(config: X402Config) { memo: `micopay:${config.service}`, expires_at: Math.floor(Date.now() / 1000) + 300, // 5 min service: config.service, - network: STELLAR_NETWORK.toLowerCase(), + network: NETWORK_NAME, instructions: "Send a Stellar USDC payment to pay_to with the specified memo. Include the signed XDR in X-PAYMENT header.", }, diff --git a/apps/api/src/routes/activation.ts b/apps/api/src/routes/activation.ts new file mode 100644 index 0000000..ab8b995 --- /dev/null +++ b/apps/api/src/routes/activation.ts @@ -0,0 +1,166 @@ +import type { FastifyInstance } from "fastify"; +import { isValidClassicAddress } from "xrpl"; +import { + createActivationPayload, + createCancelPayload, + getActivationPayloadStatus, + getPendingCancelAfter, + clearPendingCancelAfter, + xummConfigured, +} from "../lib/xumm.js"; +import { accountExists, fetchTxSequence } from "../lib/xrpl-leg.js"; +import { trackActivationEscrow } from "../lib/activationSweeper.js"; + +/** + * Endpoint público (sin x402, sin login) para la estrategia de 300 cuentas + * de Make Waves: arma la transacción y crea un payload de Xaman, el usuario + * la firma escaneando el QR o abriendo el deep link con su propia wallet. + * Ver docs/ESTRATEGIA_300_CUENTAS.md. + * + * A propósito NUNCA toca una seed ni firma nada — solo pide a Xaman que + * arme el payload y luego pregunta su estado. Firmar aquí sería exactamente + * el "scripted transactions" que el T&C prohíbe (§7). + */ +export async function activationRoutes(fastify: FastifyInstance): Promise { + fastify.post<{ + Body: { account: string; amountXrp?: string; cancelAfterSeconds?: number }; + }>( + "/api/v1/xrpl/activation/payload", + { + config: { + rateLimit: { + max: 20, + timeWindow: "1 minute", + }, + }, + }, + async (request, reply) => { + if (!xummConfigured()) { + return reply.status(503).send({ + error: "Xaman no está configurado (XUMM_API_KEY/XUMM_API_SECRET) — pide credenciales en apps.xaman.dev", + }); + } + + const { account, amountXrp, cancelAfterSeconds } = request.body ?? {}; + if (typeof account !== "string" || !isValidClassicAddress(account)) { + return reply.status(400).send({ error: "account debe ser una dirección XRPL válida — es tu propia dirección, se autobloquea a sí misma" }); + } + + const amount = amountXrp ?? "1"; + const amountNum = Number(amount); + if (!Number.isFinite(amountNum) || amountNum <= 0 || amountNum > 50) { + return reply.status(400).send({ error: "amountXrp debe ser un número entre 0 y 50" }); + } + if ( + cancelAfterSeconds !== undefined && + (!Number.isInteger(cancelAfterSeconds) || cancelAfterSeconds < 600 || cancelAfterSeconds > 86400) + ) { + // Mínimo 600 y no 60: la expiración del payload se deriva de este + // plazo menos el margen de firma, así que por debajo de 10 min no + // queda ventana en la que el usuario alcance a firmar algo válido. + return reply.status(400).send({ error: "cancelAfterSeconds debe estar entre 600 y 86400" }); + } + + // Una wallet recién creada en Xaman y sin fondear no existe para XRPL + // y no puede firmar nada. Decirlo aquí, y no dejar que descubra un QR + // muerto. `null` = no se pudo comprobar; se deja pasar a propósito. + if ((await accountExists(account)) === false) { + return reply.status(400).send({ + error: + "esa dirección todavía no existe en XRPL — hay que fondearla con al menos 2.2 XRP (1 de reserva de la cuenta, 0.2 que queda retenida por el escrow, más lo que vayas a bloquear)", + }); + } + + try { + const payload = await createActivationPayload({ accountAddress: account, amountXrp: amount, cancelAfterSeconds }); + return reply.send(payload); + } catch (err) { + request.log.error(err, "activation/payload falló"); + return reply.status(502).send({ error: "no se pudo crear el payload de Xaman — reintenta" }); + } + }, + ); + + fastify.post<{ + Body: { txid: string }; + }>( + "/api/v1/xrpl/activation/reclaim", + { + config: { + rateLimit: { + max: 20, + timeWindow: "1 minute", + }, + }, + }, + async (request, reply) => { + if (!xummConfigured()) { + return reply.status(503).send({ error: "Xaman no está configurado" }); + } + + const { txid } = request.body ?? {}; + if (typeof txid !== "string" || txid.length < 10) { + return reply.status(400).send({ error: "txid inválido — es el hash de tu EscrowCreate ya firmado" }); + } + + try { + const seq = await fetchTxSequence(txid); + if (!seq) { + return reply.status(404).send({ error: "no se encontró esa transacción en el ledger todavía — espera a que confirme" }); + } + const payload = await createCancelPayload({ owner: seq.account, offerSequence: seq.sequence }); + return reply.send(payload); + } catch (err) { + request.log.error(err, "activation/reclaim falló"); + return reply.status(502).send({ error: "no se pudo armar el reclamo — reintenta" }); + } + }, + ); + + fastify.get<{ Params: { uuid: string } }>( + "/api/v1/xrpl/activation/payload/:uuid", + { + config: { + rateLimit: { + max: 120, + timeWindow: "1 minute", + }, + }, + }, + async (request, reply) => { + if (!xummConfigured()) { + return reply.status(503).send({ error: "Xaman no está configurado" }); + } + try { + const status = await getActivationPayloadStatus(request.params.uuid); + + if (status.signed && status.txid && status.account && status.dispatchedResult === "tesSUCCESS") { + const cancelAfter = getPendingCancelAfter(request.params.uuid); + if (cancelAfter !== undefined) { + // Solo se registra una vez: en cuanto se limpia el pendiente, + // pollear de nuevo ya no vuelve a golpear el ledger por el Sequence. + clearPendingCancelAfter(request.params.uuid); + fetchTxSequence(status.txid) + .then((seq) => { + if (!seq) { + request.log.warn(`no se encontró Sequence para ${status.txid} — el sweeper no podrá cancelarlo solo`); + return; + } + trackActivationEscrow({ + owner: seq.account, + offerSequence: seq.sequence, + cancelAfterRipple: cancelAfter, + }); + }) + .catch((err) => request.log.error(err, "no se pudo registrar el escrow en el sweeper")); + } + } + + return reply.send(status); + } catch (err) { + request.log.error(err, "activation/payload status falló"); + return reply.status(404).send({ error: "payload no encontrado o expirado" }); + } + }, + ); +} diff --git a/apps/api/src/scripts/live-swap-mainnet.ts b/apps/api/src/scripts/live-swap-mainnet.ts new file mode 100644 index 0000000..3518188 --- /dev/null +++ b/apps/api/src/scripts/live-swap-mainnet.ts @@ -0,0 +1,172 @@ +#!/usr/bin/env node +/** + * Smoke test del swap de dos piernas Soroban ↔ XRPL, contra MAINNET real. + * Mueve fondos reales — por eso exige CONFIRM_MAINNET_SWAP=yes explícito y + * nunca corre sin que alguien lea el plan impreso primero. + * + * npm run test:live:mainnet -w @micopay/api + * + * A diferencia de live-swap.ts (testnet, wallets XRPL efímeras del faucet): + * - Pierna Soroban: self-swap con la MISMA identidad `mainnet-bridge` como + * initiator y counterparty. El contrato no exige que sean distintos + * (atomic-swap/src/lib.rs no tiene ese assert) — lock() saca el XLM de + * mainnet-bridge, release() se lo devuelve a la misma cuenta. Flujo neto + * de valor: cero, solo fees. Evita tener que fondear una segunda + * identidad Stellar en mainnet solo para este smoke test. + * - Pierna XRPL: SÍ son dos wallets reales y distintas (XRPL_INITIATOR_SEED / + * XRPL_COUNTERPARTY_SEED en .env) — el XRP se mueve de una a otra de + * verdad, ambas del propio Raúl, ver docs/RUNBOOK_MAINNET_DEPLOY.md. + * + * Requiere en apps/api/.env: XRPL_SERVER (mainnet), XRPL_INITIATOR_SEED, + * XRPL_COUNTERPARTY_SEED (dos wallets XRPL mainnet fondeadas). Requiere en + * apps/api/.env.mainnet-smoke: STELLAR_NETWORK=PUBLIC, STELLAR_RPC_URL, + * ATOMIC_SWAP_CONTRACT_A. `stellar` CLI con la identidad `mainnet-bridge` + * fondeada (STELLAR_INITIATOR_ALIAS/STELLAR_COUNTERPARTY_ALIAS para cambiarla). + * + * Las semillas no se imprimen nunca. + */ + +import { existsSync, readFileSync } from "fs"; +import { join, dirname } from "path"; +import { fileURLToPath } from "url"; +import { execFileSync } from "child_process"; +import { isValidClassicAddress } from "xrpl"; +import { IS_MAINNET } from "../lib/stellarNetwork.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +function loadEnvFile(relPath: string): void { + const envPath = join(__dirname, "..", "..", relPath); + if (!existsSync(envPath)) return; + for (const line of readFileSync(envPath, "utf8").split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const i = trimmed.indexOf("="); + if (i === -1) continue; + process.env[trimmed.slice(0, i).trim()] = trimmed.slice(i + 1).trim(); + } +} + +// .env primero (seeds XRPL compartidas con el resto de la app), luego el +// overlay de mainnet encima — así este script nunca hereda RPC/contrato de +// testnet por accidente si alguien los deja puestos en .env. +loadEnvFile(".env"); +loadEnvFile(".env.mainnet-smoke"); + +// Import DINÁMICO, a propósito: soroban.ts lee STELLAR_RPC_URL/STELLAR_NETWORK +// en su propio top-level al importarse. Un `import` estático se ejecuta antes +// que cualquier código de este archivo (hoisting de ES modules) — con eso, +// loadEnvFile() de arriba llegaría tarde y soroban.ts se quedaría con los +// defaults de testnet aunque .env.mainnet-smoke diga PUBLIC. Ya pasó una vez. +const { executeAtomicSwapBackground } = await import("../lib/soroban.js"); +const { swapStore } = await import("../lib/swapStore.js"); +const { xrplAddressFromSeed } = await import("../lib/xrpl-leg.js"); + +const CONTRACT_A = process.env.ATOMIC_SWAP_CONTRACT_A; +const XRPL_SERVER = process.env.XRPL_SERVER; +const XRPL_INITIATOR_SEED = process.env.XRPL_INITIATOR_SEED; +const XRPL_COUNTERPARTY_SEED = process.env.XRPL_COUNTERPARTY_SEED; +const STELLAR_ALIAS = process.env.STELLAR_INITIATOR_ALIAS ?? "mainnet-bridge"; +const COUNTERPARTY_ALIAS = process.env.STELLAR_COUNTERPARTY_ALIAS ?? STELLAR_ALIAS; + +const SELL_XLM = Number(process.env.SMOKE_SELL_XLM ?? "0.5"); +const BUY_XRP = Number(process.env.SMOKE_BUY_XRP ?? "1"); +const INITIATOR_LEDGERS = 240; +const COUNTERPARTY_LEDGERS = 120; + +const stellarSecret = (alias: string): string => + execFileSync("stellar", ["keys", "show", alias], { encoding: "utf8" }).trim(); + +function requireEnv(name: string, value: string | undefined): string { + if (!value) { + console.error(`Falta ${name} — revisa apps/api/.env y .env.mainnet-smoke`); + process.exit(1); + } + return value; +} + +async function main(): Promise { + // Vía lib/stellarNetwork.ts, que acepta PUBLIC y MAINNET: comparar la grafía + // aquí bloqueaba el smoke test de mainnet a quien hubiera configurado la otra. + requireEnv("STELLAR_NETWORK=PUBLIC|MAINNET", IS_MAINNET ? "ok" : undefined); + requireEnv("ATOMIC_SWAP_CONTRACT_A", CONTRACT_A); + requireEnv("XRPL_SERVER", XRPL_SERVER); + requireEnv("XRPL_INITIATOR_SEED", XRPL_INITIATOR_SEED); + requireEnv("XRPL_COUNTERPARTY_SEED", XRPL_COUNTERPARTY_SEED); + + const xrplInitiatorAddr = xrplAddressFromSeed(XRPL_INITIATOR_SEED!); + const xrplCounterpartyAddr = xrplAddressFromSeed(XRPL_COUNTERPARTY_SEED!); + if (!isValidClassicAddress(xrplInitiatorAddr) || !isValidClassicAddress(xrplCounterpartyAddr)) { + console.error("Las seeds XRPL no derivan direcciones válidas."); + process.exit(1); + } + + const stellarAddr = execFileSync("stellar", ["keys", "address", STELLAR_ALIAS], { encoding: "utf8" }).trim(); + + console.log("=== SMOKE TEST MAINNET — lee esto antes de confirmar ==="); + console.log(`Red Soroban: PUBLIC, RPC ${process.env.STELLAR_RPC_URL}`); + console.log(`Contrato: ${CONTRACT_A}`); + console.log(`Cuenta Stellar: ${stellarAddr} (alias ${STELLAR_ALIAS}) — self-swap, initiator = counterparty`); + console.log(`Vende: ${SELL_XLM} XLM (sale y vuelve a la misma cuenta)`); + console.log(`Red XRPL: ${XRPL_SERVER}`); + console.log(`XRPL contraparte: ${xrplCounterpartyAddr} (bloquea XRP de verdad)`); + console.log(`XRPL iniciador: ${xrplInitiatorAddr} (recibe ${BUY_XRP} XRP de verdad)`); + console.log(`Compra: ${BUY_XRP} XRP — este SÍ se mueve entre las dos wallets XRPL`); + + if (process.env.CONFIRM_MAINNET_SWAP !== "yes") { + console.log("\nNo se ejecuta nada. Vuelve a correr con CONFIRM_MAINNET_SWAP=yes si el plan de arriba es correcto."); + process.exit(0); + } + + const swapId = `live_mainnet_${Date.now()}`; + const now = new Date().toISOString(); + swapStore.set(swapId, { + swap_id: swapId, + plan_id: "live_mainnet", + status: "queued", + sell_asset: "XLM", + sell_amount: String(SELL_XLM), + buy_asset: "XRP", + buy_amount: String(BUY_XRP), + chain_b: "xrpl", + txs: {}, + created_at: now, + updated_at: now, + }); + + console.log("\n[run] lock A (Soroban) → lock B (XRPL) → reveal (XRPL) → release A (Soroban)"); + const t0 = Date.now(); + await executeAtomicSwapBackground( + swapId, + stellarSecret(STELLAR_ALIAS), + stellarSecret(COUNTERPARTY_ALIAS), + CONTRACT_A!, + { initiatorSeed: XRPL_INITIATOR_SEED!, counterpartySeed: XRPL_COUNTERPARTY_SEED! }, + "XLM", + SELL_XLM, + "XRP", + BUY_XRP, + INITIATOR_LEDGERS, + COUNTERPARTY_LEDGERS, + ); + + const swap = swapStore.get(swapId)!; + const secs = ((Date.now() - t0) / 1000).toFixed(0); + console.log(`\n=== ${swap.status} en ${secs}s ===`); + if (swap.error) console.log("error:", swap.error); + console.log("secret_hash:", swap.secret_hash); + console.log("escrow XRPL:", JSON.stringify(swap.xrpl)); + console.log("txs:", JSON.stringify(swap.txs, null, 2)); + + if (swap.status !== "completed") { + console.log("\nNo completó — si algo quedó bloqueado, swapStore marca refund_pending. Revisar antes de reintentar."); + process.exit(1); + } + console.log("\nSwap de dos piernas completo en MAINNET. Las cuatro txs son citables en stellar.expert / livenet.xrpl.org."); + process.exit(0); +} + +main().catch((err) => { + console.error("FALLO:", err); + process.exit(1); +}); diff --git a/apps/api/src/services/health.ts b/apps/api/src/services/health.ts index 3525f8f..e0ce476 100644 --- a/apps/api/src/services/health.ts +++ b/apps/api/src/services/health.ts @@ -1,3 +1,4 @@ +import * as StellarSdk from "@stellar/stellar-sdk"; import { config } from "../config.js"; import { query } from "../db/schema.js"; @@ -91,10 +92,16 @@ async function checkContractDeployed(contractId: string): Promise("demo"); + // El hash elige pestaña para poder repartir un link de un solo propósito: + // `#activate` abre directo en activación. Sin esto, quien llega desde una + // campaña aterriza en la terminal de agentes y tiene que encontrar + // "🔑 Activar" entre ocho pestañas, que en un teléfono van cortadas. + const [activeTab, setActiveTab] = useState(() => { + const desdeHash = window.location.hash.replace("#", ""); + return TAB_IDS.includes(desdeHash as Tab) ? (desdeHash as Tab) : "demo"; + }); const { isDemoMode } = useDemoStatus(); + // El badge decía "testnet live" fijo, incluso con la API en mainnet. Sale + // de /health, que es quien sabe en qué red está corriendo de verdad. + const [network, setNetwork] = useState(null); + useEffect(() => { + if (!API_URL) return; + let cancelled = false; + fetch(`${API_URL}/health`) + .then((res) => res.json()) + .then((data: { network?: string }) => { + if (!cancelled) setNetwork(data.network ?? null); + }) + .catch(() => { + if (!cancelled) setNetwork(null); + }); + return () => { + cancelled = true; + }; + }, []); + // Sin API no hay nada que enseñar: cada panel de abajo vive de llamarla. Más // vale decirlo que pintar siete pestañas que fallan en silencio. if (!API_URL) return ; @@ -85,6 +114,7 @@ export default function App() { { id: "reputation", label: "⭐ Reputación" }, { id: "fund", label: "💚 Fund MicoPay" }, { id: "services", label: "📡 Servicios" }, + { id: "activate", label: "🔑 Activar" }, ]; return ( @@ -184,30 +214,50 @@ export default function App() { display: "inline-block", }} /> - testnet live + + {network === null + ? "live" + : network === "PUBLIC" + ? "mainnet live" + : "testnet live"} + · Sin cuenta · Sin API key {/* Tabs */} + {/* La fila desbordaba el ancho del documento en pantallas angostas: ocho + pestañas en un flex sin scroll hacían la página más ancha que el + teléfono, y al arrastrar aparecía el blanco del body detrás del + fondo oscuro. Que la barra scrollee por dentro deja el documento del + ancho de la pantalla. */}