diff --git a/lifecycle/deno.lock b/lifecycle/deno.lock index b4dab82..9164778 100644 --- a/lifecycle/deno.lock +++ b/lifecycle/deno.lock @@ -14,6 +14,7 @@ "npm:@stellar/stellar-sdk@^14.2.0": "14.6.1", "npm:@stellar/stellar-sdk@^14.6.1": "14.6.1", "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:postgres@3.4.7": "3.4.7" @@ -151,6 +152,12 @@ "bignumber.js@9.3.1": { "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" }, + "bip39@3.1.0": { + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "dependencies": [ + "@noble/hashes" + ] + }, "buffer@6.0.3": { "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "dependencies": [ diff --git a/lifecycle/setup-accounts-extension.ts b/lifecycle/setup-accounts-extension.ts new file mode 100644 index 0000000..2ad69d7 --- /dev/null +++ b/lifecycle/setup-accounts-extension.ts @@ -0,0 +1,204 @@ +/** + * Local Dev — Browser Extension Account Funder + * + * Specialized helper that reads the browser-wallet's dev-seed files, + * derives the Stellar account keypair from each seed mnemonic at index 0 + * (matching `Keys.deriveStellarAccountFromMnemonic` in browser-wallet/src/keys/keys.ts), + * and funds those accounts via Friendbot. + * + * Why a separate script: setup-accounts.ts is generic (takes pubkeys + * directly). This wrapper knows where the wallet's seed mnemonics live + * and how the wallet derives accounts from them, so the typical manual + * test cycle never needs to copy-paste a Stellar address. + * + * Usage (preferred — via wrapper): + * ./setup-accounts-extension.sh + * + * Usage (direct): + * deno run --allow-all lifecycle/setup-accounts-extension.ts + * + * Env overrides: + * FRIENDBOT_URL default http://localhost:8000/friendbot + * WALLET_SEED_DIR default ../../browser-wallet + * SEED_FILES comma-separated, default ".env.seed.user1,.env.seed.user2" + * DERIVATION_INDEX default 0 + */ +import { Keypair } from "stellar-sdk"; +import { Buffer } from "node:buffer"; +import { mnemonicToSeed } from "npm:bip39@3.1.0"; +import { fundAccounts, formatResults } from "./setup-accounts.ts"; + +const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL") ?? "http://localhost:8000/friendbot"; +const WALLET_SEED_DIR = Deno.env.get("WALLET_SEED_DIR") ?? + new URL("../../browser-wallet", import.meta.url).pathname; +const SEED_FILES = (Deno.env.get("SEED_FILES") ?? ".env.seed.user1,.env.seed.user2") + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); +const DERIVATION_INDEX = Number(Deno.env.get("DERIVATION_INDEX") ?? "0"); + +// ─── SLIP-0010 derivation (matches browser-wallet/src/keys/keys.ts) ──── +// +// We can't import from the wallet directly (different repo, different +// import map), so we replicate the derivation. Verified to match the +// wallet's `Keys.deriveStellarAccountFromMnemonic(mnemonic, index)`: +// - bip39 mnemonic → 64-byte seed +// - SLIP-0010 ed25519 master key from seed (HMAC-SHA512 with "ed25519 seed") +// - Hardened derivation along m/44'/148'/index' +// - Stellar Keypair.fromRawEd25519Seed(node.key) + +const ED25519_CURVE_SEED = new TextEncoder().encode("ed25519 seed"); +const HARDENED_OFFSET = 0x80000000; + +interface ExtendedKey { + key: Uint8Array; + chainCode: Uint8Array; +} + +function u32be(value: number): Uint8Array { + const out = new Uint8Array(4); + out[0] = (value >>> 24) & 0xff; + out[1] = (value >>> 16) & 0xff; + out[2] = (value >>> 8) & 0xff; + out[3] = value & 0xff; + return out; +} + +function concatBytes(...parts: Uint8Array[]): Uint8Array { + const total = parts.reduce((sum, p) => sum + p.length, 0); + const out = new Uint8Array(total); + let offset = 0; + for (const p of parts) { + out.set(p, offset); + offset += p.length; + } + return out; +} + +async function hmacSha512(key: Uint8Array, data: Uint8Array): Promise { + const cryptoKey = await crypto.subtle.importKey( + "raw", + key as unknown as BufferSource, + { name: "HMAC", hash: "SHA-512" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", cryptoKey, data as unknown as BufferSource); + return new Uint8Array(sig); +} + +async function slip10MasterKeyFromSeed(seed: Uint8Array): Promise { + const i = await hmacSha512(ED25519_CURVE_SEED, seed); + return { key: i.slice(0, 32), chainCode: i.slice(32, 64) }; +} + +async function ckdPriv(parent: ExtendedKey, index: number): Promise { + if (index < HARDENED_OFFSET) { + throw new Error("ed25519 derivation requires hardened index"); + } + const data = concatBytes(new Uint8Array([0]), parent.key, u32be(index)); + const i = await hmacSha512(parent.chainCode, data); + return { key: i.slice(0, 32), chainCode: i.slice(32, 64) }; +} + +async function deriveStellarKeypairFromMnemonic( + mnemonic: string, + index = 0, +): Promise { + const seed = await mnemonicToSeed(mnemonic); + const seedBytes = new Uint8Array(seed); + + let node = await slip10MasterKeyFromSeed(seedBytes); + // m/44'/148'/index' + const path = [44, 148, index]; + for (const segment of path) { + node = await ckdPriv(node, segment + HARDENED_OFFSET); + } + + return Keypair.fromRawEd25519Seed(Buffer.from(node.key)); +} + +// ─── Seed file parsing ───────────────────────────────────────────────── + +interface SeedFileEntry { + path: string; + mnemonic: string; + publicKey?: string; +} + +async function readSeedFile(path: string): Promise { + let content: string; + try { + content = await Deno.readTextFile(path); + } catch (err) { + if (err instanceof Deno.errors.NotFound) { + return null; + } + throw err; + } + + let mnemonic: string | undefined; + for (const line of content.split("\n")) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + if (trimmed.startsWith("SEED_MNEMONIC=")) { + mnemonic = trimmed.slice("SEED_MNEMONIC=".length).trim(); + break; + } + } + + if (!mnemonic) { + throw new Error(`Seed file ${path} has no SEED_MNEMONIC entry`); + } + + return { path, mnemonic }; +} + +async function main() { + console.log("\n=== local-dev — Browser Extension Account Funder ===\n"); + console.log(` Friendbot: ${FRIENDBOT_URL}`); + console.log(` Wallet seed dir: ${WALLET_SEED_DIR}`); + console.log(` Seed files: ${SEED_FILES.join(", ")}`); + console.log(` Derivation index: ${DERIVATION_INDEX}`); + console.log(""); + + // 1. Read each seed file and derive the Stellar pubkey + const entries: SeedFileEntry[] = []; + for (const fileName of SEED_FILES) { + const fullPath = `${WALLET_SEED_DIR}/${fileName}`; + const entry = await readSeedFile(fullPath); + if (!entry) { + console.error(` ✗ ${fileName}: not found at ${fullPath}`); + continue; + } + const kp = await deriveStellarKeypairFromMnemonic(entry.mnemonic, DERIVATION_INDEX); + entry.publicKey = kp.publicKey(); + entries.push(entry); + console.log(` ${fileName.padEnd(20)} → ${entry.publicKey}`); + } + + if (entries.length === 0) { + console.error("\nNo seed files found. Set WALLET_SEED_DIR or SEED_FILES env vars."); + Deno.exit(1); + } + + // 2. Fund all derived pubkeys via Friendbot (delegates to setup-accounts.ts) + console.log("\nFunding via Friendbot...\n"); + const pubkeys = entries.map((e) => e.publicKey!); + const results = await fundAccounts(pubkeys); + console.log(formatResults(results)); + + const failed = results.filter((r) => r.status === "FAILED"); + if (failed.length > 0) { + console.error(`\n${failed.length} account(s) failed to fund.`); + Deno.exit(1); + } + + console.log(`\n=== Funded ${results.length} extension account(s) ===\n`); + console.log("Both browser extensions can now deposit XLM into the privacy channel."); + console.log("Refresh chain state in each extension to pick up the new balance.\n"); +} + +if (import.meta.main) { + main(); +} diff --git a/lifecycle/setup-accounts.ts b/lifecycle/setup-accounts.ts new file mode 100644 index 0000000..05b79cb --- /dev/null +++ b/lifecycle/setup-accounts.ts @@ -0,0 +1,134 @@ +/** + * Local Dev — Account Funder + * + * Generic Friendbot funder. Takes Stellar public keys as CLI args (or as + * a `pubkeys` array argument when imported as a module) and fires Friendbot + * funding requests in parallel. + * + * Idempotent: Friendbot returns 400 "createAccountAlreadyExist" when an + * account is already funded — both 200 and 400 are treated as success. + * + * Usage as a CLI: + * ./setup-accounts.sh GABC... GDEF... GHIJ... + * deno run --allow-all lifecycle/setup-accounts.ts GABC... GDEF... + * + * Usage as a module: + * import { fundAccounts } from "./setup-accounts.ts"; + * const results = await fundAccounts(["GABC...", "GDEF..."]); + * + * Env overrides: + * FRIENDBOT_URL default http://localhost:8000/friendbot + */ +import { StrKey } from "stellar-sdk"; + +const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL") ?? "http://localhost:8000/friendbot"; + +export interface FundResult { + publicKey: string; + status: "FUNDED" | "ALREADY_FUNDED" | "FAILED"; + error?: string; +} + +/** + * Fund a single Stellar account via Friendbot. + * Treats both 200 (newly funded) and 400 with `createAccountAlreadyExist` + * (already funded) as success. + */ +async function fundOne(publicKey: string): Promise { + if (!StrKey.isValidEd25519PublicKey(publicKey)) { + return { + publicKey, + status: "FAILED", + error: "Not a valid Stellar public key (G...)", + }; + } + + try { + const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); + if (res.status === 200) { + return { publicKey, status: "FUNDED" }; + } + if (res.status === 400) { + const body = await res.text(); + // Friendbot returns several different "already funded" wordings depending + // on the network/version. Match all of them to be idempotent across + // local Stellar quickstart and testnet. + const lower = body.toLowerCase(); + if ( + lower.includes("already funded") || + lower.includes("already_exist") || + lower.includes("op_already_exists") + ) { + return { publicKey, status: "ALREADY_FUNDED" }; + } + return { publicKey, status: "FAILED", error: `400: ${body.slice(0, 200)}` }; + } + const body = await res.text(); + return { + publicKey, + status: "FAILED", + error: `HTTP ${res.status}: ${body.slice(0, 200)}`, + }; + } catch (err) { + return { + publicKey, + status: "FAILED", + error: err instanceof Error ? err.message : String(err), + }; + } +} + +/** + * Fund multiple Stellar accounts via Friendbot in parallel. + * Returns one result per public key in the same order. + */ +export async function fundAccounts(pubkeys: string[]): Promise { + return await Promise.all(pubkeys.map((pk) => fundOne(pk))); +} + +/** + * Format a list of fund results as a human-readable summary. + */ +export function formatResults(results: FundResult[]): string { + const lines: string[] = []; + for (const r of results) { + const tag = r.status === "FUNDED" + ? " ✓ funded " + : r.status === "ALREADY_FUNDED" + ? " ✓ already funded " + : " ✗ FAILED "; + lines.push(`${tag} ${r.publicKey}${r.error ? ` — ${r.error}` : ""}`); + } + return lines.join("\n"); +} + +async function main() { + const args = Deno.args; + if (args.length === 0 || args.includes("--help") || args.includes("-h")) { + console.log("Usage: setup-accounts.sh [ ...]"); + console.log(""); + console.log("Funds one or more Stellar accounts via Friendbot. Idempotent."); + console.log(""); + console.log("Env:"); + console.log(` FRIENDBOT_URL default ${FRIENDBOT_URL}`); + Deno.exit(args.length === 0 ? 1 : 0); + } + + console.log("\n=== local-dev — Account Funder ===\n"); + console.log(` Friendbot: ${FRIENDBOT_URL}`); + console.log(` Accounts: ${args.length}\n`); + + const results = await fundAccounts(args); + console.log(formatResults(results)); + + const failed = results.filter((r) => r.status === "FAILED"); + if (failed.length > 0) { + console.error(`\n${failed.length} account(s) failed to fund.`); + Deno.exit(1); + } + console.log(`\n=== Funded ${results.length} account(s) ===\n`); +} + +if (import.meta.main) { + main(); +} diff --git a/setup-accounts-extension.sh b/setup-accounts-extension.sh new file mode 100755 index 0000000..05358de --- /dev/null +++ b/setup-accounts-extension.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Local Dev — Browser Extension Account Funder (wrapper) +# +# Reads the browser-wallet's dev-seed files (.env.seed.user1, .env.seed.user2), +# derives the Stellar account at index 0 from each seed mnemonic using SLIP-0010 +# (matching the wallet's `Keys.deriveStellarAccountFromMnemonic`), and funds +# those accounts via Friendbot. +# +# Use this in the manual test cycle so the browser extensions land with funded +# Stellar accounts after every `down → up` (the local Stellar ledger is wiped +# each time, so the wallet's previously-funded balances are gone). +# +# Combined with the deterministic local-dev stack (setup-c + setup-pp produce +# the same contract IDs every run), the typical cycle becomes: +# +# ./down.sh && ./up.sh && ./setup-c.sh && ./setup-pp.sh && ./setup-accounts-extension.sh +# # then click reload on both extensions in chrome:// and brave://extensions +# +# Prereqs: +# - up.sh has been run (Friendbot must be reachable) +# - browser-wallet/.env.seed.user1 and .env.seed.user2 exist +# +# Env overrides: +# WALLET_SEED_DIR default ../browser-wallet +# SEED_FILES default ".env.seed.user1,.env.seed.user2" +# DERIVATION_INDEX default 0 +# +# Usage: +# ./setup-accounts-extension.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DENO_BIN="${DENO_BIN:-deno}" +command -v "$DENO_BIN" >/dev/null 2>&1 || DENO_BIN="$HOME/.deno/bin/deno" +command -v "$DENO_BIN" >/dev/null 2>&1 || { + echo "ERROR: deno not found. Run up.sh first or install Deno." >&2 + exit 1 +} + +cd "$SCRIPT_DIR/lifecycle" +exec "$DENO_BIN" run --allow-all setup-accounts-extension.ts "$@" diff --git a/setup-accounts.sh b/setup-accounts.sh new file mode 100755 index 0000000..60c8469 --- /dev/null +++ b/setup-accounts.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Local Dev — Account Funder (wrapper) +# +# Funds Stellar accounts via Friendbot. Generic — takes one or more Stellar +# public keys as arguments. Idempotent: already-funded accounts are reported +# as such and the script exits cleanly. +# +# Usage: +# ./setup-accounts.sh GABC... GDEF... GHIJ... +# +# For the typical browser-wallet manual test cycle, use the wallet-aware +# wrapper instead, which derives pubkeys from the wallet's seed mnemonics: +# ./setup-accounts-extension.sh + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DENO_BIN="${DENO_BIN:-deno}" +command -v "$DENO_BIN" >/dev/null 2>&1 || DENO_BIN="$HOME/.deno/bin/deno" +command -v "$DENO_BIN" >/dev/null 2>&1 || { + echo "ERROR: deno not found. Run up.sh first or install Deno." >&2 + exit 1 +} + +cd "$SCRIPT_DIR/lifecycle" +exec "$DENO_BIN" run --allow-all setup-accounts.ts "$@"