diff --git a/deno.lock b/deno.lock index 6f41f7d..5e515fc 100644 --- a/deno.lock +++ b/deno.lock @@ -10,6 +10,7 @@ "jsr:@std/collections@^1.1.3": "1.1.7", "jsr:@std/toml@^1.0.11": "1.0.11", "npm:@opentelemetry/api@^1.9.0": "1.9.1", + "npm:@stellar/stellar-sdk@14.2.0": "14.2.0", "npm:@stellar/stellar-sdk@^15.0.1": "15.0.1", "npm:asn1js@3.0.5": "3.0.5", "npm:buffer@6.0.3": "6.0.3", @@ -21,7 +22,7 @@ "dependencies": [ "jsr:@fifo/convee", "jsr:@std/toml", - "npm:@stellar/stellar-sdk", + "npm:@stellar/stellar-sdk@^15.0.1", "npm:buffer@^6.0.3" ] }, @@ -34,7 +35,7 @@ "jsr:@colibri/core", "jsr:@noble/curves", "jsr:@noble/hashes@^1.6.1", - "npm:@stellar/stellar-sdk", + "npm:@stellar/stellar-sdk@^15.0.1", "npm:asn1js", "npm:buffer@6.0.3" ] @@ -71,24 +72,52 @@ "@opentelemetry/api@1.9.1": { "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==" }, + "@stellar/js-xdr@3.1.2": { + "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==" + }, "@stellar/js-xdr@4.0.0": { "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==" }, + "@stellar/stellar-base@14.1.0": { + "integrity": "sha512-A8kFli6QGy22SRF45IjgPAJfUNGjnI+R7g4DF5NZYVsD1kGf7B4ITyc4OPclLV9tqNI4/lXxafGEw0JEUbHixw==", + "dependencies": [ + "@noble/curves", + "@stellar/js-xdr@3.1.2", + "base32.js", + "bignumber.js", + "buffer", + "sha.js" + ] + }, "@stellar/stellar-base@15.0.0": { "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", "dependencies": [ "@noble/curves", - "@stellar/js-xdr", + "@stellar/js-xdr@4.0.0", "base32.js", "bignumber.js", "buffer", "sha.js" ] }, + "@stellar/stellar-sdk@14.2.0": { + "integrity": "sha512-7nh2ogzLRMhfkIC0fGjn1LHUzk3jqVw8tjAuTt5ADWfL9CSGBL18ILucE9igz2L/RU2AZgeAvhujAnW91Ut/oQ==", + "dependencies": [ + "@stellar/stellar-base@14.1.0", + "axios", + "bignumber.js", + "eventsource", + "feaxios", + "randombytes", + "toml", + "urijs" + ], + "scripts": true + }, "@stellar/stellar-sdk@15.0.1": { "integrity": "sha512-iZjWKXtfohsPh+CX9wRyQNIlXLeA9VyuQB6UMC7AFBD9XnR92eOjnlfeONzk/Bsrkk6+UPlpzSy2MuF+ydHP1A==", "dependencies": [ - "@stellar/stellar-base", + "@stellar/stellar-base@15.0.0", "axios", "bignumber.js", "commander", diff --git a/down.sh b/down.sh index 8718802..99f32b2 100755 --- a/down.sh +++ b/down.sh @@ -17,6 +17,7 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" BASE_DIR="${BASE_DIR:-$(dirname "$SCRIPT_DIR")}" PROVIDER_PLATFORM_PATH="${PROVIDER_PLATFORM_PATH:-$BASE_DIR/provider-platform}" COUNCIL_PLATFORM_PATH="${COUNCIL_PLATFORM_PATH:-$BASE_DIR/council-platform}" +NETWORK_DASHBOARD_PLATFORM_PATH="${NETWORK_DASHBOARD_PLATFORM_PATH:-$BASE_DIR/network-dashboard-platform}" export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH" @@ -28,6 +29,7 @@ PAY_PLATFORM_PORT="${PAY_PLATFORM_PORT:-3025}" COUNCIL_CONSOLE_PORT="${COUNCIL_CONSOLE_PORT:-3030}" MOONLIGHT_PAY_PORT="${MOONLIGHT_PAY_PORT:-3050}" NETWORK_DASHBOARD_PORT="${NETWORK_DASHBOARD_PORT:-3040}" +NETWORK_DASHBOARD_PLATFORM_PORT="${NETWORK_DASHBOARD_PLATFORM_PORT:-3035}" PG_CONTAINER="${PG_CONTAINER:-provider-platform-db}" # Colors @@ -74,6 +76,7 @@ stop_process "provider-console" "$SCRIPT_DIR/.provider-console.pid" "$PROVIDER_C stop_process "council-console" "$SCRIPT_DIR/.council-console.pid" "$COUNCIL_CONSOLE_PORT" stop_process "moonlight-pay" "$SCRIPT_DIR/.moonlight-pay.pid" "$MOONLIGHT_PAY_PORT" stop_process "network-dashboard" "$SCRIPT_DIR/.network-dashboard.pid" "$NETWORK_DASHBOARD_PORT" +stop_process "network-dashboard-platform" "$SCRIPT_DIR/.network-dashboard-platform.pid" "$NETWORK_DASHBOARD_PLATFORM_PORT" # --- Stop PostgreSQL container --- if docker ps -a --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then @@ -97,10 +100,12 @@ for f in \ "$PROVIDER_PLATFORM_PATH/.env" \ "$COUNCIL_PLATFORM_PATH/.env" \ "$PAY_PLATFORM_PATH/.env" \ + "$NETWORK_DASHBOARD_PLATFORM_PATH/.env" \ "$SCRIPT_DIR/.local-dev-state" \ "$SCRIPT_DIR/provider.log" \ "$SCRIPT_DIR/council-platform.log" \ "$SCRIPT_DIR/pay-platform.log" \ + "$SCRIPT_DIR/network-dashboard-platform.log" \ "$SCRIPT_DIR/provider-console.log" \ "$SCRIPT_DIR/council-console.log" \ "$SCRIPT_DIR/moonlight-pay.log" \ diff --git a/infra-up.sh b/infra-up.sh index 42a1f00..f92816a 100755 --- a/infra-up.sh +++ b/infra-up.sh @@ -37,6 +37,7 @@ COUNCIL_PLATFORM_PATH="${COUNCIL_PLATFORM_PATH:-$BASE_DIR/council-platform}" PAY_PLATFORM_PATH="${PAY_PLATFORM_PATH:-$BASE_DIR/pay-platform}" MOONLIGHT_PAY_PATH="${MOONLIGHT_PAY_PATH:-$BASE_DIR/moonlight-pay}" NETWORK_DASHBOARD_PATH="${NETWORK_DASHBOARD_PATH:-$BASE_DIR/network-dashboard}" +NETWORK_DASHBOARD_PLATFORM_PATH="${NETWORK_DASHBOARD_PLATFORM_PATH:-$BASE_DIR/network-dashboard-platform}" # Ports — override via env to run multiple stacks in parallel STELLAR_RPC_PORT="${STELLAR_RPC_PORT:-8000}" # shared @@ -49,6 +50,7 @@ COUNCIL_CONSOLE_PORT="${COUNCIL_CONSOLE_PORT:-3030}" PAY_PLATFORM_PORT="${PAY_PLATFORM_PORT:-3025}" MOONLIGHT_PAY_PORT="${MOONLIGHT_PAY_PORT:-3050}" NETWORK_DASHBOARD_PORT="${NETWORK_DASHBOARD_PORT:-3040}" +NETWORK_DASHBOARD_PLATFORM_PORT="${NETWORK_DASHBOARD_PLATFORM_PORT:-3035}" # Container name — override to avoid collisions PG_CONTAINER="${PG_CONTAINER:-provider-platform-db}" @@ -66,7 +68,7 @@ error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; } section() { echo -e "\n${BLUE}=== $* ===${NC}"; } # ============================================================ -section "1/11 Prerequisites" +section "1/12 Prerequisites" # ============================================================ command -v docker >/dev/null 2>&1 || error "Docker not found." @@ -100,9 +102,10 @@ info "Deno: $($DENO_BIN --version | head -1)" [ -d "$COUNCIL_PLATFORM_PATH" ] || error "council-platform not found at $COUNCIL_PLATFORM_PATH" [ -d "$PAY_PLATFORM_PATH" ] || error "pay-platform not found at $PAY_PLATFORM_PATH" [ -d "$NETWORK_DASHBOARD_PATH" ] || error "network-dashboard not found at $NETWORK_DASHBOARD_PATH" +[ -d "$NETWORK_DASHBOARD_PLATFORM_PATH" ] || error "network-dashboard-platform not found at $NETWORK_DASHBOARD_PLATFORM_PATH" # ============================================================ -section "2/11 Jaeger (ports 4317/4318/16686)" +section "2/12 Jaeger (ports 4317/4318/16686)" # ============================================================ JAEGER_CONTAINER="${JAEGER_CONTAINER:-jaeger}" @@ -130,7 +133,7 @@ else fi # ============================================================ -section "3/11 Stellar Network" +section "3/12 Stellar Network" # ============================================================ # Start the shared Stellar network if not already running @@ -178,7 +181,7 @@ done sleep 2 # ============================================================ -section "4/11 PostgreSQL (port $PG_PORT)" +section "4/12 PostgreSQL (port $PG_PORT)" # ============================================================ if docker ps --format '{{.Names}}' | grep -q "^${PG_CONTAINER}$"; then @@ -221,7 +224,7 @@ docker exec "$PG_CONTAINER" psql -U admin -d postgres -tc \ "CREATE DATABASE pay_platform_db" >/dev/null # ============================================================ -section "5/11 Provider Platform (port $PROVIDER_PORT)" +section "5/12 Provider Platform (port $PROVIDER_PORT)" # ============================================================ cd "$PROVIDER_PLATFORM_PATH" @@ -283,7 +286,7 @@ for i in $(seq 1 15); do done # ============================================================ -section "6/11 Council Platform (port $COUNCIL_PLATFORM_PORT)" +section "6/12 Council Platform (port $COUNCIL_PLATFORM_PORT)" # ============================================================ cd "$COUNCIL_PLATFORM_PATH" @@ -335,7 +338,7 @@ for i in $(seq 1 15); do done # ============================================================ -section "7/11 Pay Platform (port $PAY_PLATFORM_PORT)" +section "7/12 Pay Platform (port $PAY_PLATFORM_PORT)" # ============================================================ cd "$PAY_PLATFORM_PATH" @@ -401,7 +404,57 @@ for i in $(seq 1 15); do done # ============================================================ -section "8/11 Provider Console (port $PROVIDER_CONSOLE_PORT)" +section "8/12 Network Dashboard Platform (port $NETWORK_DASHBOARD_PLATFORM_PORT)" +# ============================================================ + +cd "$NETWORK_DASHBOARD_PLATFORM_PATH" + +# Infra-only env: ports, log level, network bindings, council-platform +# linkage. The service has NO database — its in-memory state is rebuilt +# from council-platform + a 24h Soroban scan on every cold start, so +# there's no `deno task db:migrate` step. The new-council listener pulls +# its Channel Auth WASM hash set from soroban-core's GitHub releases at +# boot + hourly resync, so no hash is injected here. +cat > .env < "$NETWORK_DASHBOARD_PLATFORM_LOG" 2>&1 & +NETWORK_DASHBOARD_PLATFORM_PID=$! +echo "$NETWORK_DASHBOARD_PLATFORM_PID" > "$SCRIPT_DIR/.network-dashboard-platform.pid" +info "Network Dashboard Platform running (PID $NETWORK_DASHBOARD_PLATFORM_PID, log: $NETWORK_DASHBOARD_PLATFORM_LOG)" + +for i in $(seq 1 15); do + if curl -sf "http://localhost:${NETWORK_DASHBOARD_PLATFORM_PORT}/api/v1/health" >/dev/null 2>&1; then + info "Network Dashboard Platform is ready on port $NETWORK_DASHBOARD_PLATFORM_PORT." + break + fi + if [ "$i" -eq 15 ]; then + warn "Network Dashboard Platform may not be ready yet. Check $NETWORK_DASHBOARD_PLATFORM_LOG" + fi + sleep 1 +done + +# ============================================================ +section "9/12 Provider Console (port $PROVIDER_CONSOLE_PORT)" # ============================================================ cd "$PROVIDER_CONSOLE_PATH" @@ -440,7 +493,7 @@ for i in $(seq 1 10); do done # ============================================================ -section "9/11 Council Console (port $COUNCIL_CONSOLE_PORT)" +section "10/12 Council Console (port $COUNCIL_CONSOLE_PORT)" # ============================================================ cd "$COUNCIL_CONSOLE_PATH" @@ -482,7 +535,7 @@ for i in $(seq 1 10); do done # ============================================================ -section "10/11 Moonlight Pay (port $MOONLIGHT_PAY_PORT)" +section "11/12 Moonlight Pay (port $MOONLIGHT_PAY_PORT)" # ============================================================ cd "$MOONLIGHT_PAY_PATH" @@ -523,18 +576,17 @@ for i in $(seq 1 10); do done # ============================================================ -section "11/11 Network Dashboard (port $NETWORK_DASHBOARD_PORT)" +section "12/12 Network Dashboard (port $NETWORK_DASHBOARD_PORT)" # ============================================================ cd "$NETWORK_DASHBOARD_PATH" cat > public/config.js </dev/null 2>&1 || DENO_BIN="$HOME/.deno/bin/deno" +command -v "$DENO_BIN" >/dev/null 2>&1 || { + echo "ERROR: deno not found. Run up.sh first or install Deno." >&2 + exit 1 +} + +cd "$SCRIPT_DIR" +exec "$DENO_BIN" run --allow-all network-dashboard-demo.ts "$@" diff --git a/network-dashboard-demo.ts b/network-dashboard-demo.ts new file mode 100644 index 0000000..a087e7f --- /dev/null +++ b/network-dashboard-demo.ts @@ -0,0 +1,595 @@ +/** + * network-dashboard-demo + * + * Drives a randomized, bounded sequence of activity against a running + * local-dev stack so the network-dashboard at http://localhost:3040 + * paints councils + PPs + money flow in real time. Four action types, + * shuffled with dependency-aware fixups: + * + * - create: random Keypair → setup-c.ts with a random JURISDICTION → + * contract_initialized fires → dashboard listener adopts the council; + * §5 World Map gets a pin in the chosen country. + * - join: pick a known demo council → setup-pp.ts → on-chain + * add_provider fires → green "✓ PP joined" card + new PP-dot satellite. + * - remove: pick a demo council with ≥ 1 demo PP → call removeProvider + * directly via lib/admin.ts → gray "✗ PP left" card + PP-dot vanishes. + * - tx: shells out to send-loop.ts using the canonical PP from + * .local-dev-state (the operator the user has open in provider- + * console). Drives a deposit + N sends + withdraw cycle through that + * PP so the provider-side dashboards light up alongside the network + * one. Orange/blue/teal pulses on the PP's council edge. + * + * Defaults: 5 create + 8 join + 2 remove + 3 tx actions. Tunable per env + * (see "Tunables"). The script always exits after the planned sequence + * completes; it does not loop. + * + * Prereqs: + * - ./up.sh has run (full stack up) + * - For tx actions: ./setup-c.sh + ./setup-pp.sh have run so + * .local-dev-state holds an operator PP. Tx actions skip cleanly if + * the state file is missing. + * + * Usage: + * ./network-dashboard-demo.sh + * or + * deno run --allow-all network-dashboard-demo.ts + * + * Tunables (env): + * DEMO_NEW_COUNCILS=5 councils to create in this run + * DEMO_NEW_PPS=8 PP joins to perform across the new councils + * DEMO_REMOVE_PPS=2 PPs to remove from demo councils + * DEMO_TX_CYCLES=3 send-loop cycles total + * DEMO_TX_COUNT=2 sends per tx cycle (each cycle = deposit + N + withdraw) + * DEMO_CANONICAL_TX_RATIO=0.34 fraction of tx cycles routed through the + * canonical PP from .local-dev-state; the + * rest route through a random demo PP + * DEMO_SLEEP_MIN_MS=3000 min jitter between actions + * DEMO_SLEEP_MAX_MS=8000 max jitter between actions + * DEMO_SEED= seed the action shuffle for reproducibility + * DEMO_CANONICAL_STATE=

override path to .local-dev-state for tx + */ + +import { Keypair, rpc } from "npm:@stellar/stellar-sdk@14.2.0"; +import { removeProvider } from "./lib/admin.ts"; + +const COUNCIL_PLATFORM_URL = Deno.env.get("COUNCIL_URL") ?? + "http://localhost:3015"; +const RPC_URL = Deno.env.get("STELLAR_RPC_URL") ?? + "http://localhost:8000/soroban/rpc"; +const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? + "Standalone Network ; February 2017"; +const NEW_COUNCILS = Math.max( + 0, + Number(Deno.env.get("DEMO_NEW_COUNCILS") ?? "5"), +); +const NEW_PPS = Math.max(0, Number(Deno.env.get("DEMO_NEW_PPS") ?? "8")); +const REMOVE_PPS = Math.max( + 0, + Number(Deno.env.get("DEMO_REMOVE_PPS") ?? "2"), +); +const TX_CYCLES = Math.max(0, Number(Deno.env.get("DEMO_TX_CYCLES") ?? "3")); +const TX_COUNT_PER_CYCLE = Math.max( + 1, + Number(Deno.env.get("DEMO_TX_COUNT") ?? "2"), +); +/** + * Probability that a given tx cycle routes through the canonical PP + * (the one registered with provider-platform via setup-pp.sh). The rest + * route through a random demo-spawned PP so the topology shows pulses on + * multiple council edges, not only "Local Council". + */ +const CANONICAL_TX_RATIO = Math.min( + 1, + Math.max(0, Number(Deno.env.get("DEMO_CANONICAL_TX_RATIO") ?? "0.34")), +); +const SLEEP_MIN_MS = Number(Deno.env.get("DEMO_SLEEP_MIN_MS") ?? "3000"); +const SLEEP_MAX_MS = Number(Deno.env.get("DEMO_SLEEP_MAX_MS") ?? "8000"); +const SEED_RAW = Deno.env.get("DEMO_SEED"); + +const COUNCIL_NAMES = [ + "Atlantic", + "Pacific", + "Andean", + "Nordic", + "Sahel", + "Caribbean", + "Baltic", + "Mediterranean", + "Aegean", + "Adriatic", + "Caspian", + "Coral", + "Sunda", + "Bering", + "Arabian", +]; +/** + * Each freshly-created council picks one of these jurisdictions at random + * so the §5 World Map shows pins around the globe (not just the US). + * Codes line up with the dashboard's COUNTRIES map in lib/world-map.ts. + */ +const JURISDICTIONS = [ + "US", + "UY", + "BR", + "AR", + "MX", + "GB", + "DE", + "FR", + "ES", + "PT", + "SE", + "JP", + "SG", + "AU", + "ZA", + "NG", + "KE", + "IN", +]; + +/** + * The "canonical" PP from setup-pp.sh — the operator key the user has open + * in their provider-console / provider-dashboard. Tx cycles in the demo + * drive bundles through THIS PP so the provider-side dashboards light up + * alongside the network-dashboard. Path is the local-dev default state file. + */ +const CANONICAL_STATE_FILE = Deno.env.get("DEMO_CANONICAL_STATE") ?? + new URL("./.local-dev-state", import.meta.url).pathname; +const PP_LABELS = [ + "Aurora", + "Solstice", + "Equinox", + "Zenith", + "Nadir", + "Meridian", + "Tropic", + "Polar", + "Vector", + "Vertex", + "Orbital", + "Comet", + "Quasar", + "Pulsar", +]; + +type PP = { + label: string; + publicKey: string; + secretKey: string; + /** + * Path to a state file populated by setup-pp.ts (PP_PK/PP_SK + council + * context). Kept on disk so the tx action can hand the same file to + * send-loop.ts without re-deriving anything. + */ + stateFile: string; +}; + +type Council = { + name: string; + adminPk: string; + adminSk: string; + councilId: string; + channelId: string; + assetId: string; + pps: PP[]; +}; + +const councils: Council[] = []; +let nameCursor = 0; +let ppCursor = 0; + +function nextCouncilName(): string { + const base = COUNCIL_NAMES[nameCursor % COUNCIL_NAMES.length]; + const rev = Math.floor(nameCursor / COUNCIL_NAMES.length); + nameCursor++; + return rev === 0 ? `${base} Council` : `${base} Council ${rev + 1}`; +} + +function nextPpLabel(): string { + const out = PP_LABELS[ppCursor % PP_LABELS.length]; + ppCursor++; + return `${out} PP`; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function pick(arr: T[]): T { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function parseStateFile(content: string): Record { + const out: Record = {}; + for (const line of content.split(/\r?\n/)) { + const m = line.match(/^([A-Z_]+)=(.*)$/); + if (m) out[m[1]] = m[2]; + } + return out; +} + +async function runChild( + args: string[], + env: Record, +): Promise<{ code: number; stderr: string }> { + const cmd = new Deno.Command("deno", { + args: ["run", "--allow-all", ...args], + env, + stdout: "piped", + stderr: "piped", + }); + const { code, stderr } = await cmd.output(); + return { code, stderr: new TextDecoder().decode(stderr) }; +} + +async function createCouncil(): Promise { + const adminSk = Keypair.random().secret(); + const name = nextCouncilName(); + const jurisdiction = pickRandom(JURISDICTIONS); + const stateFile = `/tmp/.nd-demo-${crypto.randomUUID()}`; + console.log( + `\n[demo] creating council: ${name} (jurisdiction=${jurisdiction})`, + ); + const { code, stderr } = await runChild(["setup-c.ts"], { + ADMIN_SECRET: adminSk, + COUNCIL_NAME: name, + STATE_FILE: stateFile, + JURISDICTION: jurisdiction, + PATH: Deno.env.get("PATH") ?? "", + HOME: Deno.env.get("HOME") ?? "", + }); + if (code !== 0) { + console.error( + `[demo] setup-c failed for ${name} (exit ${code}):`, + stderr.slice(-400), + ); + return null; + } + let stateText = ""; + try { + stateText = await Deno.readTextFile(stateFile); + } catch { + console.error(`[demo] setup-c produced no state file for ${name}`); + return null; + } + await Deno.remove(stateFile).catch(() => {}); + const state = parseStateFile(stateText); + if (!state.COUNCIL_ID || !state.ADMIN_SK) { + console.error(`[demo] state file missing keys for ${name}:`, state); + return null; + } + const c: Council = { + name, + adminPk: state.ADMIN_PK ?? Keypair.fromSecret(state.ADMIN_SK).publicKey(), + adminSk: state.ADMIN_SK, + councilId: state.COUNCIL_ID, + channelId: state.CHANNEL_ID ?? "", + assetId: state.ASSET_ID ?? "", + pps: [], + }; + console.log( + `[demo] ✓ ${name} → ${c.councilId.slice(0, 12)}…`, + ); + return c; +} + +async function joinPP(council: Council): Promise { + const ppSk = Keypair.random().secret(); + const ppPk = Keypair.fromSecret(ppSk).publicKey(); + const label = nextPpLabel(); + // State file kept on disk for later reuse by removeRandomPP / runTxCycle — + // setup-pp.ts appends PP_PK / PP_SK / PROVIDER_URL after a successful join, + // and send-loop.ts can consume the resulting file as-is. + const stateFile = `/tmp/.nd-demo-pp-${crypto.randomUUID()}`; + await Deno.writeTextFile( + stateFile, + [ + `ADMIN_PK=${council.adminPk}`, + `ADMIN_SK=${council.adminSk}`, + `COUNCIL_ID=${council.councilId}`, + `CHANNEL_ID=${council.channelId}`, + `ASSET_ID=${council.assetId}`, + `COUNCIL_URL=${COUNCIL_PLATFORM_URL}`, + "", + ].join("\n"), + ); + console.log(`\n[demo] joining ${label} to ${council.name}…`); + const { code, stderr } = await runChild(["setup-pp.ts"], { + STATE_FILE: stateFile, + PP_SECRET: ppSk, + PP_LABEL: label, + PATH: Deno.env.get("PATH") ?? "", + HOME: Deno.env.get("HOME") ?? "", + }); + if (code !== 0) { + console.error( + `[demo] setup-pp failed for ${label} on ${council.name} (exit ${code}):`, + stderr.slice(-400), + ); + await Deno.remove(stateFile).catch(() => {}); + return false; + } + council.pps.push({ label, publicKey: ppPk, secretKey: ppSk, stateFile }); + console.log( + `[demo] ✓ ${label} joined ${council.name} (${council.pps.length} PP${ + council.pps.length === 1 ? "" : "s" + } total)`, + ); + return true; +} + +let sharedServer: rpc.Server | null = null; +function getRpc(): rpc.Server { + if (!sharedServer) { + sharedServer = new rpc.Server(RPC_URL, { + allowHttp: RPC_URL.startsWith("http://"), + }); + } + return sharedServer; +} + +async function removeRandomPP(): Promise { + const candidates = councils.filter((c) => c.pps.length > 0); + if (candidates.length === 0) { + console.warn("[demo] remove skipped — no councils with PPs yet"); + return false; + } + const council = pickRandom(candidates); + const idx = Math.floor(rng() * council.pps.length); + const pp = council.pps[idx]; + console.log(`\n[demo] removing ${pp.label} from ${council.name}…`); + try { + const admin = Keypair.fromSecret(council.adminSk); + await removeProvider( + getRpc(), + admin, + NETWORK_PASSPHRASE, + council.councilId, + pp.publicKey, + ); + } catch (err) { + console.error( + `[demo] remove_provider failed for ${pp.label} on ${council.name}:`, + err instanceof Error ? err.message : String(err), + ); + return false; + } + council.pps.splice(idx, 1); + await Deno.remove(pp.stateFile).catch(() => {}); + console.log( + `[demo] ✓ ${pp.label} removed from ${council.name} (${council.pps.length} PP${ + council.pps.length === 1 ? "" : "s" + } remain)`, + ); + return true; +} + +async function canonicalAvailable(): Promise { + try { + await Deno.stat(CANONICAL_STATE_FILE); + return true; + } catch { + return false; + } +} + +async function runTxCycle(): Promise { + // Per-cycle coin flip: with probability CANONICAL_TX_RATIO, drive the + // tx through the canonical PP (the operator key from setup-pp.sh, open + // in the user's provider-console). Otherwise pick a random demo PP with + // a valid state file. If the chosen path is unavailable, fall through + // to the other. + const demoPPs: Array<{ council: Council; pp: PP }> = []; + for (const c of councils) { + for (const pp of c.pps) demoPPs.push({ council: c, pp }); + } + const hasCanonical = await canonicalAvailable(); + const wantCanonical = rng() < CANONICAL_TX_RATIO; + let useCanonical: boolean; + if (wantCanonical && hasCanonical) useCanonical = true; + else if (!wantCanonical && demoPPs.length > 0) useCanonical = false; + else if (hasCanonical) useCanonical = true; + else if (demoPPs.length > 0) useCanonical = false; + else { + console.warn( + "[demo] tx skipped — no canonical state file and no demo PPs yet.", + ); + return false; + } + + let stateFile: string; + let label: string; + if (useCanonical) { + stateFile = CANONICAL_STATE_FILE; + label = "canonical PP"; + } else { + const pick = demoPPs[Math.floor(rng() * demoPPs.length)]; + stateFile = pick.pp.stateFile; + label = `${pick.pp.label} on ${pick.council.name}`; + } + + console.log( + `\n[demo] running tx cycle via ${label} (count=${TX_COUNT_PER_CYCLE})…`, + ); + const { code, stderr } = await runChild(["send-loop.ts"], { + STATE_FILE: stateFile, + COUNT: String(TX_COUNT_PER_CYCLE), + INTERVAL_MS: "800", + SEND_AMOUNT: "1", + PATH: Deno.env.get("PATH") ?? "", + HOME: Deno.env.get("HOME") ?? "", + }); + if (code !== 0) { + console.error( + `[demo] send-loop failed via ${label} (exit ${code}):`, + stderr.slice(-400), + ); + return false; + } + console.log( + `[demo] ✓ tx cycle complete via ${label} (deposit + ${TX_COUNT_PER_CYCLE} sends + withdraw)`, + ); + return true; +} + +/** + * Build the planned action list and shuffle it. + * + * Four action types with execution-time dependencies: + * - create : no dependency + * - join : at least one council in the demo pool + * - remove : at least one demo council with ≥ 1 demo PP + * - tx : `.local-dev-state` exists (the canonical PP from setup-pp.sh) + * + * tx is independent of the demo's churn — it always shells out to send-loop + * against the operator PP the user already has registered with provider- + * platform. The dependency walk only reorders create→join→remove chains. + */ +type Action = "create" | "join" | "remove" | "tx"; + +function mulberry32(seed: number): () => number { + let a = seed | 0; + return () => { + a = (a + 0x6D2B79F5) | 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const rng = SEED_RAW ? mulberry32(Number(SEED_RAW)) : Math.random; + +function shufflePlan(): Action[] { + const plan: Action[] = [ + ...Array(NEW_COUNCILS).fill("create"), + ...Array(NEW_PPS).fill("join"), + ...Array(REMOVE_PPS).fill("remove"), + ...Array(TX_CYCLES).fill("tx"), + ]; + for (let i = plan.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [plan[i], plan[j]] = [plan[j], plan[i]]; + } + // Pull dependents forward when their prerequisites haven't run yet. + // Pass 1: ensure each join has a preceding create. + // Pass 2: ensure each remove/tx has BOTH a preceding create AND join. + const findAndSwap = (i: number, want: Action): boolean => { + for (let j = i + 1; j < plan.length; j++) { + if (plan[j] === want) { + [plan[i], plan[j]] = [plan[j], plan[i]]; + return true; + } + } + return false; + }; + let creates = 0; + for (let i = 0; i < plan.length; i++) { + if (plan[i] === "create") { + creates++; + continue; + } + if (creates === 0) findAndSwap(i, "create"); + if (plan[i] === "create") creates++; + } + let pps = 0; + for (let i = 0; i < plan.length; i++) { + if (plan[i] === "join") { + pps++; + continue; + } + if (plan[i] === "remove" && pps === 0) { + findAndSwap(i, "join"); + if (plan[i] === "join") pps++; + } + } + return plan; +} + +function pickRandom(arr: T[]): T { + return arr[Math.floor(rng() * arr.length)]; +} + +function pickWithFewestPPs(arr: Council[]): Council { + // Bias joins toward councils that don't have a PP yet so the topology + // spreads even with a small NEW_PPS budget. + let best = arr[0]; + for (const c of arr) if (c.pps.length < best.pps.length) best = c; + // 60% pick the leanest; 40% truly random for variety. + return rng() < 0.6 ? best : pickRandom(arr); +} + +const plan = shufflePlan(); +console.log("[demo] network-dashboard-demo starting."); +console.log(`[demo] dashboard: http://localhost:3040`); +console.log( + `[demo] plan: ${NEW_COUNCILS} create + ${NEW_PPS} join + ${REMOVE_PPS} remove + ${TX_CYCLES} tx = ${plan.length} actions`, +); +console.log(`[demo] order: ${plan.map((a) => a[0]).join("")}`); + +const successByAction: Record = { + create: 0, + join: 0, + remove: 0, + tx: 0, +}; +let failures = 0; +const start = Date.now(); + +for (let i = 0; i < plan.length; i++) { + const action = plan[i]; + const tag = `[demo iter ${i + 1}/${plan.length}]`; + try { + let ok = false; + if (action === "create") { + const c = await createCouncil(); + if (c) { + councils.push(c); + ok = true; + } + } else if (action === "join") { + if (councils.length === 0) { + console.warn(`${tag} skipping join — no councils in pool`); + } else { + const c = pickWithFewestPPs(councils); + ok = await joinPP(c); + } + } else if (action === "remove") { + ok = await removeRandomPP(); + } else if (action === "tx") { + ok = await runTxCycle(); + } + if (ok) successByAction[action]++; + else failures++; + } catch (err) { + console.error(`${tag} threw:`, err); + failures++; + } + if (i < plan.length - 1) { + const wait = SLEEP_MIN_MS + + Math.floor(rng() * (SLEEP_MAX_MS - SLEEP_MIN_MS)); + console.log(`${tag} sleeping ${wait}ms…`); + await sleep(wait); + } +} + +// Clean up PP state files we kept around for tx reuse. +for (const c of councils) { + for (const pp of c.pps) await Deno.remove(pp.stateFile).catch(() => {}); +} + +const elapsedSec = ((Date.now() - start) / 1000).toFixed(1); +console.log(""); +console.log("[demo] sequence complete:"); +console.log( + `[demo] created councils: ${successByAction.create}/${NEW_COUNCILS}`, +); +console.log(`[demo] joined PPs: ${successByAction.join}/${NEW_PPS}`); +console.log( + `[demo] removed PPs: ${successByAction.remove}/${REMOVE_PPS}`, +); +console.log(`[demo] tx cycles: ${successByAction.tx}/${TX_CYCLES}`); +console.log(`[demo] failures: ${failures}`); +console.log(`[demo] elapsed: ${elapsedSec}s`); diff --git a/scripts/verify-deploy.sh b/scripts/verify-deploy.sh index 7242d4b..e3dc44e 100755 --- a/scripts/verify-deploy.sh +++ b/scripts/verify-deploy.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # verify-deploy.sh — single-shot deploy-tag-drift checker. # -# Probes 14 deployed endpoints (6 backend /api/v1/health, 8 frontend /health.json, +# Probes 16 deployed endpoints (8 backend /api/v1/health, 8 frontend /health.json, # testnet + mainnet for each app), reads each originating repo's latest git tag, # and prints one row per endpoint. Exits 0 if every deployed version matches the # latest tag, 1 if any drift is detected, 2 if any endpoint is unreachable or @@ -19,6 +19,8 @@ ENDPOINTS=( "pay-platform|testnet|https://pay-api-testnet.moonlightprotocol.io/api/v1/health|pay-platform" "provider-platform|mainnet|https://provider-api.moonlightprotocol.io/api/v1/health|provider-platform" "provider-platform|testnet|https://provider-api-testnet.moonlightprotocol.io/api/v1/health|provider-platform" + "network-dashboard-platform|mainnet|https://dashboard-api.moonlightprotocol.io/api/v1/health|network-dashboard-platform" + "network-dashboard-platform|testnet|https://dashboard-api-testnet.moonlightprotocol.io/api/v1/health|network-dashboard-platform" "council-console|mainnet|https://council.moonlightprotocol.io/health.json|council-console" "council-console|testnet|https://council-testnet.moonlightprotocol.io/health.json|council-console" "provider-console|mainnet|https://provider.moonlightprotocol.io/health.json|provider-console" diff --git a/setup-c.ts b/setup-c.ts index d1951ac..f509773 100644 --- a/setup-c.ts +++ b/setup-c.ts @@ -297,26 +297,36 @@ async function main() { } console.log(` Channel added: ${channelContractId} (XLM)`); - const addJurisdictionRes = await fetch( - `${COUNCIL_URL}/api/v1/council/jurisdictions?councilId=${ - encodeURIComponent(channelAuthId) - }`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${adminJwt}`, + // JURISDICTION accepts a comma-separated list of ISO 3166-1 alpha-2 codes, + // e.g. "US,UY,GB". Each is POSTed individually because the council-platform + // jurisdictions endpoint takes one country per call. Defaults to "US" so + // existing single-code callers keep working. + const jurisdictions = (Deno.env.get("JURISDICTION") ?? "US") + .split(",") + .map((c) => c.trim().toUpperCase()) + .filter((c) => c.length > 0); + for (const jurisdiction of jurisdictions) { + const addJurisdictionRes = await fetch( + `${COUNCIL_URL}/api/v1/council/jurisdictions?councilId=${ + encodeURIComponent(channelAuthId) + }`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${adminJwt}`, + }, + body: JSON.stringify({ countryCode: jurisdiction }), }, - body: JSON.stringify({ countryCode: "US" }), - }, - ); - if (!addJurisdictionRes.ok) { - throw new Error( - `Add jurisdiction failed: ${addJurisdictionRes.status} ${await addJurisdictionRes - .text()}`, ); + if (!addJurisdictionRes.ok) { + throw new Error( + `Add jurisdiction ${jurisdiction} failed: ${addJurisdictionRes.status} ${await addJurisdictionRes + .text()}`, + ); + } + console.log(` Jurisdiction added: ${jurisdiction}`); } - console.log(" Jurisdiction added: US"); console.log("\n[8/8] Write state file"); await writeStateFile({