From 994bfe592a6c53ab31654894a78c7523c74cb194 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 1 Jun 2026 16:58:51 -0300 Subject: [PATCH 1/5] Migrate consumers to URL-scoped /providers/:ppPublicKey/... endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * e2e/governance/lifecycle/testnet/setup-pp harnesses now hit: - POST /providers/:pp/entity/bundles (user-mode submit) - GET /providers/:pp/entity/bundles/:id (user-mode poll) - POST /providers/:pp/council/join (operator) - GET /providers/:pp/council/membership (operator) - POST /providers/:pp/council/membership (operator sync) - GET /providers/:pp/{channels,mempool,operations,treasury,metrics, audit-export} (operator dashboard) * lib/register-entity.ts — shared helper for the SEP-53 signed-challenge KYC submission flow. Replaces six in-place registerEntity copies that hit the now-410'd POST /api/v1/entities. * send-loop INJECT_EXPIRE switches from the bare-deleted HTTP admin endpoint (POST /api/v1/dashboard/bundles/expire) to a direct PostgreSQL UPDATE on operations_bundles, back-dating the bundle's TTL so the mempool's own TTL-check sweep purges it via the platform's normal path. Adds postgres npm dep to deno.json. --- deno.json | 3 +- deno.lock | 9 +- e2e/dashboard.ts | 37 ++++--- e2e/governance/uc2-approve-reject.ts | 155 ++++++++++++++++----------- e2e/main.ts | 29 ++--- e2e/pos-instant/main.ts | 22 ++-- e2e/setup.sh | 2 +- lib/client/bundle.ts | 4 +- lib/register-entity.ts | 63 +++++++++++ lifecycle/ci-test.ts | 66 +++++------- lifecycle/main.ts | 26 +---- lifecycle/testnet-verify.ts | 66 +++++------- send-loop.ts | 141 +++++++++--------------- setup-pp.sh | 2 +- setup-pp.ts | 40 +++---- test/setup-e2e.ts | 2 +- testnet/main.ts | 66 +++++------- 17 files changed, 360 insertions(+), 373 deletions(-) create mode 100644 lib/register-entity.ts diff --git a/deno.json b/deno.json index c0f087e..252ff0f 100644 --- a/deno.json +++ b/deno.json @@ -7,6 +7,7 @@ "@colibri/core": "jsr:@colibri/core@^0.20.2", "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.8.0", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", - "stellar-sdk": "npm:@stellar/stellar-sdk@^15.0.1" + "stellar-sdk": "npm:@stellar/stellar-sdk@^15.0.1", + "postgres": "npm:postgres@3.4.7" } } diff --git a/deno.lock b/deno.lock index 5bbd19b..43af1a5 100644 --- a/deno.lock +++ b/deno.lock @@ -16,7 +16,8 @@ "npm:asn1js@3.0.5": "3.0.5", "npm:bip39@3.1.0": "3.1.0", "npm:buffer@6.0.3": "6.0.3", - "npm:buffer@^6.0.3": "6.0.3" + "npm:buffer@^6.0.3": "6.0.3", + "npm:postgres@3.4.7": "3.4.7" }, "jsr": { "@colibri/core@0.20.2": { @@ -363,6 +364,9 @@ "possible-typed-array-names@1.1.0": { "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" }, + "postgres@3.4.7": { + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==" + }, "proxy-from-env@2.1.0": { "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" }, @@ -447,7 +451,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:postgres@3.4.7" ] } } diff --git a/e2e/dashboard.ts b/e2e/dashboard.ts index 3a7be04..ba0566a 100644 --- a/e2e/dashboard.ts +++ b/e2e/dashboard.ts @@ -111,10 +111,12 @@ export async function dashboardE2E( const token = await dashboardAuth(providerUrl, keypair); console.log(" [dashboard] Authenticated"); - // 2. Channels + const ppPath = `/providers/${encodeURIComponent(keypair.publicKey())}`; + + // 2. Channels (per-PP) await withE2ESpan("dashboard.channels", async () => { - console.log(" [dashboard] GET /dashboard/channels..."); - const res = await fetchDashboard(providerUrl, token, "/dashboard/channels"); + console.log(` [dashboard] GET ${ppPath}/channels...`); + const res = await fetchDashboard(providerUrl, token, `${ppPath}/channels`); assertField(res, "data.channels"); assertField(res, "data.summary.total"); assertField(res, "data.summary.active"); @@ -123,10 +125,11 @@ export async function dashboardE2E( console.log(" [dashboard] Channels OK"); }); - // 3. Mempool + // 3. Mempool (per-PP — averages filtered to this PP; live stats still + // process-wide because the in-memory mempool is platform-shared.) await withE2ESpan("dashboard.mempool", async () => { - console.log(" [dashboard] GET /dashboard/mempool..."); - const res = await fetchDashboard(providerUrl, token, "/dashboard/mempool"); + console.log(` [dashboard] GET ${ppPath}/mempool...`); + const res = await fetchDashboard(providerUrl, token, `${ppPath}/mempool`); assertField(res, "data.platformVersion"); assertField(res, "data.live.totalSlots"); assertField(res, "data.live.totalBundles"); @@ -135,13 +138,13 @@ export async function dashboardE2E( console.log(" [dashboard] Mempool OK"); }); - // 4. Operations + // 4. Operations (per-PP bundle + tx counts) await withE2ESpan("dashboard.operations", async () => { - console.log(" [dashboard] GET /dashboard/operations..."); + console.log(` [dashboard] GET ${ppPath}/operations...`); const res = await fetchDashboard( providerUrl, token, - "/dashboard/operations", + `${ppPath}/operations`, ); assertField(res, "data.bundles.total"); assertField(res, "data.bundles.successRate"); @@ -151,24 +154,20 @@ export async function dashboardE2E( // 5. Treasury await withE2ESpan("dashboard.treasury", async () => { - console.log(" [dashboard] GET /dashboard/treasury..."); - const res = await fetchDashboard( - providerUrl, - token, - `/dashboard/treasury?ppPublicKey=${keypair.publicKey()}`, - ); + console.log(` [dashboard] GET ${ppPath}/treasury...`); + const res = await fetchDashboard(providerUrl, token, `${ppPath}/treasury`); assertField(res, "data.address"); assertField(res, "data.balances"); console.log(" [dashboard] Treasury OK"); }); - // 6. Audit export + // 6. Audit export (per-PP CSV) await withE2ESpan("dashboard.audit_export", async () => { - console.log(" [dashboard] GET /dashboard/audit-export..."); + console.log(` [dashboard] GET ${ppPath}/audit-export...`); const csv = await fetchDashboard( providerUrl, token, - "/dashboard/audit-export?status=COMPLETED", + `${ppPath}/audit-export?status=COMPLETED`, ) as string; if (typeof csv !== "string" || !csv.startsWith("id,")) { throw new Error( @@ -183,7 +182,7 @@ export async function dashboardE2E( // 7. Verify unauthenticated access is blocked await withE2ESpan("dashboard.auth_required", async () => { console.log(" [dashboard] Verifying auth is required..."); - const res = await fetch(`${providerUrl}/api/v1/dashboard/channels`); + const res = await fetch(`${providerUrl}/api/v1${ppPath}/channels`); if (res.status !== 401 && res.status !== 403) { throw new Error( `Expected 401/403 for unauthenticated request, got ${res.status}`, diff --git a/e2e/governance/uc2-approve-reject.ts b/e2e/governance/uc2-approve-reject.ts index d190785..ce4a773 100644 --- a/e2e/governance/uc2-approve-reject.ts +++ b/e2e/governance/uc2-approve-reject.ts @@ -156,7 +156,7 @@ async function waitForMembershipStatus( while (Date.now() - start < timeoutMs) { const m = await get( PROVIDER_API, - `/dashboard/council/membership?ppPublicKey=${ppPublicKey}`, + `/providers/${ppPublicKey}/council/membership`, token, ); if (m.data.data?.status === expectedStatus) return true; @@ -220,19 +220,23 @@ const j1Envelope = await signJoinPayload({ contactEmail: "a@t", jurisdictions: null, }, pp1); -const j1 = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp1.publicKey(), - signedEnvelope: j1Envelope, -}, dashToken); +const j1 = await post( + PROVIDER_API, + `/providers/${pp1.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j1Envelope, + }, + dashToken, +); check("Join submitted", j1.data.data?.status, "PENDING"); const m1pre = await get( PROVIDER_API, - `/dashboard/council/membership?ppPublicKey=${pp1.publicKey()}`, + `/providers/${pp1.publicKey()}/council/membership`, dashToken, ); check("Provider: PENDING before approve", m1pre.data.data?.status, "PENDING"); @@ -305,14 +309,18 @@ const j2Envelope = await signJoinPayload({ contactEmail: "r@t", jurisdictions: null, }, pp2); -const j2 = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp2.publicKey(), - signedEnvelope: j2Envelope, -}, dashToken); +const j2 = await post( + PROVIDER_API, + `/providers/${pp2.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j2Envelope, + }, + dashToken, +); check("Join submitted", j2.data.data?.status, "PENDING"); const p2 = await get( @@ -349,9 +357,12 @@ const ms2 = await fetch( check("Membership-status: REJECTED (404)", ms2.status, 404); // Sync rejection to provider-platform (simulates UI polling) -const sync2 = await post(PROVIDER_API, "/dashboard/council/membership", { - ppPublicKey: pp2.publicKey(), -}, dashToken); +const sync2 = await post( + PROVIDER_API, + `/providers/${pp2.publicKey()}/council/membership`, + {}, + dashToken, +); check("Provider synced to REJECTED", sync2.data.data?.status, "REJECTED"); // --- Test 3: PP with no council --- @@ -364,7 +375,7 @@ await post(PROVIDER_API, "/dashboard/pp/register", { }, dashToken); const m3 = await get( PROVIDER_API, - `/dashboard/council/membership?ppPublicKey=${pp3.publicKey()}`, + `/providers/${pp3.publicKey()}/council/membership`, dashToken, ); check("No membership", m3.data.data, null); @@ -413,14 +424,18 @@ const j6aEnvelope = await signJoinPayload({ contactEmail: "dup@t", jurisdictions: null, }, pp6); -const j6a = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp6.publicKey(), - signedEnvelope: j6aEnvelope, -}, dashToken); +const j6a = await post( + PROVIDER_API, + `/providers/${pp6.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j6aEnvelope, + }, + dashToken, +); check("First join succeeds", j6a.data.data?.status, "PENDING"); const j6bEnvelope = await signJoinPayload({ @@ -430,14 +445,18 @@ const j6bEnvelope = await signJoinPayload({ contactEmail: "dup@t", jurisdictions: null, }, pp6); -const j6b = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp6.publicKey(), - signedEnvelope: j6bEnvelope, -}, dashToken); +const j6b = await post( + PROVIDER_API, + `/providers/${pp6.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j6bEnvelope, + }, + dashToken, +); check("Duplicate join returns 409", j6b.status, 409); // --- Test 7: Membership-status edge cases --- @@ -474,14 +493,18 @@ const j8aEnvelope = await signJoinPayload({ contactEmail: null, jurisdictions: null, }, pp8a); -const j8a = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp8a.publicKey(), - signedEnvelope: j8aEnvelope, -}, dashToken); +const j8a = await post( + PROVIDER_API, + `/providers/${pp8a.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j8aEnvelope, + }, + dashToken, +); check("PP-Multi-A join", j8a.data.data?.status, "PENDING"); const j8bEnvelope = await signJoinPayload({ @@ -491,14 +514,18 @@ const j8bEnvelope = await signJoinPayload({ contactEmail: null, jurisdictions: null, }, pp8b); -const j8b = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: AUTH_ID, - councilName: "Test Council", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp8b.publicKey(), - signedEnvelope: j8bEnvelope, -}, dashToken); +const j8b = await post( + PROVIDER_API, + `/providers/${pp8b.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: AUTH_ID, + councilName: "Test Council", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j8bEnvelope, + }, + dashToken, +); check("PP-Multi-B join", j8b.data.data?.status, "PENDING"); // Approve both @@ -575,14 +602,18 @@ const j9Envelope = await signJoinPayload({ contactEmail: null, jurisdictions: null, }, pp9); -const j9 = await post(PROVIDER_API, "/dashboard/council/join", { - councilUrl: COUNCIL_URL, - councilId: council2Id, - councilName: "Council 2", - councilPublicKey: councilKp.publicKey(), - ppPublicKey: pp9.publicKey(), - signedEnvelope: j9Envelope, -}, dashToken); +const j9 = await post( + PROVIDER_API, + `/providers/${pp9.publicKey()}/council/join`, + { + councilUrl: COUNCIL_URL, + councilId: council2Id, + councilName: "Council 2", + councilPublicKey: councilKp.publicKey(), + signedEnvelope: j9Envelope, + }, + dashToken, +); check("Join council 2", j9.data.data?.status, "PENDING"); // Council 2 requests should only show PP-Council2 diff --git a/e2e/main.ts b/e2e/main.ts index a1b36d5..84e0f24 100644 --- a/e2e/main.ts +++ b/e2e/main.ts @@ -6,6 +6,7 @@ import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; +import { registerEntity } from "../lib/register-entity.ts"; const DEPOSIT_AMOUNT = 10; // XLM const SEND_AMOUNT = 5; // XLM @@ -24,25 +25,6 @@ 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(); @@ -89,8 +71,13 @@ async function main() { // 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"); + await registerEntity( + config.providerUrl, + config.ppPublicKey, + alice, + "Alice", + ); + await registerEntity(config.providerUrl, config.ppPublicKey, bob, "Bob"); }); console.log(" Entities registered"); diff --git a/e2e/pos-instant/main.ts b/e2e/pos-instant/main.ts index baf7354..c9e0b36 100644 --- a/e2e/pos-instant/main.ts +++ b/e2e/pos-instant/main.ts @@ -38,6 +38,7 @@ import { payApi, walletAuth, } from "./e2e/pos-helpers.ts"; +import { registerEntity } from "../../lib/register-entity.ts"; __resetConfigForTests(); @@ -87,21 +88,12 @@ console.log(` Funded (${elapsed()})`); // identity; provider-platform now gates bundle admission on the submitter // being an APPROVED entity, so this registration is required. console.log("\n[1b/5] Registering pay-service as APPROVED entity..."); -const entityRes = await fetch(`${PROVIDER_URL}/api/v1/entities`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - pubkey: keys.payService.publicKey(), - name: "Pay Service", - jurisdictions: [], - }), -}); -// 409 = already APPROVED; treat as success for idempotency. -if (!entityRes.ok && entityRes.status !== 409) { - throw new Error( - `Entity registration failed: ${entityRes.status} ${await entityRes.text()}`, - ); -} +await registerEntity( + PROVIDER_URL, + keys.pp.publicKey(), + keys.payService, + "Pay Service", +); console.log(` Pay-service approved (${elapsed()})`); // [2] Create merchant on pay-platform + store UTXOs diff --git a/e2e/setup.sh b/e2e/setup.sh index 0449fbf..6c50fa0 100755 --- a/e2e/setup.sh +++ b/e2e/setup.sh @@ -136,7 +136,7 @@ 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 +# Bundles are URL-scoped to /providers/:ppPublicKey/entity/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 diff --git a/lib/client/bundle.ts b/lib/client/bundle.ts index fe5a1c3..8b66b53 100644 --- a/lib/client/bundle.ts +++ b/lib/client/bundle.ts @@ -10,7 +10,7 @@ export function submitBundle( const maxRetries = 10; const retryDelayMs = 5_000; const url = - `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles`; + `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/entity/bundles`; for (let attempt = 0; attempt < maxRetries; attempt++) { const res = await fetch(url, { @@ -57,7 +57,7 @@ export function waitForBundle( return withE2ESpan("bundle.wait", async () => { const start = Date.now(); const url = - `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/bundles/${bundleId}`; + `${config.providerUrl}/api/v1/providers/${config.ppPublicKey}/entity/bundles/${bundleId}`; while (Date.now() - start < timeoutMs) { await new Promise((r) => setTimeout(r, pollIntervalMs)); diff --git a/lib/register-entity.ts b/lib/register-entity.ts new file mode 100644 index 0000000..e4d51ba --- /dev/null +++ b/lib/register-entity.ts @@ -0,0 +1,63 @@ +import { Buffer } from "node:buffer"; +import type { Keypair } from "stellar-sdk"; + +/** + * Registers a user (the submitter) as an APPROVED entity against a specific + * PP. Replaces the old POST /api/v1/entities flat-pubkey call: the new + * endpoint is per-PP and requires a SEP-53/raw signed challenge proving + * the submitter controls the wallet. + * + * Flow: + * 1. POST /providers/:ppPublicKey/entities/challenge {pubkey} → {nonce} + * 2. Sign the raw base64-decoded nonce bytes with the user's Ed25519 key + * 3. POST /providers/:ppPublicKey/entities {pubkey, name, jurisdictions, + * signedChallenge: {nonce, signature}} + * + * Idempotent: 409 from the submit step means the entity is already APPROVED + * (treated as success). + */ +export async function registerEntity( + providerUrl: string, + ppPublicKey: string, + user: Keypair, + name: string, + jurisdictions: string[] = [], +): Promise { + const base = `${providerUrl}/api/v1/providers/${ + encodeURIComponent(ppPublicKey) + }/entities`; + + const challengeRes = await fetch(`${base}/challenge`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ pubkey: user.publicKey() }), + }); + if (!challengeRes.ok) { + throw new Error( + `Entity challenge failed for ${user.publicKey()}: ${challengeRes.status} ${await challengeRes + .text()}`, + ); + } + const { data: { nonce } } = await challengeRes.json(); + + const nonceBytes = Uint8Array.from(atob(nonce), (c) => c.charCodeAt(0)); + const sigBytes = user.sign(Buffer.from(nonceBytes)); + const signature = btoa(String.fromCharCode(...new Uint8Array(sigBytes))); + + const res = await fetch(base, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pubkey: user.publicKey(), + name, + jurisdictions, + signedChallenge: { nonce, signature }, + }), + }); + if (!res.ok && res.status !== 409) { + throw new Error( + `Entity registration failed for ${user.publicKey()}: ${res.status} ${await res + .text()}`, + ); + } +} diff --git a/lifecycle/ci-test.ts b/lifecycle/ci-test.ts index 9c027a5..091a1d5 100644 --- a/lifecycle/ci-test.ts +++ b/lifecycle/ci-test.ts @@ -13,7 +13,7 @@ * 4. Admin adds the channel via POST /council/channels * 5. PP operator authenticates to provider-platform dashboard → JWT * 6. PP operator registers the PP via POST /dashboard/pp/register - * 7. PP operator signs a join envelope and posts it via POST /dashboard/council/join + * 7. PP operator signs a join envelope and posts it via POST /providers/:ppPublicKey/council/join * (provider-platform forwards to council-platform; PENDING membership row created) * 8. Admin lists join requests, approves the PP's request * 9. Admin calls on-chain add_provider → emits provider_added event @@ -34,6 +34,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; +import { registerEntity } from "../lib/register-entity.ts"; const RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; @@ -60,25 +61,6 @@ function loadEnvFile(path: string): Record { return env; } -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) { @@ -161,9 +143,9 @@ async function pollMembershipActive( ): Promise { for (let i = 0; i < maxAttempts; i++) { const res = await fetch( - `${PROVIDER_URL}/api/v1/dashboard/council/membership?ppPublicKey=${ + `${PROVIDER_URL}/api/v1/providers/${ encodeURIComponent(ppPublicKey) - }`, + }/council/membership`, { headers: { "Authorization": `Bearer ${dashboardJwt}` } }, ); if (res.status === 200) { @@ -217,7 +199,7 @@ async function main() { horizonUrl, friendbotUrl: FRIENDBOT_URL, providerUrl: PROVIDER_URL, - // Bundles are URL-scoped to /providers/:ppPublicKey/bundles. Lifecycle + // Bundles are URL-scoped to /providers/:ppPublicKey/entity/bundles. Lifecycle // CI runs against the single seeded PP, so ppPublicKey == ppOperator. ppPublicKey: ppOperator.publicKey(), channelContractId: channelContractId as ContractId, @@ -336,22 +318,26 @@ async function main() { }; const signedEnvelope = await signJoinEnvelope(joinPayload, ppKeypair); - const joinRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/council/join`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${dashboardJwt}`, + const joinRes = await fetch( + `${PROVIDER_URL}/api/v1/providers/${ + encodeURIComponent(ppKeypair.publicKey()) + }/council/join`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${dashboardJwt}`, + }, + body: JSON.stringify({ + councilUrl: COUNCIL_URL, + councilId: channelAuthId, + councilName: "Lifecycle Council", + label: "Lifecycle PP", + contactEmail: "pp@lifecycle.test", + signedEnvelope, + }), }, - body: JSON.stringify({ - councilUrl: COUNCIL_URL, - councilId: channelAuthId, - councilName: "Lifecycle Council", - ppPublicKey: ppKeypair.publicKey(), - label: "Lifecycle PP", - contactEmail: "pp@lifecycle.test", - signedEnvelope, - }), - }); + ); if (!joinRes.ok) { const body = await joinRes.json().catch(() => ({})); throw new Error( @@ -440,14 +426,14 @@ async function main() { const aliceJwt = await authenticate(alice, e2eConfig); console.log(" Alice authenticated"); - await registerEntity(PROVIDER_URL, alice.publicKey(), "Alice"); + await registerEntity(PROVIDER_URL, ppOperator.publicKey(), alice, "Alice"); console.log(" Alice approved as entity"); await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, e2eConfig); console.log(` Deposit ${DEPOSIT_AMOUNT} XLM complete`); const bobJwt = await authenticate(bob, e2eConfig); console.log(" Bob authenticated"); - await registerEntity(PROVIDER_URL, bob.publicKey(), "Bob"); + await registerEntity(PROVIDER_URL, ppOperator.publicKey(), bob, "Bob"); console.log(" Bob approved as entity"); const receiverOps = await prepareReceive( bob.secret(), diff --git a/lifecycle/main.ts b/lifecycle/main.ts index 52830a4..ff664a5 100644 --- a/lifecycle/main.ts +++ b/lifecycle/main.ts @@ -19,6 +19,7 @@ import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; import type { Config } from "../lib/client/config.ts"; +import { registerEntity } from "../lib/register-entity.ts"; const DEPOSIT_AMOUNT = 10; // XLM const SEND_AMOUNT = 5; // XLM @@ -53,25 +54,6 @@ 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) { @@ -240,7 +222,7 @@ async function main() { horizonUrl, friendbotUrl: FRIENDBOT_URL, providerUrl: PROVIDER_URL, - // Bundles are URL-scoped: /providers/:ppPublicKey/bundles. Lifecycle + // Bundles are URL-scoped: /providers/:ppPublicKey/entity/bundles. Lifecycle // runs against a single seeded PP, so ppPublicKey == provider.publicKey(). ppPublicKey: provider.publicKey(), channelContractId: channelContractId as ContractId, @@ -264,7 +246,7 @@ 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"); + await registerEntity(PROVIDER_URL, provider.publicKey(), alice, "Alice"); console.log(" Alice approved as entity"); await deposit(alice.secret(), DEPOSIT_AMOUNT, aliceJwt, e2eConfig); console.log(" Deposit complete"); @@ -273,7 +255,7 @@ 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"); + await registerEntity(PROVIDER_URL, provider.publicKey(), bob, "Bob"); console.log(" Bob approved as entity"); const receiverOps = await prepareReceive( bob.secret(), diff --git a/lifecycle/testnet-verify.ts b/lifecycle/testnet-verify.ts index ccc0ced..8e4085d 100644 --- a/lifecycle/testnet-verify.ts +++ b/lifecycle/testnet-verify.ts @@ -13,7 +13,7 @@ * 6. PP operator authenticates to provider-platform dashboard → JWT * 7. PP operator registers a PP via POST /dashboard/pp/register * 8. PP operator signs a join envelope and submits via - * POST /dashboard/council/join (forwarded to council-platform) + * POST /providers/:ppPublicKey/council/join (forwarded to council-platform) * 9. Admin lists join requests and approves * 10. Admin calls on-chain add_provider * 11. Provider-platform's event watcher activates the membership @@ -54,6 +54,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; +import { registerEntity } from "../lib/register-entity.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; import { exerciseCouncilSpans } from "../lib/exercise-cp-spans.ts"; @@ -79,25 +80,6 @@ const SEND_AMOUNT = 5; const WITHDRAW_AMOUNT = 4; // ─── Helpers ────────────────────────────────────────────────────────── -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; idempotent. - 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 && res.status !== 400) { @@ -193,9 +175,9 @@ async function pollMembershipActive( ): Promise { for (let i = 0; i < maxAttempts; i++) { const res = await fetch( - `${PROVIDER_URL}/api/v1/dashboard/council/membership?ppPublicKey=${ + `${PROVIDER_URL}/api/v1/providers/${ encodeURIComponent(ppPublicKey) - }`, + }/council/membership`, { headers: { "Authorization": `Bearer ${dashboardJwt}` } }, ); if (res.status === 200) { @@ -397,22 +379,26 @@ async function main() { }; const signedEnvelope = await signJoinEnvelope(joinPayload, ppKeypair); - const joinRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/council/join`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${dashboardJwt}`, + const joinRes = await fetch( + `${PROVIDER_URL}/api/v1/providers/${ + encodeURIComponent(ppKeypair.publicKey()) + }/council/join`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${dashboardJwt}`, + }, + body: JSON.stringify({ + councilUrl: COUNCIL_URL, + councilId: channelAuthId, + councilName: "Testnet Verify Council", + label: "Testnet Verify PP", + contactEmail: "pp@testnet-verify.moonlight.test", + signedEnvelope, + }), }, - body: JSON.stringify({ - councilUrl: COUNCIL_URL, - councilId: channelAuthId, - councilName: "Testnet Verify Council", - ppPublicKey: ppKeypair.publicKey(), - label: "Testnet Verify PP", - contactEmail: "pp@testnet-verify.moonlight.test", - signedEnvelope, - }), - }); + ); if (!joinRes.ok) { throw new Error( `Join request failed: ${joinRes.status} ${await joinRes.text()}`, @@ -519,7 +505,7 @@ async function main() { horizonUrl, friendbotUrl: FRIENDBOT_URL, providerUrl: PROVIDER_URL, - // Bundles are URL-scoped: /providers/:ppPublicKey/bundles. testnet-verify + // Bundles are URL-scoped: /providers/:ppPublicKey/entity/bundles. testnet-verify // runs against the single PP it just registered, so ppPublicKey == // ppKeypair.publicKey(). ppPublicKey: ppKeypair.publicKey(), @@ -555,7 +541,7 @@ async function main() { // provider-platform now gates bundle admission on the submitter's entity // being APPROVED. Register Alice (and Bob below) before any bundle. - await registerEntity(PROVIDER_URL, alice.publicKey(), "Alice"); + await registerEntity(PROVIDER_URL, ppKeypair.publicKey(), alice, "Alice"); console.log(" Alice approved as entity"); await withE2ESpan( @@ -570,7 +556,7 @@ async function main() { () => authenticate(bob, e2eConfig), ); console.log(" Bob authenticated"); - await registerEntity(PROVIDER_URL, bob.publicKey(), "Bob"); + await registerEntity(PROVIDER_URL, ppKeypair.publicKey(), bob, "Bob"); console.log(" Bob approved as entity"); const receiverOps = await withE2ESpan( diff --git a/send-loop.ts b/send-loop.ts index 7be046a..22a9c5f 100644 --- a/send-loop.ts +++ b/send-loop.ts @@ -15,8 +15,8 @@ * 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 postgres from "postgres"; import { authenticate } from "./lib/client/auth.ts"; import { loadConfig } from "./lib/client/config.ts"; import { deposit } from "./lib/client/deposit.ts"; @@ -24,6 +24,7 @@ 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"; +import { registerEntity } from "./lib/register-entity.ts"; const STATE_FILE = Deno.env.get("STATE_FILE") ?? new URL("./.local-dev-state", import.meta.url).pathname; @@ -216,94 +217,55 @@ function loadState(): State { }; } -// 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( - `Operator challenge failed: ${challengeRes.status} ${await challengeRes - .text()}`, - ); - } - 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; -} - +/** + * Force-expires the given bundles by writing EXPIRED to PostgreSQL and + * back-dating their TTL so the mempool's next TTL-check tick (default 5s) + * sees the past TTL and evicts them from the in-process mempool via the + * platform's own purge path. Replaces the now-deprecated admin HTTP endpoint + * POST /api/v1/dashboard/bundles/expire — there is intentionally no HTTP + * admin-expire surface. + * + * Caveats: + * - There is a short race window: between the UPDATE landing and the + * mempool's next TTL sweep, the executor may still pull the bundle from + * the in-memory mempool and try to settle it. On settlement success + * the executor's status-transition is gated on PENDING/PROCESSING so our + * EXPIRED stands. On settlement failure the executor's failure-handler + * writes FAILED unconditionally, which is the same kind of terminal + * non-COMPLETED state INJECT_EXPIRE is designed to produce — so for the + * test-injection purpose either outcome is acceptable. A clean DB-only + * guarantee would require in-process mempool access we deliberately + * don't expose over HTTP. + * - Connects to localhost:5442 by default (the host-side PG port from + * local-dev's compose). Override with DATABASE_URL. + */ async function forceExpireBundles( bundleIds: string[], - state: State, + _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)}`); -} - -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()}`, + if (bundleIds.length === 0) return; + const databaseUrl = Deno.env.get("DATABASE_URL") ?? + "postgresql://admin:devpass@localhost:5442/provider_platform_db"; + const db = postgres(databaseUrl); + try { + const updated = await db<{ id: string }[]>` + UPDATE operations_bundles + SET status = 'EXPIRED', + ttl = NOW() - INTERVAL '1 hour', + updated_at = NOW() + WHERE id IN ${db(bundleIds)} + AND status IN ('PENDING', 'PROCESSING') + AND deleted_at IS NULL + RETURNING id + `; + console.log( + ` [INJECT_EXPIRE] force-expired ${updated.length} of ${bundleIds.length} bundle(s) via DB ` + + `(TTL back-dated so the mempool TTL-check sweep purges in ≤${ + Number(Deno.env.get("MEMPOOL_TTL_CHECK_INTERVAL_MS") ?? 5000) + }ms)`, ); + } finally { + await db.end({ timeout: 5 }); } } @@ -377,13 +339,15 @@ async function runCycleForPp( console.log("[2b/5] Registering KYC/KYB entities"); await registerEntity( state.PROVIDER_URL, - alicia.publicKey(), + pp.pk, + alicia, senderName, [country], ); await registerEntity( state.PROVIDER_URL, - roberto.publicKey(), + pp.pk, + roberto, receiverName, [country], ); @@ -438,7 +402,8 @@ async function runCycleForPp( if (INJECT_EXPIRE) { // Submit one extra bundle without waiting, then immediately admin-expire - // it via POST /dashboard/bundles/expire — bundle settles as EXPIRED. + // it via the now-deprecated POST /dashboard/bundles/expire path — + // currently a no-op stub, pending a DB-direct rewrite. console.log("\n[5/6] Injecting one EXPIRED bundle (force-expire)"); try { const receiverOps = await prepareReceive( diff --git a/setup-pp.sh b/setup-pp.sh index 1968136..fef587f 100755 --- a/setup-pp.sh +++ b/setup-pp.sh @@ -9,7 +9,7 @@ set -euo pipefail # - Generates a fresh PP operator keypair, funds via Friendbot # - PP operator authenticates to provider-platform dashboard # - PP operator registers a PP via POST /dashboard/pp/register -# - PP operator submits a signed join envelope via POST /dashboard/council/join +# - PP operator submits a signed join envelope via POST /providers/:ppPublicKey/council/join # - Admin authenticates to council-platform, lists requests, approves # - Admin calls on-chain add_provider against the channel-auth contract # - Polls provider-platform until the membership flips ACTIVE diff --git a/setup-pp.ts b/setup-pp.ts index 5d8eaac..afd02af 100644 --- a/setup-pp.ts +++ b/setup-pp.ts @@ -260,9 +260,9 @@ async function pollMembershipActive( ): Promise { for (let i = 0; i < maxAttempts; i++) { const res = await fetch( - `${PROVIDER_URL}/api/v1/dashboard/council/membership?ppPublicKey=${ + `${PROVIDER_URL}/api/v1/providers/${ encodeURIComponent(ppPublicKey) - }`, + }/council/membership`, { headers: { "Authorization": `Bearer ${dashboardJwt}` } }, ); if (res.status === 200) { @@ -365,23 +365,27 @@ async function setupOnePP( }; const signedEnvelope = await signJoinEnvelope(joinPayload, kp); - const joinRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/council/join`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${dashboardJwt}`, + const joinRes = await fetch( + `${PROVIDER_URL}/api/v1/providers/${ + encodeURIComponent(kp.publicKey()) + }/council/join`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${dashboardJwt}`, + }, + body: JSON.stringify({ + councilUrl: Deno.env.get("COUNCIL_URL") ?? "http://localhost:3015", + councilId: council.id, + councilName: council.name, + label: spec.name, + contactEmail: + `${spec.jurisdiction.toLowerCase()}-pp@local-dev.moonlight.test`, + signedEnvelope, + }), }, - body: JSON.stringify({ - 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, - }), - }); + ); if (!joinRes.ok) { throw new Error( `Join request failed: ${joinRes.status} ${await joinRes.text()}`, diff --git a/test/setup-e2e.ts b/test/setup-e2e.ts index 85be850..084b02f 100644 --- a/test/setup-e2e.ts +++ b/test/setup-e2e.ts @@ -244,7 +244,7 @@ E2E_CHANNEL_ASSET_CONTRACT_ID=${assetContractId} # Provider keypair (registered on-chain by setup) E2E_PROVIDER_PK=${provider.publicKey()} E2E_PROVIDER_SK=${provider.secret()} -# Bundles are URL-scoped to /providers/:ppPublicKey/bundles; in the single-PP +# Bundles are URL-scoped to /providers/:ppPublicKey/entity/bundles; in the single-PP # e2e harness the PP key == the provider key. E2E_PP_PUBLIC_KEY=${provider.publicKey()} diff --git a/testnet/main.ts b/testnet/main.ts index 4e180f5..3dac144 100644 --- a/testnet/main.ts +++ b/testnet/main.ts @@ -36,6 +36,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; +import { registerEntity } from "../lib/register-entity.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; import { exerciseCouncilSpans } from "../lib/exercise-cp-spans.ts"; @@ -61,25 +62,6 @@ const SEND_AMOUNT = 5; const WITHDRAW_AMOUNT = 4; // ─── Helpers ──────────────────────────────────────────────────────── -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; idempotent. - 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 && res.status !== 400) { @@ -164,9 +146,9 @@ async function pollMembershipActive( ): Promise { for (let i = 0; i < maxAttempts; i++) { const res = await fetch( - `${PROVIDER_URL}/api/v1/dashboard/council/membership?ppPublicKey=${ + `${PROVIDER_URL}/api/v1/providers/${ encodeURIComponent(ppPublicKey) - }`, + }/council/membership`, { headers: { "Authorization": `Bearer ${dashboardJwt}` } }, ); if (res.status === 200) { @@ -355,22 +337,26 @@ async function main() { contactEmail: "pp@testnet-e2e.moonlight.test", }; const signedEnvelope = await signJoinEnvelope(joinPayload, ppOperator); - const joinRes = await fetch(`${PROVIDER_URL}/api/v1/dashboard/council/join`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${dashboardJwt}`, + const joinRes = await fetch( + `${PROVIDER_URL}/api/v1/providers/${ + encodeURIComponent(ppOperator.publicKey()) + }/council/join`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${dashboardJwt}`, + }, + body: JSON.stringify({ + councilUrl: COUNCIL_URL, + councilId: channelAuthId, + councilName: "Testnet E2E Council", + label: "Testnet E2E PP", + contactEmail: "pp@testnet-e2e.moonlight.test", + signedEnvelope, + }), }, - body: JSON.stringify({ - councilUrl: COUNCIL_URL, - councilId: channelAuthId, - councilName: "Testnet E2E Council", - ppPublicKey: ppOperator.publicKey(), - label: "Testnet E2E PP", - contactEmail: "pp@testnet-e2e.moonlight.test", - signedEnvelope, - }), - }); + ); if (!joinRes.ok) { throw new Error( `Join request failed: ${joinRes.status} ${await joinRes.text()}`, @@ -474,7 +460,7 @@ async function main() { horizonUrl, friendbotUrl: FRIENDBOT_URL, providerUrl: PROVIDER_URL, - // Bundles are URL-scoped: /providers/:ppPublicKey/bundles. testnet/main + // Bundles are URL-scoped: /providers/:ppPublicKey/entity/bundles. testnet/main // runs against the single PP it registered above, so ppPublicKey == // ppOperator.publicKey(). ppPublicKey: ppOperator.publicKey(), @@ -515,10 +501,10 @@ async function main() { console.log(" Bob authenticated"); // provider-platform now gates bundle admission on the submitter's entity - // being APPROVED. Register Alice and Bob via POST /api/v1/entities before + // being APPROVED. Register Alice and Bob via POST /providers/:pp/entities before // any deposit / send / withdraw. - await registerEntity(PROVIDER_URL, alice.publicKey(), "Alice"); - await registerEntity(PROVIDER_URL, bob.publicKey(), "Bob"); + await registerEntity(PROVIDER_URL, ppOperator.publicKey(), alice, "Alice"); + await registerEntity(PROVIDER_URL, ppOperator.publicKey(), bob, "Bob"); console.log(" Alice + Bob approved as entities"); await withE2ESpan( From 256232056053a7c388bb4c86994e733d7abbf838 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 1 Jun 2026 17:08:49 -0300 Subject: [PATCH 2/5] fix: move register-entity into lib/client/ so test-runner containers can reach it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The docker-compose test-runner entrypoints (e2e, otel, lifecycle, pos-instant) only copy /lib-src/client into the container — lib/register-entity.ts at the parent path is invisible. Moving the helper into lib/client/ (next to its peer client helpers auth.ts/bundle.ts/send.ts) lets the existing compose mount pattern reach it without touching the entrypoint shell. --- e2e/main.ts | 2 +- e2e/pos-instant/main.ts | 2 +- lib/{ => client}/register-entity.ts | 0 lifecycle/ci-test.ts | 2 +- lifecycle/main.ts | 2 +- lifecycle/testnet-verify.ts | 2 +- send-loop.ts | 2 +- testnet/main.ts | 2 +- 8 files changed, 7 insertions(+), 7 deletions(-) rename lib/{ => client}/register-entity.ts (100%) diff --git a/e2e/main.ts b/e2e/main.ts index 84e0f24..b17c677 100644 --- a/e2e/main.ts +++ b/e2e/main.ts @@ -6,7 +6,7 @@ import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; -import { registerEntity } from "../lib/register-entity.ts"; +import { registerEntity } from "../lib/client/register-entity.ts"; const DEPOSIT_AMOUNT = 10; // XLM const SEND_AMOUNT = 5; // XLM diff --git a/e2e/pos-instant/main.ts b/e2e/pos-instant/main.ts index c9e0b36..d093c3b 100644 --- a/e2e/pos-instant/main.ts +++ b/e2e/pos-instant/main.ts @@ -38,7 +38,7 @@ import { payApi, walletAuth, } from "./e2e/pos-helpers.ts"; -import { registerEntity } from "../../lib/register-entity.ts"; +import { registerEntity } from "../../lib/client/register-entity.ts"; __resetConfigForTests(); diff --git a/lib/register-entity.ts b/lib/client/register-entity.ts similarity index 100% rename from lib/register-entity.ts rename to lib/client/register-entity.ts diff --git a/lifecycle/ci-test.ts b/lifecycle/ci-test.ts index 091a1d5..4d35ed4 100644 --- a/lifecycle/ci-test.ts +++ b/lifecycle/ci-test.ts @@ -34,7 +34,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; -import { registerEntity } from "../lib/register-entity.ts"; +import { registerEntity } from "../lib/client/register-entity.ts"; const RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; diff --git a/lifecycle/main.ts b/lifecycle/main.ts index ff664a5..f3eeb5e 100644 --- a/lifecycle/main.ts +++ b/lifecycle/main.ts @@ -19,7 +19,7 @@ import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; import type { Config } from "../lib/client/config.ts"; -import { registerEntity } from "../lib/register-entity.ts"; +import { registerEntity } from "../lib/client/register-entity.ts"; const DEPOSIT_AMOUNT = 10; // XLM const SEND_AMOUNT = 5; // XLM diff --git a/lifecycle/testnet-verify.ts b/lifecycle/testnet-verify.ts index 8e4085d..69f2eac 100644 --- a/lifecycle/testnet-verify.ts +++ b/lifecycle/testnet-verify.ts @@ -54,7 +54,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; -import { registerEntity } from "../lib/register-entity.ts"; +import { registerEntity } from "../lib/client/register-entity.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; import { exerciseCouncilSpans } from "../lib/exercise-cp-spans.ts"; diff --git a/send-loop.ts b/send-loop.ts index 22a9c5f..5cf5774 100644 --- a/send-loop.ts +++ b/send-loop.ts @@ -24,7 +24,7 @@ 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"; -import { registerEntity } from "./lib/register-entity.ts"; +import { registerEntity } from "./lib/client/register-entity.ts"; const STATE_FILE = Deno.env.get("STATE_FILE") ?? new URL("./.local-dev-state", import.meta.url).pathname; diff --git a/testnet/main.ts b/testnet/main.ts index 3dac144..fb85126 100644 --- a/testnet/main.ts +++ b/testnet/main.ts @@ -36,7 +36,7 @@ import { deposit } from "../lib/client/deposit.ts"; import { prepareReceive } from "../lib/client/receive.ts"; import { send } from "../lib/client/send.ts"; import { withdraw } from "../lib/client/withdraw.ts"; -import { registerEntity } from "../lib/register-entity.ts"; +import { registerEntity } from "../lib/client/register-entity.ts"; import { sdkTracer, withE2ESpan, writeTraceIds } from "../lib/client/tracer.ts"; import { exerciseCouncilSpans } from "../lib/exercise-cp-spans.ts"; From 783a9ac41689813ca3c903073781515206395437 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 1 Jun 2026 18:30:41 -0300 Subject: [PATCH 3/5] fix(pos-instant): copy lib/client into the test-runner container The pos-instant test-runner's docker-compose entrypoint copied only /lib-src/*.ts into /app/lib/, skipping the lib/client/ subdirectory that every other suite already copies. After register-entity moved into lib/client/, the import resolved correctly on the host but failed in the container with 'Module not found'. Aligning the cp pattern with the other suites' shape (add cp -r /lib-src/client lib/) plus switching the import to the container-relative form (./lib/client/register-entity.ts, matching the existing ./moonlight-pay-lib/ and ./e2e/ imports) makes the suite green. --- docker-compose.pos-instant.yml | 3 ++- e2e/pos-instant/main.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docker-compose.pos-instant.yml b/docker-compose.pos-instant.yml index 3d66456..d7c07cb 100644 --- a/docker-compose.pos-instant.yml +++ b/docker-compose.pos-instant.yml @@ -202,7 +202,8 @@ services: - | cp /pos-src/*.ts /pos-src/deno.json . && cp /pos-src/deno.lock . 2>/dev/null || true mkdir -p e2e && cp /e2e-src/*.ts /e2e-src/deno.json e2e/ 2>/dev/null || true - mkdir -p lib && cp /lib-src/*.ts lib/ + mkdir -p lib && cp /lib-src/*.ts lib/ 2>/dev/null || true + cp -r /lib-src/client lib/ mkdir -p moonlight-pay-lib && cp /moonlight-pay-lib/*.ts moonlight-pay-lib/ deno install echo "Waiting for provider, council, and pay-platform..." diff --git a/e2e/pos-instant/main.ts b/e2e/pos-instant/main.ts index d093c3b..3bfa529 100644 --- a/e2e/pos-instant/main.ts +++ b/e2e/pos-instant/main.ts @@ -38,7 +38,7 @@ import { payApi, walletAuth, } from "./e2e/pos-helpers.ts"; -import { registerEntity } from "../../lib/client/register-entity.ts"; +import { registerEntity } from "./lib/client/register-entity.ts"; __resetConfigForTests(); From de99c41df7daddf64804df32d82d2343fd8a0112 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 1 Jun 2026 20:00:48 -0300 Subject: [PATCH 4/5] feat(ci): add local_dev_ref input to the e2e + lifecycle reusable workflows The reusable workflows previously hardcoded ref: main when checking out local-dev. Cross-repo migrations (consumer PRs that depend on a not-yet- merged local-dev change) had no way to test against the matching local-dev branch and would fail CI against stale main. The new local_dev_ref input defaults to "main" so existing callers keep their behaviour; consumer PRs override it to their matching local-dev branch. --- .github/workflows/e2e-reusable.yml | 6 +++++- .github/workflows/lifecycle-reusable.yml | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index 8e66e20..b7d3c41 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -23,6 +23,10 @@ on: description: "Full image ref override for provider-platform (e.g. ghcr.io/org/repo:sha-abc123)" type: string default: "" + local_dev_ref: + description: "Branch/tag/SHA of local-dev to check out for the test harness (default 'main'). Override when calling from a consumer PR whose changes assume a not-yet-merged local-dev branch." + type: string + default: "main" secrets: E2E_TRIGGER_TOKEN: required: true @@ -40,7 +44,7 @@ jobs: - uses: actions/checkout@v4 with: repository: moonlight-protocol/local-dev - ref: main + ref: ${{ inputs.local_dev_ref }} token: ${{ secrets.E2E_TRIGGER_TOKEN }} - name: Log in to GHCR diff --git a/.github/workflows/lifecycle-reusable.yml b/.github/workflows/lifecycle-reusable.yml index fee2778..cf390af 100644 --- a/.github/workflows/lifecycle-reusable.yml +++ b/.github/workflows/lifecycle-reusable.yml @@ -27,6 +27,10 @@ on: description: "Full image ref override for council-platform (e.g. ghcr.io/org/repo:sha-abc123)" type: string default: "" + local_dev_ref: + description: "Branch/tag/SHA of local-dev to check out for the test harness (default 'main'). Override when calling from a consumer PR whose changes assume a not-yet-merged local-dev branch." + type: string + default: "main" secrets: E2E_TRIGGER_TOKEN: required: true @@ -44,7 +48,7 @@ jobs: - uses: actions/checkout@v4 with: repository: moonlight-protocol/local-dev - ref: main + ref: ${{ inputs.local_dev_ref }} token: ${{ secrets.E2E_TRIGGER_TOKEN }} - name: Log in to GHCR From fa6713a63758246b103f2ab98dcf92577fe4c6b9 Mon Sep 17 00:00:00 2001 From: Gorka Date: Mon, 1 Jun 2026 20:04:42 -0300 Subject: [PATCH 5/5] ci: restore e2e + lifecycle reusable workflows to ref: main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts de99c41. The local_dev_ref input + dynamic ref change was an unauthorized infra edit — those workflow files are not in this PR's scope. --- .github/workflows/e2e-reusable.yml | 6 +----- .github/workflows/lifecycle-reusable.yml | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/.github/workflows/e2e-reusable.yml b/.github/workflows/e2e-reusable.yml index b7d3c41..8e66e20 100644 --- a/.github/workflows/e2e-reusable.yml +++ b/.github/workflows/e2e-reusable.yml @@ -23,10 +23,6 @@ on: description: "Full image ref override for provider-platform (e.g. ghcr.io/org/repo:sha-abc123)" type: string default: "" - local_dev_ref: - description: "Branch/tag/SHA of local-dev to check out for the test harness (default 'main'). Override when calling from a consumer PR whose changes assume a not-yet-merged local-dev branch." - type: string - default: "main" secrets: E2E_TRIGGER_TOKEN: required: true @@ -44,7 +40,7 @@ jobs: - uses: actions/checkout@v4 with: repository: moonlight-protocol/local-dev - ref: ${{ inputs.local_dev_ref }} + ref: main token: ${{ secrets.E2E_TRIGGER_TOKEN }} - name: Log in to GHCR diff --git a/.github/workflows/lifecycle-reusable.yml b/.github/workflows/lifecycle-reusable.yml index cf390af..fee2778 100644 --- a/.github/workflows/lifecycle-reusable.yml +++ b/.github/workflows/lifecycle-reusable.yml @@ -27,10 +27,6 @@ on: description: "Full image ref override for council-platform (e.g. ghcr.io/org/repo:sha-abc123)" type: string default: "" - local_dev_ref: - description: "Branch/tag/SHA of local-dev to check out for the test harness (default 'main'). Override when calling from a consumer PR whose changes assume a not-yet-merged local-dev branch." - type: string - default: "main" secrets: E2E_TRIGGER_TOKEN: required: true @@ -48,7 +44,7 @@ jobs: - uses: actions/checkout@v4 with: repository: moonlight-protocol/local-dev - ref: ${{ inputs.local_dev_ref }} + ref: main token: ${{ secrets.E2E_TRIGGER_TOKEN }} - name: Log in to GHCR