From 9ddb67aa0e2e68ea9f3864c6d71f30c6140128d6 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 18 Mar 2026 17:23:12 -0300 Subject: [PATCH] feat(lifecycle): manage Stellar node for local/CI parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The lifecycle test now starts its own Stellar quickstart container with --limits unlimited, ensuring identical behavior locally and in CI: - Contract events are captured in both environments - No dependency on an externally-running Stellar node - Dedicated port (8028) avoids collisions with other local-dev services config.ts simplified to just paths — network config is derived from the managed node. provider.ts now manages Stellar + PostgreSQL + provider-platform as a single infrastructure stack. --- lifecycle/config.ts | 77 +--------- lifecycle/main.ts | 178 ++++++++++------------- lifecycle/provider.ts | 327 ++++++++++++++++++++++++------------------ 3 files changed, 269 insertions(+), 313 deletions(-) diff --git a/lifecycle/config.ts b/lifecycle/config.ts index 20f5fe4..88b6b65 100644 --- a/lifecycle/config.ts +++ b/lifecycle/config.ts @@ -1,83 +1,18 @@ -import { NetworkConfig } from "@colibri/core"; -import type { StellarNetworkId } from "@moonlight/moonlight-sdk"; - const WASM_DIR = new URL("../e2e/wasms", import.meta.url).pathname; export interface LifecycleConfig { - networkPassphrase: string; - rpcUrl: string; - horizonUrl: string; - friendbotUrl: string; - allowHttp: boolean; channelAuthWasmPath: string; privacyChannelWasmPath: string; providerPlatformPath: string; - providerUrl?: string; - networkConfig: NetworkConfig; - networkId: StellarNetworkId; } export function loadConfig(): LifecycleConfig { - const network = Deno.env.get("NETWORK") ?? "local"; - - const channelAuthWasmPath = Deno.env.get("CHANNEL_AUTH_WASM") ?? - `${WASM_DIR}/channel_auth_contract.wasm`; - const privacyChannelWasmPath = Deno.env.get("PRIVACY_CHANNEL_WASM") ?? - `${WASM_DIR}/privacy_channel.wasm`; - const providerPlatformPath = Deno.env.get("PROVIDER_PLATFORM_PATH") ?? - `${Deno.env.get("HOME")}/repos/provider-platform`; - const providerUrl = Deno.env.get("PROVIDER_URL"); - - if (network === "testnet") { - const networkPassphrase = "Test SDF Network ; September 2015"; - const rpcUrl = Deno.env.get("STELLAR_RPC_URL") ?? - "https://soroban-testnet.stellar.org"; - const horizonUrl = Deno.env.get("HORIZON_URL") ?? - "https://horizon-testnet.stellar.org"; - const friendbotUrl = Deno.env.get("FRIENDBOT_URL") ?? - "https://friendbot.stellar.org"; - - return { - networkPassphrase, - rpcUrl, - horizonUrl, - friendbotUrl, - allowHttp: false, - channelAuthWasmPath, - privacyChannelWasmPath, - providerPlatformPath, - providerUrl, - networkConfig: NetworkConfig.TestNet({ allowHttp: false }), - networkId: networkPassphrase as StellarNetworkId, - }; - } - - // Local - const networkPassphrase = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? - "Standalone Network ; February 2017"; - const rpcUrl = Deno.env.get("STELLAR_RPC_URL") ?? - "http://localhost:8000/soroban/rpc"; - const horizonUrl = rpcUrl.replace("/soroban/rpc", ""); - const friendbotUrl = Deno.env.get("FRIENDBOT_URL") ?? - "http://localhost:8000/friendbot"; - return { - networkPassphrase, - rpcUrl, - horizonUrl, - friendbotUrl, - allowHttp: true, - channelAuthWasmPath, - privacyChannelWasmPath, - providerPlatformPath, - providerUrl, - networkConfig: NetworkConfig.CustomNet({ - networkPassphrase, - rpcUrl, - horizonUrl, - friendbotUrl, - allowHttp: true, - }), - networkId: networkPassphrase as StellarNetworkId, + channelAuthWasmPath: Deno.env.get("CHANNEL_AUTH_WASM") ?? + `${WASM_DIR}/channel_auth_contract.wasm`, + privacyChannelWasmPath: Deno.env.get("PRIVACY_CHANNEL_WASM") ?? + `${WASM_DIR}/privacy_channel.wasm`, + providerPlatformPath: Deno.env.get("PROVIDER_PLATFORM_PATH") ?? + `${Deno.env.get("HOME")}/repos/provider-platform`, }; } diff --git a/lifecycle/main.ts b/lifecycle/main.ts index 245074b..f1e636b 100644 --- a/lifecycle/main.ts +++ b/lifecycle/main.ts @@ -1,5 +1,6 @@ import { Keypair } from "stellar-sdk"; -import type { ContractId } from "@colibri/core"; +import { NetworkConfig, type ContractId } from "@colibri/core"; +import type { StellarNetworkId } from "@moonlight/moonlight-sdk"; import type { Config } from "../e2e/config.ts"; import { loadConfig } from "./config.ts"; import { createServer } from "./soroban.ts"; @@ -11,7 +12,7 @@ import { } from "./deploy.ts"; import { addProvider, removeProvider } from "./admin.ts"; import { extractEvents, verifyEvent } from "./events.ts"; -import { startProvider, type ProviderInstance } from "./provider.ts"; +import { startProvider, startStellar } from "./provider.ts"; // Existing E2E modules for the payment flow import { authenticate } from "../e2e/auth.ts"; @@ -41,126 +42,118 @@ async function fundAccount( async function main() { const startTime = Date.now(); const config = loadConfig(); - let providerInstance: ProviderInstance | null = null; + + // Cleanup functions accumulated during setup + const cleanups: (() => Promise)[] = []; console.log("\n=== Moonlight Protocol — Full Lifecycle E2E ===\n"); try { - // ── Setup ────────────────────────────────────────────────────── - console.log("[setup] Initializing..."); - const server = createServer(config.rpcUrl, config.allowHttp); - console.log(` Network: ${config.networkPassphrase}`); - console.log(` RPC: ${config.rpcUrl}`); + // ── Start Stellar node ──────────────────────────────────────── + console.log("[infra] Starting Stellar node (--limits unlimited)..."); + const stellar = await startStellar(); + cleanups.push(stellar.stopStellar); + const { rpcUrl, friendbotUrl, horizonUrl, networkPassphrase } = stellar; + console.log(` RPC: ${rpcUrl}`); + console.log(` Friendbot: ${friendbotUrl}`); + + const server = createServer(rpcUrl); + // ── Generate & fund accounts ────────────────────────────────── const admin = Keypair.random(); const provider = Keypair.random(); const treasury = Keypair.random(); + console.log(`\n[setup] Accounts`); console.log(` Admin: ${admin.publicKey()}`); console.log(` Provider: ${provider.publicKey()}`); console.log(` Treasury: ${treasury.publicKey()}`); console.log("\n[setup] Funding accounts..."); - await fundAccount(config.friendbotUrl, admin.publicKey()); + await fundAccount(friendbotUrl, admin.publicKey()); console.log(" Admin funded"); - await fundAccount(config.friendbotUrl, treasury.publicKey()); + await fundAccount(friendbotUrl, treasury.publicKey()); console.log(" Treasury funded"); - // ── Step 1: Deploy Council (Channel Auth) ────────────────────── + // ── Step 1: Deploy Council (Channel Auth) ──────────────────── console.log("\n[1/7] Deploy Council (Channel Auth)"); const channelAuthWasm = await Deno.readFile(config.channelAuthWasmPath); const channelAuthHash = await uploadWasm( - server, - admin, - config.networkPassphrase, - channelAuthWasm, + server, admin, networkPassphrase, channelAuthWasm, ); const { contractId: channelAuthId, txResponse: authDeployTx } = await deployChannelAuth( - server, - admin, - config.networkPassphrase, - channelAuthHash, + server, admin, networkPassphrase, channelAuthHash, ); const deployEvents = extractEvents(authDeployTx); - const initResult = verifyEvent(deployEvents, "contract_initialized", true); - if (initResult.found) console.log(" ContractInitialized event verified"); + const initResult = verifyEvent( + deployEvents, "contract_initialized", true, + ); + if (initResult.found) console.log(" contract_initialized event verified"); - // ── Step 2: Deploy Channel (Privacy Channel) ────────────────── + // ── Step 2: Deploy Channel (Privacy Channel) ───────────────── console.log("\n[2/7] Deploy Channel (Privacy Channel)"); console.log(" Deploying native XLM SAC..."); const assetContractId = await getOrDeployNativeSac( - server, - admin, - config.networkPassphrase, + server, admin, networkPassphrase, ); const privacyChannelWasm = await Deno.readFile( config.privacyChannelWasmPath, ); const privacyChannelHash = await uploadWasm( - server, - admin, - config.networkPassphrase, - privacyChannelWasm, + server, admin, networkPassphrase, privacyChannelWasm, ); const channelContractId = await deployPrivacyChannel( - server, - admin, - config.networkPassphrase, - privacyChannelHash, - channelAuthId, - assetContractId, + server, admin, networkPassphrase, + privacyChannelHash, channelAuthId, assetContractId, ); - // ── Step 3: Add Privacy Provider ────────────────────────────── + // ── Step 3: Add Privacy Provider ───────────────────────────── console.log("\n[3/7] Add Privacy Provider"); const addTx = await addProvider( - server, - admin, - config.networkPassphrase, - channelAuthId, - provider.publicKey(), + server, admin, networkPassphrase, + channelAuthId, provider.publicKey(), ); const addEvents = extractEvents(addTx); const addResult = verifyEvent(addEvents, "provider_added", true); - if (addResult.found) console.log(" ProviderAdded event verified"); - - // ── Start provider-platform for payment flow ────────────────── - let providerUrl: string; - if (config.providerUrl) { - // Use externally-managed provider - providerUrl = config.providerUrl; - console.log(`\n[provider] Using existing provider at ${providerUrl}`); - } else { - // Start a fresh provider configured for these contracts - console.log("\n[provider] Starting provider-platform..."); - providerInstance = await startProvider({ - providerPlatformPath: config.providerPlatformPath, - rpcUrl: config.rpcUrl, - channelContractId, - channelAuthId, - assetContractId, - providerSecretKey: provider.secret(), - treasuryPublicKey: treasury.publicKey(), - treasurySecretKey: treasury.secret(), - }); - providerUrl = providerInstance.url; - } + if (addResult.found) console.log(" provider_added event verified"); - // Build a Config compatible with the existing E2E modules + // ── Start provider-platform ────────────────────────────────── + console.log("\n[infra] Starting provider-platform..."); + const providerInfra = await startProvider({ + providerPlatformPath: config.providerPlatformPath, + rpcUrl, + channelContractId, + channelAuthId, + assetContractId, + providerSecretKey: provider.secret(), + treasuryPublicKey: treasury.publicKey(), + treasurySecretKey: treasury.secret(), + }); + cleanups.push(providerInfra.stopProvider); + const { providerUrl } = providerInfra; + + // Build config for existing E2E modules + const networkConfig = NetworkConfig.CustomNet({ + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, + allowHttp: true, + }); const e2eConfig: Config = { - networkPassphrase: config.networkPassphrase, - rpcUrl: config.rpcUrl, - horizonUrl: config.horizonUrl, - friendbotUrl: config.friendbotUrl, + networkPassphrase, + rpcUrl, + horizonUrl, + friendbotUrl, providerUrl, channelContractId: channelContractId as ContractId, channelAuthId: channelAuthId as ContractId, channelAssetContractId: assetContractId as ContractId, - networkConfig: config.networkConfig, - networkId: config.networkId, + networkConfig, + networkId: networkPassphrase as StellarNetworkId, providerSecretKey: provider.secret(), }; @@ -169,8 +162,8 @@ async function main() { console.log(`\n Alice: ${alice.publicKey()}`); console.log(` Bob: ${bob.publicKey()}`); - await fundAccount(config.friendbotUrl, alice.publicKey()); - await fundAccount(config.friendbotUrl, bob.publicKey()); + await fundAccount(friendbotUrl, alice.publicKey()); + await fundAccount(friendbotUrl, bob.publicKey()); console.log(" Users funded"); // ── Step 4: Deposit ────────────────────────────────────────── @@ -185,43 +178,30 @@ async function main() { const bobJwt = await authenticate(bob, e2eConfig); console.log(" Bob authenticated"); const receiverOps = await prepareReceive( - bob.secret(), - SEND_AMOUNT, - e2eConfig, + bob.secret(), SEND_AMOUNT, e2eConfig, ); await send( - alice.secret(), - receiverOps, - SEND_AMOUNT, - aliceJwt, - e2eConfig, + alice.secret(), receiverOps, SEND_AMOUNT, aliceJwt, e2eConfig, ); console.log(" Send complete"); // ── Step 6: Withdraw ───────────────────────────────────────── console.log(`\n[6/7] Withdraw (${WITHDRAW_AMOUNT} XLM)`); await withdraw( - bob.secret(), - bob.publicKey(), - WITHDRAW_AMOUNT, - bobJwt, - e2eConfig, + bob.secret(), bob.publicKey(), WITHDRAW_AMOUNT, bobJwt, e2eConfig, ); console.log(" Withdraw complete"); // ── Step 7: Remove Privacy Provider ────────────────────────── console.log("\n[7/7] Remove Privacy Provider"); const removeTx = await removeProvider( - server, - admin, - config.networkPassphrase, - channelAuthId, - provider.publicKey(), + server, admin, networkPassphrase, + channelAuthId, provider.publicKey(), ); const removeEvents = extractEvents(removeTx); const removeResult = verifyEvent(removeEvents, "provider_removed", true); - if (removeResult.found) console.log(" ProviderRemoved event verified"); + if (removeResult.found) console.log(" provider_removed event verified"); // ── Summary ────────────────────────────────────────────────── const deployment = { @@ -233,8 +213,7 @@ async function main() { treasuryPublicKey: treasury.publicKey(), }; await Deno.writeTextFile( - DEPLOYMENT_PATH, - JSON.stringify(deployment, null, 2), + DEPLOYMENT_PATH, JSON.stringify(deployment, null, 2), ); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); @@ -247,12 +226,13 @@ async function main() { console.log(` Config written to: ${DEPLOYMENT_PATH}`); console.log(`\n=== Lifecycle E2E passed in ${elapsed}s ===`); } finally { - // Always clean up the provider - if (providerInstance) { - console.log("\n[cleanup] Stopping provider-platform..."); - await providerInstance.stop(); - console.log(" Cleaned up"); + console.log("\n[cleanup] Stopping infrastructure..."); + for (const cleanup of cleanups.reverse()) { + try { + await cleanup(); + } catch { /* best effort */ } } + console.log(" Done"); } } diff --git a/lifecycle/provider.ts b/lifecycle/provider.ts index 78ff35a..3af1ab1 100644 --- a/lifecycle/provider.ts +++ b/lifecycle/provider.ts @@ -1,10 +1,14 @@ /** - * Manages a provider-platform instance for local dev lifecycle testing. - * Starts PostgreSQL, writes config, runs migrations, and starts the provider from source. + * Manages the full infrastructure for the lifecycle test: + * - Stellar quickstart node (with --limits unlimited for event parity with CI) + * - PostgreSQL for the provider-platform + * - Provider-platform process (from source) * - * In CI, docker-compose handles this instead (see docker-compose.yml). + * In CI, docker-compose manages all of this instead (see docker-compose.yml). */ +const STELLAR_CONTAINER = "lifecycle-e2e-stellar"; +const STELLAR_PORT = 8028; const PG_CONTAINER = "lifecycle-e2e-db"; const PG_PORT = 5452; const PG_DB = "lifecycle_e2e_db"; @@ -12,14 +16,19 @@ const PG_USER = "admin"; const PG_PASS = "devpass"; const PROVIDER_PORT = 3030; -export interface ProviderInstance { - url: string; +const NETWORK_PASSPHRASE = "Standalone Network ; February 2017"; + +export interface Infrastructure { + rpcUrl: string; + friendbotUrl: string; + horizonUrl: string; + networkPassphrase: string; + providerUrl: string; stop: () => Promise; } -export interface ProviderStartOptions { +export interface InfrastructureOptions { providerPlatformPath: string; - rpcUrl: string; channelContractId: string; channelAuthId: string; assetContractId: string; @@ -28,24 +37,111 @@ export interface ProviderStartOptions { treasurySecretKey: string; } +// ── Public API ─────────────────────────────────────────────────── + +/** + * Start the Stellar node and wait for Friendbot. + * Call this before deploying contracts. + */ +export async function startStellar(): Promise<{ + rpcUrl: string; + friendbotUrl: string; + horizonUrl: string; + networkPassphrase: string; + stopStellar: () => Promise; +}> { + console.log(" Starting Stellar node..."); + await ensureContainer(STELLAR_CONTAINER, [ + "run", "-d", "--name", STELLAR_CONTAINER, + "-p", `${STELLAR_PORT}:8000`, + "stellar/quickstart:latest", + "--local", "--limits", "unlimited", + ]); + + // Wait for RPC health + const rpcUrl = `http://localhost:${STELLAR_PORT}/soroban/rpc`; + console.log(" Waiting for Stellar RPC..."); + for (let i = 0; i < 180; i++) { + try { + const res = await fetch(rpcUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", id: 1, method: "getHealth", + }), + }); + const data = await res.json(); + if (data.result?.status === "healthy") { + console.log(" Stellar RPC healthy"); + break; + } + } catch { /* not ready */ } + if (i === 179) throw new Error("Stellar RPC did not become healthy"); + await sleep(1000); + } + + // Wait for Friendbot + const friendbotUrl = `http://localhost:${STELLAR_PORT}/friendbot`; + console.log(" Waiting for Friendbot..."); + for (let i = 0; i < 180; i++) { + try { + const res = await fetch( + `${friendbotUrl}?addr=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`, + ); + if (res.status === 200 || res.status === 400) { + console.log(" Friendbot ready"); + break; + } + } catch { /* not ready */ } + if (i === 179) throw new Error("Friendbot did not become ready"); + await sleep(1000); + } + + return { + rpcUrl, + friendbotUrl, + horizonUrl: `http://localhost:${STELLAR_PORT}`, + networkPassphrase: NETWORK_PASSPHRASE, + stopStellar: () => removeContainer(STELLAR_CONTAINER), + }; +} + +/** + * Start PostgreSQL + provider-platform configured for the given contracts. + * Call this after deploying contracts and registering the provider. + */ export async function startProvider( - opts: ProviderStartOptions, -): Promise { - const { providerPlatformPath } = opts; + opts: InfrastructureOptions & { rpcUrl: string }, +): Promise<{ providerUrl: string; stopProvider: () => Promise }> { + const { providerPlatformPath, rpcUrl } = opts; const databaseUrl = `postgresql://${PG_USER}:${PG_PASS}@localhost:${PG_PORT}/${PG_DB}`; - // 1. Start PostgreSQL + // PostgreSQL console.log(" Starting PostgreSQL..."); - await ensurePostgres(); + await ensureContainer(PG_CONTAINER, [ + "run", "-d", "--name", PG_CONTAINER, + "-p", `${PG_PORT}:5432`, + "-e", `POSTGRES_USER=${PG_USER}`, + "-e", `POSTGRES_PASSWORD=${PG_PASS}`, + "-e", `POSTGRES_DB=${PG_DB}`, + "postgres:18", + ]); + console.log(" Waiting for PostgreSQL..."); + for (let i = 0; i < 30; i++) { + const ready = await docker([ + "exec", PG_CONTAINER, "pg_isready", "-U", PG_USER, + ]); + if (ready.success) { console.log(" PostgreSQL ready"); break; } + if (i === 29) throw new Error("PostgreSQL did not become ready"); + await sleep(1000); + } - // 2. Back up existing .env and write ours - const envBackup = await backupAndWriteEnv(opts, databaseUrl); + // Write .env (backup existing) + const envBackup = await backupAndWriteEnv(opts, providerPlatformPath, databaseUrl, rpcUrl); - // 3. Install deps (idempotent) + // Install deps + migrations await run("deno", ["install"], providerPlatformPath, "install deps"); - - // 4. Run migrations console.log(" Running migrations..."); await run( "deno", @@ -54,7 +150,7 @@ export async function startProvider( "migrations", ); - // 5. Start provider-platform + // Start provider process console.log(" Starting provider-platform..."); const child = new Deno.Command("deno", { args: ["run", "--allow-all", "--unstable-kv", "src/main.ts"], @@ -63,116 +159,115 @@ export async function startProvider( stderr: "piped", }).spawn(); - // Drain stdout to log file const logPath = new URL("./provider.log", import.meta.url).pathname; const logFile = await Deno.open(logPath, { - write: true, - create: true, - truncate: true, + write: true, create: true, truncate: true, }); const logWriter = logFile.writable.getWriter(); child.stdout - .pipeTo( - new WritableStream({ - write(chunk) { - return logWriter.write(chunk); - }, - }), - ) + .pipeTo(new WritableStream({ write(chunk) { return logWriter.write(chunk); } })) .catch(() => {}); child.stderr .pipeTo(new WritableStream({ write() {} })) .catch(() => {}); - // 6. Wait for ready - const url = `http://localhost:${PROVIDER_PORT}`; - await waitForReady(url); - console.log(` Provider ready at ${url} (log: ${logPath})`); + const providerUrl = `http://localhost:${PROVIDER_PORT}`; + await waitForReady(providerUrl); + console.log(` Provider ready at ${providerUrl} (log: ${logPath})`); return { - url, - async stop() { - try { - child.kill("SIGTERM"); - } catch { /* already dead */ } - try { - await child.status; - } catch { /* ignore */ } - try { - logWriter.close(); - } catch { /* ignore */ } + providerUrl, + async stopProvider() { + try { child.kill("SIGTERM"); } catch { /* dead */ } + try { await child.status; } catch { /* ignore */ } + try { logWriter.close(); } catch { /* ignore */ } await restoreEnv(providerPlatformPath, envBackup); - await stopPostgres(); + await removeContainer(PG_CONTAINER); }, }; } -// ── PostgreSQL ──────────────────────────────────────────────────── +// ── Docker helpers ─────────────────────────────────────────────── -async function ensurePostgres(): Promise { +async function ensureContainer( + name: string, + runArgs: string[], +): Promise { const check = await docker([ - "ps", - "--filter", - `name=^${PG_CONTAINER}$`, - "--format", - "{{.Names}}", + "ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}", ]); - - if (decode(check.stdout).trim() === PG_CONTAINER) { - console.log(" PostgreSQL already running"); + if (decode(check.stdout).trim() === name) { + console.log(` ${name} already running`); return; } + await docker(["rm", "-f", name]); + const result = await docker(runArgs); + if (!result.success) { + throw new Error(`Failed to start ${name}: ${decode(result.stderr)}`); + } +} - await docker(["rm", "-f", PG_CONTAINER]); +async function removeContainer(name: string): Promise { + await docker(["rm", "-f", name]); +} - const start = await docker([ - "run", "-d", "--name", PG_CONTAINER, - "-p", `${PG_PORT}:5432`, - "-e", `POSTGRES_USER=${PG_USER}`, - "-e", `POSTGRES_PASSWORD=${PG_PASS}`, - "-e", `POSTGRES_DB=${PG_DB}`, - "postgres:18", - ]); +async function docker(args: string[]): Promise { + return new Deno.Command("docker", { + args, stdout: "piped", stderr: "piped", + }).output(); +} - if (!start.success) { - throw new Error( - `Failed to start PostgreSQL: ${decode(start.stderr)}`, - ); - } +function decode(buf: Uint8Array): string { + return new TextDecoder().decode(buf); +} - for (let i = 0; i < 30; i++) { - const ready = await docker([ - "exec", PG_CONTAINER, "pg_isready", "-U", PG_USER, - ]); - if (ready.success) { - console.log(" PostgreSQL ready"); - return; - } - await new Promise((r) => setTimeout(r, 1000)); +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function run( + cmd: string, + args: string[], + cwd: string, + label: string, +): Promise { + const result = await new Deno.Command(cmd, { + args, cwd, stdout: "piped", stderr: "piped", + }).output(); + if (!result.success) { + throw new Error(`${label} failed:\n${decode(result.stderr)}`); } - throw new Error("PostgreSQL did not become ready after 30s"); } -async function stopPostgres(): Promise { - await docker(["rm", "-f", PG_CONTAINER]); +async function waitForReady(url: string, timeoutMs = 60_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + await fetch( + `${url}/api/v1/stellar/auth?account=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`, + ); + return; + } catch { /* not ready */ } + await sleep(1000); + } + throw new Error(`Provider not ready after ${timeoutMs}ms`); } // ── .env management ────────────────────────────────────────────── async function backupAndWriteEnv( - opts: ProviderStartOptions, + opts: InfrastructureOptions, + providerPath: string, databaseUrl: string, + rpcUrl: string, ): Promise { - const envPath = `${opts.providerPlatformPath}/.env`; + const envPath = `${providerPath}/.env`; let backupPath: string | null = null; - try { const existing = await Deno.readTextFile(envPath); backupPath = `${envPath}.lifecycle-backup`; await Deno.writeTextFile(backupPath, existing); - } catch { - // No existing .env - } + } catch { /* no existing .env */ } const content = `# Generated by lifecycle E2E test — will be restored after test PORT=${PROVIDER_PORT} @@ -183,7 +278,7 @@ SERVICE_DOMAIN=localhost DATABASE_URL=${databaseUrl} NETWORK=local -STELLAR_RPC_URL=${opts.rpcUrl} +STELLAR_RPC_URL=${rpcUrl} NETWORK_FEE=1000000000 CHANNEL_CONTRACT_ID=${opts.channelContractId} CHANNEL_AUTH_ID=${opts.channelAuthId} @@ -206,7 +301,6 @@ MEMPOOL_EXECUTOR_INTERVAL_MS=5000 MEMPOOL_VERIFIER_INTERVAL_MS=10000 MEMPOOL_TTL_CHECK_INTERVAL_MS=60000 `; - await Deno.writeTextFile(envPath, content); return backupPath; } @@ -223,59 +317,6 @@ async function restoreEnv( await Deno.remove(backupPath); } catch { /* best effort */ } } else { - try { - await Deno.remove(envPath); - } catch { /* ignore */ } - } -} - -// ── Helpers ────────────────────────────────────────────────────── - -async function docker(args: string[]): Promise { - return new Deno.Command("docker", { - args, - stdout: "piped", - stderr: "piped", - }).output(); -} - -function decode(buf: Uint8Array): string { - return new TextDecoder().decode(buf); -} - -async function run( - cmd: string, - args: string[], - cwd: string, - label: string, -): Promise { - const result = await new Deno.Command(cmd, { - args, - cwd, - stdout: "piped", - stderr: "piped", - }).output(); - - if (!result.success) { - throw new Error(`${label} failed:\n${decode(result.stderr)}`); - } -} - -async function waitForReady( - url: string, - timeoutMs = 60_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - await fetch( - `${url}/api/v1/stellar/auth?account=GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF`, - ); - return; - } catch { - // Not ready yet - } - await new Promise((r) => setTimeout(r, 1000)); + try { await Deno.remove(envPath); } catch { /* ignore */ } } - throw new Error(`Provider not ready after ${timeoutMs}ms`); }