diff --git a/e2e/deno.lock b/e2e/deno.lock index 7f03d9e..7f19239 100644 --- a/e2e/deno.lock +++ b/e2e/deno.lock @@ -1,8 +1,62 @@ { "version": "5", "specifiers": { + "jsr:@colibri/core@~0.20.2": "0.20.2", + "jsr:@fifo/convee@1.0.0": "1.0.0", + "jsr:@moonlight/moonlight-sdk@0.8": "0.8.0", + "jsr:@noble/curves@^1.8.0": "1.9.0", + "jsr:@noble/hashes@1.8.0": "1.8.0", + "jsr:@noble/hashes@^1.6.1": "1.8.0", + "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@^15.0.1": "15.0.1" + "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", + "npm:buffer@^6.0.3": "6.0.3" + }, + "jsr": { + "@colibri/core@0.20.2": { + "integrity": "2cf091a7d0045b542a9b45097a57d068c5388542243c24d7804fdbc08f5a74e1", + "dependencies": [ + "jsr:@fifo/convee", + "jsr:@std/toml", + "npm:@stellar/stellar-sdk", + "npm:buffer@^6.0.3" + ] + }, + "@fifo/convee@1.0.0": { + "integrity": "b61bfa222b9b8a53f0f2af1f35148fd54d86afc2fc3d6b5d073d56aaa369d9a3" + }, + "@moonlight/moonlight-sdk@0.8.0": { + "integrity": "680e75432c2d84707a9d7bf9df9564304066eb08d5dbc88eafdfe167eecfd423", + "dependencies": [ + "jsr:@colibri/core", + "jsr:@noble/curves", + "jsr:@noble/hashes@^1.6.1", + "npm:@stellar/stellar-sdk", + "npm:asn1js", + "npm:buffer@6.0.3" + ] + }, + "@noble/curves@1.9.0": { + "integrity": "efa55b3375b755706462a083060ee91e1f79973568cb670f02e885538ed1661b", + "dependencies": [ + "jsr:@noble/hashes@1.8.0" + ] + }, + "@noble/hashes@1.8.0": { + "integrity": "b52a2fcb4d02f8d8137871564a31f1ee9e2b0d15eedabbf32d2f7333f0abc939" + }, + "@std/collections@1.1.7": { + "integrity": "56f659d011218a69740b12829cf5ea2c9b70bbed0af02597e27dc1eb5e69e208" + }, + "@std/toml@1.0.11": { + "integrity": "e084988b872ca4bad6aedfb7350f6eeed0e8ba88e9ee5e1590621c5b5bb8f715", + "dependencies": [ + "jsr:@std/collections" + ] + } }, "npm": { "@noble/curves@1.9.7": { @@ -46,6 +100,14 @@ ], "bin": true }, + "asn1js@3.0.5": { + "integrity": "sha512-FVnvrKJwpt9LP2lAMl8qZswRNm3T4q9CON+bxldk2iwk3FFpuwhx2FfinyitizWHsVYyaY+y5JzDR0rCMV5yTQ==", + "dependencies": [ + "pvtsutils", + "pvutils", + "tslib" + ] + }, "asynckit@0.4.0": { "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, @@ -267,6 +329,15 @@ "proxy-from-env@2.1.0": { "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, + "pvtsutils@1.3.6": { + "integrity": "sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==", + "dependencies": [ + "tslib" + ] + }, + "pvutils@1.1.5": { + "integrity": "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA==" + }, "randombytes@2.1.0": { "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", "dependencies": [ @@ -307,6 +378,9 @@ "toml@3.0.0": { "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "typed-array-buffer@1.0.3": { "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", "dependencies": [ @@ -336,7 +410,8 @@ "jsr:@colibri/core@~0.20.2", "jsr:@moonlight/moonlight-sdk@0.8", "npm:@opentelemetry/api@^1.9.0", - "npm:@stellar/stellar-sdk@^15.0.1" + "npm:@stellar/stellar-sdk@^15.0.1", + "npm:buffer@6.0.3" ] } } diff --git a/e2e/main.ts b/e2e/main.ts index 293174e..a1b36d5 100644 --- a/e2e/main.ts +++ b/e2e/main.ts @@ -24,6 +24,25 @@ async function fundAccount( } } +async function registerEntity( + providerUrl: string, + pubkey: string, + name: string, +): Promise { + const res = await fetch(`${providerUrl}/api/v1/entities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey, name, jurisdictions: [] }), + }); + // Idempotent: 409 means already APPROVED, treat as success. + if (!res.ok && res.status !== 409) { + throw new Error( + `Entity registration failed for ${pubkey}: ${res.status} ${await res + .text()}`, + ); + } +} + async function main() { const startTime = Date.now(); @@ -66,6 +85,15 @@ async function main() { ); console.log(` Bob authenticated`); + // Step 4b: Register Alice + Bob as APPROVED entities (KYC/KYB). Bundle + // submission now gates on the submitter's entity being APPROVED. + console.log("\n[4b/8] Registering Alice + Bob as APPROVED entities..."); + await withE2ESpan("e2e.register_entities", async () => { + await registerEntity(config.providerUrl, alice.publicKey(), "Alice"); + await registerEntity(config.providerUrl, bob.publicKey(), "Bob"); + }); + console.log(" Entities registered"); + // Step 5: Alice deposits into channel console.log(`\n[5/8] Alice depositing ${DEPOSIT_AMOUNT} XLM into channel...`); await withE2ESpan( diff --git a/e2e/setup.sh b/e2e/setup.sh index 61f96df..0449fbf 100755 --- a/e2e/setup.sh +++ b/e2e/setup.sh @@ -136,6 +136,10 @@ E2E_CHANNEL_ASSET_CONTRACT_ID=$TOKEN_ID E2E_PROVIDER_PK=$PROVIDER_PK E2E_PROVIDER_SK=$PROVIDER_SK E2E_TREASURY_PK=$TREASURY_PK +# Bundles are URL-scoped to /providers/:ppPublicKey/bundles. lib/client/config.ts +# requires E2E_PP_PUBLIC_KEY for every submission; in the single-PP e2e harness +# it is the same as E2E_PROVIDER_PK. +E2E_PP_PUBLIC_KEY=$PROVIDER_PK EOF # --- Write seed.json for provider DB seeding --- diff --git a/lib/client/bundle.ts b/lib/client/bundle.ts index 70cd27a..fe5a1c3 100644 --- a/lib/client/bundle.ts +++ b/lib/client/bundle.ts @@ -1,23 +1,19 @@ import type { Config } from "./config.ts"; import { withE2ESpan } from "./tracer.ts"; -export interface SubmitBundleOptions { - jurisdictionFrom?: string; - jurisdictionTo?: string; -} - export function submitBundle( jwt: string, operationsMLXDR: string[], config: Config, - options: SubmitBundleOptions = {}, ): Promise { return withE2ESpan("bundle.submit", async () => { const maxRetries = 10; const retryDelayMs = 5_000; + const url = + `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles`; for (let attempt = 0; attempt < maxRetries; attempt++) { - const res = await fetch(`${config.providerUrl}/api/v1/bundle`, { + const res = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json", @@ -26,17 +22,11 @@ export function submitBundle( body: JSON.stringify({ operationsMLXDR, channelContractId: config.channelContractId, - ...(options.jurisdictionFrom !== undefined - ? { jurisdictionFrom: options.jurisdictionFrom } - : {}), - ...(options.jurisdictionTo !== undefined - ? { jurisdictionTo: options.jurisdictionTo } - : {}), }), }); if (res.status === 429) { - await res.text(); // drain body + await res.text(); await new Promise((r) => setTimeout(r, retryDelayMs)); continue; } @@ -66,16 +56,16 @@ export function waitForBundle( ): Promise { return withE2ESpan("bundle.wait", async () => { const start = Date.now(); + const url = + `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles/${bundleId}`; while (Date.now() - start < timeoutMs) { await new Promise((r) => setTimeout(r, pollIntervalMs)); - const res = await fetch( - `${config.providerUrl}/api/v1/bundle/${bundleId}`, - { headers: { "Authorization": `Bearer ${jwt}` } }, - ); + const res = await fetch(url, { + headers: { "Authorization": `Bearer ${jwt}` }, + }); - // Retry on rate limit if (res.status === 429) { await new Promise((r) => setTimeout(r, pollIntervalMs)); continue; diff --git a/lib/client/config.ts b/lib/client/config.ts index 703699f..4086612 100644 --- a/lib/client/config.ts +++ b/lib/client/config.ts @@ -7,6 +7,8 @@ export interface Config { horizonUrl: string; friendbotUrl: string; providerUrl: string; + /** PP that owns this bundle's processing — encoded into the URL. */ + ppPublicKey: string; channelContractId: ContractId; channelAuthId: ContractId; channelAssetContractId: ContractId; @@ -74,12 +76,22 @@ export function loadConfig(): Config { const providerSecretKey = Deno.env.get("E2E_PROVIDER_SK") ?? env["E2E_PROVIDER_SK"]; + const ppPublicKey = Deno.env.get("E2E_PP_PUBLIC_KEY") ?? + env["E2E_PP_PUBLIC_KEY"] ?? ""; + if (!ppPublicKey) { + throw new Error( + "Missing E2E_PP_PUBLIC_KEY — bundle submissions are URL-scoped per PP. " + + "Set E2E_PP_PUBLIC_KEY before loading the config.", + ); + } + return { networkPassphrase, rpcUrl, horizonUrl, friendbotUrl, providerUrl, + ppPublicKey, channelContractId, channelAuthId, channelAssetContractId, diff --git a/lib/client/deposit.ts b/lib/client/deposit.ts index 0934bdd..309f286 100644 --- a/lib/client/deposit.ts +++ b/lib/client/deposit.ts @@ -60,7 +60,7 @@ export async function deposit( // 5. Submit bundle const operationsMLXDR = [depositOp.toMLXDR(), createOp.toMLXDR()]; - const bundleId = await submitBundle(jwt, operationsMLXDR, config, options); + const bundleId = await submitBundle(jwt, operationsMLXDR, config); console.log(` Bundle submitted: ${bundleId}`); await waitForBundle(jwt, bundleId, config); diff --git a/lib/client/fail-inject.ts b/lib/client/fail-inject.ts new file mode 100644 index 0000000..1303a6d --- /dev/null +++ b/lib/client/fail-inject.ts @@ -0,0 +1,82 @@ +import { + MoonlightOperation, + type MoonlightTracer, + UTXOStatus, +} from "@moonlight/moonlight-sdk"; +import type { Config } from "./config.ts"; +import { getLatestLedger, setupAccount } from "./account.ts"; +import { submitBundle } from "./bundle.ts"; +import { deposit } from "./deposit.ts"; + +/** + * Submits a bundle that the server admits (entity APPROVED, channel + * membership OK, signatures valid, UTXO exists on chain) but the executor's + * on-chain simulation rejects. Result: bundle settles as FAILED. + * + * Trick: we deposit a small known amount to mint a fresh UNSPENT UTXO, + * then build a SPEND of that UTXO with a CREATE condition for 100× the + * amount. The server's pre-validation passes (UTXO exists, signatures + * verify) but the on-chain channel contract enforces sum(SPEND) == + * sum(CREATE) and rejects. + */ +export async function injectFailingBundle( + senderSecret: string, + jwt: string, + config: Config, + tracer?: MoonlightTracer, +): Promise { + // 0.5 + 0.05 deposit fee = 0.55 (clean float). Using 0.1 would produce + // 0.15000000000000002 inside Colibri's fromDecimals and trip its + // fractional-digits guard before the bundle is ever submitted. + const FAIL_DEPOSIT = 0.5; + + // 1. Deposit a small amount so the executor sees an on-chain UTXO. The + // deposit completes normally — we'll overspend its product UTXO next. + await deposit(senderSecret, FAIL_DEPOSIT, jwt, config, tracer); + + // 2. Re-derive the account so the new UNSPENT UTXO is loaded. + const { accountHandler } = await setupAccount( + senderSecret, + config, + 2, + tracer, + ); + + const unspent = accountHandler.getUTXOsByState(UTXOStatus.UNSPENT); + const source = unspent.find((u) => u.balance > 0n); + if (!source) { + throw new Error( + "No UNSPENT UTXO found after deposit — can't forge overspend", + ); + } + + // 3. Build a CREATE for source.balance × 2. The on-chain channel contract + // enforces sum(SPEND) == sum(CREATE), so the executor's fee aggregation + // rejects (sum-of-spends − sum-of-creates < 0 → MoonlightOperation.create + // asserts amount > 0). Using 2× keeps the amount a clean fixed-point + // multiple (avoids the SDK's float-precision-fractional-digits guard + // that trips on, e.g., balance + 1n stroop). + const free = accountHandler.getUTXOsByState(UTXOStatus.FREE); + if (free.length < 1) { + throw new Error("No FREE UTXO available to sink the overspend CREATE"); + } + const sink = free[0]; + const createOp = MoonlightOperation.create( + sink.publicKey, + source.balance * 2n, + ); + + const ledgerSequence = await getLatestLedger(config.rpcUrl); + const expiration = ledgerSequence + 1000; + + let spendOp = MoonlightOperation.spend(source.publicKey); + spendOp = spendOp.addCondition(createOp.toCondition()); + const signedSpend = await spendOp.signWithUTXO( + source, + config.channelContractId, + expiration, + ); + + const operationsMLXDR = [createOp.toMLXDR(), signedSpend.toMLXDR()]; + return await submitBundle(jwt, operationsMLXDR, config); +} diff --git a/lib/client/send.ts b/lib/client/send.ts index 14465fd..af8ad65 100644 --- a/lib/client/send.ts +++ b/lib/client/send.ts @@ -12,6 +12,10 @@ const SEND_FEE = 0.1; // LOW entropy fee export interface SendOptions { jurisdictionFrom?: string; jurisdictionTo?: string; + // When false, return the bundle id immediately after submission without + // awaiting completion. Used by send-loop's expire mode so it can force- + // expire the bundle before it settles. + waitForCompletion?: boolean; } export async function send( @@ -22,7 +26,7 @@ export async function send( config: Config, tracer?: MoonlightTracer, options: SendOptions = {}, -): Promise { +): Promise { const feeBigInt = fromDecimals(SEND_FEE, 7); const amountBigInt = fromDecimals(amount, 7); const totalToSpend = amountBigInt + feeBigInt; @@ -92,9 +96,13 @@ export async function send( ...createOps.map((op) => op.toMLXDR()), ...spendOps.map((op) => op.toMLXDR()), ]; - const bundleId = await submitBundle(jwt, operationsMLXDR, config, options); + const bundleId = await submitBundle(jwt, operationsMLXDR, config); console.log(` Bundle submitted: ${bundleId}`); + if (options.waitForCompletion === false) { + return bundleId; + } await waitForBundle(jwt, bundleId, config); console.log(` Bundle completed`); + return bundleId; } diff --git a/lib/client/withdraw.ts b/lib/client/withdraw.ts index bab4365..c9abf1d 100644 --- a/lib/client/withdraw.ts +++ b/lib/client/withdraw.ts @@ -89,7 +89,7 @@ export async function withdraw( ...changeCreateOps.map((op) => op.toMLXDR()), ...spendOps.map((op) => op.toMLXDR()), ]; - const bundleId = await submitBundle(jwt, operationsMLXDR, config, options); + const bundleId = await submitBundle(jwt, operationsMLXDR, config); console.log(` Bundle submitted: ${bundleId}`); await waitForBundle(jwt, bundleId, config); diff --git a/lifecycle/main.ts b/lifecycle/main.ts index 6d324f2..52830a4 100644 --- a/lifecycle/main.ts +++ b/lifecycle/main.ts @@ -53,6 +53,25 @@ async function waitForFriendbot(): Promise { throw new Error("Friendbot did not become ready after 180s"); } +async function registerEntity( + providerUrl: string, + pubkey: string, + name: string, +): Promise { + const res = await fetch(`${providerUrl}/api/v1/entities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey, name, jurisdictions: [] }), + }); + // 409 = already APPROVED; treat as success for idempotency. + if (!res.ok && res.status !== 409) { + throw new Error( + `Entity registration failed for ${pubkey}: ${res.status} ${await res + .text()}`, + ); + } +} + async function fundAccount(publicKey: string): Promise { const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); if (!res.ok) { @@ -221,6 +240,9 @@ async function main() { horizonUrl, friendbotUrl: FRIENDBOT_URL, providerUrl: PROVIDER_URL, + // Bundles are URL-scoped: /providers/:ppPublicKey/bundles. Lifecycle + // runs against a single seeded PP, so ppPublicKey == provider.publicKey(). + ppPublicKey: provider.publicKey(), channelContractId: channelContractId as ContractId, channelAuthId: channelAuthId as ContractId, channelAssetContractId: assetContractId as ContractId, @@ -242,6 +264,8 @@ async function main() { console.log(`\n[4/7] Deposit (${DEPOSIT_AMOUNT} XLM)`); const aliceJwt = await authenticate(alice, e2eConfig); console.log(" Alice authenticated"); + await registerEntity(PROVIDER_URL, alice.publicKey(), "Alice"); + console.log(" Alice approved as entity"); await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, e2eConfig); console.log(" Deposit complete"); @@ -249,6 +273,8 @@ async function main() { console.log(`\n[5/7] Send (${SEND_AMOUNT} XLM)`); const bobJwt = await authenticate(bob, e2eConfig); console.log(" Bob authenticated"); + await registerEntity(PROVIDER_URL, bob.publicKey(), "Bob"); + console.log(" Bob approved as entity"); const receiverOps = await prepareReceive( bob.secret(), SEND_AMOUNT, diff --git a/network-dashboard-demo.ts b/network-dashboard-demo.ts index a087e7f..c4cfb8d 100644 --- a/network-dashboard-demo.ts +++ b/network-dashboard-demo.ts @@ -1,595 +1,109 @@ /** - * network-dashboard-demo + * Local Dev — Network Dashboard Demo (multi-country) * - * 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: + * The councils + PPs are now created statically by setup-c.sh + setup-pp.sh + * (3 councils, 12 PPs — one per country). This demo just orchestrates + * send-loop runs across every country so the provider-console dashboards + * (one per PP) fill with realistic activity. * - * - 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. + * For each country in the fleet: + * shell out to send-loop.ts with TARGET_COUNTRY= + * → registers Alicia + Roberto as entities, then deposit/sends/withdraw * * 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 + * - up.sh has run + * - setup-c.sh has run (3 councils) + * - setup-pp.sh has run (12 PPs) * - * 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". + * Env overrides: + * ONLY_COUNTRIES comma-separated list, e.g. "AR,BR,US" — runs only those. + * Default: every country present in .local-dev-state. + * STATE_FILE default ./.local-dev-state */ -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") ?? +const STATE_FILE = Deno.env.get("STATE_FILE") ?? 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)]; -} +const ONLY_COUNTRIES = (Deno.env.get("ONLY_COUNTRIES") ?? "") + .split(",") + .map((c) => c.trim().toUpperCase()) + .filter((c) => c.length > 0); 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), + const map: Record = {}; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const eq = trimmed.indexOf("="); + if (eq === -1) continue; + map[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return map; +} + +function loadCountries(): string[] { + const map = parseStateFile(Deno.readTextFileSync(STATE_FILE)); + const count = Number(map.PP_COUNT ?? "0"); + if (!count) { + throw new Error( + `State file ${STATE_FILE} has PP_COUNT=0. Re-run setup-pp.sh.`, ); - 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 all: string[] = []; + for (let i = 1; i <= count; i++) { + const j = map[`PP_${i}_JURISDICTION`]; + if (j) all.push(j.toUpperCase()); } - 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; + if (ONLY_COUNTRIES.length > 0) { + return all.filter((c) => ONLY_COUNTRIES.includes(c)); } - 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; + return all; } -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") ?? "", +async function runSendLoopFor(country: string): Promise { + const here = new URL("./", import.meta.url).pathname; + const cmd = new Deno.Command("bash", { + args: [`${here}send-loop.sh`], + env: { TARGET_COUNTRY: country }, + stdout: "inherit", + stderr: "inherit", }); + const { code } = await cmd.output(); 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; + throw new Error(`send-loop failed for ${country} (exit ${code})`); + } +} + +async function main(): Promise { + const startTime = Date.now(); + console.log("\n=== local-dev — Network Dashboard Demo ===\n"); + const countries = loadCountries(); + if (countries.length === 0) { + throw new Error("No countries found in state file."); + } + console.log(` Will run send-loop across ${countries.length} countries:`); + console.log(` ${countries.join(", ")}\n`); + + let ok = 0; + let fail = 0; + for (let i = 0; i < countries.length; i++) { + const c = countries[i]; + console.log(`\n──── [${i + 1}/${countries.length}] ${c} ────`); + try { + await runSendLoopFor(c); + ok++; + } catch (err) { + fail++; + console.error(` ✗ ${c} failed:`, err); } - 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 elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`\n=== Demo complete in ${elapsed}s ===`); + console.log(` Succeeded: ${ok}/${countries.length}`); + console.log(` Failed: ${fail}/${countries.length}`); } -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`); +main().catch((err) => { + console.error("\n=== Demo FAILED ==="); + console.error(err); + Deno.exit(1); +}); diff --git a/send-loop.ts b/send-loop.ts index d4eec76..7be046a 100644 --- a/send-loop.ts +++ b/send-loop.ts @@ -1,37 +1,28 @@ /** - * Local Dev — Alice→Bob send loop with deposit + withdraw + * Local Dev — All-countries send loop * - * Each run does one full lifecycle: Alice deposits into the channel, performs - * N sends to Bob (sleeping INTERVAL_MS between each), then Bob withdraws back - * to his real Stellar address. Useful for watching the provider-console - * dashboard fill up across all event kinds. + * One invocation walks every country registered in .local-dev-state. For + * each country the loop runs a full cycle: deposit → COUNT sends → one + * FAILED bundle (overspend) → one EXPIRED bundle (force-expire) → withdraw. * - * Each bundle is tagged with a random from/to pair drawn from the council's - * accepted jurisdictions so the dashboard has flag data to render. - * - * Reuses the lib/client helpers so the bundles travel the same code path as - * the e2e suite. - * - * Prereqs: ./up.sh → ./setup-c.sh → ./setup-pp.sh has run. - * - * Usage (preferred — via wrapper): - * ./send-loop.sh - * - * Usage (direct): - * deno run --allow-all send-loop.ts + * Prereqs: ./up.sh → ./setup-c.sh → ./setup-pp.sh * * Env overrides: - * COUNT default 5 number of sends in the cycle - * INTERVAL_MS default 1000 pause between sends - * SEND_AMOUNT default 1 XLM per send - * STATE_FILE default ./.local-dev-state + * COUNT default 5 normal sends per country + * INTERVAL_MS default 1000 pause between sends within a country + * SEND_AMOUNT default 1 XLM per send + * FAIL default true inject one FAILED bundle per country + * EXPIRE default true inject one EXPIRED bundle per country + * STATE_FILE default ./.local-dev-state */ +import { Buffer } from "node:buffer"; import { Keypair } from "stellar-sdk"; import { authenticate } from "./lib/client/auth.ts"; import { loadConfig } from "./lib/client/config.ts"; import { deposit } from "./lib/client/deposit.ts"; import { prepareReceive } from "./lib/client/receive.ts"; import { send } from "./lib/client/send.ts"; +import { injectFailingBundle } from "./lib/client/fail-inject.ts"; import { withdraw } from "./lib/client/withdraw.ts"; const STATE_FILE = Deno.env.get("STATE_FILE") ?? @@ -39,80 +30,281 @@ const STATE_FILE = Deno.env.get("STATE_FILE") ?? const COUNT = Number(Deno.env.get("COUNT") ?? "5"); const INTERVAL_MS = Number(Deno.env.get("INTERVAL_MS") ?? "1000"); const SEND_AMOUNT = Number(Deno.env.get("SEND_AMOUNT") ?? "1"); -const WITHDRAW_AMOUNT = 0.5; // less than SEND_AMOUNT so it fits in one UTXO + fee - -const DEPOSIT_BUFFER = 2; // headroom for per-send fees +const WITHDRAW_AMOUNT = 0.5; +const DEPOSIT_BUFFER = 2; +// Each cycle injects one failing and one expiring bundle in addition to the +// normal sends, so dashboards always see a mix of stages. Opt out with +// FAIL=false / EXPIRE=false. +const INJECT_FAIL = (Deno.env.get("FAIL") ?? "true").toLowerCase() !== "false"; +const INJECT_EXPIRE = + (Deno.env.get("EXPIRE") ?? "true").toLowerCase() !== "false"; -type ParsedState = { - councilId: string; - councilUrl: string; +// First-name pools per country. send-loop picks two random distinct names per +// cycle so dashboards see entity variety beyond "Alicia / Roberto". +const ENTITY_NAMES_BY_COUNTRY: Record = { + // Mercosur (Spanish / Portuguese) + AR: [ + "Alicia", + "Roberto", + "Carmen", + "Diego", + "Sofía", + "Mateo", + "Lucía", + "Joaquín", + ], + BR: [ + "Mariana", + "Pedro", + "Beatriz", + "Lucas", + "Camila", + "Felipe", + "Larissa", + "Gustavo", + ], + UY: ["Valentina", "Sebastián", "Florencia", "Tomás", "Catalina", "Nicolás"], + PY: ["Carolina", "Andrés", "Daniela", "Pablo", "Verónica", "Hugo"], + // Europe + GB: [ + "Emma", + "Oliver", + "Sophia", + "Harry", + "Lily", + "James", + "Charlotte", + "Henry", + ], + FR: [ + "Camille", + "Antoine", + "Léa", + "Hugo", + "Manon", + "Lucas", + "Clara", + "Nathan", + ], + DE: ["Sophie", "Maximilian", "Anna", "Felix", "Mia", "Leon", "Emma", "Paul"], + ES: [ + "Sara", + "Javier", + "Paula", + "Alejandro", + "Lucía", + "Mario", + "Marta", + "David", + ], + IT: [ + "Giulia", + "Lorenzo", + "Sofia", + "Matteo", + "Aurora", + "Andrea", + "Martina", + "Riccardo", + ], + // North America + US: ["Emily", "Michael", "Olivia", "David", "Ava", "James", "Mia", "John"], + MX: [ + "Sofía", + "Diego", + "Valentina", + "Santiago", + "Ximena", + "Mateo", + "Renata", + "Sebastián", + ], + CA: ["Emma", "Liam", "Olivia", "Noah", "Ava", "Ethan", "Isabella", "Mason"], }; -function loadState(): ParsedState { - const content = Deno.readTextFileSync(STATE_FILE); +interface CouncilEntry { + id: string; + name: string; + channel: string; + jurisdictions: string[]; +} + +interface PpEntry { + index: number; + pk: string; + sk: string; + name: string; + councilIndex: number; // 1-based as stored + jurisdiction: string; +} + +interface State { + COUNCIL_URL: string; + PROVIDER_URL: string; + NETWORK_PASSPHRASE?: string; + RPC_URL?: string; + FRIENDBOT_URL?: string; + ASSET_ID: string; + OPERATOR_PK?: string; + OPERATOR_SK?: string; + councils: CouncilEntry[]; + pps: PpEntry[]; +} + +function parseStateFile(content: string): Record { const map: Record = {}; for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eq = trimmed.indexOf("="); if (eq === -1) continue; - map[trimmed.slice(0, eq)] = trimmed.slice(eq + 1); - } - // Set env vars the SDK config loader expects. - if (map.CHANNEL_ID) Deno.env.set("E2E_CHANNEL_CONTRACT_ID", map.CHANNEL_ID); - if (map.COUNCIL_ID) Deno.env.set("E2E_CHANNEL_AUTH_ID", map.COUNCIL_ID); - if (map.ASSET_ID) Deno.env.set("E2E_CHANNEL_ASSET_CONTRACT_ID", map.ASSET_ID); - if (map.PROVIDER_URL) Deno.env.set("PROVIDER_URL", map.PROVIDER_URL); - if (map.NETWORK_PASSPHRASE) { - Deno.env.set("STELLAR_NETWORK_PASSPHRASE", map.NETWORK_PASSPHRASE); - } - if (map.RPC_URL) Deno.env.set("STELLAR_RPC_URL", map.RPC_URL); - if (map.FRIENDBOT_URL) Deno.env.set("FRIENDBOT_URL", map.FRIENDBOT_URL); - return { councilId: map.COUNCIL_ID, councilUrl: map.COUNCIL_URL }; + map[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim(); + } + return map; } -async function fetchAcceptedJurisdictions( - state: ParsedState, -): Promise { - const url = `${state.councilUrl}/api/v1/public/council?councilId=${ - encodeURIComponent(state.councilId) - }`; - const res = await fetch(url); - if (!res.ok) { +function loadState(): State { + const content = Deno.readTextFileSync(STATE_FILE); + const map = parseStateFile(content); + + const councilCount = Number(map.COUNCIL_COUNT ?? "0"); + if (!councilCount) { throw new Error( - `Council summary fetch failed: ${res.status} ${await res.text()}`, + `State file ${STATE_FILE} has COUNCIL_COUNT=0. Re-run setup-c.sh.`, ); } - const { data } = await res.json(); - const councilCodes: string[] = (data?.jurisdictions ?? []).map( - (j: { countryCode: string }) => j.countryCode, - ); - const providerCodes: string[] = (data?.providers ?? []).flatMap(( - p: { jurisdictions: string[] | null }, - ) => p.jurisdictions ?? []); - const merged = Array.from( - new Set([...councilCodes, ...providerCodes].map((c) => c.toUpperCase())), - ); - if (merged.length === 0) { + const councils: CouncilEntry[] = []; + for (let i = 1; i <= councilCount; i++) { + councils.push({ + id: map[`COUNCIL_${i}_ID`], + name: map[`COUNCIL_${i}_NAME`], + channel: map[`COUNCIL_${i}_CHANNEL`], + jurisdictions: (map[`COUNCIL_${i}_JURISDICTIONS`] ?? "").split(",") + .filter((j) => j), + }); + } + + const ppCount = Number(map.PP_COUNT ?? "0"); + if (!ppCount) { + throw new Error( + `State file ${STATE_FILE} has PP_COUNT=0. Re-run setup-pp.sh.`, + ); + } + const pps: PpEntry[] = []; + for (let i = 1; i <= ppCount; i++) { + pps.push({ + index: i, + pk: map[`PP_${i}_PK`], + sk: map[`PP_${i}_SK`], + name: map[`PP_${i}_NAME`], + councilIndex: Number(map[`PP_${i}_COUNCIL_INDEX`]), + jurisdiction: map[`PP_${i}_JURISDICTION`], + }); + } + + return { + COUNCIL_URL: map.COUNCIL_URL, + PROVIDER_URL: map.PROVIDER_URL ?? "http://localhost:3010", + NETWORK_PASSPHRASE: map.NETWORK_PASSPHRASE, + RPC_URL: map.RPC_URL, + FRIENDBOT_URL: map.FRIENDBOT_URL, + ASSET_ID: map.ASSET_ID, + OPERATOR_PK: map.OPERATOR_PK, + OPERATOR_SK: map.OPERATOR_SK, + councils, + pps, + }; +} + +// Wallet-auth flow for the operator dashboard JWT (SEP-43 challenge/verify). +// Mirrors local-dev/setup-pp.ts walletAuth(), inlined to avoid a one-off +// shared util. +async function dashboardWalletAuth( + providerUrl: string, + operator: Keypair, +): Promise { + const base = `${providerUrl}/api/v1/dashboard/auth`; + const challengeRes = await fetch(`${base}/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ publicKey: operator.publicKey() }), + }); + if (!challengeRes.ok) { throw new Error( - "No jurisdictions known; setup-c.sh seeds US and setup-pp.sh claims UY — re-run them.", + `Operator challenge failed: ${challengeRes.status} ${await challengeRes + .text()}`, ); } - return merged; + const { data: { nonce } } = await challengeRes.json(); + const nonceBytes = Uint8Array.from(atob(nonce), (c) => c.charCodeAt(0)); + const sig = operator.sign(Buffer.from(nonceBytes)); + const signature = btoa(String.fromCharCode(...new Uint8Array(sig))); + const verifyRes = await fetch(`${base}/verify`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nonce, + signature, + publicKey: operator.publicKey(), + }), + }); + if (!verifyRes.ok) { + throw new Error( + `Operator verify failed: ${verifyRes.status} ${await verifyRes.text()}`, + ); + } + const { data: { token } } = await verifyRes.json(); + return token as string; } -function pickRandom(arr: T[]): T { - return arr[Math.floor(Math.random() * arr.length)]; +async function forceExpireBundles( + bundleIds: string[], + state: State, +): Promise { + if (!state.OPERATOR_SK) { + throw new Error( + "OPERATOR_SK missing from state — re-run setup-pp.sh so the operator JWT can be obtained.", + ); + } + const operator = Keypair.fromSecret(state.OPERATOR_SK); + const jwt = await dashboardWalletAuth(state.PROVIDER_URL, operator); + const res = await fetch( + `${state.PROVIDER_URL}/api/v1/dashboard/bundles/expire`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${jwt}`, + }, + body: JSON.stringify({ bundleIds }), + }, + ); + if (!res.ok) { + throw new Error( + `Force-expire failed: ${res.status} ${await res.text()}`, + ); + } + const json = await res.json(); + console.log(` Expire response: ${JSON.stringify(json.data ?? json)}`); } -function jurisdictionsFor(accepted: string[]): { - jurisdictionFrom: string; - jurisdictionTo: string; -} { - return { - jurisdictionFrom: pickRandom(accepted), - jurisdictionTo: pickRandom(accepted), - }; +async function registerEntity( + providerUrl: string, + pubkey: string, + name: string, + jurisdictions: string[], +): Promise { + const res = await fetch(`${providerUrl}/api/v1/entities`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey, name, jurisdictions }), + }); + if (!res.ok && res.status !== 409) { + throw new Error( + `Entity registration failed for ${pubkey}: ${res.status} ${await res + .text()}`, + ); + } } async function fund(friendbotUrl: string, publicKey: string): Promise { @@ -124,89 +316,203 @@ async function fund(friendbotUrl: string, publicKey: string): Promise { } } -async function main(): Promise { - const state = loadState(); +async function runCycleForPp( + state: State, + pp: PpEntry, +): Promise { + const country = pp.jurisdiction.toUpperCase(); + const council = state.councils[pp.councilIndex - 1]; + if (!council) { + throw new Error( + `PP ${pp.name} references council index ${pp.councilIndex} but state only has ${state.councils.length} councils.`, + ); + } + + // Wire env vars that lib/client/config.ts consumes. These are mutated per + // country since each PP/council pair uses a different channel. + Deno.env.set("E2E_CHANNEL_CONTRACT_ID", council.channel); + Deno.env.set("E2E_CHANNEL_AUTH_ID", council.id); + Deno.env.set("E2E_CHANNEL_ASSET_CONTRACT_ID", state.ASSET_ID); + Deno.env.set("PROVIDER_URL", state.PROVIDER_URL); + Deno.env.set("E2E_PP_PUBLIC_KEY", pp.pk); + if (state.NETWORK_PASSPHRASE) { + Deno.env.set("STELLAR_NETWORK_PASSPHRASE", state.NETWORK_PASSPHRASE); + } + if (state.RPC_URL) Deno.env.set("STELLAR_RPC_URL", state.RPC_URL); + if (state.FRIENDBOT_URL) Deno.env.set("FRIENDBOT_URL", state.FRIENDBOT_URL); + const config = loadConfig(); - const accepted = await fetchAcceptedJurisdictions(state); - - console.log("\n=== local-dev — Alice→Bob send loop ===\n"); - console.log(` Count: ${COUNT}`); - console.log(` Interval: ${INTERVAL_MS}ms`); - console.log(` Send amount: ${SEND_AMOUNT} XLM`); - console.log(` Withdraw amount: ${WITHDRAW_AMOUNT} XLM`); - console.log(` Provider: ${config.providerUrl}`); - console.log(` Jurisdictions: ${accepted.join(", ")}`); - - const alice = Keypair.random(); - const bob = Keypair.random(); - console.log(` Alice: ${alice.publicKey()}`); - console.log(` Bob: ${bob.publicKey()}\n`); - - console.log("[1/5] Funding Alice + Bob via Friendbot"); - await fund(config.friendbotUrl, alice.publicKey()); - await fund(config.friendbotUrl, bob.publicKey()); - - console.log("[2/5] Authenticating both with provider"); - const aliceJwt = await authenticate(alice, config); - const bobJwt = await authenticate(bob, config); - - const depositAmount = SEND_AMOUNT * COUNT + DEPOSIT_BUFFER; - const depositJurisdictions = jurisdictionsFor(accepted); - console.log( - `[3/5] Alice depositing ${depositAmount} XLM (${depositJurisdictions.jurisdictionFrom}→${depositJurisdictions.jurisdictionTo})`, + + console.log("\n=== local-dev — Alicia → Roberto send loop ===\n"); + console.log(` Country: ${country}`); + console.log(` PP: ${pp.name}`); + console.log(` PP pubkey: ${pp.pk}`); + console.log(` Council: ${council.name}`); + console.log(` Channel: ${council.channel}`); + console.log(` Provider URL: ${state.PROVIDER_URL}`); + console.log(` Cycle: ${COUNT} sends × ${SEND_AMOUNT} XLM`); + console.log(""); + + // Pick two distinct random names from the country's pool. + const namePool = ENTITY_NAMES_BY_COUNTRY[country] ?? ["Alicia", "Roberto"]; + const shuffled = [...namePool].sort(() => Math.random() - 0.5); + const senderFirstName = shuffled[0]; + const receiverFirstName = shuffled[1] ?? shuffled[0]; + const senderName = `${senderFirstName} ${country}`; + const receiverName = `${receiverFirstName} ${country}`; + + const alicia = Keypair.random(); + const roberto = Keypair.random(); + console.log(` ${senderName}: ${alicia.publicKey()}`); + console.log(` ${receiverName}: ${roberto.publicKey()}\n`); + + console.log("[1/5] Funding via Friendbot"); + await fund(config.friendbotUrl, alicia.publicKey()); + await fund(config.friendbotUrl, roberto.publicKey()); + + console.log("[2/5] Authenticating to provider"); + const aliciaJwt = await authenticate(alicia, config); + const robertoJwt = await authenticate(roberto, config); + + console.log("[2b/5] Registering KYC/KYB entities"); + await registerEntity( + state.PROVIDER_URL, + alicia.publicKey(), + senderName, + [country], ); - await deposit( - alice.secret(), - depositAmount, - aliceJwt, - config, - undefined, - depositJurisdictions, + await registerEntity( + state.PROVIDER_URL, + roberto.publicKey(), + receiverName, + [country], ); - console.log(`[4/5] Sending ${COUNT} bundles, ${INTERVAL_MS}ms apart\n`); + // Deposit covers normal sends + the expire-injection sends (which still + // reserve UTXOs even though the bundle is force-expired before it settles). + // The fail-injection bundle deliberately overspends, so we don't budget it. + const expireExtras = INJECT_EXPIRE ? 1 : 0; + const depositAmount = SEND_AMOUNT * (COUNT + expireExtras) + DEPOSIT_BUFFER; + console.log(`[3/6] ${senderName} depositing ${depositAmount} XLM`); + await deposit(alicia.secret(), depositAmount, aliciaJwt, config); + + console.log(`[4/6] Sending ${COUNT} bundles, ${INTERVAL_MS}ms apart\n`); for (let i = 1; i <= COUNT; i++) { const startedAt = Date.now(); - const receiverOps = await prepareReceive(bob.secret(), SEND_AMOUNT, config); - const j = jurisdictionsFor(accepted); + const receiverOps = await prepareReceive( + roberto.secret(), + SEND_AMOUNT, + config, + ); await send( - alice.secret(), + alicia.secret(), receiverOps, SEND_AMOUNT, - aliceJwt, + aliciaJwt, config, - undefined, - j, ); console.log( - ` ${i}/${COUNT} sent ${SEND_AMOUNT} XLM (${j.jurisdictionFrom}→${j.jurisdictionTo}) ${ - Date.now() - startedAt - }ms`, + ` ${i}/${COUNT} sent ${SEND_AMOUNT} XLM ${Date.now() - startedAt}ms`, ); if (i < COUNT) { await new Promise((r) => setTimeout(r, INTERVAL_MS)); } } - const withdrawJurisdictions = jurisdictionsFor(accepted); - console.log( - `\n[5/5] Bob withdrawing ${WITHDRAW_AMOUNT} XLM to ${bob.publicKey()} (${withdrawJurisdictions.jurisdictionFrom}→${withdrawJurisdictions.jurisdictionTo})`, - ); + if (INJECT_FAIL) { + // Deposit a small amount, then submit a SPEND for that UTXO with a + // CREATE for 1 stroop more. Server admits (UTXO exists, signatures + // valid), executor sim rejects because sum(SPEND) != sum(CREATE). + console.log("\n[5/6] Injecting one FAILED bundle (2× overspend)"); + try { + const bundleId = await injectFailingBundle( + alicia.secret(), + aliciaJwt, + config, + ); + console.log(` submitted ${bundleId}, expecting FAILED at sim`); + } catch (err) { + console.log(` fail injection raised: ${(err as Error).message}`); + } + } + + if (INJECT_EXPIRE) { + // Submit one extra bundle without waiting, then immediately admin-expire + // it via POST /dashboard/bundles/expire — bundle settles as EXPIRED. + console.log("\n[5/6] Injecting one EXPIRED bundle (force-expire)"); + try { + const receiverOps = await prepareReceive( + roberto.secret(), + SEND_AMOUNT, + config, + ); + const bundleId = await send( + alicia.secret(), + receiverOps, + SEND_AMOUNT, + aliciaJwt, + config, + undefined, + { waitForCompletion: false }, + ); + console.log(` submitted ${bundleId}, force-expiring…`); + await forceExpireBundles([bundleId], state); + } catch (err) { + console.log(` expire injection raised: ${(err as Error).message}`); + } + } + + console.log(`\n[6/6] ${receiverName} withdrawing ${WITHDRAW_AMOUNT} XLM`); await withdraw( - bob.secret(), - bob.publicKey(), + roberto.secret(), + roberto.publicKey(), WITHDRAW_AMOUNT, - bobJwt, + robertoJwt, config, - undefined, - withdrawJurisdictions, ); - console.log("\n=== Done ===\n"); + console.log("\n=== Done ==="); +} + +async function main(): Promise { + const state = loadState(); + const totalStart = Date.now(); + const failures: { country: string; error: string }[] = []; + + console.log( + `\n=== local-dev — all-countries send loop (${state.pps.length} countries) ===\n`, + ); + + for (let i = 0; i < state.pps.length; i++) { + const pp = state.pps[i]; + const country = pp.jurisdiction.toUpperCase(); + console.log(`\n──── [${i + 1}/${state.pps.length}] ${country} ────`); + try { + await runCycleForPp(state, pp); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(` Cycle failed for ${country}: ${message}`); + failures.push({ country, error: message }); + } + } + + const elapsedS = ((Date.now() - totalStart) / 1000).toFixed(1); + console.log( + `\n=== Complete in ${elapsedS}s — ${ + state.pps.length - failures.length + }/${state.pps.length} countries OK ===`, + ); + if (failures.length > 0) { + console.error("Failures:"); + for (const f of failures) { + console.error(` ${f.country}: ${f.error}`); + } + Deno.exit(1); + } } main().catch((err) => { - console.error("\n=== send-loop FAILED ==="); + console.error("\n=== Send loop FAILED ==="); console.error(err); Deno.exit(1); }); diff --git a/setup-c.ts b/setup-c.ts index f509773..27b9349 100644 --- a/setup-c.ts +++ b/setup-c.ts @@ -1,42 +1,38 @@ /** - * Local Dev — Council Setup + * Local Dev — Council Setup (multi-council) * - * Sets up a council against a running local-dev stack. This is the production - * flow exercised end-to-end against the local Stellar node and the local - * council-platform service. Steps mirror lifecycle/testnet-verify.ts: + * Creates THREE councils against the local stack, each with its own + * channel-auth + privacy-channel contracts, name, and accepted jurisdictions. + * Drives the full production API surface for every council. * - * 1. Generate fresh admin keypair, fund via Friendbot - * 2. Deploy Channel Auth contract → councilId - * 3. Deploy native XLM SAC (or fetch existing) - * 4. Deploy Privacy Channel contract → channelContractId - * 5. Admin authenticates to council-platform → JWT - * 6. Admin creates the council via PUT /council/metadata - * 7. Admin adds the channel via POST /council/channels - * 8. Write all IDs + admin SK to .local-dev-state for setup-pp.sh and the - * browser-wallet seed files to consume + * Mercado Libre Mercosur — AR, BR, UY, PY + * Amazon Europe — GB, FR, DE, ES, IT + * Amazon North America — US, MX, CA * - * Why production-like: every API call here is the same one council-console - * makes against the deployed council-platform. If a council-platform release - * breaks the public surface, this script breaks too — that's the point. + * Steps per council: + * 1. Deploy Channel Auth contract (deterministic salt per council index) + * 2. Deploy Privacy Channel contract (deterministic salt per council index) + * 3. PUT /council/metadata (name + description) + * 4. POST /council/channels (XLM channel) + * 5. POST /council/jurisdictions (one per jurisdiction) + * + * Shared: + * - Native XLM SAC is deployed once and reused for all 3 councils + * - Admin keypair is shared + * - JWT is per-council via SEP-43 challenge/verify + * + * State file format (.local-dev-state) keys: + * ADMIN_PK, ADMIN_SK, ASSET_ID, COUNCIL_URL, NETWORK_PASSPHRASE, + * RPC_URL, FRIENDBOT_URL, + * COUNCIL_COUNT, + * COUNCIL__ID, COUNCIL__NAME, COUNCIL__CHANNEL, + * COUNCIL__JURISDICTIONS (comma-separated, e.g. AR,BR,UY,PY) * * Prereqs: * - up.sh has run (Stellar quickstart on :8000, council-platform on :3015) * - * Usage (preferred — via wrapper): + * Usage: * ./setup-c.sh - * - * Usage (direct): - * deno run --allow-all setup-c.ts - * - * Env overrides: - * STELLAR_RPC_URL default http://localhost:8000/soroban/rpc - * FRIENDBOT_URL default http://localhost:8000/friendbot - * STELLAR_NETWORK_PASSPHRASE default "Standalone Network ; February 2017" - * COUNCIL_URL default http://localhost:3015 - * STATE_FILE default ./.local-dev-state - * COUNCIL_NAME default "Local Council" - * CHANNEL_AUTH_WASM default ./e2e/wasms/channel_auth_contract.wasm - * PRIVACY_CHANNEL_WASM default ./e2e/wasms/privacy_channel.wasm */ import { Keypair } from "npm:@stellar/stellar-sdk@14.2.0"; import { Buffer } from "node:buffer"; @@ -58,38 +54,37 @@ const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? const COUNCIL_URL = Deno.env.get("COUNCIL_URL") ?? "http://localhost:3015"; const STATE_FILE = Deno.env.get("STATE_FILE") ?? new URL("./.local-dev-state", import.meta.url).pathname; -const COUNCIL_NAME = Deno.env.get("COUNCIL_NAME") ?? "Local Council"; const CHANNEL_AUTH_WASM = Deno.env.get("CHANNEL_AUTH_WASM") ?? new URL("./e2e/wasms/channel_auth_contract.wasm", import.meta.url).pathname; const PRIVACY_CHANNEL_WASM = Deno.env.get("PRIVACY_CHANNEL_WASM") ?? new URL("./e2e/wasms/privacy_channel.wasm", import.meta.url).pathname; -// ─── DETERMINISTIC LOCAL-DEV IDENTITY ────────────────────────────────── -// -// Fixed admin secret + fixed contract salts → same admin G-address and -// same channel-auth / privacy-channel contract IDs every run. This means -// the wallet's seed file (channel contract ID) and any tooling that -// references the council ID can be set ONCE and never change across -// `down → up → setup-c` cycles. -// -// SAFETY: this secret is for local-dev against `Standalone Network ; -// February 2017` ONLY. It is hard-coded in source so it must NEVER be -// reused on testnet/mainnet. The Friendbot funding is gated to the local -// network passphrase via FRIENDBOT_URL above. -// -// Override with env vars if you want to register a separate admin -// (e.g. testing multi-council scenarios on the local stack). -// -// Admin G-address: GAEILCNSC4ZTA63RK3ACSADVSWC47NRG7KFVYHZ4HKS265YEZVEHWMHG const ADMIN_SECRET = Deno.env.get("ADMIN_SECRET") ?? "SAQCGLJ2JISI67QGG457IBN2DY6YW5GGS2OMQU5KNLXB3TWVUIR2RD74"; -// Salts are arbitrary 32-byte values. hexToFixedBuffer pads with leading -// zeros so a 20-hex-char tail is fine. The tails spell "LOCAL_CAUT" and -// "LOCAL_PCHC" — recognizable in logs/dumps and obviously dev-only. -const CHANNEL_AUTH_SALT_HEX = Deno.env.get("CHANNEL_AUTH_SALT_HEX") ?? - "4c4f43414c5f43415554"; // "LOCAL_CAUT" -const PRIVACY_CHANNEL_SALT_HEX = Deno.env.get("PRIVACY_CHANNEL_SALT_HEX") ?? - "4c4f43414c5f50434843"; // "LOCAL_PCHC" +const CHANNEL_AUTH_SALT_BASE = Deno.env.get("CHANNEL_AUTH_SALT_HEX") ?? + "4c4f43414c5f43415554"; +const PRIVACY_CHANNEL_SALT_BASE = Deno.env.get("PRIVACY_CHANNEL_SALT_HEX") ?? + "4c4f43414c5f50434843"; + +interface CouncilSpec { + name: string; + jurisdictions: string[]; +} + +const COUNCILS: CouncilSpec[] = [ + { + name: "Mercado Libre Mercosur", + jurisdictions: ["AR", "BR", "UY", "PY"], + }, + { + name: "Amazon Europe", + jurisdictions: ["GB", "FR", "DE", "ES", "IT"], + }, + { + name: "Amazon North America", + jurisdictions: ["US", "MX", "CA"], + }, +]; function hexToFixedBuffer(hex: string, length = 32): Buffer { const bytes = Buffer.alloc(length); @@ -98,8 +93,14 @@ function hexToFixedBuffer(hex: string, length = 32): Buffer { return bytes; } +function saltForCouncil(baseHex: string, index: number): Buffer { + // Append 4 hex digits of the index so each council gets a distinct salt + // (and therefore a distinct deterministic contract ID). + const idxHex = index.toString(16).padStart(4, "0"); + return hexToFixedBuffer(baseHex + idxHex); +} + async function fundAccount(publicKey: string): Promise { - // Friendbot returns 200 on first fund, 400 "already funded" on retry. Both fine. const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); if (!res.ok && res.status !== 400) { throw new Error( @@ -119,7 +120,6 @@ async function warmupCouncil(): Promise { throw new Error(`council-platform not reachable at ${COUNCIL_URL}`); } -/** SEP-43/53 wallet auth: challenge → sign → verify → JWT. */ async function walletAuth(keypair: Keypair): Promise { const challengeRes = await fetch( `${COUNCIL_URL}/api/v1/admin/auth/challenge`, @@ -137,8 +137,6 @@ async function walletAuth(keypair: Keypair): Promise { } const { data: { nonce } } = await challengeRes.json(); - // The nonce is a base64-encoded random 32 bytes. We sign the raw bytes — - // council-platform's verifier accepts SEP-43, SEP-53, or raw formats. const nonceBytes = Uint8Array.from(atob(nonce), (c) => c.charCodeAt(0)); const sig = keypair.sign(Buffer.from(nonceBytes)); const signature = btoa(String.fromCharCode(...new Uint8Array(sig))); @@ -161,7 +159,7 @@ async function walletAuth(keypair: Keypair): Promise { async function writeStateFile(state: Record): Promise { const lines = [ "# Generated by setup-c.sh — regenerated on every run.", - "# Consumed by setup-pp.sh and other follow-up scripts.", + "# Consumed by setup-pp.sh, setup-pay.sh, send-loop.sh.", `# Created: ${new Date().toISOString()}`, "", ...Object.entries(state).map(([k, v]) => `${k}=${v}`), @@ -170,87 +168,60 @@ async function writeStateFile(state: Record): Promise { await Deno.writeTextFile(STATE_FILE, lines.join("\n")); } -async function main() { - const startTime = Date.now(); - - console.log("\n=== local-dev — Council Setup ===\n"); - console.log(` RPC: ${RPC_URL}`); - console.log(` Friendbot: ${FRIENDBOT_URL}`); - console.log(` Council: ${COUNCIL_URL}`); - console.log(` State file: ${STATE_FILE}`); - - console.log("\n[1/8] Warmup council-platform"); - await warmupCouncil(); - console.log(" council-platform reachable"); - - // Deterministic admin keypair — same address every run. See header comment. - const admin = Keypair.fromSecret(ADMIN_SECRET); - console.log(`\n Admin: ${admin.publicKey()}`); +interface DeployedCouncil { + spec: CouncilSpec; + index: number; + channelAuthId: string; + channelContractId: string; +} - console.log("\n[2/8] Funding admin via Friendbot"); - await fundAccount(admin.publicKey()); - console.log(" Admin funded"); +async function setupOneCouncil( + spec: CouncilSpec, + index: number, + admin: Keypair, + // deno-lint-ignore no-explicit-any + server: any, + assetContractId: string, + channelAuthWasmHash: Buffer, + privacyChannelWasmHash: Buffer, +): Promise { + const tag = `[${index + 1}/${COUNCILS.length}] ${spec.name}`; + console.log(`\n=== ${tag} ===`); - console.log("\n[3/8] Deploy Channel Auth contract"); - const server = createServer(RPC_URL, true); - const channelAuthWasm = await Deno.readFile(CHANNEL_AUTH_WASM); - const channelAuthHash = await uploadWasm( - server, - admin, - NETWORK_PASSPHRASE, - channelAuthWasm, - ); - // Fixed salt → deterministic contract ID across runs. - const channelAuthSalt = hexToFixedBuffer(CHANNEL_AUTH_SALT_HEX); + console.log(" Deploying Channel Auth…"); + const channelAuthSalt = saltForCouncil(CHANNEL_AUTH_SALT_BASE, index); const { contractId: channelAuthId, txResponse: authDeployTx } = await deployChannelAuth( server, admin, NETWORK_PASSPHRASE, - channelAuthHash, + channelAuthWasmHash, channelAuthSalt, ); if ( verifyEvent(extractEvents(authDeployTx), "contract_initialized", true).found ) { - console.log(" contract_initialized event verified"); + console.log(" contract_initialized event verified"); } - console.log(` Channel Auth: ${channelAuthId}`); - - console.log("\n[4/8] Deploy native XLM SAC"); - const assetContractId = await getOrDeployNativeSac( - server, - admin, - NETWORK_PASSPHRASE, - ); - console.log(` XLM SAC: ${assetContractId}`); + console.log(` Channel Auth: ${channelAuthId}`); - console.log("\n[5/8] Deploy Privacy Channel contract"); - const privacyChannelWasm = await Deno.readFile(PRIVACY_CHANNEL_WASM); - const privacyChannelHash = await uploadWasm( - server, - admin, - NETWORK_PASSPHRASE, - privacyChannelWasm, - ); - // Fixed salt → deterministic contract ID across runs. - const privacyChannelSalt = hexToFixedBuffer(PRIVACY_CHANNEL_SALT_HEX); + console.log(" Deploying Privacy Channel…"); + const privacyChannelSalt = saltForCouncil(PRIVACY_CHANNEL_SALT_BASE, index); const channelContractId = await deployPrivacyChannel( server, admin, NETWORK_PASSPHRASE, - privacyChannelHash, + privacyChannelWasmHash, channelAuthId, assetContractId, privacyChannelSalt, ); - console.log(` Privacy Channel: ${channelContractId}`); + console.log(` Privacy Channel: ${channelContractId}`); - console.log("\n[6/8] Admin authenticates to council-platform"); + console.log(" Authenticating admin to council-platform…"); const adminJwt = await walletAuth(admin); - console.log(" Admin JWT acquired"); - console.log("\n[7/8] Admin creates council + adds channel"); + console.log(" Creating council metadata…"); const createRes = await fetch(`${COUNCIL_URL}/api/v1/council/metadata`, { method: "PUT", headers: { @@ -259,8 +230,8 @@ async function main() { }, body: JSON.stringify({ councilId: channelAuthId, - name: COUNCIL_NAME, - description: "Local-dev council created by setup-c.sh", + name: spec.name, + description: `Local-dev council: ${spec.name}`, contactEmail: "local-dev@moonlight.test", }), }); @@ -269,8 +240,9 @@ async function main() { `Create council failed: ${createRes.status} ${await createRes.text()}`, ); } - console.log(` Council created: ${channelAuthId}`); + console.log(` Council created: ${channelAuthId}`); + console.log(" Adding XLM channel…"); const addChannelRes = await fetch( `${COUNCIL_URL}/api/v1/council/channels?councilId=${ encodeURIComponent(channelAuthId) @@ -295,18 +267,10 @@ async function main() { .text()}`, ); } - console.log(` Channel added: ${channelContractId} (XLM)`); - - // 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( + + console.log(" Adding jurisdictions…"); + for (const j of spec.jurisdictions) { + const r = await fetch( `${COUNCIL_URL}/api/v1/council/jurisdictions?councilId=${ encodeURIComponent(channelAuthId) }`, @@ -316,43 +280,118 @@ async function main() { "Content-Type": "application/json", "Authorization": `Bearer ${adminJwt}`, }, - body: JSON.stringify({ countryCode: jurisdiction }), + body: JSON.stringify({ countryCode: j }), }, ); - if (!addJurisdictionRes.ok) { + if (!r.ok) { throw new Error( - `Add jurisdiction ${jurisdiction} failed: ${addJurisdictionRes.status} ${await addJurisdictionRes - .text()}`, + `Add jurisdiction ${j} failed: ${r.status} ${await r.text()}`, ); } - console.log(` Jurisdiction added: ${jurisdiction}`); + console.log(` + ${j}`); } - console.log("\n[8/8] Write state file"); - await writeStateFile({ + return { spec, index, channelAuthId, channelContractId }; +} + +async function main() { + const startTime = Date.now(); + + console.log("\n=== local-dev — Council Setup (multi-council) ===\n"); + console.log(` RPC: ${RPC_URL}`); + console.log(` Friendbot: ${FRIENDBOT_URL}`); + console.log(` Council: ${COUNCIL_URL}`); + console.log(` State file: ${STATE_FILE}`); + console.log(` Councils to create: ${COUNCILS.length}`); + + console.log("\n[1/4] Warmup council-platform"); + await warmupCouncil(); + console.log(" council-platform reachable"); + + const admin = Keypair.fromSecret(ADMIN_SECRET); + console.log(` Admin: ${admin.publicKey()}`); + + console.log("\n[2/4] Funding admin via Friendbot"); + await fundAccount(admin.publicKey()); + + console.log("\n[3/4] Pre-deploy: WASM upload + native XLM SAC"); + const server = createServer(RPC_URL, true); + const channelAuthWasm = await Deno.readFile(CHANNEL_AUTH_WASM); + const channelAuthHash = await uploadWasm( + server, + admin, + NETWORK_PASSPHRASE, + channelAuthWasm, + ); + console.log(` Channel Auth WASM hash: ${channelAuthHash.toString("hex")}`); + + const privacyChannelWasm = await Deno.readFile(PRIVACY_CHANNEL_WASM); + const privacyChannelHash = await uploadWasm( + server, + admin, + NETWORK_PASSPHRASE, + privacyChannelWasm, + ); + console.log( + ` Privacy Channel WASM hash: ${privacyChannelHash.toString("hex")}`, + ); + + const assetContractId = await getOrDeployNativeSac( + server, + admin, + NETWORK_PASSPHRASE, + ); + console.log(` XLM SAC: ${assetContractId}`); + + console.log(`\n[4/4] Setting up ${COUNCILS.length} councils`); + const deployed: DeployedCouncil[] = []; + for (let i = 0; i < COUNCILS.length; i++) { + deployed.push( + await setupOneCouncil( + COUNCILS[i], + i, + admin, + server, + assetContractId, + channelAuthHash, + privacyChannelHash, + ), + ); + } + + console.log("\nWriting state file…"); + const state: Record = { ADMIN_PK: admin.publicKey(), ADMIN_SK: admin.secret(), - COUNCIL_ID: channelAuthId, - CHANNEL_ID: channelContractId, ASSET_ID: assetContractId, COUNCIL_URL, NETWORK_PASSPHRASE, RPC_URL, FRIENDBOT_URL, - }); + COUNCIL_COUNT: String(deployed.length), + }; + for (const d of deployed) { + const i = d.index + 1; + state[`COUNCIL_${i}_ID`] = d.channelAuthId; + state[`COUNCIL_${i}_NAME`] = d.spec.name; + state[`COUNCIL_${i}_CHANNEL`] = d.channelContractId; + state[`COUNCIL_${i}_JURISDICTIONS`] = d.spec.jurisdictions.join(","); + } + await writeStateFile(state); console.log(` State written to ${STATE_FILE}`); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`\n=== Council setup complete in ${elapsed}s ===\n`); - console.log(` Admin: ${admin.publicKey()}`); - console.log(` Council ID: ${channelAuthId}`); - console.log(` Privacy Channel: ${channelContractId}`); - console.log(` XLM SAC: ${assetContractId}`); + for (const d of deployed) { + console.log(` ${d.spec.name}`); + console.log(` Council ID: ${d.channelAuthId}`); + console.log(` Privacy Channel: ${d.channelContractId}`); + console.log(` Jurisdictions: ${d.spec.jurisdictions.join(", ")}`); + } console.log(""); console.log( - "Next: ./setup-pp.sh to register a privacy provider in this council.", + "Next: ./setup-pp.sh to register the 12 PPs across the councils.", ); - console.log(""); } main().catch((err) => { diff --git a/setup-pp.ts b/setup-pp.ts index e17659c..5d8eaac 100644 --- a/setup-pp.ts +++ b/setup-pp.ts @@ -1,48 +1,36 @@ /** - * Local Dev — Privacy Provider Setup + * Local Dev — Privacy Provider Setup (multi-PP) * - * Registers a Privacy Provider in the council created by setup-c.sh. This - * exercises the production join flow end-to-end against the local stack: + * Registers 12 PPs, one per country across the 3 councils created by + * setup-c.sh. Each PP goes through the full production join flow against the + * local stack: register → join request → admin approve → add_provider on + * chain → wait ACTIVE. * - * 1. Load admin SK + council ID from .local-dev-state (written by setup-c.sh) - * 2. Generate a fresh PP operator keypair, fund via Friendbot - * 3. PP operator authenticates to provider-platform dashboard → JWT - * 4. PP operator registers a PP via POST /dashboard/pp/register - * 5. PP operator submits a signed join envelope via POST /dashboard/council/join - * (provider-platform forwards it to council-platform's join-request endpoint) - * 6. Admin (loaded from state) authenticates to council-platform → JWT - * 7. Admin lists pending join requests, finds ours, calls - * POST /council/provider-requests/:id/approve - * 8. Admin calls add_provider on-chain (channel-auth contract) - * 9. Wait for provider-platform's event watcher to flip the membership ACTIVE - * 10. Append PP info to .local-dev-state + * Mercosur (council 1): + * Mercado Libre Argentina Provider (AR) + * Mercado Libre Brazil Provider (BR) + * Mercado Libre Uruguay Provider (UY) + * Mercado Libre Paraguay Provider (PY) + * Europe (council 2): + * Amazon UK Provider (GB) + * Amazon France Provider (FR) + * Amazon Germany Provider (DE) + * Amazon Spain Provider (ES) + * Amazon Italy Provider (IT) + * North America (council 3): + * Amazon US Provider (US) + * Amazon Mexico Provider (MX) + * Amazon Canada Provider (CA) * - * Why production-like: every step here is the same one council-console and - * provider-console make. If a platform release breaks the public surface, this - * script breaks too — that's the point. + * Each PP gets a fresh keypair derived deterministically from PP_SECRET + + * index so re-runs (against a fresh ledger) yield the same pubkeys. * * Prereqs: - * - up.sh has run (Stellar quickstart, postgres, jaeger, both platforms) - * - setup-c.sh has run (.local-dev-state exists with admin + council IDs) + * - up.sh has run + * - setup-c.sh has run (.local-dev-state has COUNCIL_COUNT + COUNCIL__*) * - * Idempotency: each run generates a fresh PP keypair. Re-running will register - * a second PP in the same council (multi-PP). To "reset", run down → up → - * setup-c → setup-pp. - * - * Usage (preferred — via wrapper): + * Usage: * ./setup-pp.sh - * - * Usage (direct): - * deno run --allow-all setup-pp.ts - * - * Env overrides: - * STELLAR_RPC_URL default http://localhost:8000/soroban/rpc - * FRIENDBOT_URL default http://localhost:8000/friendbot - * STELLAR_NETWORK_PASSPHRASE default "Standalone Network ; February 2017" - * COUNCIL_URL default (loaded from state file) - * PROVIDER_URL default http://localhost:3010 - * STATE_FILE default ./.local-dev-state - * PP_LABEL default "Local PP" */ import { Keypair } from "npm:@stellar/stellar-sdk@14.2.0"; import { Buffer } from "node:buffer"; @@ -59,28 +47,68 @@ const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? const PROVIDER_URL = Deno.env.get("PROVIDER_URL") ?? "http://localhost:3010"; const STATE_FILE = Deno.env.get("STATE_FILE") ?? new URL("./.local-dev-state", import.meta.url).pathname; -const PP_LABEL = Deno.env.get("PP_LABEL") ?? "Local PP"; - -// ─── DETERMINISTIC LOCAL-DEV PP IDENTITY ─────────────────────────────── -// -// Same fixed-secret approach as setup-c.ts. Re-running setup-pp against -// a fresh `up.sh` ledger registers the SAME PP G-address — so any client -// that has the PP's pubkey baked in (the wallet's seed file, manual test -// configs, etc.) keeps working without updates. -// -// SAFETY: local-dev only. See setup-c.ts for the same warning. -// -// PP G-address: GBW7TE4PGNEKFAH7DRZBA3CDQIFLNT22ZQO2G5DJSNRGKCS5PYKTIMWV -const PP_SECRET = Deno.env.get("PP_SECRET") ?? + +// One operator keypair owns all 12 PPs (matches the provider-console flow: +// the user signs once, the SPA derives a masterSeed from their signature, +// and PP keys are SHA-256(masterSeed || "pp" || index) — see +// provider-console/src/lib/wallet.ts). +const OPERATOR_SECRET = Deno.env.get("OPERATOR_SECRET") ?? + Deno.env.get("PP_SECRET") ?? "SDRTOKYHEEVBDTC3QPFKKGS5EGTFSXM4B6HTGO2JLY6ZRH4XHICZQTLI"; +interface ProviderSpec { + name: string; + councilIndex: number; // 0-based into COUNCILS + jurisdiction: string; +} + +const PROVIDERS: ProviderSpec[] = [ + // Mercosur + { + name: "Mercado Libre Argentina Provider", + councilIndex: 0, + jurisdiction: "AR", + }, + { + name: "Mercado Libre Brazil Provider", + councilIndex: 0, + jurisdiction: "BR", + }, + { + name: "Mercado Libre Uruguay Provider", + councilIndex: 0, + jurisdiction: "UY", + }, + { + name: "Mercado Libre Paraguay Provider", + councilIndex: 0, + jurisdiction: "PY", + }, + // Europe + { name: "Amazon UK Provider", councilIndex: 1, jurisdiction: "GB" }, + { name: "Amazon France Provider", councilIndex: 1, jurisdiction: "FR" }, + { name: "Amazon Germany Provider", councilIndex: 1, jurisdiction: "DE" }, + { name: "Amazon Spain Provider", councilIndex: 1, jurisdiction: "ES" }, + { name: "Amazon Italy Provider", councilIndex: 1, jurisdiction: "IT" }, + // North America + { name: "Amazon US Provider", councilIndex: 2, jurisdiction: "US" }, + { name: "Amazon Mexico Provider", councilIndex: 2, jurisdiction: "MX" }, + { name: "Amazon Canada Provider", councilIndex: 2, jurisdiction: "CA" }, +]; + +interface CouncilState { + id: string; + name: string; + channel: string; + jurisdictions: string[]; +} + interface State { ADMIN_SK: string; ADMIN_PK: string; - COUNCIL_ID: string; - CHANNEL_ID: string; ASSET_ID: string; COUNCIL_URL: string; + councils: CouncilState[]; } async function loadState(): Promise { @@ -103,19 +131,35 @@ async function loadState(): Promise { const required = [ "ADMIN_SK", "ADMIN_PK", - "COUNCIL_ID", - "CHANNEL_ID", "ASSET_ID", "COUNCIL_URL", + "COUNCIL_COUNT", ]; for (const key of required) { if (!env[key]) { - throw new Error( - `State file missing ${key}. Re-run setup-c.sh.`, - ); + throw new Error(`State file missing ${key}. Re-run setup-c.sh.`); + } + } + const count = Number(env.COUNCIL_COUNT); + const councils: CouncilState[] = []; + for (let i = 1; i <= count; i++) { + const id = env[`COUNCIL_${i}_ID`]; + const name = env[`COUNCIL_${i}_NAME`]; + const channel = env[`COUNCIL_${i}_CHANNEL`]; + const jurisdictions = (env[`COUNCIL_${i}_JURISDICTIONS`] ?? "").split(",") + .filter((j) => j); + if (!id || !name || !channel) { + throw new Error(`State file missing COUNCIL_${i}_* fields.`); } + councils.push({ id, name, channel, jurisdictions }); } - return env as unknown as State; + return { + ADMIN_SK: env.ADMIN_SK, + ADMIN_PK: env.ADMIN_PK, + ASSET_ID: env.ASSET_ID, + COUNCIL_URL: env.COUNCIL_URL, + councils, + }; } async function appendStateLines(lines: Record): Promise { @@ -149,7 +193,6 @@ async function warmupService(name: string, url: string): Promise { throw new Error(`${name} not reachable at ${url}`); } -/** Wallet auth: challenge → sign nonce → verify → JWT. */ async function walletAuth( baseUrl: string, authRoute: string, @@ -187,7 +230,6 @@ async function walletAuth( return token; } -/** Sign a join request envelope (matches council-platform's signed-payload.ts). */ async function signJoinEnvelope( payload: T, keypair: Keypair, @@ -210,7 +252,6 @@ async function signJoinEnvelope( }; } -/** Poll provider-platform until the membership for ppPublicKey becomes ACTIVE. */ async function pollMembershipActive( ppPublicKey: string, dashboardJwt: string, @@ -233,54 +274,67 @@ async function pollMembershipActive( throw new Error( `Membership for ${ppPublicKey} did not become ACTIVE after ${ maxAttempts * intervalMs - }ms. ` + - `Check provider-platform's event watcher logs.`, + }ms.`, ); } -async function main() { - const startTime = Date.now(); - - console.log("\n=== local-dev — Privacy Provider Setup ===\n"); - - console.log("[1/10] Load council state from setup-c.sh"); - const state = await loadState(); - const admin = Keypair.fromSecret(state.ADMIN_SK); - console.log(` Admin: ${state.ADMIN_PK}`); - console.log(` Council ID: ${state.COUNCIL_ID}`); - console.log(` Council: ${state.COUNCIL_URL}`); - console.log(` Provider: ${PROVIDER_URL}`); - - console.log("\n[2/10] Warmup provider-platform"); - await warmupService("provider-platform", PROVIDER_URL); - console.log(" provider-platform reachable"); +/** + * Mirrors provider-console/src/lib/wallet.ts: + * masterSeed = SHA-256(operator.sign("Moonlight: Derive server key")) + * PP_i = SHA-256(masterSeed || "pp" || i) → Ed25519 seed + * + * Returns the masterSeed so callers can derive any number of PPs from it. + */ +async function deriveMasterSeed(operator: Keypair): Promise { + const message = new TextEncoder().encode("Moonlight: Derive server key"); + const signature = new Uint8Array(operator.sign(Buffer.from(message))); + return new Uint8Array(await crypto.subtle.digest("SHA-256", signature)); +} - // Deterministic PP operator key. Same address every run, so anything that - // has the PP pubkey baked in (wallet seed, manual configs) keeps working - // across `down`/`up` cycles. See PP_SECRET comment at the top of the file. - const ppOperator = Keypair.fromSecret(PP_SECRET); - console.log(`\n PP Operator: ${ppOperator.publicKey()}`); +async function deriveKeypair( + masterSeed: Uint8Array, + index: number, +): Promise { + const tag = new TextEncoder().encode("pp"); + const idxBytes = new TextEncoder().encode(String(index)); + const buf = new Uint8Array(masterSeed.length + tag.length + idxBytes.length); + buf.set(masterSeed, 0); + buf.set(tag, masterSeed.length); + buf.set(idxBytes, masterSeed.length + tag.length); + const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", buf)); + return Keypair.fromRawEd25519Seed(Buffer.from(digest)); +} - console.log("\n[3/10] Funding PP operator via Friendbot"); - await fundAccount(ppOperator.publicKey()); - console.log(" PP Operator funded"); +interface RegisteredPP { + spec: ProviderSpec; + index: number; + publicKey: string; + secret: string; + councilId: string; +} - console.log( - "\n[4/10] PP operator authenticates to provider-platform dashboard", - ); - const dashboardJwt = await walletAuth( - PROVIDER_URL, - "/api/v1/dashboard/auth", - ppOperator, - ); - console.log(" Dashboard JWT acquired"); +async function setupOnePP( + spec: ProviderSpec, + index: number, + kp: Keypair, + dashboardJwt: string, + adminKp: Keypair, + admin: { jwtForCouncil: () => Promise }, + council: CouncilState, + // deno-lint-ignore no-explicit-any + server: any, +): Promise { + const tag = `[${ + index + 1 + }/${PROVIDERS.length}] ${spec.name} (${spec.jurisdiction})`; + console.log(`\n=== ${tag} ===`); + console.log(` Keypair: ${kp.publicKey()}`); + console.log(` Council: ${council.name} (${council.id.slice(0, 8)}…)`); - // For local-dev simplicity, the PP key IS the operator key (derivationIndex 0). - // In production a PP operator would derive distinct keys per PP from a master - // seed; here we cheat to keep the script linear. - const ppKeypair = ppOperator; + console.log(" Funding via Friendbot…"); + await fundAccount(kp.publicKey()); - console.log("\n[5/10] Register PP via /dashboard/pp/register"); + console.log(" Registering PP under operator…"); const regRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/pp/register`, { method: "POST", headers: { @@ -288,9 +342,9 @@ async function main() { "Authorization": `Bearer ${dashboardJwt}`, }, body: JSON.stringify({ - secretKey: ppKeypair.secret(), - derivationIndex: 0, - label: PP_LABEL, + secretKey: kp.secret(), + derivationIndex: index, + label: spec.name, }), }); if (!regRes.ok) { @@ -298,18 +352,18 @@ async function main() { `PP register failed: ${regRes.status} ${await regRes.text()}`, ); } - console.log(` PP registered: ${ppKeypair.publicKey()}`); - console.log("\n[6/10] Submit signed join request"); + console.log(" Submitting join request…"); const joinPayload = { - publicKey: ppKeypair.publicKey(), - councilId: state.COUNCIL_ID, - label: PP_LABEL, - contactEmail: "pp@local-dev.moonlight.test", - jurisdictions: ["UY"], + publicKey: kp.publicKey(), + councilId: council.id, + label: spec.name, + contactEmail: + `${spec.jurisdiction.toLowerCase()}-pp@local-dev.moonlight.test`, + jurisdictions: [spec.jurisdiction], callbackEndpoint: PROVIDER_URL, }; - const signedEnvelope = await signJoinEnvelope(joinPayload, ppKeypair); + const signedEnvelope = await signJoinEnvelope(joinPayload, kp); const joinRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/council/join`, { method: "POST", @@ -318,12 +372,13 @@ async function main() { "Authorization": `Bearer ${dashboardJwt}`, }, body: JSON.stringify({ - councilUrl: state.COUNCIL_URL, - councilId: state.COUNCIL_ID, - councilName: "Local Council", - ppPublicKey: ppKeypair.publicKey(), - label: PP_LABEL, - contactEmail: "pp@local-dev.moonlight.test", + councilUrl: Deno.env.get("COUNCIL_URL") ?? "http://localhost:3015", + councilId: council.id, + councilName: council.name, + ppPublicKey: kp.publicKey(), + label: spec.name, + contactEmail: + `${spec.jurisdiction.toLowerCase()}-pp@local-dev.moonlight.test`, signedEnvelope, }), }); @@ -332,23 +387,14 @@ async function main() { `Join request failed: ${joinRes.status} ${await joinRes.text()}`, ); } - const joinBody = await joinRes.json(); - console.log( - ` Join request submitted: ${joinBody.data?.joinRequestId} (PENDING)`, - ); - - console.log("\n[7/10] Admin authenticates to council-platform"); - const adminJwt = await walletAuth( - state.COUNCIL_URL, - "/api/v1/admin/auth", - admin, - ); - console.log(" Admin JWT acquired"); - console.log("\n[8/10] Admin approves the join request"); + console.log(" Admin approving join…"); + const adminJwt = await admin.jwtForCouncil(); const listRes = await fetch( - `${state.COUNCIL_URL}/api/v1/council/provider-requests?councilId=${ - encodeURIComponent(state.COUNCIL_ID) + `${ + Deno.env.get("COUNCIL_URL") ?? "http://localhost:3015" + }/api/v1/council/provider-requests?councilId=${ + encodeURIComponent(council.id) }`, { headers: { "Authorization": `Bearer ${adminJwt}` } }, ); @@ -359,16 +405,17 @@ async function main() { } const { data: requests } = await listRes.json(); const ourRequest = requests?.find?.( - (r: { publicKey: string }) => r.publicKey === ppKeypair.publicKey(), + (r: { publicKey: string }) => r.publicKey === kp.publicKey(), ); if (!ourRequest) { throw new Error( `Could not find our join request among ${requests?.length ?? 0} requests`, ); } - const approveRes = await fetch( - `${state.COUNCIL_URL}/api/v1/council/provider-requests/${ourRequest.id}/approve`, + `${ + Deno.env.get("COUNCIL_URL") ?? "http://localhost:3015" + }/api/v1/council/provider-requests/${ourRequest.id}/approve`, { method: "POST", headers: { "Authorization": `Bearer ${adminJwt}` }, @@ -379,44 +426,136 @@ async function main() { `Approve failed: ${approveRes.status} ${await approveRes.text()}`, ); } - console.log(" Join request approved (DB updated)"); - console.log("\n[9/10] Admin calls add_provider on-chain"); - const server = createServer(RPC_URL, true); + console.log(" Admin add_provider on-chain…"); const addTx = await addProvider( server, - admin, + adminKp, NETWORK_PASSPHRASE, - state.COUNCIL_ID, - ppKeypair.publicKey(), + council.id, + kp.publicKey(), ); if (!verifyEvent(extractEvents(addTx), "provider_added", true).found) { throw new Error("provider_added event not emitted"); } - console.log(" provider_added event verified"); - console.log("\n[10/10] Wait for membership to become ACTIVE"); - await pollMembershipActive(ppKeypair.publicKey(), dashboardJwt); - console.log(" Membership ACTIVE"); + console.log(" Waiting for membership ACTIVE…"); + await pollMembershipActive(kp.publicKey(), dashboardJwt); + console.log(" ✓ ACTIVE"); + + return { + spec, + index, + publicKey: kp.publicKey(), + secret: kp.secret(), + councilId: council.id, + }; +} + +async function main() { + const startTime = Date.now(); + console.log("\n=== local-dev — Privacy Provider Setup (multi-PP) ===\n"); + + console.log("[1/5] Load state from setup-c.sh"); + const state = await loadState(); + const adminKp = Keypair.fromSecret(state.ADMIN_SK); + console.log(` Admin: ${state.ADMIN_PK}`); + console.log(` Councils: ${state.councils.length}`); + console.log(` PPs to register: ${PROVIDERS.length}`); + + console.log("\n[2/5] Warmup provider-platform"); + await warmupService("provider-platform", PROVIDER_URL); + + // Admin JWT is per-call (council-platform may rotate sessions); cache lazily. + let cachedAdminJwt: string | null = null; + const adminCtx = { + jwtForCouncil: async () => { + if (cachedAdminJwt) return cachedAdminJwt; + cachedAdminJwt = await walletAuth( + state.COUNCIL_URL, + "/api/v1/admin/auth", + adminKp, + ); + return cachedAdminJwt; + }, + }; + + console.log("\n[3/5] Fund admin + operator via Friendbot"); + await fundAccount(state.ADMIN_PK); + + const operatorKp = Keypair.fromSecret(OPERATOR_SECRET); + console.log(` Operator: ${operatorKp.publicKey()}`); + await fundAccount(operatorKp.publicKey()); + + const server = createServer(RPC_URL, true); - await appendStateLines({ - PP_PK: ppKeypair.publicKey(), - PP_SK: ppKeypair.secret(), + console.log( + "\n[4/5] Operator authenticates once + derives 12 PP keys from masterSeed", + ); + const operatorJwt = await walletAuth( PROVIDER_URL, - }); + "/api/v1/dashboard/auth", + operatorKp, + ); + const masterSeed = await deriveMasterSeed(operatorKp); + + // Derive every PP keypair up front so we can fund + register them as the + // same operator owns them all. + const ppKeypairs = await Promise.all( + PROVIDERS.map((_, i) => deriveKeypair(masterSeed, i)), + ); + + console.log(`\n[5/5] Registering ${PROVIDERS.length} PPs under operator`); + const registered: RegisteredPP[] = []; + for (let i = 0; i < PROVIDERS.length; i++) { + const spec = PROVIDERS[i]; + const council = state.councils[spec.councilIndex]; + if (!council) { + throw new Error( + `Provider spec at index ${i} references councilIndex ${spec.councilIndex}, but only ${state.councils.length} councils exist.`, + ); + } + registered.push( + await setupOnePP( + spec, + i, + ppKeypairs[i], + operatorJwt, + adminKp, + adminCtx, + council, + server, + ), + ); + } + + console.log("\nWriting state…"); + const lines: Record = { + PROVIDER_URL, + OPERATOR_PK: operatorKp.publicKey(), + OPERATOR_SK: operatorKp.secret(), + PP_COUNT: String(registered.length), + }; + for (const r of registered) { + const i = r.index + 1; + lines[`PP_${i}_PK`] = r.publicKey; + lines[`PP_${i}_SK`] = r.secret; + lines[`PP_${i}_NAME`] = r.spec.name; + lines[`PP_${i}_COUNCIL_INDEX`] = String(r.spec.councilIndex + 1); + lines[`PP_${i}_JURISDICTION`] = r.spec.jurisdiction; + } + await appendStateLines(lines); const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`\n=== PP setup complete in ${elapsed}s ===\n`); - console.log(` PP public key: ${ppKeypair.publicKey()}`); - console.log(` Council ID: ${state.COUNCIL_ID}`); - console.log(` Channel ID: ${state.CHANNEL_ID}`); - console.log(` Provider URL: ${PROVIDER_URL}`); - console.log(""); + for (const r of registered) { + console.log(` ${r.spec.name} (${r.spec.jurisdiction})`); + console.log(` Pubkey: ${r.publicKey}`); + console.log(` Council: ${r.councilId}`); + } console.log( - "The browser-wallet (or any client) can now authenticate against the", + "\nNext: ./setup-pay.sh and ./send-loop.sh to drive bundles across the fleet.", ); - console.log("provider and submit bundles targeting the privacy channel."); - console.log(""); } main().catch((err) => {