From fab712d730bd0750394dfff0f914113e85f7c37c Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Fri, 14 Aug 2026 00:47:17 -0600 Subject: [PATCH 01/16] fix(api): soroban.ts firmaba con passphrase de testnet aunque RPC_URL fuera mainnet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NET estaba fijo a StellarSdk.Networks.TESTNET sin leer STELLAR_NETWORK. Con STELLAR_RPC_URL apuntando a mainnet (como en el deploy de hoy), la red del RPC y el passphrase de firma quedaban desincronizados, y el Asset.contractId() de USDC/XLM calculaba el SAC de la red que no es. Encontrado corriendo el smoke test de mainnet contra el contrato recién desplegado. --- apps/api/src/lib/soroban.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/api/src/lib/soroban.ts b/apps/api/src/lib/soroban.ts index b882906..561d20c 100644 --- a/apps/api/src/lib/soroban.ts +++ b/apps/api/src/lib/soroban.ts @@ -19,7 +19,11 @@ import { swapStore, type SwapState } from "./swapStore.js"; import { lockXrplLeg, revealOnXrpl, cancelXrplLeg, xrplAddressFromSeed } from "./xrpl-leg.js"; const RPC_URL = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org"; -const NET = StellarSdk.Networks.TESTNET; +// El deploy pone STELLAR_NETWORK=PUBLIC en mainnet — sin este check, RPC_URL +// podía apuntar a mainnet mientras las txs se firmaban con el passphrase de +// testnet (red equivocada = firma inválida, y el Asset.contractId() de abajo +// también calcularía el SAC de la red que no es). +const NET = process.env.STELLAR_NETWORK === "PUBLIC" ? StellarSdk.Networks.PUBLIC : StellarSdk.Networks.TESTNET; const USDC_ISSUER = process.env.USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; const USDC_SAC = new StellarSdk.Asset("USDC", USDC_ISSUER).contractId(NET); From a42993bd81d0193c9e96940ce40e5e98ccdb6371 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Fri, 14 Aug 2026 00:50:50 -0600 Subject: [PATCH 02/16] =?UTF-8?q?feat(api,web):=20activaci=C3=B3n=20de=20c?= =?UTF-8?q?uentas=20XRPL=20v=C3=ADa=20Xaman=20para=20las=20300=20de=20Make?= =?UTF-8?q?=20Waves?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoint público (sin login) que arma un EscrowCreate/EscrowCancel sin firmar y lo manda como payload de Xaman — el usuario firma con su propia wallet, el backend nunca toca una seed. Es la única forma que no pisa la cláusula anti-sybil del T&C (§7, scripted transactions), ver docs/ESTRATEGIA_300_CUENTAS.md. Incluye el sweeper que cancela solo los escrows vencidos (XRPL no lo hace automático) y el tab "Activar" en el dashboard. --- apps/api/package.json | 6 +- apps/api/src/index.ts | 4 + apps/api/src/lib/activationSweeper.ts | 95 +++ apps/api/src/lib/xrpl-leg.ts | 72 ++ apps/api/src/lib/xumm.ts | 146 +++++ apps/api/src/routes/activation.ts | 153 +++++ apps/web/src/App.tsx | 5 +- apps/web/src/components/ActivationPanel.tsx | 358 ++++++++++ docs/ESTRATEGIA_300_CUENTAS.md | 75 +++ package-lock.json | 690 +++++++++++++++++++- 10 files changed, 1600 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/lib/activationSweeper.ts create mode 100644 apps/api/src/lib/xumm.ts create mode 100644 apps/api/src/routes/activation.ts create mode 100644 apps/web/src/components/ActivationPanel.tsx create mode 100644 docs/ESTRATEGIA_300_CUENTAS.md diff --git a/apps/api/package.json b/apps/api/package.json index 9ff3958..eb17a5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "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/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/xrpl-leg.ts b/apps/api/src/lib/xrpl-leg.ts index a40966b..cda4da4 100644 --- a/apps/api/src/lib/xrpl-leg.ts +++ b/apps/api/src/lib/xrpl-leg.ts @@ -144,3 +144,75 @@ export async function cancelXrplLeg(params: { export function xrplAddressFromSeed(seed: string): string { return Wallet.fromSeed(seed).address; } + +/** + * 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 { + const cancelAfterSec = params.cancelAfterSeconds ?? 300; + 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..2738473 --- /dev/null +++ b/apps/api/src/lib/xumm.ts @@ -0,0 +1,146 @@ +/** + * 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 { 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(); + +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. + // 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"); + + // 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/routes/activation.ts b/apps/api/src/routes/activation.ts new file mode 100644 index 0000000..ee1eab6 --- /dev/null +++ b/apps/api/src/routes/activation.ts @@ -0,0 +1,153 @@ +import type { FastifyInstance } from "fastify"; +import { isValidClassicAddress } from "xrpl"; +import { + createActivationPayload, + createCancelPayload, + getActivationPayloadStatus, + getPendingCancelAfter, + clearPendingCancelAfter, + xummConfigured, +} from "../lib/xumm.js"; +import { 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 < 60 || cancelAfterSeconds > 86400) + ) { + return reply.status(400).send({ error: "cancelAfterSeconds debe estar entre 60 y 86400" }); + } + + 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/web/src/App.tsx b/apps/web/src/App.tsx index 6e0e90a..512294f 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -7,11 +7,12 @@ import ZKDemoTerminal from "./components/ZKDemoTerminal"; import ReputationPanel from "./components/ReputationPanel"; import BazaarFeed from "./components/BazaarFeed"; import DemoBanner from "./components/DemoBanner"; +import ActivationPanel from "./components/ActivationPanel"; import { useDemoStatus } from "./hooks/useDemoStatus"; import { API_URL, APP_URL } from "./config"; -type Tab = "demo" | "swap" | "zk" | "bazaar" | "reputation" | "fund" | "services"; +type Tab = "demo" | "swap" | "zk" | "bazaar" | "reputation" | "fund" | "services" | "activate"; // No login gate here on purpose: this dashboard is a human observer console // for the agent economy demo, not something an agent itself ever sees — the @@ -85,6 +86,7 @@ export default function App() { { id: "reputation", label: "⭐ Reputación" }, { id: "fund", label: "💚 Fund MicoPay" }, { id: "services", label: "📡 Servicios" }, + { id: "activate", label: "🔑 Activar" }, ]; return ( @@ -231,6 +233,7 @@ export default function App() { {activeTab === "reputation" && } {activeTab === "fund" && } {activeTab === "services" && } + {activeTab === "activate" && } ); diff --git a/apps/web/src/components/ActivationPanel.tsx b/apps/web/src/components/ActivationPanel.tsx new file mode 100644 index 0000000..8bd5d42 --- /dev/null +++ b/apps/web/src/components/ActivationPanel.tsx @@ -0,0 +1,358 @@ +import { useState, useCallback, useEffect, useRef } from "react"; + +interface Props { + apiUrl: string; +} + +interface Payload { + uuid: string; + qrPng: string; + deepLink: string; +} + +type Step = "idle" | "creating" | "waiting" | "done" | "error"; +type ReclaimStep = "idle" | "creating" | "waiting" | "done" | "error"; + +const CANCEL_AFTER_SECONDS = 300; // default de activationTxJson en el backend + +// Sin custodia: el backend arma la transacción y crea un payload de Xaman +// (QR + deep link), pero quien firma es la propia wallet del usuario — +// escaneando o abriendo la app. El backend nunca ve una seed. Ver +// docs/ESTRATEGIA_300_CUENTAS.md — es el único diseño que no pisa la +// cláusula anti-sybil del T&C de Make Waves (§7): automatizar la firma del +// lado del team sería "scripted transactions". +export default function ActivationPanel({ apiUrl }: Props) { + const [step, setStep] = useState("idle"); + const [account, setAccount] = useState(""); + const [amountXrp, setAmountXrp] = useState("1"); + const [payload, setPayload] = useState(null); + const [txid, setTxid] = useState(null); + const [signedAt, setSignedAt] = useState(null); + const [error, setError] = useState(null); + const pollRef = useRef | null>(null); + + const [reclaimStep, setReclaimStep] = useState("idle"); + const [reclaimPayload, setReclaimPayload] = useState(null); + const [reclaimTxid, setReclaimTxid] = useState(null); + const [reclaimError, setReclaimError] = useState(null); + const [secondsLeft, setSecondsLeft] = useState(0); + const reclaimPollRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }, []); + + useEffect(() => stopPolling, [stopPolling]); + + const stopReclaimPolling = useCallback(() => { + if (reclaimPollRef.current) { + clearInterval(reclaimPollRef.current); + reclaimPollRef.current = null; + } + }, []); + + useEffect(() => stopReclaimPolling, [stopReclaimPolling]); + + // Cuenta regresiva hasta que el CancelAfter ya pasó — antes de eso XRPL + // rechaza el EscrowCancel (tecNO_PERMISSION), no tiene caso ni intentarlo. + useEffect(() => { + if (!signedAt) return; + const tick = () => { + const left = CANCEL_AFTER_SECONDS - Math.floor((Date.now() - signedAt) / 1000); + setSecondsLeft(Math.max(0, left)); + }; + tick(); + const id = setInterval(tick, 1000); + return () => clearInterval(id); + }, [signedAt]); + + const start = useCallback(async () => { + if (!account.trim().startsWith("r") || account.trim().length < 20) { + setStep("error"); + setError("Pega tu dirección XRPL completa (empieza con 'r')"); + return; + } + setStep("creating"); + setError(null); + setPayload(null); + setTxid(null); + try { + const res = await fetch(`${apiUrl}/api/v1/xrpl/activation/payload`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ account: account.trim(), amountXrp }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `El servidor respondió ${res.status}`); + } + const created: Payload = await res.json(); + setPayload(created); + setStep("waiting"); + + pollRef.current = setInterval(async () => { + try { + const statusRes = await fetch(`${apiUrl}/api/v1/xrpl/activation/payload/${created.uuid}`); + if (!statusRes.ok) return; + const status = await statusRes.json(); + if (status.signed && status.txid) { + // signed:true solo dice que aprobaste en Xaman — hay que + // revisar si la red de verdad la aceptó antes de celebrar. + if (status.dispatchedResult && status.dispatchedResult !== "tesSUCCESS") { + stopPolling(); + setStep("error"); + setError(`La red rechazó la transacción: ${status.dispatchedResult}`); + return; + } + stopPolling(); + setTxid(status.txid); + setSignedAt(Date.now()); + setStep("done"); + } else if (status.cancelled || status.expired) { + stopPolling(); + setStep("error"); + setError(status.cancelled ? "Cancelaste la firma en la app" : "El código expiró — intenta de nuevo"); + } + } catch { + // un fallo de red puntual no debe tumbar el polling + } + }, 2000); + } catch (err) { + setStep("error"); + setError(err instanceof Error ? err.message : "No se pudo crear el código de firma"); + } + }, [account, amountXrp, apiUrl, stopPolling]); + + const reclaim = useCallback(async () => { + if (!txid) return; + setReclaimStep("creating"); + setReclaimError(null); + setReclaimPayload(null); + setReclaimTxid(null); + try { + const res = await fetch(`${apiUrl}/api/v1/xrpl/activation/reclaim`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ txid }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `El servidor respondió ${res.status}`); + } + const created: Payload = await res.json(); + setReclaimPayload(created); + setReclaimStep("waiting"); + + reclaimPollRef.current = setInterval(async () => { + try { + const statusRes = await fetch(`${apiUrl}/api/v1/xrpl/activation/payload/${created.uuid}`); + if (!statusRes.ok) return; + const status = await statusRes.json(); + if (status.signed && status.txid) { + if (status.dispatchedResult && status.dispatchedResult !== "tesSUCCESS") { + stopReclaimPolling(); + setReclaimStep("error"); + setReclaimError( + status.dispatchedResult === "tecNO_PERMISSION" + ? "Todavía no pasa el tiempo de espera — intenta en unos minutos" + : `La red rechazó la cancelación: ${status.dispatchedResult}`, + ); + return; + } + stopReclaimPolling(); + setReclaimTxid(status.txid); + setReclaimStep("done"); + } else if (status.cancelled || status.expired) { + stopReclaimPolling(); + setReclaimStep("error"); + setReclaimError(status.cancelled ? "Cancelaste la firma en la app" : "El código expiró — intenta de nuevo"); + } + } catch { + // un fallo de red puntual no debe tumbar el polling + } + }, 2000); + } catch (err) { + setReclaimStep("error"); + setReclaimError(err instanceof Error ? err.message : "No se pudo crear el código de reclamo"); + } + }, [txid, apiUrl, stopReclaimPolling]); + + const box: React.CSSProperties = { + background: "#111827", + border: "1px solid #1f2937", + borderRadius: "0.5rem", + padding: "1.5rem", + marginBottom: "1rem", + }; + + const buttonStyle: React.CSSProperties = { + padding: "0.6rem 1.2rem", + fontSize: "0.875rem", + background: "#4ade80", + color: "#052e16", + border: "none", + borderRadius: "0.375rem", + fontWeight: "bold", + cursor: "pointer", + }; + + return ( +
+
+

+ Activa tu cuenta en el puente +

+

+ Bloqueas un poco de XRP en un EscrowCreate real — + nunca puede cobrarlo nadie más que tú, y lo reclamas cuando quieras. + Escaneas con Xaman y firmas ahí — + nosotros nunca vemos tu llave. +

+
+ + {(step === "idle" || step === "creating" || step === "error") && ( +
+ + setAccount(e.target.value)} + style={{ + width: "100%", + padding: "0.5rem 0.75rem", + background: "#0f172a", + border: "1px solid #1f2937", + borderRadius: "0.375rem", + color: "white", + fontSize: "0.8rem", + fontFamily: "monospace", + marginBottom: "1rem", + }} + /> + + setAmountXrp(e.target.value)} + style={{ + width: "100%", + padding: "0.5rem 0.75rem", + background: "#0f172a", + border: "1px solid #1f2937", + borderRadius: "0.375rem", + color: "white", + fontSize: "0.875rem", + marginBottom: "1rem", + }} + /> + + {error && ( +

{error}

+ )} +
+ )} + + {step === "waiting" && payload && ( +
+ Código QR de Xaman +

+ Escanea con la app de Xaman, o ábrela directo desde este teléfono +

+ + Abrir en Xaman + +

+ Esperando que firmes... +

+
+ )} + + {step === "done" && txid && ( +
+

+ ✓ Activado on-chain +

+ {txid} +
+ )} + + {step === "done" && (reclaimStep === "idle" || reclaimStep === "creating" || reclaimStep === "error") && ( +
+

+ Reclama tu XRP de vuelta — lo firmas tú mismo, sin esperar a nadie más. +

+ + {reclaimError && ( +

{reclaimError}

+ )} +
+ )} + + {step === "done" && reclaimStep === "waiting" && reclaimPayload && ( +
+ Código QR de Xaman — reclamo +

+ Escanea o abre en Xaman para confirmar el reclamo +

+ + Abrir en Xaman + +
+ )} + + {step === "done" && reclaimStep === "done" && reclaimTxid && ( +
+

+ ✓ XRP reclamado +

+ {reclaimTxid} +
+ )} +
+ ); +} diff --git a/docs/ESTRATEGIA_300_CUENTAS.md b/docs/ESTRATEGIA_300_CUENTAS.md new file mode 100644 index 0000000..44cf754 --- /dev/null +++ b/docs/ESTRATEGIA_300_CUENTAS.md @@ -0,0 +1,75 @@ +# Estrategia — 300 cuentas activas (Make Waves) + +Borrador para discutir con el team. No es código, es la forma de conseguir +las 300 direcciones distintas sin pisar la cláusula anti-sybil del T&C +(§7): *"Wash trading, sybil attacks, self-dealing, scripted transactions... +strictly prohibited... disqualified and any provisional prize forfeited."* + +## La regla, en una frase + +**Cada una de las 300 firma con su propia wallet.** El backend puede armar +la transacción entera — monto, condición, el `SourceTag` — pero el botón +de firmar lo aprieta la persona, no un script del team. Eso es lo único +que separa "300 usuarios reales" de "sybil attack". + +## Qué transacción usar + +Tres opciones, de más simple a más fiel al producto: + +| Opción | Qué firma el usuario | Riesgo | +|---|---|---| +| (a) Payment simple | Un pago cualquiera con el source tag | Barato y rápido, pero es el patrón más fácil de leer como "inorgánico" — el T&C dice que el jurado puede *descontar* tx que "reasonably believed to be inorganic" aunque técnicamente sí cuenten | +| (b) EscrowCreate real (recomendada) | Bloquea un monto pequeño propio (ej. 1-2 XRP) contra el contrato del puente, con el source tag | Usa la primitiva real del hackathon (`EscrowCreate`), no es un gesto vacío — más defendible ante revisión manual | +| (c) Swap atómico completo | El flujo de dos piernas real (XRPL↔Soroban) | El más fiel al producto, pero necesita contraparte para cada uno de los 300 — no escala en 3 días sin automatizar la contraparte, y automatizar la contraparte del team sí es zona gris (self-dealing en una pierna) | + +Recomiendo (b): real, barato, usa la primitiva del reto, y no depende de +tener 300 contrapartes humanas simultáneas. Decisión final del team — yo +no puedo elegir el trade-off costo/tiempo por ustedes. + +## Flujo mínimo por usuario (<1 minuto, sin fricción) + +1. Usuario abre un link/QR (web, sin login — como pide `UX_MANIFESTO` para + MicoPay en general) +2. El backend arma el payload de `EscrowCreate` con `SourceTag: 2607170001` + ya puesto — el usuario no toca esa parte +3. El usuario conecta su wallet (Xumm/Crossmark vía deep link o extensión) + y firma. Un clic. +4. Confirmación en pantalla con el link al explorer (`livenet.xrpl.org`) — + prueba social, la persona ve que de verdad pasó algo on-chain + +No hace falta cuenta, contraseña ni KYC del usuario — solo la wallet. + +## Qué falta construir (no está hecho hoy) + +- Endpoint que arme el `EscrowCreate` payload (existe la lógica en + `apps/api/src/lib/xrpl-leg.ts`, pero hoy firma con la llave de la + plataforma — hay que separar "armar la tx" de "firmarla", y devolver el + payload sin firmar para que la wallet del usuario lo firme) +- Página pública mínima que reciba el payload y lo mande a Xumm/Crossmark + (no existe — la consola actual es solo demo de agentes, no tiene este + flujo de usuario final) +- Definir el monto y qué pasa con los fondos bloqueados (¿se le devuelven + al usuario tras el timeout? ¿el team los recibe como "prueba"? — esto sí + es decisión de producto/negocio, no técnica) + +## Riesgo de "inorgánico" aunque se cumpla la letra + +El T&C separa dos cosas: descalificación (sybil/scripted, prohibido tajante) +y descuento discrecional del jurado ("we reserve the right to manually +review... and to discount transactions reasonably believed to be inorganic"). +300 transacciones idénticas, del mismo monto, en la misma hora, calzan con +"inorgánico" aunque cada una la haya firmado una persona distinta. Para +que se vea real: + +- Variar el monto un poco por usuario (o dejar que cada quien elija) +- Repartir en el tiempo — no lanzar todo en una sola campaña de una hora +- Que la distribución (a quién le llega el link) sea gente real con motivo + real de probarlo, no una lista comprada — esto es trabajo de comunidad/ + outreach del team, no algo que yo pueda ejecutar + +## Cómo llegar a 300 personas reales + +Fuera de mi alcance decidirlo — es la parte de comunidad/canales del team +(Discord de XRPL Commons, red de comercios de MicoPay en México, redes del +equipo). Lo anoto aquí porque es el paso que más tiempo real va a tomar, +más que cualquier cosa de código. diff --git a/package-lock.json b/package-lock.json index 1fcbce7..3b45235 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,8 @@ "tsx": "^4.19.0", "typescript": "^5.7.3", "viem": "^2.54.1", - "xrpl": "^5.0.0" + "xrpl": "^5.0.0", + "xumm-sdk": "^1.11.2" }, "devDependencies": { "fast-check": "^4.8.0", @@ -2562,6 +2563,15 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/websocket": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/websocket/-/websocket-1.0.10.tgz", + "integrity": "sha512-svjGZvPB7EzuYS94cI7a+qhwgGU1y89wUgjT6E2wVUfmAGIvRfT7obBvRtnhXCSsoMdlG4gBFGE7MfkIXZLoww==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@vitejs/plugin-react": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", @@ -2861,6 +2871,19 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/assert": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/assert/-/assert-2.1.0.tgz", + "integrity": "sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.2", + "is-nan": "^1.3.2", + "object-is": "^1.1.5", + "object.assign": "^4.1.4", + "util": "^0.12.5" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2923,6 +2946,21 @@ "postcss": "^8.1.0" } }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/avvio": { "version": "8.4.0", "resolved": "https://registry.npmjs.org/avvio/-/avvio-8.4.0.tgz", @@ -3102,6 +3140,37 @@ "ieee754": "^1.2.1" } }, + "node_modules/bufferutil": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", + "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -3115,6 +3184,22 @@ "node": ">= 0.4" } }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001809", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001809.tgz", @@ -3257,6 +3342,19 @@ "dev": true, "license": "MIT" }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/data-urls": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", @@ -3305,6 +3403,40 @@ "dev": true, "license": "MIT" }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -3512,6 +3644,46 @@ "node": ">= 0.4" } }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", @@ -3569,6 +3741,21 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", @@ -3588,12 +3775,31 @@ "node": ">= 0.6" } }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, "node_modules/eventemitter3": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/eventsource": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", @@ -3625,6 +3831,15 @@ "node": ">=12.0.0" } }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, "node_modules/fast-check": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.9.0.tgz", @@ -3868,6 +4083,57 @@ "is-retry-allowed": "^3.0.0" } }, + "node_modules/fetch-ponyfill": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/fetch-ponyfill/-/fetch-ponyfill-7.1.0.tgz", + "integrity": "sha512-FhbbL55dj/qdVO3YNK7ZEkshvj3eQ7EuIGV2I6ic/2YiocvyWv+7jg2s4AyS0wdRU75s3tA8ZxI/xPigb0v5Aw==", + "license": "MIT", + "dependencies": { + "node-fetch": "~2.6.1" + } + }, + "node_modules/fetch-ponyfill/node_modules/node-fetch": { + "version": "2.6.13", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", + "integrity": "sha512-StxNAxh15zr77QvvkmveSQ8uCQ4+v5FkvNTj0OESmiHu+VRi/gXArXtkWMElOsOUNLtUEvI4yS+rdtOHZTwlQA==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/fetch-ponyfill/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/fetch-ponyfill/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/fetch-ponyfill/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/file-uri-to-path": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", @@ -3933,6 +4199,21 @@ } } }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/form-data": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", @@ -4010,6 +4291,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4097,6 +4387,18 @@ "dev": true, "license": "ISC" }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4250,6 +4552,69 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-nan": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-nan/-/is-nan-1.3.2.tgz", + "integrity": "sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.0", + "define-properties": "^1.1.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -4257,6 +4622,24 @@ "dev": true, "license": "MIT" }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-retry-allowed": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-retry-allowed/-/is-retry-allowed-3.0.0.tgz", @@ -4269,6 +4652,27 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "license": "MIT" + }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -4891,6 +5295,65 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "license": "ISC" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { "version": "2.0.53", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.53.tgz", @@ -4901,6 +5364,51 @@ "node": ">=18" } }, + "node_modules/object-is": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz", + "integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -4957,6 +5465,12 @@ "integrity": "sha512-Y8xOCT2eiKGYDzMW9R4x5cmfc3vGaaI4EL2pwhDmodWw1HlK18YcZ4uJxc7Rdp7/gGzAygzH9SXr6GKYIXbRcQ==", "license": "MIT" }, + "node_modules/os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==", + "license": "MIT" + }, "node_modules/ox": { "version": "0.14.33", "resolved": "https://registry.npmjs.org/ox/-/ox-0.14.33.tgz", @@ -5257,6 +5771,15 @@ ], "license": "MIT" }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -5684,6 +6207,23 @@ ], "license": "MIT" }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/safe-regex2": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-3.1.0.tgz", @@ -5788,6 +6328,23 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -6109,6 +6666,21 @@ "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", "license": "Unlicense" }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "license": "ISC" + }, + "node_modules/typedarray-to-buffer": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", + "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", + "license": "MIT", + "dependencies": { + "is-typedarray": "^1.0.0" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -6190,6 +6762,32 @@ "punycode": "^2.1.0" } }, + "node_modules/utf-8-validate": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", + "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "node-gyp-build": "^4.3.0" + }, + "engines": { + "node": ">=6.14.2" + } + }, + "node_modules/util": { + "version": "0.12.5", + "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", + "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "is-arguments": "^1.0.4", + "is-generator-function": "^1.0.7", + "is-typed-array": "^1.1.3", + "which-typed-array": "^1.1.2" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -6910,6 +7508,38 @@ "node": ">=20" } }, + "node_modules/websocket": { + "version": "1.0.35", + "resolved": "https://registry.npmjs.org/websocket/-/websocket-1.0.35.tgz", + "integrity": "sha512-/REy6amwPZl44DDzvRCkaI1q1bIiQB0mEFQLUrhz3z2EK91cp3n72rAjUlrTP0zV22HJIUOVHQGPxhFRjxjt+Q==", + "license": "Apache-2.0", + "dependencies": { + "bufferutil": "^4.0.1", + "debug": "^2.2.0", + "es5-ext": "^0.10.63", + "typedarray-to-buffer": "^3.1.5", + "utf-8-validate": "^5.0.2", + "yaeti": "^0.0.6" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/websocket/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/websocket/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", @@ -6935,6 +7565,27 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -7082,6 +7733,43 @@ "node": ">=0.4" } }, + "node_modules/xumm-sdk": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/xumm-sdk/-/xumm-sdk-1.11.2.tgz", + "integrity": "sha512-yCS7o0hd36Ijg+FeIYB4ghnx/26kcmnN1ngvTiFzoXY3j/qmTvtlFmuvAwp58kwFM3LIhz3F7XTZjG+CqVheOQ==", + "license": "MIT", + "dependencies": { + "@types/websocket": "^1.0.5", + "assert": "^2.0.0", + "bignumber.js": "^9.0.0", + "buffer": "^6.0.3", + "debug": "^4.1.1", + "events": "^3.3.0", + "fetch-ponyfill": "^7.1.0", + "node-fetch": "^2.6.1", + "os-browserify": "^0.3.0", + "websocket": "^1.0.34" + } + }, + "node_modules/xumm-sdk/node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/yaeti": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/yaeti/-/yaeti-0.0.6.tgz", + "integrity": "sha512-MvQa//+KcZCUkBTIC9blM+CU9J2GzuTytsOUwf2lidtvkx/6gnEp1QvJv34t9vdjhFmha/mUiNDbN0D0mJWdug==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "engines": { + "node": ">=0.10.32" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", From e36f365b05aa92ec05fcebf0b1738601588a3b80 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Fri, 14 Aug 2026 00:50:58 -0600 Subject: [PATCH 03/16] chore: mueve el deploy a Railway (builder Railpack, no Docker en api/web) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit apps/web/Dockerfile pasa a Dockerfile.local — se conserva para build local, pero Railway construye api y web con Railpack (railway.json en cada uno), no con el Dockerfile. El proyecto real es micopaybridge-activation en Railway, no Render (render.yaml es aparte, para si el team decide usarlo después). --- apps/web/{Dockerfile => Dockerfile.local} | 0 apps/web/railway.json | 7 +++++++ railway.json | 14 ++++++++++++++ 3 files changed, 21 insertions(+) rename apps/web/{Dockerfile => Dockerfile.local} (100%) create mode 100644 apps/web/railway.json create mode 100644 railway.json diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile.local similarity index 100% rename from apps/web/Dockerfile rename to apps/web/Dockerfile.local diff --git a/apps/web/railway.json b/apps/web/railway.json new file mode 100644 index 0000000..1f835bf --- /dev/null +++ b/apps/web/railway.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "RAILPACK", + "buildCommand": "npm install --include=dev && npx vite build" + } +} diff --git a/railway.json b/railway.json new file mode 100644 index 0000000..090b246 --- /dev/null +++ b/railway.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "RAILPACK", + "buildCommand": "npm install && npm run build" + }, + "deploy": { + "startCommand": "node apps/api/dist/index.js", + "healthcheckPath": "/health", + "healthcheckTimeout": 120, + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} From 68ef8bafb42a670d1fb22072231715e0b6a62f4b Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Fri, 14 Aug 2026 00:51:09 -0600 Subject: [PATCH 04/16] docs(deploy): runbook de mainnet + smoke test dedicado, tras el deploy real de hoy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit atomic-swap ya está en Stellar mainnet (CB5TCVEBQDU...UGPU, ver render.yaml), y las dos piernas XRPL smoke-testeadas end-to-end contra mainnet real. live-swap-mainnet.ts es necesario aparte de live-swap.ts: ese es solo testnet (wallets XRPL efímeras del faucet, alias Stellar de testnet fijos) y no sirve para probar mainnet ni con las envs cambiadas. Este exige CONFIRM_MAINNET_SWAP=yes y imprime el plan completo antes de mover nada — mueve fondos reales. --- apps/api/src/scripts/live-swap-mainnet.ts | 169 ++++++++++++++++++++++ docs/RUNBOOK_MAINNET_DEPLOY.md | 104 +++++++++++++ render.yaml | 93 ++++++++++++ 3 files changed, 366 insertions(+) create mode 100644 apps/api/src/scripts/live-swap-mainnet.ts create mode 100644 docs/RUNBOOK_MAINNET_DEPLOY.md create mode 100644 render.yaml 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..d333d9f --- /dev/null +++ b/apps/api/src/scripts/live-swap-mainnet.ts @@ -0,0 +1,169 @@ +#!/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"; + +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 { + requireEnv("STELLAR_NETWORK=PUBLIC", process.env.STELLAR_NETWORK === "PUBLIC" ? "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/docs/RUNBOOK_MAINNET_DEPLOY.md b/docs/RUNBOOK_MAINNET_DEPLOY.md new file mode 100644 index 0000000..0a990bf --- /dev/null +++ b/docs/RUNBOOK_MAINNET_DEPLOY.md @@ -0,0 +1,104 @@ +# Runbook — deploy a mainnet (MicoPay Bridge) + +No ejecutar nada de esto sin luz verde del team. Preparado para que, cuando +la den, sea copiar-pegar y no perder tiempo decidiendo comandos. + +Los pasos que mueven fondos reales (deploy con XLM real, fondeo de wallets +XRPL) los corre **Raúl**, no un agente — son transferencias irreversibles. + +## 0. Antes de arrancar + +- [ ] Team confirmó cuenta/proyecto Render para `micopaybridge-api` +- [ ] Team confirmó que `apps/api` de este repo va a mainnet para el reto +- [ ] Reconfirmado en el Hacker Dashboard: fecha límite del Mainnet Gate y + que el source tag sigue siendo `2607170001` +- [ ] Identidad Stellar CLI para el deploy, fondeada en mainnet con XLM real + (no la testnet `raul-bridge`/`mota-agent` — esas no sirven aquí) + +## 1. Contrato Soroban — `atomic-swap` (AtomicSwapHTLC) + +Workspace mezcla dos majors de `soroban-sdk` (`Cargo.toml` raíz de +`contracts/`, comentario en el bloque `[workspace]`). `atomic-swap` y su +dependencia `htlc-core` van en `soroban-sdk 21.7.6` → target +`wasm32-unknown-unknown`. No usar `wasm32v1-none` (ese es para +`zk-verifier`, que no hace falta para el puente). + +```bash +cd "contracts" + +# build — target correcto para este contrato, no el del workspace completo +stellar contract build --package atomic-swap + +# el wasm optimizado queda en: +# target/wasm32-unknown-unknown/release/atomic_swap.wasm + +# deploy a mainnet — pide confirmación, cuesta XLM real +stellar contract deploy \ + --wasm target/wasm32-unknown-unknown/release/atomic_swap.wasm \ + --source \ + --network mainnet +``` + +`atomic-swap/src/lib.rs` no tiene `initialize`/constructor — no hace falta +pasar argumentos de instancia al deploy. El contrato queda listo para +recibir `lock()` directo. + +El comando imprime el `Contract ID` nuevo (empieza con `C...`). Copiarlo a: + +- `render.yaml` → `ESCROW_CONTRACT_ID` y `ATOMIC_SWAP_CONTRACT_A` (los dos + TODO marcados) +- Dashboard de Render, si se prefiere no versionarlo ni siquiera como TODO + +No hace falta desplegar `ZK-verifier` ni `micopay-escrow` para el puente — +son de otras piezas del monorepo, no de M4.5. + +## 2. Pierna XRPL — sin contrato, solo cuentas fondeadas + +XRPL no tiene contrato que desplegar: `EscrowCreate`/`EscrowFinish` son +primitivas nativas del ledger. Lo único que hace falta: + +- [ ] Dos wallets XRPL mainnet fondeadas con XRP real (reserva de cuenta + + lo que se vaya a mover) — las que hoy son demo en testnet + (`XRPL_COUNTERPARTY_SEED`, `XRPL_INITIATOR_SEED` en `.env`) +- [ ] Verificar que las claves no se pegan en ningún archivo versionado — + van directo al dashboard de Render como secret (`sync: false`, ya en + `render.yaml`) + +**Ya verificado, no tocar:** `apps/api/src/lib/xrpl-leg.ts:70,107,133` +mete `SourceTag: bt.SOURCE_TAG` en las tres transacciones (`EscrowCreate`, +`EscrowFinish`, `EscrowCancel`), y `bridge-translate.js:33` fija +`SOURCE_TAG = 2607170001` — coincide con el source tag del Hacker +Dashboard. El etiquetado ya está bien cableado; el riesgo no está ahí. + +## 3. Variables de red — completar en `render.yaml` + +Los dos `# VERIFICAR antes de desplegar` del archivo: + +- `STELLAR_RPC_URL` — `https://mainnet.sorobanrpc.com` es el endpoint + público estándar; confirmar que sigue vivo antes del deploy +- `XRPL_SERVER` — `wss://xrplcluster.com` es el cluster público estándar; + alternativa: `wss://s2.ripple.com` (full history) +- `USDC_ISSUER` — **no completar de memoria**. Si el swap mueve USDC en la + pierna Stellar, el emisor mainnet correcto se confirma en + https://stellar.expert o con el propio Circle, nunca a ojo — un emisor + equivocado manda fondos a la dirección que no es. + +## 4. Smoke test — un swap real, antes de abrir a usuarios + +No saltarse este paso. Antes de anunciar nada: + +```bash +npm run test:live -w @micopay/api +``` + +Con las envs de mainnet cargadas, no las de testnet. Confirmar en un +explorer real (stellar.expert, livenet.xrpl.org) que las dos piernas +cerraron y que la tx de XRPL trae el source tag `2607170001` visible. + +## 5. Lo que este runbook NO resuelve + +Desplegar esto no genera las 300 cuentas distintas que pide el reto — eso +sigue abierto, ver conversación sobre replantear la estrategia (T&C solo +exige 1 tx firmada por dirección, no un swap atómico completo). Este +runbook solo deja la infraestructura lista para que, decidida la +estrategia, no haya que perder tiempo en comandos. diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..f518a7f --- /dev/null +++ b/render.yaml @@ -0,0 +1,93 @@ +services: + # ── MicoPay Bridge API (agentes, x402, swap atómico XRPL<->Soroban) ──────── + # Monorepo npm workspaces + turbo: build corre desde la raíz para que + # @micopay/sdk, @micopay/types y @micopaybridge/xrpl-bridge se compilen + # antes que apps/api (turbo.json: build depende de ^build). + - type: web + name: micopaybridge-api + runtime: node + buildCommand: npm install && npm run build + startCommand: node apps/api/dist/index.js + healthCheckPath: /health + envVars: + - key: NODE_ENV + value: production + - key: PORT + value: 3000 + + # ── Stellar / Soroban — MAINNET ────────────────────────────────────── + - key: STELLAR_RPC_URL + value: https://mainnet.sorobanrpc.com # VERIFICAR antes de desplegar + - key: STELLAR_NETWORK + value: PUBLIC + - key: MOCK_STELLAR + value: "false" + # atomic-swap (AtomicSwapHTLC) desplegado 2026-08-14, source mainnet-bridge + # (GBW7XHCAX5IWIMZ44KIXLBJNM5DPQKCJXUGAFTJXV3RG6OAFWV23BA3R): + # https://stellar.expert/explorer/public/contract/CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU + - key: ESCROW_CONTRACT_ID + value: "CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU" + - key: ATOMIC_SWAP_CONTRACT_A + value: "CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU" + - key: ZK_VERIFIER_CONTRACT_ID + value: "TODO: ZkVerifierRegistry mainnet, si aplica" + # Circle, home_domain=circle.com, verificado en Horizon + stellar.expert (2026-08-14) + - key: USDC_ISSUER + value: "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN" + + # ── XRPL — MAINNET ──────────────────────────────────────────────────── + - key: XRPL_SERVER + value: wss://xrplcluster.com # VERIFICAR antes de desplegar + + # ── x402 / seguridad ────────────────────────────────────────────────── + - key: X402_MOCK_MODE + value: "false" + - key: DEMO_MODE + value: "false" + - key: ALLOW_CLIENT_ROOTS + value: "false" + + - key: DATABASE_URL + fromDatabase: + name: micopaybridge-db + property: connectionString + + # Secretos — configurar a mano en el dashboard de Render, nunca aquí: + - key: PLATFORM_SECRET_KEY + sync: false + - key: DEMO_AGENT_SECRET_KEY + sync: false + - key: DEMO_AGENT_PUBLIC_KEY + sync: false + - key: XRPL_COUNTERPARTY_SEED + sync: false + - key: XRPL_INITIATOR_SEED + sync: false + # Estrategia de 300 cuentas (activación vía Xaman) — sin esto el tab + # "Activar" responde 503. Crear proyecto en apps.xaman.dev. + - key: XUMM_API_KEY + sync: false + - key: XUMM_API_SECRET + sync: false + # Paga el fee de EscrowCancel cuando vence el timeout — necesita unos + # pocos XRP en mainnet, no del usuario. Ver .env.example. + - key: XRPL_SWEEPER_SEED + sync: false + - key: JWT_SECRET + sync: false + - key: SECRET_ENCRYPTION_KEY + sync: false + - key: ADMIN_SECRET_KEY + sync: false + - key: OPERATOR_SECRET_KEY + sync: false + - key: ANTHROPIC_API_KEY + sync: false + - key: CORS_ALLOWED_ORIGINS + sync: false # obligatorio en producción — sin esto se rechazan todos los CORS + +databases: + - name: micopaybridge-db + plan: free + databaseName: micopaybridge + user: micopaybridge From 13a350165cafcc7106b32222206347691325451c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 14 Aug 2026 12:18:17 -0600 Subject: [PATCH 05/16] fix(api): el swap y el cobro acaban en la misma red MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Con la configuración de mainnet de este PR (STELLAR_NETWORK=PUBLIC en render.yaml) el swap se iba a la red real y la capa de pago se quedaba en testnet. Cada archivo interpretaba la variable por su cuenta y esperaba una palabra distinta: lib/soroban.ts STELLAR_NETWORK === "PUBLIC" → mainnet middleware/x402.ts STELLAR_NETWORK === "MAINNET" → mainnet No existía ningún valor que pusiera a los dos en la red real: PUBLIC el swap movía fondos reales, pero verifyPayment enviaba el XDR a horizon-testnet: se pagaba con USDC de faucet — gratis — y se recibía un swap con dinero de verdad. Afecta a los trece endpoints de pago, incluido swaps/execute (0.05) y fund_micopay (0.10). MAINNET los pagos iban bien, pero el swap se firmaba con el passphrase de testnet y la red lo rechazaba. Ni el typecheck ni los tests lo veían, porque cada archivo es coherente consigo mismo y las suites corren con el valor por defecto, donde ambos coinciden en testnet. La contradicción solo aparecía con dinero real delante. - Nuevo lib/stellarNetwork.ts: el único módulo que interpreta la variable. Acepta 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. Rechazar una sería otra forma del mismo fallo. - soroban.ts, x402.ts y scripts/live-swap-mainnet.ts pasan a usarlo. En el script era un guardarraíl que clavaba la grafía y habría bloqueado el smoke test de mainnet a quien configurara la otra. - El nombre de red que se anuncia en el reto 402 se normaliza: dependía de cómo se hubiera escrito la variable, así que el mismo despliegue podía decirle "public" o "mainnet" a los agentes. Tests: cuatro casos, incluido uno que recorre el árbol y falla si algún módulo vuelve a comparar STELLAR_NETWORK por su cuenta. Ese caso ya encontró el tercer sitio (el script del smoke test) que la revisión a mano se había saltado. Verificado sobre esta rama: typecheck limpio, npm test en verde. Co-Authored-By: Claude Opus 5 --- apps/api/src/__tests__/stellarNetwork.test.ts | 93 +++++++++++++++++++ apps/api/src/lib/soroban.ts | 11 ++- apps/api/src/lib/stellarNetwork.ts | 46 +++++++++ apps/api/src/middleware/x402.ts | 14 +-- apps/api/src/scripts/live-swap-mainnet.ts | 5 +- 5 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/__tests__/stellarNetwork.test.ts create mode 100644 apps/api/src/lib/stellarNetwork.ts diff --git a/apps/api/src/__tests__/stellarNetwork.test.ts b/apps/api/src/__tests__/stellarNetwork.test.ts new file mode 100644 index 0000000..3077f15 --- /dev/null +++ b/apps/api/src/__tests__/stellarNetwork.test.ts @@ -0,0 +1,93 @@ +/** + * 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 raiz = new URL("../", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); + 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/lib/soroban.ts b/apps/api/src/lib/soroban.ts index 561d20c..0dbf572 100644 --- a/apps/api/src/lib/soroban.ts +++ b/apps/api/src/lib/soroban.ts @@ -17,13 +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"; -// El deploy pone STELLAR_NETWORK=PUBLIC en mainnet — sin este check, RPC_URL -// podía apuntar a mainnet mientras las txs se firmaban con el passphrase de -// testnet (red equivocada = firma inválida, y el Asset.contractId() de abajo -// también calcularía el SAC de la red que no es). -const NET = process.env.STELLAR_NETWORK === "PUBLIC" ? StellarSdk.Networks.PUBLIC : 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/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/scripts/live-swap-mainnet.ts b/apps/api/src/scripts/live-swap-mainnet.ts index d333d9f..3518188 100644 --- a/apps/api/src/scripts/live-swap-mainnet.ts +++ b/apps/api/src/scripts/live-swap-mainnet.ts @@ -31,6 +31,7 @@ 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)); @@ -85,7 +86,9 @@ function requireEnv(name: string, value: string | undefined): string { } async function main(): Promise { - requireEnv("STELLAR_NETWORK=PUBLIC", process.env.STELLAR_NETWORK === "PUBLIC" ? "ok" : undefined); + // 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); From 3f8e6a84ecf84f70e7e4e237b405c033837c0bf6 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Wed, 26 Aug 2026 19:16:42 -0600 Subject: [PATCH 06/16] test(api): fix two tests that depended on the developer's machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither failure came from production code. stellarNetwork.test.ts walked the source tree using `.pathname` of a file URL, which leaves spaces percent-encoded. The repo lives under "Micopay Bridge", so readdirSync got "Micopay%20Bridge" and threw ENOENT. Use fileURLToPath instead. x402.test.ts (SEC-C2) saved and restored X402_MOCK_MODE rather than clearing it. With X402_MOCK_MODE=true in a local .env the restore put the bypass back, so the test asserting that a mock payment header is rejected got 200 instead of 402 — green in CI, red locally. Delete the variable in beforeAll and restore the original in afterAll. The middleware gate itself was correct and is unchanged. Co-Authored-By: Claude Opus 5 --- apps/api/src/__tests__/stellarNetwork.test.ts | 5 ++++- apps/api/src/__tests__/x402.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/api/src/__tests__/stellarNetwork.test.ts b/apps/api/src/__tests__/stellarNetwork.test.ts index 3077f15..0efc719 100644 --- a/apps/api/src/__tests__/stellarNetwork.test.ts +++ b/apps/api/src/__tests__/stellarNetwork.test.ts @@ -65,8 +65,11 @@ describe("red de Stellar — una sola interpretación", () => { // 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"); - const raiz = new URL("../", import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, "$1"); + // 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) => { 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", From 1dae0e8f4013d1190ff943f7cd4764d3036855d4 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Wed, 26 Aug 2026 19:16:49 -0600 Subject: [PATCH 07/16] docs(gate): verified mainnet state ahead of the validation meeting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every figure checked against chain and repo on 2026-08-26, not against earlier notes: contract storage and events, both XRPL balances and their zero owner count, the two source-tagged transactions, RPC health, repo visibility and license. Records what is still blocked — Railway trial expired so no public URL, the unset XRPL_SWEEPER_SEED that leaves user escrows waiting on a manual EscrowCancel, and the 5.23 XLM left on mainnet-bridge, which covers invocations but not another 7.29 XLM wasm upload. States the metrics summary honestly: the only two tagged addresses belong to the team, so nothing counts toward the 300 yet. Co-Authored-By: Claude Opus 5 --- docs/GATE_MAINNET_2026-08-26.md | 120 ++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 docs/GATE_MAINNET_2026-08-26.md diff --git a/docs/GATE_MAINNET_2026-08-26.md b/docs/GATE_MAINNET_2026-08-26.md new file mode 100644 index 0000000..2fd52d5 --- /dev/null +++ b/docs/GATE_MAINNET_2026-08-26.md @@ -0,0 +1,120 @@ +# Mainnet Gate — estado verificado 2026-08-26 + +Todo lo de aquí se comprobó contra la cadena y el repo el 2026-08-26, no +contra notas anteriores. Los números que no pude verificar están marcados +como tales. + +Panel del Hacker Dashboard hoy: *"Your project isn't eligible yet — Book +time with a mentor to validate your mainnet deployment to stay in the +running."* El gate sigue abierto; falta agendar. + +--- + +## 1. Lo que ya está en mainnet + +### Stellar / Soroban + +| Dato | Valor | +|---|---| +| Contrato `AtomicSwapHTLC` | `CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU` | +| Creador | `GBW7XHCAX5IWIMZ44KIXLBJNM5DPQKCJXUGAFTJXV3RG6OAFWV23BA3R` | +| Desplegado | 2026-08-14 | +| Estado on-chain | 2 eventos, 2 entradas de storage, `errors: null` | +| Verificación de código en stellar.expert | **`unverified`** | + +https://stellar.expert/explorer/public/contract/CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU + +### XRPL + +| Cuenta | Balance | Escrows abiertos | +|---|---|---| +| `rETyvXg5pFdFL4KxfZASejmpGuh2urh5cY` (initiator) | 5.999579 XRP | 0 | +| `rQaPLVuFeb8LdwPx51c4wP6Kb9MNHN9i9t` (counterparty) | 3.999993 XRP | 0 | + +`OwnerCount: 0` en ambas — el swap del 14 ago cerró limpio, no quedó nada +colgado. + +### Transacciones con el source tag `2607170001` + +Las únicas que existen hoy en mainnet: + +| Tipo | Hash | Ledger | Resultado | +|---|---|---|---| +| `EscrowCreate` | `C57169418DAFE632…` | 106282703 | `tesSUCCESS` | +| `EscrowFinish` | `731495D15390B777…` | 106282705 | `tesSUCCESS` | + +### Saldos operativos + +- `mainnet-bridge` (Stellar): **5.2283611 XLM**. Alcanza para invocaciones + (~0.1–0.2 XLM), **no** para volver a subir el wasm (costó 7.29 XLM). +- XRPL: 10 XRP entre las dos wallets, reserva base incluida. + +--- + +## 2. Estado del código + +- `npm run build` — 4/4 tareas verdes +- `npm test -w @micopay/api` — **113 pasan, 1 skip, 1 falla** + - La que falla es `agent-execute.test.ts`: necesita Postgres en + `localhost:5432` (`ECONNREFUSED`). No es del flujo de activación. +- Source tag cableado en los 5 constructores de tx de `lib/xrpl-leg.ts`, + incluido `activationTxJson` — el payload que firma el usuario final desde + Xaman lo lleva. +- RPC Soroban `soroban-rpc.mainnet.stellar.gateway.fm`: `healthy`. + **No usar `mainnet.sorobanrpc.com`** — dio `transaction submission + timeout` dos veces el 14 ago. `render.yaml:20` todavía lo tiene puesto. + +--- + +## 3. Entregables del §8 — checklist + +| Entregable | Estado | +|---|---| +| App viva en mainnet | ❌ **bloqueado** — trial de Railway vencido, `api` y `web` con 0 réplicas | +| Repo público con README + LICENSE | ✅ `Micopay/micopaybridge` y `Micopay/micopay-protocol`, ambos PUBLIC + MIT | +| Video ≤3 min | ⚠️ el de la ficha (`youtube.com/watch?v=2XfkGeQFXik`) es de julio y es testnet — sin confirmar si sirve | +| Pitch deck | ⚠️ sin verificar | +| Resumen de métricas | ver §4 | + +--- + +## 4. Resumen de métricas (honesto) + +| Métrica | Valor | +|---|---| +| Transacciones en mainnet con el source tag | 2 | +| Direcciones distintas que firmaron | 2 | +| Volumen movido | 1 XRP | +| **Cuentas que cuentan para las 300** | **0** | + +Las dos direcciones son del propio equipo. El T&C §7 prohíbe self-dealing, +así que no suman. El conteo real arranca cuando firme la primera persona +ajena al equipo. + +--- + +## 5. Lo que bloquea, en orden + +1. **Agendar la reunión de validación.** Cuesta $0 y es lo que el panel + pide. Sin eso el proyecto no entra al leaderboard aunque el resto esté + perfecto. +2. **Hosting.** Railway pide plan ($5/mes, Hobby incluye $5 de consumo; + estos dos servicios gastan menos). Alternativa gratis: Render, pero se + duerme a los 15 min y hay que corregir el RPC de `render.yaml`. + Sin URL pública no hay forma de que 300 personas firmen. +3. **Cuál repo miran los jueces.** La ficha del reto apunta a + `micopay-protocol`; todo el XRPL vive en `micopaybridge`. Cerrarlo con + Mota o actualizar la ficha. +4. **PR #3** (`Micopay/micopaybridge#3`) — abierto desde el 14 ago, sin + reviews, 21 commits atrás de `main`. Trae la activación vía Xaman. +5. **`XRPL_SWEEPER_SEED` no está configurado.** El `activationSweeper` solo + loguea avisos: el XRP que bloqueen los usuarios no se les regresa solo + hasta que alguien mande el `EscrowCancel`. + +--- + +## 6. Opcional, ayuda en la revisión + +Verificar el código del contrato en stellar.expert (hoy `unverified`). +Un contrato con fuente verificada es más fácil de defender ante el jurado +que uno que solo se ve como wasm. From cccc5f89a7c0de711920748a2e9fdf3c91bb1cc2 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Mon, 31 Aug 2026 22:12:46 -0600 Subject: [PATCH 08/16] fix(deploy): point render.yaml at the Soroban RPC that actually accepted a tx MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mainnet.sorobanrpc.com was left in with a "verify before deploying" note. It was verified, and it failed: during the 2026-08-14 mainnet deploy it returned "transaction submission timeout" twice in a row, with the transaction never reaching the ledger (getTransaction returned NOT_FOUND and the account was never charged). The gateway.fm endpoint took it on the first try and still reports healthy. Leaving the failing value behind a TODO meant the next person to deploy would rediscover it the slow way. Also ignore .claude/, which holds the local launch.json used to start the dev servers — developer tooling, not part of the product. Co-Authored-By: Claude Opus 5 --- .gitignore | 4 ++++ render.yaml | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) 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/render.yaml b/render.yaml index f518a7f..dbf82c4 100644 --- a/render.yaml +++ b/render.yaml @@ -16,8 +16,12 @@ services: value: 3000 # ── Stellar / Soroban — MAINNET ────────────────────────────────────── + # mainnet.sorobanrpc.com no: dio "transaction submission timeout" dos + # veces seguidas en el deploy del 2026-08-14 (la tx nunca llegó al + # ledger, verificado con getTransaction -> NOT_FOUND). Este respondió + # al primer intento, y sigue healthy al 2026-08-26. - key: STELLAR_RPC_URL - value: https://mainnet.sorobanrpc.com # VERIFICAR antes de desplegar + value: https://soroban-rpc.mainnet.stellar.gateway.fm - key: STELLAR_NETWORK value: PUBLIC - key: MOCK_STELLAR From dd9030d89d3cb20a906a5b68ece7728b3824066e Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Mon, 31 Aug 2026 22:57:24 -0600 Subject: [PATCH 09/16] =?UTF-8?q?fix(api):=20health=20real=20=E2=80=94=20g?= =?UTF-8?q?etLedgerEntries=20y=20migraciones=20en=20el=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El check de contratos usaba getLedgerEntry, que ya no existe: el RPC de mainnet (27.1.1, protocolo 27) responde -32601, y el catch lo traducía a "Empty response". El contrato SÍ estaba desplegado; /health?detailed=true devolvía 503 unhealthy por un método muerto. Ahora usa getLedgerEntries con la LedgerKey de instancia en XDR base64, que devuelve 1 entrada para CB5TCVEBQDUI2GSQZLMUA2H7FHFHCQLKVGZYJZBECPDVKZCI3PFZUGPU. `build: tsc` no copiaba src/db/migrations al dist, así que runMigrations tiraba ENOENT, el catch de arrancarMigraciones se lo tragaba y las tablas nunca se creaban. El build ahora las copia. Verificado en mainnet: 200 degraded, database up, escrow deployed:true, "✅ Migration 001_initial_schema.sql successful" en el arranque. Co-Authored-By: Claude Opus 5 --- apps/api/package.json | 2 +- apps/api/src/services/health.ts | 39 +++++++++++++++++++-------------- 2 files changed, 23 insertions(+), 18 deletions(-) diff --git a/apps/api/package.json b/apps/api/package.json index eb17a5a..4e1d554 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,7 +6,7 @@ "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", 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 Date: Mon, 31 Aug 2026 22:57:31 -0600 Subject: [PATCH 10/16] =?UTF-8?q?fix(activaci=C3=B3n):=20ventana=20de=20fi?= =?UTF-8?q?rma=20de=201=20h,=20no=205=20min?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rippled rechaza el EscrowCreate si CancelAfter ya pasó cuando llega la firma, y el payload de Xaman vive bastante más que 300 s. Quien instalaba Xaman por primera vez firmaba una transacción muerta: fee quemado, sin escrow, y sin contar para las 300 cuentas del Mainnet Gate. El plazo solo marca desde cuándo se puede reclamar, así que alargarlo no le quita nada al usuario. El contador del panel iba con el mismo número. Verificado contra la API de Xaman sobre el payload real: SourceTag 2607170001, EscrowCreate, Destination == Account, ventana de 60 min. Co-Authored-By: Claude Opus 5 --- apps/api/src/lib/xrpl-leg.ts | 6 +++++- apps/web/src/components/ActivationPanel.tsx | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/xrpl-leg.ts b/apps/api/src/lib/xrpl-leg.ts index cda4da4..afbcb48 100644 --- a/apps/api/src/lib/xrpl-leg.ts +++ b/apps/api/src/lib/xrpl-leg.ts @@ -205,7 +205,11 @@ export function activationTxJson(params: { amountXrp: string; cancelAfterSeconds?: number; }): Omit { - const cancelAfterSec = params.cancelAfterSeconds ?? 300; + // 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", diff --git a/apps/web/src/components/ActivationPanel.tsx b/apps/web/src/components/ActivationPanel.tsx index 8bd5d42..6f7eb5a 100644 --- a/apps/web/src/components/ActivationPanel.tsx +++ b/apps/web/src/components/ActivationPanel.tsx @@ -13,7 +13,7 @@ interface Payload { type Step = "idle" | "creating" | "waiting" | "done" | "error"; type ReclaimStep = "idle" | "creating" | "waiting" | "done" | "error"; -const CANCEL_AFTER_SECONDS = 300; // default de activationTxJson en el backend +const CANCEL_AFTER_SECONDS = 3600; // default de activationTxJson en el backend // Sin custodia: el backend arma la transacción y crea un payload de Xaman // (QR + deep link), pero quien firma es la propia wallet del usuario — From f73bb99a71433d0d96b86ee410a5ec173f603c30 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Mon, 31 Aug 2026 22:57:38 -0600 Subject: [PATCH 11/16] fix(web): badge de red real y config de deploy propia MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El header decía "testnet live" fijo mientras la API corría en PUBLIC con el contrato de mainnet — lo primero que ve quien valide el Mainnet Gate. Ahora sale de /health, que es quien sabe en qué red está. El web no tenía config propia, así que heredaba el railway.json de la raíz y arrancaba con el startCommand del api: servía los 404 de Fastify en vez de la SPA. railway.web.json no define startCommand y deja que Railpack sirva los estáticos. Del lado de Railway (no versionado) quedan RAILPACK_SPA_OUTPUT_DIR y RAILPACK_STATIC_FILE_ROOT en apps/web/dist, y VITE_BASE=/ con VITE_OUT_DIR=dist para montar la SPA en la raíz en vez de /bridge/. Co-Authored-By: Claude Opus 5 --- apps/web/src/App.tsx | 29 +++++++++++++++++++++++++++-- railway.web.json | 11 +++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) create mode 100644 railway.web.json diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 512294f..f5e65bf 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import FundWidget from "./components/FundWidget"; import ServiceCatalog from "./components/ServiceCatalog"; import DemoTerminal from "./components/DemoTerminal"; @@ -74,6 +74,25 @@ export default function App() { const [activeTab, setActiveTab] = useState("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 ; @@ -186,7 +205,13 @@ export default function App() { display: "inline-block", }} /> - testnet live + + {network === null + ? "live" + : network === "PUBLIC" + ? "mainnet live" + : "testnet live"} + · Sin cuenta · Sin API key diff --git a/railway.web.json b/railway.web.json new file mode 100644 index 0000000..e233158 --- /dev/null +++ b/railway.web.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { + "builder": "RAILPACK", + "buildCommand": "npm install && npm run build" + }, + "deploy": { + "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 3 + } +} From 79e2d769d383633f5fbcdb5a2d97e5911feaa708 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Tue, 1 Sep 2026 23:16:46 -0600 Subject: [PATCH 12/16] docs(gate): correct the date and record that the app is live MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The file was dated 2026-08-26, taken from the last upstream commit rather than from the day it was written. Renamed and redated to 2026-09-01. Two claims in it are no longer true and were worth more than a date fix: the Railway plan is paid and both services are up, so "app viva en mainnet" is met — /health reports network PUBLIC — and the pitch deck was found (10 slides), leaving the video as the only open §8 deliverable. The test count moves from 113 to 200 because the branch now sits on upstream/main and inherits the bazaar suite. Also notes that PR #3 is MERGEABLE and CLEAN: what holds it is review, not conflicts. Co-Authored-By: Claude Opus 5 --- ...26-08-26.md => GATE_MAINNET_2026-09-01.md} | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) rename docs/{GATE_MAINNET_2026-08-26.md => GATE_MAINNET_2026-09-01.md} (81%) diff --git a/docs/GATE_MAINNET_2026-08-26.md b/docs/GATE_MAINNET_2026-09-01.md similarity index 81% rename from docs/GATE_MAINNET_2026-08-26.md rename to docs/GATE_MAINNET_2026-09-01.md index 2fd52d5..9a3c767 100644 --- a/docs/GATE_MAINNET_2026-08-26.md +++ b/docs/GATE_MAINNET_2026-09-01.md @@ -1,6 +1,7 @@ -# Mainnet Gate — estado verificado 2026-08-26 +# Mainnet Gate — estado verificado 2026-09-01 -Todo lo de aquí se comprobó contra la cadena y el repo el 2026-08-26, no +Todo lo de aquí se comprobó contra la cadena, el repo y los servicios +en vivo el 2026-09-01, no contra notas anteriores. Los números que no pude verificar están marcados como tales. @@ -54,7 +55,8 @@ Las únicas que existen hoy en mainnet: ## 2. Estado del código - `npm run build` — 4/4 tareas verdes -- `npm test -w @micopay/api` — **113 pasan, 1 skip, 1 falla** +- `npm test -w @micopay/api` — **200 pasan, 1 skip, 1 falla** (la rama se + rebaseó sobre `upstream/main`, así que hereda la suite de bazaar entera) - La que falla es `agent-execute.test.ts`: necesita Postgres en `localhost:5432` (`ECONNREFUSED`). No es del flujo de activación. - Source tag cableado en los 5 constructores de tx de `lib/xrpl-leg.ts`, @@ -70,10 +72,10 @@ Las únicas que existen hoy en mainnet: | Entregable | Estado | |---|---| -| App viva en mainnet | ❌ **bloqueado** — trial de Railway vencido, `api` y `web` con 0 réplicas | +| App viva en mainnet | ✅ web-production-eb54.up.railway.app y api-production-9ec74.up.railway.app; `/health` responde `network: PUBLIC` | | Repo público con README + LICENSE | ✅ `Micopay/micopaybridge` y `Micopay/micopay-protocol`, ambos PUBLIC + MIT | +| Pitch deck | ✅ `MicoPay_Atomic_Bridge_CORREGIDO.pdf`, 10 láminas — decía "testnet" en 3 lugares, se le anexa una lámina de estado en mainnet | | Video ≤3 min | ⚠️ el de la ficha (`youtube.com/watch?v=2XfkGeQFXik`) es de julio y es testnet — sin confirmar si sirve | -| Pitch deck | ⚠️ sin verificar | | Resumen de métricas | ver §4 | --- @@ -98,15 +100,14 @@ ajena al equipo. 1. **Agendar la reunión de validación.** Cuesta $0 y es lo que el panel pide. Sin eso el proyecto no entra al leaderboard aunque el resto esté perfecto. -2. **Hosting.** Railway pide plan ($5/mes, Hobby incluye $5 de consumo; - estos dos servicios gastan menos). Alternativa gratis: Render, pero se - duerme a los 15 min y hay que corregir el RPC de `render.yaml`. - Sin URL pública no hay forma de que 300 personas firmen. +2. **El video.** Es el único entregable del §8 que sigue sin resolver, y + faltar a uno es descalificación. 3. **Cuál repo miran los jueces.** La ficha del reto apunta a `micopay-protocol`; todo el XRPL vive en `micopaybridge`. Cerrarlo con Mota o actualizar la ficha. 4. **PR #3** (`Micopay/micopaybridge#3`) — abierto desde el 14 ago, sin - reviews, 21 commits atrás de `main`. Trae la activación vía Xaman. + reviews. Está `MERGEABLE` / `CLEAN`: lo detiene la falta de revisión, no + el diff. La rama ya se rebaseó sobre `upstream/main` y pasa la suite. 5. **`XRPL_SWEEPER_SEED` no está configurado.** El `activationSweeper` solo loguea avisos: el XRP que bloqueen los usuarios no se les regresa solo hasta que alguien mande el `EscrowCancel`. From 768751ae9c8744a0e2e39c61e62f4b848741051d Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Tue, 1 Sep 2026 23:37:06 -0600 Subject: [PATCH 13/16] fix(activation): stop issuing sign requests that cannot produce an escrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways a person could sign, burn a fee, and end up with no escrow — and therefore no source-tagged transaction, so their address never counted toward the 300. CancelAfter is anchored when the payload is created, but Xaman keeps a payload for 24h by default. With the one-hour default that left 23 hours in which the QR still scanned and signed while the transaction was already expired; rippled rejects it. The comment in xrpl-leg.ts already described this failure from the 300s days — raising it to 3600s shrank the window without closing it. The payload's lifetime is now derived from the actual CancelAfter minus a signing margin, so it can never outlive its own deadline. Measured against Xaman: CancelAfter at +3599s, payload expiry at +3300s. The route also accepted any well-formed address without asking the ledger whether it exists. A wallet freshly created in Xaman and never funded is actNotFound and cannot sign anything — precisely the person this campaign is trying to reach. It now checks account_info first and says what to do, including the real reserve arithmetic. When the check itself fails the request is let through: an unreachable node should not block everyone. The minimum cancelAfterSeconds moves from 60 to 600, because below ten minutes the derived signing window leaves no honest room. minutosDeFirma returns <= 0 for those cases rather than rounding up to one minute — that clamp reintroduced the same bug at the lower bound, which the new test caught. Co-Authored-By: Claude Opus 5 --- .../src/__tests__/activationExpiry.test.ts | 44 +++++++++++++++++++ apps/api/src/lib/xrpl-leg.ts | 30 +++++++++++++ apps/api/src/lib/xumm.ts | 38 +++++++++++++++- apps/api/src/routes/activation.ts | 19 ++++++-- 4 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 apps/api/src/__tests__/activationExpiry.test.ts 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/lib/xrpl-leg.ts b/apps/api/src/lib/xrpl-leg.ts index afbcb48..8f3d014 100644 --- a/apps/api/src/lib/xrpl-leg.ts +++ b/apps/api/src/lib/xrpl-leg.ts @@ -145,6 +145,36 @@ 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 diff --git a/apps/api/src/lib/xumm.ts b/apps/api/src/lib/xumm.ts index 2738473..d82a6e1 100644 --- a/apps/api/src/lib/xumm.ts +++ b/apps/api/src/lib/xumm.ts @@ -8,6 +8,7 @@ * 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; @@ -42,6 +43,34 @@ export interface ActivationPayload { */ 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; @@ -64,8 +93,15 @@ export async function createActivationPayload(params: { // 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 } as 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 diff --git a/apps/api/src/routes/activation.ts b/apps/api/src/routes/activation.ts index ee1eab6..ab8b995 100644 --- a/apps/api/src/routes/activation.ts +++ b/apps/api/src/routes/activation.ts @@ -8,7 +8,7 @@ import { clearPendingCancelAfter, xummConfigured, } from "../lib/xumm.js"; -import { fetchTxSequence } from "../lib/xrpl-leg.js"; +import { accountExists, fetchTxSequence } from "../lib/xrpl-leg.js"; import { trackActivationEscrow } from "../lib/activationSweeper.js"; /** @@ -53,9 +53,22 @@ export async function activationRoutes(fastify: FastifyInstance): Promise } if ( cancelAfterSeconds !== undefined && - (!Number.isInteger(cancelAfterSeconds) || cancelAfterSeconds < 60 || cancelAfterSeconds > 86400) + (!Number.isInteger(cancelAfterSeconds) || cancelAfterSeconds < 600 || cancelAfterSeconds > 86400) ) { - return reply.status(400).send({ error: "cancelAfterSeconds debe estar entre 60 y 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 { From e5d4c68157b2b4e9d6a13bbab44862f221f380a3 Mon Sep 17 00:00:00 2001 From: vallejoraul08-debug Date: Wed, 2 Sep 2026 00:25:30 -0600 Subject: [PATCH 14/16] feat(web): make the activation link land where it is useful on a phone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes aimed at the same funnel. The tab now reads from the URL hash, so #activate opens the activation panel directly instead of the agent demo terminal — without it, someone arriving from a campaign has to find the "Activar" tab among eight, and that row is cut off on a narrow screen. The sign step also led with a 220px QR code on every device. Nobody scans their own screen, so on a phone the only actionable control sat below a useless image. On touch devices the "Abrir en Xaman" link now comes first and the QR folds away behind a summary for anyone using a second device; desktop keeps the QR up top. Detection is pointer:coarse rather than width, because what decides whether a QR is useful is finger versus mouse, not how many pixels the window has. Deliberately not auto-navigating to the deep link on load: without Xaman installed that strands the user on a broken page, and iOS blocks navigation that does not originate in a user gesture. Verified by hand on a phone that the link does open the app when tapped. Co-Authored-By: Claude Opus 5 --- apps/web/src/App.tsx | 16 +++++++- apps/web/src/components/ActivationPanel.tsx | 45 +++++++++++++++++---- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f5e65bf..b25fce6 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -14,6 +14,8 @@ import { API_URL, APP_URL } from "./config"; type Tab = "demo" | "swap" | "zk" | "bazaar" | "reputation" | "fund" | "services" | "activate"; +const TAB_IDS: Tab[] = ["demo", "swap", "zk", "bazaar", "reputation", "fund", "services", "activate"]; + // No login gate here on purpose: this dashboard is a human observer console // for the agent economy demo, not something an agent itself ever sees — the // whole pitch is agents talk to the API directly, no account, no API key. @@ -71,7 +73,14 @@ function ApiNoConfigurada() { } export default function App() { - const [activeTab, setActiveTab] = useState("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 @@ -229,7 +238,10 @@ export default function App() { {tabs.map((tab) => (