Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fab712d
fix(api): soroban.ts firmaba con passphrase de testnet aunque RPC_URL…
vallejoraul08-debug Aug 14, 2026
a42993b
feat(api,web): activación de cuentas XRPL vía Xaman para las 300 de M…
vallejoraul08-debug Aug 14, 2026
e36f365
chore: mueve el deploy a Railway (builder Railpack, no Docker en api/…
vallejoraul08-debug Aug 14, 2026
68ef8ba
docs(deploy): runbook de mainnet + smoke test dedicado, tras el deplo…
vallejoraul08-debug Aug 14, 2026
13a3501
fix(api): el swap y el cobro acaban en la misma red
claude Aug 14, 2026
3f8e6a8
test(api): fix two tests that depended on the developer's machine
vallejoraul08-debug Aug 27, 2026
1dae0e8
docs(gate): verified mainnet state ahead of the validation meeting
vallejoraul08-debug Aug 27, 2026
cccc5f8
fix(deploy): point render.yaml at the Soroban RPC that actually accep…
vallejoraul08-debug Sep 1, 2026
dd9030d
fix(api): health real — getLedgerEntries y migraciones en el build
vallejoraul08-debug Sep 1, 2026
9ac4b02
fix(activación): ventana de firma de 1 h, no 5 min
vallejoraul08-debug Sep 1, 2026
f73bb99
fix(web): badge de red real y config de deploy propia
vallejoraul08-debug Sep 1, 2026
79e2d76
docs(gate): correct the date and record that the app is live
vallejoraul08-debug Sep 2, 2026
768751a
fix(activation): stop issuing sign requests that cannot produce an es…
vallejoraul08-debug Sep 2, 2026
e5d4c68
feat(web): make the activation link land where it is useful on a phone
vallejoraul08-debug Sep 2, 2026
7bc065a
fix(web): keep the console inside the screen on a phone
vallejoraul08-debug Sep 2, 2026
81a50aa
fix(web): show the QR expanded by default on phones
vallejoraul08-debug Sep 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
8 changes: 5 additions & 3 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"build": "tsc",
"build": "tsc && node -e \"require('fs').cpSync('src/db/migrations','dist/db/migrations',{recursive:true})\"",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:live": "tsx src/scripts/live-swap.ts",
"test:live:mainnet": "tsx src/scripts/live-swap-mainnet.ts",
"test:recovery": "tsx src/scripts/live-recovery.ts",
"test:concurrency": "tsx src/scripts/concurrency-check.ts",
"seed": "tsx src/scripts/seed.ts",
Expand All @@ -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",
Expand All @@ -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",
Expand Down
44 changes: 44 additions & 0 deletions apps/api/src/__tests__/activationExpiry.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
96 changes: 96 additions & 0 deletions apps/api/src/__tests__/stellarNetwork.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* El swap y la capa de pago tienen que acabar en la MISMA red.
*
* No lo estaban: `lib/soroban.ts` se pasaba a mainnet con `STELLAR_NETWORK=PUBLIC`
* y `middleware/x402.ts` con `MAINNET`. Con el valor que pone el deploy
* (`PUBLIC`) el swap movía fondos reales mientras los pagos se verificaban
* contra Horizon de testnet: pagar con USDC de faucet y recibir un swap real.
* Con `MAINNET` se invertía y el swap se firmaba con el passphrase equivocado.
*
* Cada archivo era coherente consigo mismo, así que nada lo detectaba. Este
* test mira las dos mitades a la vez, que es donde vivía el fallo.
*/
import { describe, it, expect, afterEach, beforeEach } from "vitest";
import { Networks } from "@stellar/stellar-sdk";

const ORIGINAL = process.env.STELLAR_NETWORK;

/** Recarga el módulo con el entorno actual (se evalúa al importarse). */
async function cargar(valor: string | undefined) {
if (valor === undefined) delete process.env.STELLAR_NETWORK;
else process.env.STELLAR_NETWORK = valor;
const vitest = await import("vitest");
vitest.vi.resetModules();
return import("../lib/stellarNetwork.js");
}

beforeEach(() => {
delete process.env.STELLAR_NETWORK;
});

afterEach(() => {
if (ORIGINAL === undefined) delete process.env.STELLAR_NETWORK;
else process.env.STELLAR_NETWORK = ORIGINAL;
});

describe("red de Stellar — una sola interpretación", () => {
it("las dos grafías de la red real llevan a mainnet", async () => {
for (const valor of ["PUBLIC", "MAINNET", "public", "mainnet", " Public "]) {
const net = await cargar(valor);
expect(net.IS_MAINNET, `valor ${JSON.stringify(valor)}`).toBe(true);
expect(net.NETWORK_PASSPHRASE).toBe(Networks.PUBLIC);
expect(net.HORIZON_URL).toBe("https://horizon.stellar.org");
}
});

it("por defecto, y ante cualquier otra cosa, testnet", async () => {
for (const valor of [undefined, "TESTNET", "testnet", "", "produccion"]) {
const net = await cargar(valor);
expect(net.IS_MAINNET, `valor ${JSON.stringify(valor)}`).toBe(false);
expect(net.NETWORK_PASSPHRASE).toBe(Networks.TESTNET);
expect(net.HORIZON_URL).toBe("https://horizon-testnet.stellar.org");
}
});

it("el nombre que se anuncia a los agentes no depende de cómo se escribió", async () => {
// Va en el reto 402. Si dependiera de la grafía, el mismo despliegue diría
// "public" o "mainnet" según quién configuró el entorno.
expect((await cargar("PUBLIC")).NETWORK_NAME).toBe("public");
expect((await cargar("MAINNET")).NETWORK_NAME).toBe("public");
expect((await cargar("TESTNET")).NETWORK_NAME).toBe("testnet");
});

it("no queda ningún módulo interpretando STELLAR_NETWORK por su cuenta", async () => {
// La regresión de verdad: el fallo no fue un valor mal escrito, fue que dos
// archivos decidían la red cada uno por su lado. Que solo lo haga uno.
const { readFileSync, readdirSync, statSync } = await import("node:fs");
const { join } = await import("node:path");
const { fileURLToPath } = await import("node:url");

// fileURLToPath y no .pathname: el segundo deja los espacios de la ruta
// como %20 y readdirSync no encuentra el directorio.
const raiz = fileURLToPath(new URL("../", import.meta.url));
const culpables: string[] = [];

const recorrer = (dir: string) => {
for (const entrada of readdirSync(dir)) {
const ruta = join(dir, entrada);
if (statSync(ruta).isDirectory()) {
if (entrada !== "node_modules" && entrada !== "__tests__") recorrer(ruta);
continue;
}
if (!entrada.endsWith(".ts")) continue;
if (ruta.endsWith(join("lib", "stellarNetwork.ts"))) continue;
const texto = readFileSync(ruta, "utf8");
// Leer la variable para registrarla o mostrarla está bien; lo que no
// puede hacerse fuera del módulo es COMPARARLA para decidir la red.
if (/process\.env\.STELLAR_NETWORK\s*===/.test(texto)) {
culpables.push(ruta.slice(raiz.length));
}
}
};
recorrer(raiz);

expect(culpables, `deben usar lib/stellarNetwork.ts: ${culpables.join(", ")}`).toEqual([]);
});
});
13 changes: 13 additions & 0 deletions apps/api/src/__tests__/x402.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions apps/api/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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).
Expand Down
95 changes: 95 additions & 0 deletions apps/api/src/lib/activationSweeper.ts
Original file line number Diff line number Diff line change
@@ -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<string, PendingCancel>();
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<void> {
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);
}
7 changes: 6 additions & 1 deletion apps/api/src/lib/soroban.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,14 @@ import crypto from "crypto";
import * as bt from "@micopaybridge/xrpl-bridge/bridge-translate";
import { swapStore, type SwapState } from "./swapStore.js";
import { lockXrplLeg, revealOnXrpl, cancelXrplLeg, xrplAddressFromSeed } from "./xrpl-leg.js";
import { NETWORK_PASSPHRASE } from "./stellarNetwork.js";

const RPC_URL = process.env.STELLAR_RPC_URL ?? "https://soroban-testnet.stellar.org";
const NET = StellarSdk.Networks.TESTNET;
// La red sale de lib/stellarNetwork.ts, que es la única que interpreta
// STELLAR_NETWORK. Este archivo la resolvía por su cuenta aceptando solo
// "PUBLIC", y x402 solo "MAINNET": no había valor que pusiera a los dos en la
// red real. De NET dependen el passphrase de firma y los SAC de abajo.
const NET = NETWORK_PASSPHRASE;

const USDC_ISSUER = process.env.USDC_ISSUER ?? "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";
const USDC_SAC = new StellarSdk.Asset("USDC", USDC_ISSUER).contractId(NET);
Expand Down
46 changes: 46 additions & 0 deletions apps/api/src/lib/stellarNetwork.ts
Original file line number Diff line number Diff line change
@@ -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";
Loading
Loading