From d9c162c650b1ba7e48b87fe2c6d5aa584e286fbc Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 29 Jul 2026 11:41:51 -0300 Subject: [PATCH 1/3] fix(recording): skip Friendbot funding on mainnet Mainnet has no Friendbot, so funding every account and aborting on failure made mainnet runs impossible. When TARGET=mainnet, skip funding and log that the four deterministic accounts (admin/pp/alice/bob) must be pre-funded with real XLM out of band. Local/testnet behavior unchanged. --- recording/setup-recording-keys.ts | 35 +++++++++++++++++++------------ 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/recording/setup-recording-keys.ts b/recording/setup-recording-keys.ts index 9f4dc98..3538685 100644 --- a/recording/setup-recording-keys.ts +++ b/recording/setup-recording-keys.ts @@ -270,20 +270,29 @@ async function main() { const keys = await deriveRecordingRunKeys(masterSecret, runId); - console.log("Funding accounts via Friendbot..."); - Deno.env.set("FRIENDBOT_URL", friendbot); - const fundResults = await fundAccounts([ - keys.admin.publicKey, - keys.pp.publicKey, - keys.alice.primary.publicKey, - keys.bob.primary.publicKey, - ]); - console.log(formatResults(fundResults)); + if (target === "mainnet") { + // Mainnet has no Friendbot. The four deterministic accounts (admin, pp, + // alice, bob) must be pre-funded with real XLM out of band before the run. + console.log( + "TARGET=mainnet — skipping Friendbot funding. Ensure admin/pp/alice/bob " + + "are pre-funded (see keys.txt for the addresses).", + ); + } else { + console.log("Funding accounts via Friendbot..."); + Deno.env.set("FRIENDBOT_URL", friendbot); + const fundResults = await fundAccounts([ + keys.admin.publicKey, + keys.pp.publicKey, + keys.alice.primary.publicKey, + keys.bob.primary.publicKey, + ]); + console.log(formatResults(fundResults)); - const failed = fundResults.filter((r) => r.status === "FAILED"); - if (failed.length > 0) { - console.error(`\n${failed.length} account(s) failed to fund. Aborting.`); - Deno.exit(1); + const failed = fundResults.filter((r) => r.status === "FAILED"); + if (failed.length > 0) { + console.error(`\n${failed.length} account(s) failed to fund. Aborting.`); + Deno.exit(1); + } } await ensureDir(outputDir); From be18ebfafd4f04117785cacd03acf8f1205619e7 Mon Sep 17 00:00:00 2001 From: Gorka Date: Fri, 31 Jul 2026 17:29:31 -0300 Subject: [PATCH 2/3] recording: adapt the Playwright rig for the mainnet run. Switches Freighter to Public for mainnet, retitles the councils and README to Tranche 3, and drops the deposit/send/withdraw and OpEx funding amounts from testnet scale to the amounts actually used on mainnet. --- recording/playwright/README.md | 2 +- recording/playwright/fixtures/freighter-setup.ts | 5 +++-- recording/playwright/specs/01-council-onboard.spec.ts | 2 +- .../specs/02-provider-create-join-approve.spec.ts | 10 ++++++---- recording/playwright/specs/03-private-transfer.spec.ts | 10 +++++----- 5 files changed, 16 insertions(+), 13 deletions(-) diff --git a/recording/playwright/README.md b/recording/playwright/README.md index 3da8787..344577e 100644 --- a/recording/playwright/README.md +++ b/recording/playwright/README.md @@ -1,6 +1,6 @@ # Recording rig — Playwright specs -Per-section silent video recordings for the Tranche 2 demo. Each spec produces a +Per-section silent video recordings for the Tranche 3 demo. Each spec produces a `.webm` under `recording/runs//videos/
/`. Audio + subtitles are added in post. diff --git a/recording/playwright/fixtures/freighter-setup.ts b/recording/playwright/fixtures/freighter-setup.ts index 19c18d1..bea8a4e 100644 --- a/recording/playwright/fixtures/freighter-setup.ts +++ b/recording/playwright/fixtures/freighter-setup.ts @@ -11,6 +11,7 @@ import { type BrowserContext } from "@playwright/test"; import { addLocalNetwork, setupFreighterAccount, + switchToMainnet, switchToTestnet, } from "../../../playwright/fixtures/freighter"; @@ -109,9 +110,9 @@ export async function setupFreighterForKyc( await addLocalNetwork(page); } else if (network === "testnet") { await switchToTestnet(page); + } else if (network === "mainnet") { + await switchToMainnet(page); } - // mainnet: leave Freighter on its default Public network — mirrors the - // createUserContext path, which also does not switch for mainnet. await page.close(); return { extensionId }; } diff --git a/recording/playwright/specs/01-council-onboard.spec.ts b/recording/playwright/specs/01-council-onboard.spec.ts index 8afe21b..7bd313e 100644 --- a/recording/playwright/specs/01-council-onboard.spec.ts +++ b/recording/playwright/specs/01-council-onboard.spec.ts @@ -39,7 +39,7 @@ import { RECORDING_CONTEXT_OPTIONS } from "../fixtures/recording-context"; const COUNCIL_NAME = getCouncilName(); const COUNCIL_DESCRIPTION = - "Privacy-preserving payments council for the Tranche 2 demo recording."; + "Privacy-preserving payments council for the Tranche 3 demo recording."; const COUNCIL_EMAIL = "demo@moonlight.test"; const JURISDICTION = getJurisdiction(); diff --git a/recording/playwright/specs/02-provider-create-join-approve.spec.ts b/recording/playwright/specs/02-provider-create-join-approve.spec.ts index 0925d2e..db7bb19 100644 --- a/recording/playwright/specs/02-provider-create-join-approve.spec.ts +++ b/recording/playwright/specs/02-provider-create-join-approve.spec.ts @@ -77,11 +77,13 @@ test("02 — provider create + join + approve", async () => { await typeSlowly(providerPage.locator("#pp-email"), PROVIDER_EMAIL); await clickWithPause(providerPage.locator("#next-btn")); - // Beat 3 — fund PP operator. 10 XLM (the prior default) is insufficient - // for testnet Soroban resource fees on `transact`, which simulate to - // ~100 XLM per bundle. Fund 500 so the operator can pay several bundles. + // Beat 3 — fund the PP operator's OpEx account (demo beat). Phase 02 itself + // never spends the OpEx — the join/approve/add_provider beats are all + // admin-signed. The OpEx is only drawn on in phase 03 (bundle resource + // fees). On mainnet, seed a small amount here and top the OpEx up out of + // band before phase 03 rather than moving real XLM at testnet scale. await providerPage.waitForSelector("#fund-amount", { timeout: 15_000 }); - await typeSlowly(providerPage.locator("#fund-amount"), "500"); + await typeSlowly(providerPage.locator("#fund-amount"), "10"); await withWalletApproval(ppCtx.context, providerPage, async () => { await clickWithPause(providerPage.locator("#fund-btn")); }); diff --git a/recording/playwright/specs/03-private-transfer.spec.ts b/recording/playwright/specs/03-private-transfer.spec.ts index e4d7eb4..5480839 100644 --- a/recording/playwright/specs/03-private-transfer.spec.ts +++ b/recording/playwright/specs/03-private-transfer.spec.ts @@ -190,7 +190,7 @@ test("03 — private transfer (Bob receive → Alice deposit + send → Alice wi let aliceHandle: WalletContextHandle | undefined; try { - const bobMlxdr = await showReceive(bob.wallet, { amount: "25" }); + const bobMlxdr = await showReceive(bob.wallet, { amount: "15" }); if (!bobMlxdr || bobMlxdr.trim().length === 0) { throw new Error("Bob's MLXDR was not captured"); } @@ -216,18 +216,18 @@ test("03 — private transfer (Bob receive → Alice deposit + send → Alice wi }); aliceHandle = alice.handle; - await deposit(alice.wallet, { amount: "100" }); + await deposit(alice.wallet, { amount: "20" }); await send(alice.wallet, { receiverMlxdr: bobMlxdr, - amount: "25", + amount: "15", }); await closeReceiveConfirmation(bob.wallet); - await waitForConfidentialBalance(bob.wallet, 24.99); + await waitForConfidentialBalance(bob.wallet, 14.99); await withdraw(bob.wallet, { - amount: "20", + amount: "10", destinationAddress: env.BOB_PK, }); From 84e35c42059e501fc2abacd343fbd9f49fda90ba Mon Sep 17 00:00:00 2001 From: Gorka Date: Fri, 31 Jul 2026 17:29:31 -0300 Subject: [PATCH 3/3] recording: add channel scan, fund recovery and demo activity scripts. Headless SDK-driven tools written during the mainnet run: read a wallet's confidential balance, withdraw UTXO funds back to a public account without a browser, and generate minimal round-tripping activity so the dashboard shows events and volume. --- recording/demo-activity.ts | 189 +++++++++++++++++++++++++++++ recording/recover-channel-funds.ts | 174 ++++++++++++++++++++++++++ recording/scan-channel.ts | 65 ++++++++++ 3 files changed, 428 insertions(+) create mode 100644 recording/demo-activity.ts create mode 100644 recording/recover-channel-funds.ts create mode 100644 recording/scan-channel.ts diff --git a/recording/demo-activity.ts b/recording/demo-activity.ts new file mode 100644 index 0000000..84b02d4 --- /dev/null +++ b/recording/demo-activity.ts @@ -0,0 +1,189 @@ +// Generate minimal-amount confidential activity on a mainnet privacy channel so +// the network dashboard shows deposit / transfer / withdraw events + volume. +// Headless (no browser), sdk-driven. Amounts round-trip (recoverable); only the +// per-op settlement gas (~0.05-0.17 XLM, opex-paid) is truly spent. +import { + ChannelReadMethods, + MoonlightOperation, + PrivacyChannel, + StellarDerivator, + UtxoBasedStellarAccount, + UTXOStatus, +} from "jsr:@moonlight/moonlight-sdk@^0.13.0"; +import { fromDecimals, NetworkConfig } from "jsr:@colibri/core@^0.23.0"; +import { Buffer } from "node:buffer"; +import { Keypair, Networks, TransactionBuilder } from "npm:@stellar/stellar-sdk@^16.1.0"; + +const PASSPHRASE = "Public Global Stellar Network ; September 2015"; +const RPC = Deno.env.get("SCAN_RPC") ?? "https://mainnet.sorobanrpc.com"; +const CHANNEL = "CCLTT2ZJMMSKMUFTMDGZRRT76LFXK6INYM35VFKVZF5ZB4S7LQVEDZZ7"; +const QUORUM = "CABD46PWY4NN7VTXETAUZE5MVS5PRGMWCR2UV74RS25GQR3VXMYTTSEV"; +const PP_BASE = "https://provider-api.moonlightprotocol.io"; +const PP_PUBKEY = "GDIUMTDESAL2CKFHFQZKPTP5OC2RMAHRSV3BDNXA4ZCIFDR6WIB74LAN"; +const FEE = 0.1; // LOW entropy + +// small amounts — round-trip, only gas is spent +const BOB_SK = Deno.env.get("BOB_SK")!; +const ALICE_SK = Deno.env.get("ALICE_SK")!; +const ALICE_PK = Keypair.fromSecret(ALICE_SK).publicKey(); +const DEPOSIT = Number(Deno.env.get("DEPOSIT") ?? "5"); +const SEND = Number(Deno.env.get("SEND") ?? "3"); +const WITHDRAW = Number(Deno.env.get("WITHDRAW") ?? "2"); + +const nc = NetworkConfig.CustomNet({ networkPassphrase: PASSPHRASE, rpcUrl: RPC, horizonUrl: "https://horizon.stellar.org" }); + +async function latestLedger(): Promise { + const r = await fetch(RPC, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }) }); + return (await r.json()).result.sequence as number; +} +let ASSET = ""; +async function assetId(): Promise { + if (ASSET) return ASSET; + const p = new PrivacyChannel(nc, CHANNEL as never, QUORUM as never, CHANNEL as never); + ASSET = String(await p.read({ method: ChannelReadMethods.asset, methodArgs: {} })); + return ASSET; +} +async function mkAccount(secret: string) { + const asset = await assetId(); + const derivator = new StellarDerivator().withNetworkAndContract(PASSPHRASE as never, CHANNEL as never); + const channelClient = new PrivacyChannel(nc, CHANNEL as never, QUORUM as never, asset as never); + const acct = new UtxoBasedStellarAccount({ + root: secret as never, + derivator, + options: { + batchSize: 50, + fetchBalances(pks: Uint8Array[]) { + return channelClient.read({ method: ChannelReadMethods.utxo_balances, methodArgs: { utxos: pks.map((pk) => Buffer.from(pk)) } }); + }, + }, + }); + let s = 0; + while (acct.getUTXOsByState(UTXOStatus.FREE).length < 10 && s < 12) { await acct.deriveBatch({}); await acct.batchLoad(); s++; } + return acct; +} +function partition(total: bigint, parts: number): bigint[] { + const base = total / BigInt(parts); + const out = Array(parts).fill(base); + out[0] += total - base * BigInt(parts); + return out; +} +async function auth(kp: Keypair): Promise { + const ch = await (await fetch(`${PP_BASE}/api/v1/stellar/auth?account=${kp.publicKey()}`)).json(); + const tx = TransactionBuilder.fromXDR(ch.data.challenge, Networks.PUBLIC); + tx.sign(kp); + const a = await (await fetch(`${PP_BASE}/api/v1/stellar/auth`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ signedChallenge: tx.toXDR(), ppPublicKey: PP_PUBKEY }) })).json(); + if (!a.data?.jwt) throw new Error(`auth failed: ${JSON.stringify(a)}`); + return a.data.jwt; +} +async function submit(jwt: string, mlxdr: string[], label: string) { + const r = await (await fetch(`${PP_BASE}/api/v1/providers/${PP_PUBKEY}/entity/bundles`, { method: "POST", headers: { "content-type": "application/json", Authorization: `Bearer ${jwt}` }, body: JSON.stringify({ operationsMLXDR: mlxdr, channelContractId: CHANNEL }) })).json(); + const id = r.data?.operationsBundleId; + if (!id) throw new Error(`${label} submit failed: ${JSON.stringify(r)}`); + const start = Date.now(); + while (Date.now() - start < 180_000) { + await new Promise((x) => setTimeout(x, 5000)); + const pj = await (await fetch(`${PP_BASE}/api/v1/providers/${PP_PUBKEY}/entity/bundles/${id}`, { headers: { Authorization: `Bearer ${jwt}` } })).json(); + if (pj.data?.status === "COMPLETED") { console.log(` ${label}: COMPLETED (${id.slice(0, 8)})`); return; } + if (["FAILED", "EXPIRED"].includes(pj.data?.status)) throw new Error(`${label} ${pj.data.status}: ${JSON.stringify(pj.data?.failureDetail)}`); + } + throw new Error(`${label} poll timeout`); +} + +async function deposit(secret: string, amountXlm: number) { + console.log(`deposit ${amountXlm} XLM (${Keypair.fromSecret(secret).publicKey().slice(0, 8)})`); + const kp = Keypair.fromSecret(secret); + const acct = await mkAccount(secret); + const reserved = acct.reserveUTXOs(1); + if (!reserved) throw new Error("reserve failed"); + const amt = fromDecimals(amountXlm, 7); + const total = fromDecimals(amountXlm + FEE, 7); + const amts = partition(amt, reserved.length); + const createOps = reserved.map((u: any, i: number) => MoonlightOperation.create(u.publicKey, amts[i])); + const asset = await assetId(); + const exp = (await latestLedger()) + 1000; + const depositOp = await MoonlightOperation.deposit(kp.publicKey() as never, total) + .addConditions(createOps.map((o: any) => o.toCondition())) + .signWithEd25519(kp as never, exp, CHANNEL as never, asset as never, PASSPHRASE); + const mlxdr = [depositOp.toMLXDR(), ...createOps.map((o: any) => o.toMLXDR())]; + await submit(await auth(kp), mlxdr, "deposit"); +} + +async function receive(secret: string, amountXlm: number): Promise { + const acct = await mkAccount(secret); + const reserved = acct.reserveUTXOs(5); + if (!reserved || reserved.length !== 5) throw new Error("receive reserve failed"); + const amts = partition(fromDecimals(amountXlm, 7), 5); + const createOps = reserved.map((u: any, i: number) => MoonlightOperation.create(u.publicKey, amts[i])); + return createOps.map((o: any) => o.toMLXDR()); +} + +async function send(secret: string, receiverMLXDR: string[], amountXlm: number) { + console.log(`send ${amountXlm} XLM -> ${ALICE_PK.slice(0, 8)}`); + const kp = Keypair.fromSecret(secret); + const acct = await mkAccount(secret); + const total = fromDecimals(amountXlm + FEE, 7); + let best: any = null, smallest = Infinity; + for (let a = 0; a < 5; a++) { + const sel = acct.selectUTXOsForTransfer(total, "random" as never); + if (!sel) break; + if (sel.selectedUTXOs.length < smallest) { smallest = sel.selectedUTXOs.length; best = sel; } + if (sel.selectedUTXOs.length <= 10) break; + } + if (!best) throw new Error("insufficient balance for send"); + const receiverOps = receiverMLXDR.map((m) => MoonlightOperation.fromMLXDR(m)); + const createOps: any[] = receiverOps.map((op: any) => MoonlightOperation.create(op.getUtxo(), op.getAmount())); + if (best.changeAmount > 0n) { + const changeUtxos = acct.reserveUTXOs(1); + if (changeUtxos?.length) createOps.push(MoonlightOperation.create(changeUtxos[0].publicKey, best.changeAmount)); + } + const exp = (await latestLedger()) + 1000; + const spendOps: any[] = []; + for (const utxo of best.selectedUTXOs) { + const sp = MoonlightOperation.spend(utxo.publicKey); + for (const c of createOps) sp.addCondition(c.toCondition()); + await sp.signWithUTXO(utxo, CHANNEL as never, exp); + spendOps.push(sp); + } + const mlxdr = [...createOps.map((o: any) => o.toMLXDR()), ...spendOps.map((o: any) => o.toMLXDR())]; + await submit(await auth(kp), mlxdr, "send"); +} + +async function withdraw(secret: string, dest: string, amountXlm: number) { + console.log(`withdraw ${amountXlm} XLM -> ${dest.slice(0, 8)}`); + const kp = Keypair.fromSecret(secret); + const acct = await mkAccount(secret); + const total = fromDecimals(amountXlm + FEE, 7); + let best: any = null, smallest = Infinity; + for (let a = 0; a < 5; a++) { + const sel = acct.selectUTXOsForTransfer(total, "random" as never); + if (!sel) break; + if (sel.selectedUTXOs.length < smallest) { smallest = sel.selectedUTXOs.length; best = sel; } + if (sel.selectedUTXOs.length <= 10) break; + } + if (!best) throw new Error("insufficient balance for withdraw"); + const wOp = MoonlightOperation.withdraw(dest as never, fromDecimals(amountXlm, 7)); + const createOps: any[] = []; + if (best.changeAmount > 0n) { + const c = acct.reserveUTXOs(1); + if (c?.length) createOps.push(MoonlightOperation.create(c[0].publicKey, best.changeAmount)); + } + const exp = (await latestLedger()) + 1000; + const spendOps: any[] = []; + for (const utxo of best.selectedUTXOs) { + const sp = MoonlightOperation.spend(utxo.publicKey); + sp.addCondition(wOp.toCondition()); + for (const c of createOps) sp.addCondition(c.toCondition()); + await sp.signWithUTXO(utxo, CHANNEL as never, exp); + spendOps.push(sp); + } + const mlxdr = [wOp.toMLXDR(), ...createOps.map((o: any) => o.toMLXDR()), ...spendOps.map((o: any) => o.toMLXDR())]; + await submit(await auth(kp), mlxdr, "withdraw"); +} + +// ---- orchestrate: Bob deposit -> Alice receive -> Bob send -> Alice withdraw ---- +console.log(`cycle: deposit ${DEPOSIT} / send ${SEND} / withdraw ${WITHDRAW}\n`); +await deposit(BOB_SK, DEPOSIT); +const rcv = await receive(ALICE_SK, SEND); +await send(BOB_SK, rcv, SEND); +await withdraw(ALICE_SK, ALICE_PK, WITHDRAW); +console.log("\nDONE — dashboard should now show deposit + transfer + withdraw + volume"); diff --git a/recording/recover-channel-funds.ts b/recording/recover-channel-funds.ts new file mode 100644 index 0000000..9180962 --- /dev/null +++ b/recording/recover-channel-funds.ts @@ -0,0 +1,174 @@ +// Recover confidential (UTXO) funds from a moonlight privacy channel to the +// owner's public Stellar account — headless, no browser. Mirrors the wallet's +// prepareWithdrawOperations + PrivacyProviderClient submit path. +// +// Usage: +// OWNER_SK=S... DEST=G... AMOUNT=4.6 deno run -A recover-channel-funds.ts +// (DEST defaults to the owner's own public key; AMOUNT="max" auto-computes) +import { + ChannelReadMethods, + MoonlightOperation, + PrivacyChannel, + StellarDerivator, + UtxoBasedStellarAccount, + UTXOStatus, +} from "jsr:@moonlight/moonlight-sdk@^0.13.0"; +import { fromDecimals, NetworkConfig } from "jsr:@colibri/core@^0.23.0"; +import { Buffer } from "node:buffer"; +import { Keypair, Networks, TransactionBuilder } from "npm:@stellar/stellar-sdk@^16.1.0"; + +const PASSPHRASE = "Public Global Stellar Network ; September 2015"; +const RPC = Deno.env.get("SCAN_RPC") ?? "https://mainnet.sorobanrpc.com"; +const CHANNEL = "CCLTT2ZJMMSKMUFTMDGZRRT76LFXK6INYM35VFKVZF5ZB4S7LQVEDZZ7"; +const QUORUM = "CABD46PWY4NN7VTXETAUZE5MVS5PRGMWCR2UV74RS25GQR3VXMYTTSEV"; +const PP_BASE = "https://provider-api.moonlightprotocol.io"; +const PP_PUBKEY = "GDIUMTDESAL2CKFHFQZKPTP5OC2RMAHRSV3BDNXA4ZCIFDR6WIB74LAN"; +const FEE_XLM = 0.1; // LOW entropy fee +const DRY_RUN = Deno.env.get("DRY_RUN") === "1"; + +const networkConfig = NetworkConfig.CustomNet({ + networkPassphrase: PASSPHRASE, + rpcUrl: RPC, + horizonUrl: "https://horizon.stellar.org", +}); + +async function rpcLatestLedger(): Promise { + const res = await fetch(RPC, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }), + }); + const j = await res.json(); + return j.result.sequence as number; +} + +// ---- SEP-10 auth against the PP ---- +async function authenticate(kp: Keypair): Promise { + const ch = await fetch(`${PP_BASE}/api/v1/stellar/auth?account=${kp.publicKey()}`); + const chJson = await ch.json(); + const challengeXdr: string = chJson.data.challenge; + const tx = TransactionBuilder.fromXDR(challengeXdr, Networks.PUBLIC); + tx.sign(kp); + const signedChallenge = tx.toXDR(); + const auth = await fetch(`${PP_BASE}/api/v1/stellar/auth`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ signedChallenge, ppPublicKey: PP_PUBKEY }), + }); + const authJson = await auth.json(); + const jwt = authJson.data?.jwt; + if (!jwt) throw new Error(`auth failed: ${JSON.stringify(authJson)}`); + console.log(` auth ok (entityStatus=${authJson.data?.entityStatus})`); + return jwt; +} + +// ---- build withdraw operations (mirror of wallet prepareWithdrawOperations) ---- +async function buildWithdraw(rootSecret: string, dest: string, amountXlm: number, assetId: string) { + const derivator = new StellarDerivator().withNetworkAndContract(PASSPHRASE as never, CHANNEL as never); + const channelClient = new PrivacyChannel(networkConfig, CHANNEL as never, QUORUM as never, assetId as never); + const acct = new UtxoBasedStellarAccount({ + root: rootSecret as never, + derivator, + options: { + batchSize: 50, + fetchBalances(pks: Uint8Array[]) { + return channelClient.read({ + method: ChannelReadMethods.utxo_balances, + methodArgs: { utxos: pks.map((pk) => Buffer.from(pk)) }, + }); + }, + }, + }); + + let safety = 0; + while (acct.getUTXOsByState(UTXOStatus.FREE).length < 10 && safety < 12) { + await acct.deriveBatch({}); + await acct.batchLoad(); + safety++; + } + + const amountBig = fromDecimals(amountXlm, 7); + const feeBig = fromDecimals(FEE_XLM, 7); + const totalToSpend = amountBig + feeBig; + + let best: { selectedUTXOs: any[]; totalAmount: bigint; changeAmount: bigint } | null = null; + let smallest = Infinity; + for (let a = 0; a < 5; a++) { + const sel = acct.selectUTXOsForTransfer(totalToSpend, "random" as never); + if (!sel) break; + if (sel.selectedUTXOs.length < smallest) { smallest = sel.selectedUTXOs.length; best = sel; } + if (sel.selectedUTXOs.length <= 10) break; + } + if (!best) throw new Error(`insufficient confidential balance for ${amountXlm}+fee`); + + const { selectedUTXOs, changeAmount } = best; + const withdrawOp = MoonlightOperation.withdraw(dest as never, amountBig); + + const createOps: any[] = []; + if (changeAmount > 0n) { + const changeUtxos = acct.reserveUTXOs(1); + if (changeUtxos?.length) { + createOps.push(MoonlightOperation.create(changeUtxos[0].publicKey, changeAmount)); + } + } + + const expiration = (await rpcLatestLedger()) + 1000; + const spendOps: any[] = []; + for (const utxo of selectedUTXOs) { + const spendOp = MoonlightOperation.spend(utxo.publicKey); + spendOp.addCondition(withdrawOp.toCondition()); + for (const c of createOps) spendOp.addCondition(c.toCondition()); + await spendOp.signWithUTXO(utxo, CHANNEL as never, expiration); + spendOps.push(spendOp); + } + + return [withdrawOp.toMLXDR(), ...createOps.map((o) => o.toMLXDR()), ...spendOps.map((o) => o.toMLXDR())]; +} + +async function submitBundle(jwt: string, mlxdr: string[]): Promise { + const res = await fetch(`${PP_BASE}/api/v1/providers/${PP_PUBKEY}/entity/bundles`, { + method: "POST", + headers: { "content-type": "application/json", Authorization: `Bearer ${jwt}` }, + body: JSON.stringify({ operationsMLXDR: mlxdr, channelContractId: CHANNEL }), + }); + const j = await res.json(); + const id = j.data?.operationsBundleId; + if (!id) throw new Error(`submit failed (${res.status}): ${JSON.stringify(j)}`); + console.log(` bundle ${id} submitted, polling...`); + const start = Date.now(); + while (Date.now() - start < 180_000) { + await new Promise((r) => setTimeout(r, 5000)); + const pr = await fetch(`${PP_BASE}/api/v1/providers/${PP_PUBKEY}/entity/bundles/${id}`, { + headers: { Authorization: `Bearer ${jwt}` }, + }); + const pj = await pr.json(); + const st = pj.data?.status; + if (st === "COMPLETED") return "COMPLETED"; + if (st === "FAILED" || st === "EXPIRED") { + throw new Error(`bundle ${st}: ${JSON.stringify(pj.data?.failureDetail ?? {})}`); + } + } + throw new Error("bundle poll timeout"); +} + +// ---- main ---- +const kp = Keypair.fromSecret(Deno.env.get("OWNER_SK")!); +const dest = Deno.env.get("DEST") ?? kp.publicKey(); +console.log(`owner ${kp.publicKey().slice(0, 8)} -> dest ${dest.slice(0, 8)} | channel ${CHANNEL.slice(0, 8)}`); + +// read asset once +const assetProbe = new PrivacyChannel(networkConfig, CHANNEL as never, QUORUM as never, CHANNEL as never); +const assetId = String(await assetProbe.read({ method: ChannelReadMethods.asset, methodArgs: {} })); + +const amount = Number(Deno.env.get("AMOUNT")); +if (!Number.isFinite(amount) || amount <= 0) throw new Error("set AMOUNT="); + +console.log(`building withdraw of ${amount} XLM (+${FEE_XLM} fee)...`); +const mlxdr = await buildWithdraw(Deno.env.get("OWNER_SK")!, dest, amount, assetId); +console.log(` built ${mlxdr.length} operations`); + +if (DRY_RUN) { console.log("DRY_RUN=1 — not submitting."); Deno.exit(0); } + +const jwt = await authenticate(kp); +const result = await submitBundle(jwt, mlxdr); +console.log(`DONE: ${result}`); diff --git a/recording/scan-channel.ts b/recording/scan-channel.ts new file mode 100644 index 0000000..466cc07 --- /dev/null +++ b/recording/scan-channel.ts @@ -0,0 +1,65 @@ +// Read-only: scan a wallet's confidential (UTXO) balance in a privacy channel. +// No submission, no signing beyond local derivation. Validates the sdk path. +import { + ChannelReadMethods, + PrivacyChannel, + UtxoBasedStellarAccount, +} from "jsr:@moonlight/moonlight-sdk@^0.13.0"; +import { NetworkConfig } from "jsr:@colibri/core@^0.23.0"; + +const MAINNET_PASSPHRASE = "Public Global Stellar Network ; September 2015"; +const RPC = Deno.env.get("SCAN_RPC") ?? "https://mainnet.sorobanrpc.com"; +const CHANNEL = "CCLTT2ZJMMSKMUFTMDGZRRT76LFXK6INYM35VFKVZF5ZB4S7LQVEDZZ7"; +const QUORUM = "CABD46PWY4NN7VTXETAUZE5MVS5PRGMWCR2UV74RS25GQR3VXMYTTSEV"; +const TARGET_COUNT = Number(Deno.env.get("SCAN_COUNT") ?? "150"); + +const networkConfig = NetworkConfig.CustomNet({ + networkPassphrase: MAINNET_PASSPHRASE, + rpcUrl: RPC, + horizonUrl: "https://horizon.stellar.org", +}); + +async function scan(label: string, rootSecret: string) { + // Resolve the channel's asset contract. + const probe = new PrivacyChannel( + networkConfig, + CHANNEL as never, + QUORUM as never, + // asset unknown yet — read it off the channel first via a bare read. + CHANNEL as never, + ); + const assetId = await probe.read({ + method: ChannelReadMethods.asset, + methodArgs: {}, + }); + + const channelClient = new PrivacyChannel( + networkConfig, + CHANNEL as never, + QUORUM as never, + assetId as never, + ); + + const acct = UtxoBasedStellarAccount.fromPrivacyChannel({ + channelClient, + root: rootSecret as never, + options: { startIndex: 0, batchSize: 50 }, + }); + + for (let s = 0; s < TARGET_COUNT; s += 50) { + await acct.deriveBatch({ startIndex: s, count: Math.min(50, TARGET_COUNT - s) }); + } + await acct.batchLoad(); + + const all = acct.getAllUTXOs() as unknown as Array<{ index: number; balance: bigint | string }>; + const funded = all.filter((u) => BigInt(u.balance) > 0n); + const total = funded.reduce((sum, u) => sum + BigInt(u.balance), 0n); + console.log(`${label}: ${funded.length} funded UTXOs, balance = ${Number(total) / 1e7} XLM (${total} stroops)`); + for (const u of funded) console.log(` idx ${u.index}: ${Number(BigInt(u.balance)) / 1e7} XLM`); +} + +const ALICE_SK = Deno.env.get("OLD_ALICE_SK")!; +const BOB_SK = Deno.env.get("OLD_BOB_SK")!; +console.log(`RPC: ${RPC} | channel ${CHANNEL.slice(0, 8)} | scanning ${TARGET_COUNT} indices\n`); +await scan("old Alice", ALICE_SK); +await scan("old Bob", BOB_SK);