diff --git a/.local-dev-keys b/.local-dev-keys new file mode 100644 index 0000000..5448ee5 --- /dev/null +++ b/.local-dev-keys @@ -0,0 +1,11 @@ +# Generated by setup-keys.sh — deterministic keypairs for local-dev roles. +# Stable across down/up cycles. Consumed by up.sh and setup-* scripts. +# Created: 2026-04-13T14:31:41.159Z + +# Contract deployer admin (used by setup-c.sh) +DEPLOYER_PK=GCJ47FR4UFYJT66UKLVR2HINC4EPYEHHIYOQDLNFRRFKM6WXWBTZ6C44 +DEPLOYER_SK=SBQ4UD27UYLU7J5WBVPNA6D6SEPULQCZXC226CCOGJCQIGKYACFTHLXT + +# Pay-platform admin (used by setup-pay.sh, referenced in ADMIN_WALLETS) +PAY_ADMIN_PK=GC4LMKEIIGVOGXT2MTT36B6ZOGKNZLUBZP5WARLERNOJKTEXD54V46TZ +PAY_ADMIN_SK=SBRAOJNG7NSAVMNXKAXR6UZRSFM47EWQFDHE6TLIMC4VG76DGQTZRYJH diff --git a/docker-compose.pos-instant.yml b/docker-compose.pos-instant.yml index 6d80a01..9db69d4 100644 --- a/docker-compose.pos-instant.yml +++ b/docker-compose.pos-instant.yml @@ -161,6 +161,7 @@ services: CHALLENGE_TTL: "300000" SESSION_TTL: "86400000" ADMIN_WALLETS: "GB577NO7D3YMZKHFQQ7OEOXXL33BRV3YRJPP3MI7UT5ZF2XNOSEQX7FN" + PAY_SERVICE_SK: "SDYYLPL7KNGYUHD35BYZ3LLJR7G2BA4UIFUH2SMRNEIICHY5VQXWUJDW" OTEL_DENO: "true" OTEL_SERVICE_NAME: "pay-platform" OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" @@ -191,6 +192,7 @@ services: - shared-config:/config:ro - ./e2e:/e2e-src:ro - ./e2e/pos-instant:/pos-src:ro + - ${MOONLIGHT_PAY_PATH:-../moonlight-pay}/src/lib:/moonlight-pay-lib:ro working_dir: /app entrypoint: - sh @@ -198,6 +200,7 @@ 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 moonlight-pay-lib && cp /moonlight-pay-lib/*.ts moonlight-pay-lib/ deno install echo "Waiting for provider, council, and pay-platform..." for i in $$(seq 1 60); do @@ -230,6 +233,7 @@ services: DATABASE_URL: postgresql://admin:devpass@db:5432/provider_platform_db WASM_DIR: /wasms OTEL_DENO: "true" + PAY_SERVICE_SK: "SDYYLPL7KNGYUHD35BYZ3LLJR7G2BA4UIFUH2SMRNEIICHY5VQXWUJDW" OTEL_SERVICE_NAME: "moonlight-e2e" OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" diff --git a/docker-compose.pos-self-custodial.yml b/docker-compose.pos-self-custodial.yml index 8f1cf12..6a20968 100644 --- a/docker-compose.pos-self-custodial.yml +++ b/docker-compose.pos-self-custodial.yml @@ -160,6 +160,7 @@ services: CHALLENGE_TTL: "300000" SESSION_TTL: "86400000" ADMIN_WALLETS: "GB577NO7D3YMZKHFQQ7OEOXXL33BRV3YRJPP3MI7UT5ZF2XNOSEQX7FN" + PAY_SERVICE_SK: "SDYYLPL7KNGYUHD35BYZ3LLJR7G2BA4UIFUH2SMRNEIICHY5VQXWUJDW" OTEL_DENO: "true" OTEL_SERVICE_NAME: "pay-platform" OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" @@ -190,6 +191,7 @@ services: - shared-config:/config:ro - ./e2e:/e2e-src:ro - ./e2e/pos-self-custodial:/pos-src:ro + - ${MOONLIGHT_PAY_PATH:-../moonlight-pay}/src/lib:/moonlight-pay-lib:ro working_dir: /app entrypoint: - sh @@ -197,6 +199,7 @@ 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 moonlight-pay-lib && cp /moonlight-pay-lib/*.ts moonlight-pay-lib/ deno install echo "Waiting for provider, council, and pay-platform..." for i in $$(seq 1 60); do @@ -229,6 +232,7 @@ services: DATABASE_URL: postgresql://admin:devpass@db:5432/provider_platform_db WASM_DIR: /wasms OTEL_DENO: "true" + PAY_SERVICE_SK: "SDYYLPL7KNGYUHD35BYZ3LLJR7G2BA4UIFUH2SMRNEIICHY5VQXWUJDW" OTEL_SERVICE_NAME: "moonlight-e2e" OTEL_EXPORTER_OTLP_ENDPOINT: "http://jaeger:4318" OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf" diff --git a/e2e/pos-helpers.ts b/e2e/pos-helpers.ts new file mode 100644 index 0000000..ecec3fa --- /dev/null +++ b/e2e/pos-helpers.ts @@ -0,0 +1,214 @@ +/** + * Shared test utilities for POS e2e tests. + * + * Provides: + * - createTestSigner: mock Signer that replaces Freighter with a raw Keypair + * - createTestSignMessage: mock signMessage for self-custodial password signing + * - payApi: HTTP helper for pay-platform API calls + * - getPayJwt: authenticate with pay-platform and return JWT + * - fundAccount: fund a Stellar account via Friendbot + * - generateP256PublicKey: generate a random P256 public key for merchant UTXOs + */ +import { Keypair, authorizeEntry } from "stellar-sdk"; +import { Buffer } from "node:buffer"; + +// ─── Pay-platform API helpers ────────────────────────────────── + +export async function fundAccount( + friendbotUrl: string, + publicKey: string, +): Promise { + for (let attempt = 0; attempt < 10; attempt++) { + try { + const res = await fetch(`${friendbotUrl}?addr=${publicKey}`); + if (res.ok || res.status === 400) return; + } catch { /* retry */ } + await new Promise((r) => setTimeout(r, 2000)); + } + throw new Error(`Friendbot failed for ${publicKey}`); +} + +export async function payApi( + payApiUrl: string, + path: string, + opts: RequestInit = {}, +): Promise { + return fetch(`${payApiUrl}${path}`, { + ...opts, + headers: { + "Content-Type": "application/json", + ...(opts.headers as Record ?? {}), + }, + }); +} + +export async function getPayJwt( + payApiUrl: string, + publicKey: string, + secretKey: string, +): Promise { + const kp = Keypair.fromSecret(secretKey); + const chRes = await payApi(payApiUrl, "/auth/challenge", { + method: "POST", + body: JSON.stringify({ publicKey }), + }); + const { data: { nonce } } = await chRes.json(); + const sig = Buffer.from(kp.sign(Buffer.from(nonce, "base64"))).toString( + "base64", + ); + const vfRes = await payApi(payApiUrl, "/auth/verify", { + method: "POST", + body: JSON.stringify({ publicKey, nonce, signature: sig }), + }); + const { data: { token } } = await vfRes.json(); + return token; +} + +// ─── Mock signer (replaces Freighter) ────────────────────────── + +/** + * Creates a Signer-compatible object from a raw Keypair. + * Same interface as createWalletSigner in moonlight-pay, but signs + * with the keypair directly instead of going through Freighter. + * + * signSorobanAuthEntry uses the Stellar SDK's authorizeEntry — the same + * signing logic the SDK uses internally for Keypair signers — ensuring + * the mock doesn't diverge from the real implementation. + */ +export function createTestSigner( + keypair: Keypair, + networkPassphrase: string, +) { + return { + publicKey: () => keypair.publicKey(), + + sign: (data: Uint8Array) => { + return Promise.resolve(keypair.sign(Buffer.from(data))); + }, + + signTransaction: async ( + xdr: string, + opts?: { networkPassphrase?: string }, + ) => { + const { TransactionBuilder } = await import("stellar-sdk"); + const tx = TransactionBuilder.fromXDR( + xdr, + opts?.networkPassphrase ?? networkPassphrase, + ); + tx.sign(keypair); + return { signedTxXdr: tx.toXDR() }; + }, + + signSorobanAuthEntry: async ( + authEntry: unknown, + signatureExpirationLedger: number, + entryNetworkPassphrase: string, + ) => { + // Use the SDK's authorizeEntry — same code path as when + // signWithEd25519 receives a Keypair instead of a Signer. + return await authorizeEntry( + authEntry, + keypair, + signatureExpirationLedger, + entryNetworkPassphrase, + ); + }, + + signsFor: (pk: string) => pk === keypair.publicKey(), + }; +} + +/** + * Mock signMessage — signs the message bytes with the raw keypair. + * Replaces Freighter's SEP-43 signMessage in tests. + */ +export function createTestSignMessage(keypair: Keypair) { + return async (message: string): Promise => { + const msgBytes = new TextEncoder().encode(message); + const sig = keypair.sign(Buffer.from(msgBytes)); + return Buffer.from(sig).toString("base64"); + }; +} + +// ─── P256 key generation (for merchant UTXOs) ────────────────── + +function buildPkcs8P256(rawPrivateKey: Uint8Array): ArrayBuffer { + const header = new Uint8Array([ + 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48, + 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, + 0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, + ]); + const result = new Uint8Array(header.length + 32); + result.set(header); + result.set(rawPrivateKey, header.length); + return result.buffer as ArrayBuffer; +} + +function base64UrlToBytes(b64url: string): Uint8Array { + const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); + const pad = (4 - (b64.length % 4)) % 4; + const binary = atob(b64 + "=".repeat(pad)); + return Uint8Array.from(binary, (c) => c.charCodeAt(0)); +} + +export async function generateP256PublicKey(): Promise { + const seed = crypto.getRandomValues(new Uint8Array(32)); + const seedBuf = new ArrayBuffer(32); + new Uint8Array(seedBuf).set(seed); + const expandKey = await crypto.subtle.importKey( + "raw", + seedBuf, + "HKDF", + false, + ["deriveBits"], + ); + const expanded = await crypto.subtle.deriveBits( + { + name: "HKDF", + hash: "SHA-256", + salt: new Uint8Array(0), + info: new TextEncoder().encode("moonlight-p256"), + }, + expandKey, + 384, + ); + const privateKeyBytes = new Uint8Array(expanded).slice(0, 32); + const cryptoKey = await crypto.subtle.importKey( + "pkcs8", + buildPkcs8P256(privateKeyBytes), + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign"], + ); + const jwk = await crypto.subtle.exportKey("jwk", cryptoKey); + const publicKey = new Uint8Array(65); + publicKey[0] = 0x04; + publicKey.set(base64UrlToBytes(jwk.x!), 1); + publicKey.set(base64UrlToBytes(jwk.y!), 33); + return publicKey; +} + +// ─── Contract config loader ──────────────────────────────────── + +export function loadContractsEnv( + path = "/config/contracts.env", +): { channelAuthId: string; privacyChannelId: string; assetId: string } { + const env: Record = {}; + try { + for ( + const line of Deno.readTextFileSync(path).split("\n") + ) { + const t = line.trim(); + if (!t || t.startsWith("#")) continue; + const eq = t.indexOf("="); + if (eq !== -1) env[t.slice(0, eq)] = t.slice(eq + 1); + } + } catch { + throw new Error(`Failed to read ${path}`); + } + return { + channelAuthId: env["E2E_CHANNEL_AUTH_ID"], + privacyChannelId: env["E2E_CHANNEL_CONTRACT_ID"], + assetId: env["E2E_CHANNEL_ASSET_CONTRACT_ID"], + }; +} diff --git a/e2e/pos-instant/deno.json b/e2e/pos-instant/deno.json index f7b50fb..624fa7f 100644 --- a/e2e/pos-instant/deno.json +++ b/e2e/pos-instant/deno.json @@ -3,6 +3,8 @@ "imports": { "@colibri/core": "jsr:@colibri/core@^0.16.1", "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.7.0", + "@noble/curves": "jsr:@noble/curves@^1.8.0", + "@noble/curves/p256": "jsr:@noble/curves@^1.8.0/p256", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" } diff --git a/e2e/pos-instant/main.ts b/e2e/pos-instant/main.ts index c1c2d54..a68bd39 100644 --- a/e2e/pos-instant/main.ts +++ b/e2e/pos-instant/main.ts @@ -1,196 +1,53 @@ /** * POS Instant Payment E2E test. * - * Exercises the crypto-instant payment flow end-to-end: - * 1. Create merchant + customer accounts on pay-platform - * 2. Store merchant receive UTXOs - * 3. Seed council + PP config via admin API - * 4. Call /pay/instant/prepare → council config + merchant UTXOs - * 5. Authenticate customer with provider-platform → JWT - * 6. Generate temporary P256 keypairs for the one-hop transfer - * 7. Build ONE atomic bundle: - * - DEPOSIT (customer signs, conditioned on temp CREATEs) - * - temp CREATEs (at temp P256 keys) - * - SPENDs from temp keys (signed with P256, conditioned on merchant CREATEs) - * - merchant CREATEs (at merchant receive UTXOs) - * 8. Submit via pay-platform /pay/instant/submit - * (pay-platform proxies to provider-platform, records transaction, marks UTXOs spent) - * 9. Verify transaction recorded + merchant balance updated - * - * This mirrors the flow in moonlight-pay/src/lib/instant-payment.ts - * but without Freighter — the test signs with the customer's raw keypair. + * Calls the actual moonlight-pay `executeInstantPayment` function — the + * same code path the browser UI uses. The only difference is the signer: + * a raw-keypair mock instead of Freighter, producing the same output. */ import { Keypair } from "stellar-sdk"; -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; -import type { Ed25519PublicKey, ContractId } from "@colibri/core"; -import { Buffer } from "node:buffer"; const PAY_API = Deno.env.get("PAY_API")!; +const PAY_URL = PAY_API.replace("/api/v1", ""); const PROVIDER_URL = Deno.env.get("PROVIDER_URL")!; -const STELLAR_RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? "Standalone Network ; February 2017"; +// Stub window config before importing moonlight-pay modules +// deno-lint-ignore no-explicit-any +(globalThis as any).window = { + __PAY_CONFIG__: { + environment: "test", + stellarNetwork: "standalone", + payPlatformUrl: PAY_URL, + }, +}; + +import { executeInstantPayment } from "./moonlight-pay-lib/instant-payment.ts"; +import { __resetConfigForTests } from "./moonlight-pay-lib/config.ts"; +import { + createTestSigner, + fundAccount, + generateP256PublicKey, + getPayJwt, + loadContractsEnv, + payApi, +} from "./e2e/pos-helpers.ts"; + +__resetConfigForTests(); + const ADMIN_PK = "GB577NO7D3YMZKHFQQ7OEOXXL33BRV3YRJPP3MI7UT5ZF2XNOSEQX7FN"; const ADMIN_SK = "SAKAWUZTXAQTKDYIMWEUGXDIPVVS3CIYEHH5UWFV6NFC7YDMX2G7OWOU"; +const PAY_SERVICE_SK = Deno.env.get("PAY_SERVICE_SK") ?? + "SCTQUHSGRWMHZZ7XNTQZJZYZTHLBJFUQVRHYVH4N7GJPVXZQ4OMI5IEQ"; +const PAY_SERVICE_PK = Keypair.fromSecret(PAY_SERVICE_SK).publicKey(); const PAYMENT_XLM = 5; const PAYMENT_STROOPS = BigInt(PAYMENT_XLM) * 10_000_000n; -const DEPOSIT_FEE_STROOPS = 500_000n; // 0.05 XLM entropy fee const startTime = Date.now(); -function elapsed(): string { - return `${((Date.now() - startTime) / 1000).toFixed(1)}s`; -} - -// ─── Helpers ─────────────────────────────────────────────────── - -async function fundAccount(publicKey: string): Promise { - for (let attempt = 0; attempt < 10; attempt++) { - try { - const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); - if (res.ok || res.status === 400) return; - } catch { /* retry */ } - await new Promise((r) => setTimeout(r, 2000)); - } - throw new Error(`Friendbot failed for ${publicKey}`); -} - -async function payApi(path: string, opts: RequestInit = {}): Promise { - return fetch(`${PAY_API}${path}`, { - ...opts, - headers: { - "Content-Type": "application/json", - ...(opts.headers as Record ?? {}), - }, - }); -} - -async function getPayJwt(publicKey: string, secretKey: string): Promise { - const kp = Keypair.fromSecret(secretKey); - const chRes = await payApi("/auth/challenge", { - method: "POST", - body: JSON.stringify({ publicKey }), - }); - const { data: { nonce } } = await chRes.json(); - const sig = Buffer.from(kp.sign(Buffer.from(nonce, "base64"))).toString("base64"); - const vfRes = await payApi("/auth/verify", { - method: "POST", - body: JSON.stringify({ publicKey, nonce, signature: sig }), - }); - const { data: { token } } = await vfRes.json(); - return token; -} - -async function getLatestLedger(): Promise { - const res = await fetch(STELLAR_RPC_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }), - }); - const data = await res.json(); - return data.result.sequence; -} - -// ─── P256 key helpers ────────────────────────────────────────── - -function buildPkcs8P256(rawPrivateKey: Uint8Array): ArrayBuffer { - const header = new Uint8Array([ - 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, - 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, - 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04, - 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, - ]); - const result = new Uint8Array(header.length + 32); - result.set(header); - result.set(rawPrivateKey, header.length); - return result.buffer as ArrayBuffer; -} - -function base64UrlToBytes(b64url: string): Uint8Array { - const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const binary = atob(b64 + "=".repeat(pad)); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); -} - -async function generateTempP256(): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { - const seed = crypto.getRandomValues(new Uint8Array(32)); - const seedBuf = new ArrayBuffer(32); - new Uint8Array(seedBuf).set(seed); - const expandKey = await crypto.subtle.importKey("raw", seedBuf, "HKDF", false, ["deriveBits"]); - const expanded = await crypto.subtle.deriveBits( - { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("moonlight-p256") }, - expandKey, 384, - ); - const privateKeyBytes = new Uint8Array(expanded).slice(0, 32); - const cryptoKey = await crypto.subtle.importKey( - "pkcs8", buildPkcs8P256(privateKeyBytes), { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"], - ); - const jwk = await crypto.subtle.exportKey("jwk", cryptoKey); - const publicKey = new Uint8Array(65); - publicKey[0] = 0x04; - publicKey.set(base64UrlToBytes(jwk.x!), 1); - publicKey.set(base64UrlToBytes(jwk.y!), 33); - return { publicKey, privateKey: privateKeyBytes }; -} - -function makeUtxoAdapter(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) { - return { - publicKey: kp.publicKey, - signPayload: async (hash: Uint8Array) => { - const hashBuf = new ArrayBuffer(hash.length); - new Uint8Array(hashBuf).set(hash); - const key = await crypto.subtle.importKey( - "pkcs8", buildPkcs8P256(kp.privateKey), - { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"], - ); - return new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, hashBuf)); - }, - }; -} - -function partitionAmount(total: bigint, parts: number): bigint[] { - if (parts <= 1) return [total]; - const result: bigint[] = []; - let remaining = total; - for (let i = 0; i < parts - 1; i++) { - const maxForThis = remaining - BigInt(parts - i - 1); - const portion = 1n + BigInt(Math.floor(Math.random() * Number(maxForThis - 1n))); - result.push(portion); - remaining -= portion; - } - result.push(remaining); - return result; -} - -// ─── Provider auth (transaction-based challenge) ─────────────── - -async function authenticateWithProvider(kp: Keypair): Promise { - const { loadConfig } = await import("./e2e/config.ts"); - const { authenticate } = await import("./e2e/auth.ts"); - return authenticate(kp, loadConfig()); -} - -// ─── Read contract config ────────────────────────────────────── - -function loadContractsEnv(): { channelAuthId: string; privacyChannelId: string; assetId: string } { - const env: Record = {}; - try { - for (const line of Deno.readTextFileSync("/config/contracts.env").split("\n")) { - const t = line.trim(); - if (!t || t.startsWith("#")) continue; - const eq = t.indexOf("="); - if (eq !== -1) env[t.slice(0, eq)] = t.slice(eq + 1); - } - } catch { throw new Error("Failed to read /config/contracts.env"); } - return { - channelAuthId: env["E2E_CHANNEL_AUTH_ID"], - privacyChannelId: env["E2E_CHANNEL_CONTRACT_ID"], - assetId: env["E2E_CHANNEL_ASSET_CONTRACT_ID"], - }; -} +const elapsed = () => `${((Date.now() - startTime) / 1000).toFixed(1)}s`; // ─── Main flow ───────────────────────────────────────────────── @@ -201,20 +58,22 @@ console.log(` Channel Auth: ${channelAuthId}`); console.log(` Privacy Channel: ${privacyChannelId}`); console.log(` Asset: ${assetId}`); -// [1] Create accounts -console.log("\n[1/9] Creating merchant and customer accounts..."); +// [1] Create + fund accounts +console.log("\n[1/5] Creating merchant and customer accounts..."); const merchant = Keypair.random(); const customer = Keypair.random(); console.log(` Merchant: ${merchant.publicKey()}`); console.log(` Customer: ${customer.publicKey()}`); -await fundAccount(merchant.publicKey()); -await fundAccount(customer.publicKey()); +await fundAccount(FRIENDBOT_URL, merchant.publicKey()); +await fundAccount(FRIENDBOT_URL, customer.publicKey()); +await fundAccount(FRIENDBOT_URL, ADMIN_PK); +await fundAccount(FRIENDBOT_URL, PAY_SERVICE_PK); console.log(` Funded (${elapsed()})`); -// [2] Create merchant on pay-platform -console.log("\n[2/9] Creating merchant account on pay-platform..."); -const merchantJwt = await getPayJwt(merchant.publicKey(), merchant.secret()); -const createRes = await payApi("/account", { +// [2] Create merchant on pay-platform + store UTXOs +console.log("\n[2/5] Creating merchant account + UTXOs..."); +const merchantJwt = await getPayJwt(PAY_API, merchant.publicKey(), merchant.secret()); +const createRes = await payApi(PAY_API, "/account", { method: "POST", headers: { Authorization: `Bearer ${merchantJwt}` }, body: JSON.stringify({ @@ -224,176 +83,71 @@ const createRes = await payApi("/account", { }), }); if (createRes.status !== 201 && createRes.status !== 200) { - throw new Error(`Create merchant account failed: ${createRes.status} ${await createRes.text()}`); + throw new Error(`Create merchant failed: ${createRes.status} ${await createRes.text()}`); } -console.log(` Merchant account created (${elapsed()})`); -// [3] Store merchant receive UTXOs (real P256 keys) -console.log("\n[3/9] Generating and storing merchant receive UTXOs..."); -const merchantUtxoKeypairs: Array<{ publicKey: Uint8Array; privateKey: Uint8Array }> = []; -const merchantUtxoPayloads = []; +const utxoPayloads = []; for (let i = 0; i < 5; i++) { - const kp = await generateTempP256(); - merchantUtxoKeypairs.push(kp); - merchantUtxoPayloads.push({ - utxoPublicKey: btoa(String.fromCharCode(...kp.publicKey)), + const pk = await generateP256PublicKey(); + utxoPayloads.push({ + utxoPublicKey: btoa(String.fromCharCode(...pk)), derivationIndex: i, }); } -const utxoRes = await payApi("/utxo/receive", { +const utxoRes = await payApi(PAY_API, "/utxo/receive", { method: "POST", headers: { Authorization: `Bearer ${merchantJwt}` }, - body: JSON.stringify({ utxos: merchantUtxoPayloads }), + body: JSON.stringify({ utxos: utxoPayloads }), }); if (utxoRes.status !== 201 && utxoRes.status !== 200) { throw new Error(`Store UTXOs failed: ${utxoRes.status} ${await utxoRes.text()}`); } -console.log(` ${merchantUtxoPayloads.length} UTXOs stored (${elapsed()})`); +console.log(` Merchant + ${utxoPayloads.length} UTXOs created (${elapsed()})`); -// [4] Seed council + PP config -console.log("\n[4/9] Seeding council and PP config..."); -await fundAccount(ADMIN_PK); -const adminJwt = await getPayJwt(ADMIN_PK, ADMIN_SK); -const councilInsertRes = await payApi("/admin/councils", { +// [3] Seed council + PP +console.log("\n[3/5] Seeding council and PP config..."); +const adminJwt = await getPayJwt(PAY_API, ADMIN_PK, ADMIN_SK); +const councilRes = await payApi(PAY_API, "/admin/councils", { method: "POST", headers: { Authorization: `Bearer ${adminJwt}` }, body: JSON.stringify({ name: "Test Council", channelAuthId, - privacyChannelId, - assetId, networkPassphrase: NETWORK_PASSPHRASE, - jurisdictionCodes: "US,GB,DE", + channels: [{ assetCode: "XLM", assetContractId: assetId, privacyChannelId }], + jurisdictions: ["US", "GB", "DE"], active: true, }), }); -if (!councilInsertRes.ok) throw new Error(`Council insert failed: ${councilInsertRes.status} ${await councilInsertRes.text()}`); -const { data: council } = await councilInsertRes.json(); -console.log(` Council created: ${council.id}`); +if (!councilRes.ok) throw new Error(`Council failed: ${councilRes.status} ${await councilRes.text()}`); +const { data: council } = await councilRes.json(); -const ppInsertRes = await payApi(`/admin/councils/${council.id}/pps`, { +const ppRes = await payApi(PAY_API, `/admin/councils/${council.id}/pps`, { method: "POST", headers: { Authorization: `Bearer ${adminJwt}` }, body: JSON.stringify({ name: "Test PP", url: PROVIDER_URL, publicKey: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", active: true }), }); -if (!ppInsertRes.ok) throw new Error(`PP insert failed: ${ppInsertRes.status} ${await ppInsertRes.text()}`); -const { data: pp } = await ppInsertRes.json(); -console.log(` PP created: ${pp.id} (${elapsed()})`); - -// [5] Prepare instant payment -console.log("\n[5/9] Preparing instant payment..."); -const prepareRes = await payApi("/pay/instant/prepare", { - method: "POST", - body: JSON.stringify({ - merchantWallet: merchant.publicKey(), - amountXlm: String(PAYMENT_XLM), - customerWallet: customer.publicKey(), - }), +if (!ppRes.ok) throw new Error(`PP failed: ${ppRes.status} ${await ppRes.text()}`); +console.log(` Council + PP seeded (${elapsed()})`); + +// [4] Execute instant payment via the actual moonlight-pay function +console.log("\n[4/5] Executing instant payment..."); +const signer = createTestSigner(customer, NETWORK_PASSPHRASE); +const result = await executeInstantPayment({ + customerWallet: customer.publicKey(), + merchantWallet: merchant.publicKey(), + amountXlm: String(PAYMENT_XLM), + assetCode: "XLM", + description: "POS instant e2e test", + signer, + onStatus: (msg) => console.log(` [status] ${msg}`), }); -if (!prepareRes.ok) { - throw new Error(`Prepare failed: ${prepareRes.status} ${await prepareRes.text()}`); -} -const { data: prepare } = await prepareRes.json(); -console.log(` Prepared: ${prepare.merchantUtxos.length} merchant UTXOs (${elapsed()})`); - -// [6] Authenticate customer with provider-platform -console.log("\n[6/9] Authenticating customer with provider-platform..."); -const providerJwt = await authenticateWithProvider(customer); -console.log(` Authenticated (${elapsed()})`); - -// [7] Build the atomic instant payment bundle -console.log("\n[7/9] Building instant payment bundle..."); -const ledgerSequence = await getLatestLedger(); -const expiration = ledgerSequence + 1000; -const tempCount = prepare.merchantUtxos.length; - -// Generate temp P256 keypairs -const tempKeypairs: Array<{ publicKey: Uint8Array; privateKey: Uint8Array }> = []; -for (let i = 0; i < tempCount; i++) { - tempKeypairs.push(await generateTempP256()); -} - -// Merchant CREATE ops — distribute payment across merchant UTXOs -const merchantAmounts = partitionAmount(PAYMENT_STROOPS, prepare.merchantUtxos.length); -const merchantCreateOps = prepare.merchantUtxos.map( - (u: { utxoPublicKey: string }, i: number) => { - const pubKeyBytes = Uint8Array.from(atob(u.utxoPublicKey), (c) => c.charCodeAt(0)); - return MoonlightOperation.create(pubKeyBytes, merchantAmounts[i]); - }, -); - -// Temp CREATE ops — distribute payment across temp keys -const tempAmounts = partitionAmount(PAYMENT_STROOPS, tempCount); -const tempCreateOps = tempKeypairs.map((kp, i) => - MoonlightOperation.create(kp.publicKey, tempAmounts[i]) -); - -// DEPOSIT — customer signs, conditioned on temp CREATEs -const depositOp = await MoonlightOperation.deposit( - customer.publicKey() as Ed25519PublicKey, - PAYMENT_STROOPS + DEPOSIT_FEE_STROOPS, -) - .addConditions(tempCreateOps.map((op) => op.toCondition())) - .signWithEd25519( - customer, - expiration, - privacyChannelId as ContractId, - assetId as ContractId, - NETWORK_PASSPHRASE, - ); - -// SPEND ops — from temp keys, conditioned on merchant CREATEs -const spendOps = []; -for (let i = 0; i < tempKeypairs.length; i++) { - const spendOp = MoonlightOperation.spend(tempKeypairs[i].publicKey); - for (const merchantCreate of merchantCreateOps) { - spendOp.addCondition(merchantCreate.toCondition()); - } - await spendOp.signWithUTXO( - makeUtxoAdapter(tempKeypairs[i]), - privacyChannelId as ContractId, - expiration, - ); - spendOps.push(spendOp); -} - -// Assemble: DEPOSIT, temp CREATEs, SPENDs, merchant CREATEs -const operationsMLXDR = [ - depositOp.toMLXDR(), - ...tempCreateOps.map((op) => op.toMLXDR()), - ...spendOps.map((op) => op.toMLXDR()), - ...merchantCreateOps.map((op) => op.toMLXDR()), -]; -console.log(` Bundle: ${operationsMLXDR.length} operations (${elapsed()})`); - -// [8] Submit via pay-platform -console.log("\n[8/9] Submitting via pay-platform..."); -const submitRes = await payApi("/pay/instant/submit", { - method: "POST", - body: JSON.stringify({ - customerWallet: customer.publicKey(), - merchantWallet: merchant.publicKey(), - amountStroops: PAYMENT_STROOPS.toString(), - description: "POS instant e2e test", - operationsMLXDR, - merchantUtxoIds: prepare.merchantUtxos.map((u: { id: string }) => u.id), - ppUrl: PROVIDER_URL, - ppAuthToken: providerJwt, - channelContractId: privacyChannelId, - }), -}); -if (!submitRes.ok) { - const err = await submitRes.text(); - throw new Error(`Submit failed: ${submitRes.status} ${err}`); -} -const { data: submitData } = await submitRes.json(); -console.log(` Transaction recorded: ${submitData.transactionId} (${elapsed()})`); -console.log(` Bundle ID: ${submitData.bundleId}`); -console.log(` Status: ${submitData.status}`); +console.log(` Transaction: ${result.transactionId} (${elapsed()})`); +console.log(` Status: ${result.status}`); -// [9] Verify merchant balance -console.log("\n[9/9] Verifying merchant balance..."); -const balanceRes = await payApi("/transactions/balance", { +// [5] Verify merchant balance +console.log("\n[5/5] Verifying merchant balance..."); +const balanceRes = await payApi(PAY_API, "/transactions/balance", { headers: { Authorization: `Bearer ${merchantJwt}` }, }); if (!balanceRes.ok) throw new Error(`Balance check failed: ${balanceRes.status}`); diff --git a/e2e/pos-self-custodial/deno.json b/e2e/pos-self-custodial/deno.json index f7b50fb..624fa7f 100644 --- a/e2e/pos-self-custodial/deno.json +++ b/e2e/pos-self-custodial/deno.json @@ -3,6 +3,8 @@ "imports": { "@colibri/core": "jsr:@colibri/core@^0.16.1", "@moonlight/moonlight-sdk": "jsr:@moonlight/moonlight-sdk@^0.7.0", + "@noble/curves": "jsr:@noble/curves@^1.8.0", + "@noble/curves/p256": "jsr:@noble/curves@^1.8.0/p256", "@opentelemetry/api": "npm:@opentelemetry/api@^1.9.0", "stellar-sdk": "npm:@stellar/stellar-sdk@14.2.0" } diff --git a/e2e/pos-self-custodial/main.ts b/e2e/pos-self-custodial/main.ts index c904fa1..2246ecb 100644 --- a/e2e/pos-self-custodial/main.ts +++ b/e2e/pos-self-custodial/main.ts @@ -1,221 +1,55 @@ /** * POS Self-custodial Payment E2E test. * - * Exercises the crypto self-custodial payment flow end-to-end: - * 1. Create merchant + customer accounts on pay-platform - * 2. Store merchant receive UTXOs - * 3. Seed council + PP config via admin API - * 4. Call /pay/instant/prepare → council config + merchant UTXOs - * 5. Authenticate customer with provider-platform → JWT - * 6. Derive customer's UTXO key: password → sign → SHA-256 - * 7. Derive customer's P256 keypairs from UTXO key - * 8. Build ONE atomic bundle: - * - DEPOSIT (customer signs, conditioned on customer CREATEs) - * - customer CREATEs (at customer's derived P256 keys) - * - SPENDs from customer keys (signed with P256, conditioned on merchant CREATEs) - * - merchant CREATEs (at merchant receive UTXOs) - * 9. Submit via pay-platform /pay/instant/submit - * 10. Verify transaction recorded + merchant balance updated - * - * This mirrors the flow in moonlight-pay/src/lib/selfcustodial-payment.ts - * but without Freighter — the test signs with the customer's raw keypair. + * Calls the actual moonlight-pay `executeSelfCustodialPayment` function — + * the same code path the browser UI uses. The signer and signMessage are + * raw-keypair mocks instead of Freighter, producing the same output. */ import { Keypair } from "stellar-sdk"; -import { MoonlightOperation } from "@moonlight/moonlight-sdk"; -import type { Ed25519PublicKey, ContractId } from "@colibri/core"; -import { Buffer } from "node:buffer"; const PAY_API = Deno.env.get("PAY_API")!; +const PAY_URL = PAY_API.replace("/api/v1", ""); const PROVIDER_URL = Deno.env.get("PROVIDER_URL")!; -const STELLAR_RPC_URL = Deno.env.get("STELLAR_RPC_URL")!; const FRIENDBOT_URL = Deno.env.get("FRIENDBOT_URL")!; const NETWORK_PASSPHRASE = Deno.env.get("STELLAR_NETWORK_PASSPHRASE") ?? "Standalone Network ; February 2017"; +// Stub window config before importing moonlight-pay modules +// deno-lint-ignore no-explicit-any +(globalThis as any).window = { + __PAY_CONFIG__: { + environment: "test", + stellarNetwork: "standalone", + payPlatformUrl: PAY_URL, + }, +}; + +import { executeSelfCustodialPayment } from "./moonlight-pay-lib/selfcustodial-payment.ts"; +import { __resetConfigForTests } from "./moonlight-pay-lib/config.ts"; +import { + createTestSignMessage, + createTestSigner, + fundAccount, + generateP256PublicKey, + getPayJwt, + loadContractsEnv, + payApi, +} from "./e2e/pos-helpers.ts"; + +__resetConfigForTests(); + const ADMIN_PK = "GB577NO7D3YMZKHFQQ7OEOXXL33BRV3YRJPP3MI7UT5ZF2XNOSEQX7FN"; const ADMIN_SK = "SAKAWUZTXAQTKDYIMWEUGXDIPVVS3CIYEHH5UWFV6NFC7YDMX2G7OWOU"; +const PAY_SERVICE_SK = Deno.env.get("PAY_SERVICE_SK") ?? + "SCTQUHSGRWMHZZ7XNTQZJZYZTHLBJFUQVRHYVH4N7GJPVXZQ4OMI5IEQ"; +const PAY_SERVICE_PK = Keypair.fromSecret(PAY_SERVICE_SK).publicKey(); const PAYMENT_XLM = 5; const PAYMENT_STROOPS = BigInt(PAYMENT_XLM) * 10_000_000n; -const DEPOSIT_FEE_STROOPS = 500_000n; const CUSTOMER_PASSWORD = "test-self-custodial-password-2026"; const startTime = Date.now(); -function elapsed(): string { - return `${((Date.now() - startTime) / 1000).toFixed(1)}s`; -} - -// ─── Helpers ─────────────────────────────────────────────────── - -async function fundAccount(publicKey: string): Promise { - for (let attempt = 0; attempt < 10; attempt++) { - try { - const res = await fetch(`${FRIENDBOT_URL}?addr=${publicKey}`); - if (res.ok || res.status === 400) return; - } catch { /* retry */ } - await new Promise((r) => setTimeout(r, 2000)); - } - throw new Error(`Friendbot failed for ${publicKey}`); -} - -async function payApi(path: string, opts: RequestInit = {}): Promise { - return fetch(`${PAY_API}${path}`, { - ...opts, - headers: { - "Content-Type": "application/json", - ...(opts.headers as Record ?? {}), - }, - }); -} - -async function getPayJwt(publicKey: string, secretKey: string): Promise { - const kp = Keypair.fromSecret(secretKey); - const chRes = await payApi("/auth/challenge", { - method: "POST", - body: JSON.stringify({ publicKey }), - }); - const { data: { nonce } } = await chRes.json(); - const sig = Buffer.from(kp.sign(Buffer.from(nonce, "base64"))).toString("base64"); - const vfRes = await payApi("/auth/verify", { - method: "POST", - body: JSON.stringify({ publicKey, nonce, signature: sig }), - }); - const { data: { token } } = await vfRes.json(); - return token; -} - -async function getLatestLedger(): Promise { - const res = await fetch(STELLAR_RPC_URL, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "getLatestLedger" }), - }); - const data = await res.json(); - return data.result.sequence; -} - -// ─── P256 key helpers ────────────────────────────────────────── - -function buildPkcs8P256(rawPrivateKey: Uint8Array): ArrayBuffer { - const header = new Uint8Array([ - 0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, - 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, - 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03, 0x01, 0x07, 0x04, - 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20, - ]); - const result = new Uint8Array(header.length + 32); - result.set(header); - result.set(rawPrivateKey, header.length); - return result.buffer as ArrayBuffer; -} - -function base64UrlToBytes(b64url: string): Uint8Array { - const b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); - const pad = (4 - (b64.length % 4)) % 4; - const binary = atob(b64 + "=".repeat(pad)); - return Uint8Array.from(binary, (c) => c.charCodeAt(0)); -} - -async function deriveP256Keypair(seed: Uint8Array): Promise<{ publicKey: Uint8Array; privateKey: Uint8Array }> { - const seedBuf = new ArrayBuffer(seed.length); - new Uint8Array(seedBuf).set(seed); - const expandKey = await crypto.subtle.importKey("raw", seedBuf, "HKDF", false, ["deriveBits"]); - const expanded = await crypto.subtle.deriveBits( - { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(0), info: new TextEncoder().encode("moonlight-p256") }, - expandKey, 384, - ); - const privateKeyBytes = new Uint8Array(expanded).slice(0, 32); - const cryptoKey = await crypto.subtle.importKey( - "pkcs8", buildPkcs8P256(privateKeyBytes), { name: "ECDSA", namedCurve: "P-256" }, true, ["sign"], - ); - const jwk = await crypto.subtle.exportKey("jwk", cryptoKey); - const publicKey = new Uint8Array(65); - publicKey[0] = 0x04; - publicKey.set(base64UrlToBytes(jwk.x!), 1); - publicKey.set(base64UrlToBytes(jwk.y!), 33); - return { publicKey, privateKey: privateKeyBytes }; -} - -/** password → sign with ed25519 → SHA-256 → 32-byte UTXO derivation key */ -async function deriveUtxoKey(password: string, kp: Keypair): Promise { - const sigBytes = kp.sign(Buffer.from(password)); - const buf = new ArrayBuffer(sigBytes.length); - new Uint8Array(buf).set(sigBytes); - return new Uint8Array(await crypto.subtle.digest("SHA-256", buf)); -} - -/** Derive N P256 keypairs from a UTXO derivation key + index */ -async function deriveUtxoKeypairs( - utxoKey: Uint8Array, - count: number, -): Promise> { - const keypairs: Array<{ publicKey: Uint8Array; privateKey: Uint8Array }> = []; - for (let i = 0; i < count; i++) { - const indexBytes = new TextEncoder().encode(i.toString()); - const seedInput = new Uint8Array(utxoKey.length + indexBytes.length); - seedInput.set(utxoKey); - seedInput.set(indexBytes, utxoKey.length); - const seed = new Uint8Array(await crypto.subtle.digest("SHA-256", seedInput)); - keypairs.push(await deriveP256Keypair(seed)); - } - return keypairs; -} - -function makeUtxoAdapter(kp: { publicKey: Uint8Array; privateKey: Uint8Array }) { - return { - publicKey: kp.publicKey, - signPayload: async (hash: Uint8Array) => { - const hashBuf = new ArrayBuffer(hash.length); - new Uint8Array(hashBuf).set(hash); - const key = await crypto.subtle.importKey( - "pkcs8", buildPkcs8P256(kp.privateKey), - { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"], - ); - return new Uint8Array(await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, hashBuf)); - }, - }; -} - -function partitionAmount(total: bigint, parts: number): bigint[] { - if (parts <= 1) return [total]; - const result: bigint[] = []; - let remaining = total; - for (let i = 0; i < parts - 1; i++) { - const maxForThis = remaining - BigInt(parts - i - 1); - const portion = 1n + BigInt(Math.floor(Math.random() * Number(maxForThis - 1n))); - result.push(portion); - remaining -= portion; - } - result.push(remaining); - return result; -} - -// ─── Provider auth ───────────────────────────────────────────── - -async function authenticateWithProvider(kp: Keypair): Promise { - const { loadConfig } = await import("./e2e/config.ts"); - const { authenticate } = await import("./e2e/auth.ts"); - return authenticate(kp, loadConfig()); -} - -// ─── Read contract config ────────────────────────────────────── - -function loadContractsEnv(): { channelAuthId: string; privacyChannelId: string; assetId: string } { - const env: Record = {}; - try { - for (const line of Deno.readTextFileSync("/config/contracts.env").split("\n")) { - const t = line.trim(); - if (!t || t.startsWith("#")) continue; - const eq = t.indexOf("="); - if (eq !== -1) env[t.slice(0, eq)] = t.slice(eq + 1); - } - } catch { throw new Error("Failed to read /config/contracts.env"); } - return { - channelAuthId: env["E2E_CHANNEL_AUTH_ID"], - privacyChannelId: env["E2E_CHANNEL_CONTRACT_ID"], - assetId: env["E2E_CHANNEL_ASSET_CONTRACT_ID"], - }; -} +const elapsed = () => `${((Date.now() - startTime) / 1000).toFixed(1)}s`; // ─── Main flow ───────────────────────────────────────────────── @@ -226,20 +60,22 @@ console.log(` Channel Auth: ${channelAuthId}`); console.log(` Privacy Channel: ${privacyChannelId}`); console.log(` Asset: ${assetId}`); -// [1] Create accounts -console.log("\n[1/10] Creating merchant and customer accounts..."); +// [1] Create + fund accounts +console.log("\n[1/5] Creating merchant and customer accounts..."); const merchant = Keypair.random(); const customer = Keypair.random(); console.log(` Merchant: ${merchant.publicKey()}`); console.log(` Customer: ${customer.publicKey()}`); -await fundAccount(merchant.publicKey()); -await fundAccount(customer.publicKey()); +await fundAccount(FRIENDBOT_URL, merchant.publicKey()); +await fundAccount(FRIENDBOT_URL, customer.publicKey()); +await fundAccount(FRIENDBOT_URL, ADMIN_PK); +await fundAccount(FRIENDBOT_URL, PAY_SERVICE_PK); console.log(` Funded (${elapsed()})`); -// [2] Create merchant on pay-platform -console.log("\n[2/10] Creating merchant account on pay-platform..."); -const merchantJwt = await getPayJwt(merchant.publicKey(), merchant.secret()); -const createRes = await payApi("/account", { +// [2] Create merchant on pay-platform + store UTXOs +console.log("\n[2/5] Creating merchant account + UTXOs..."); +const merchantJwt = await getPayJwt(PAY_API, merchant.publicKey(), merchant.secret()); +const createRes = await payApi(PAY_API, "/account", { method: "POST", headers: { Authorization: `Bearer ${merchantJwt}` }, body: JSON.stringify({ @@ -249,173 +85,74 @@ const createRes = await payApi("/account", { }), }); if (createRes.status !== 201 && createRes.status !== 200) { - throw new Error(`Create merchant account failed: ${createRes.status} ${await createRes.text()}`); + throw new Error(`Create merchant failed: ${createRes.status} ${await createRes.text()}`); } -console.log(` Merchant account created (${elapsed()})`); -// [3] Store merchant receive UTXOs (real P256 keys) -console.log("\n[3/10] Generating and storing merchant receive UTXOs..."); -const merchantUtxoPayloads = []; +const utxoPayloads = []; for (let i = 0; i < 5; i++) { - const kp = await deriveP256Keypair(crypto.getRandomValues(new Uint8Array(32))); - merchantUtxoPayloads.push({ - utxoPublicKey: btoa(String.fromCharCode(...kp.publicKey)), + const pk = await generateP256PublicKey(); + utxoPayloads.push({ + utxoPublicKey: btoa(String.fromCharCode(...pk)), derivationIndex: i, }); } -const utxoRes = await payApi("/utxo/receive", { +const utxoRes = await payApi(PAY_API, "/utxo/receive", { method: "POST", headers: { Authorization: `Bearer ${merchantJwt}` }, - body: JSON.stringify({ utxos: merchantUtxoPayloads }), + body: JSON.stringify({ utxos: utxoPayloads }), }); if (utxoRes.status !== 201 && utxoRes.status !== 200) { throw new Error(`Store UTXOs failed: ${utxoRes.status} ${await utxoRes.text()}`); } -console.log(` ${merchantUtxoPayloads.length} UTXOs stored (${elapsed()})`); +console.log(` Merchant + ${utxoPayloads.length} UTXOs created (${elapsed()})`); -// [4] Seed council + PP config -console.log("\n[4/10] Seeding council and PP config..."); -await fundAccount(ADMIN_PK); -const adminJwt = await getPayJwt(ADMIN_PK, ADMIN_SK); -const councilInsertRes = await payApi("/admin/councils", { +// [3] Seed council + PP +console.log("\n[3/5] Seeding council and PP config..."); +const adminJwt = await getPayJwt(PAY_API, ADMIN_PK, ADMIN_SK); +const councilRes = await payApi(PAY_API, "/admin/councils", { method: "POST", headers: { Authorization: `Bearer ${adminJwt}` }, body: JSON.stringify({ name: "Test Council SC", channelAuthId, - privacyChannelId, - assetId, networkPassphrase: NETWORK_PASSPHRASE, - jurisdictionCodes: "US,GB,DE", + channels: [{ assetCode: "XLM", assetContractId: assetId, privacyChannelId }], + jurisdictions: ["US", "GB", "DE"], active: true, }), }); -if (!councilInsertRes.ok) throw new Error(`Council insert failed: ${councilInsertRes.status} ${await councilInsertRes.text()}`); -const { data: council } = await councilInsertRes.json(); -console.log(` Council created: ${council.id}`); +if (!councilRes.ok) throw new Error(`Council failed: ${councilRes.status} ${await councilRes.text()}`); +const { data: council } = await councilRes.json(); -const ppInsertRes = await payApi(`/admin/councils/${council.id}/pps`, { +const ppRes = await payApi(PAY_API, `/admin/councils/${council.id}/pps`, { method: "POST", headers: { Authorization: `Bearer ${adminJwt}` }, body: JSON.stringify({ name: "Test PP", url: PROVIDER_URL, publicKey: "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF", active: true }), }); -if (!ppInsertRes.ok) throw new Error(`PP insert failed: ${ppInsertRes.status} ${await ppInsertRes.text()}`); -const { data: pp } = await ppInsertRes.json(); -console.log(` PP created: ${pp.id} (${elapsed()})`); - -// [5] Prepare instant payment -console.log("\n[5/10] Preparing payment..."); -const prepareRes = await payApi("/pay/instant/prepare", { - method: "POST", - body: JSON.stringify({ - merchantWallet: merchant.publicKey(), - amountXlm: String(PAYMENT_XLM), - customerWallet: customer.publicKey(), - }), +if (!ppRes.ok) throw new Error(`PP failed: ${ppRes.status} ${await ppRes.text()}`); +console.log(` Council + PP seeded (${elapsed()})`); + +// [4] Execute self-custodial payment via the actual moonlight-pay function +console.log("\n[4/5] Executing self-custodial payment..."); +const signer = createTestSigner(customer, NETWORK_PASSPHRASE); +const signMessage = createTestSignMessage(customer); +const result = await executeSelfCustodialPayment({ + customerWallet: customer.publicKey(), + merchantWallet: merchant.publicKey(), + amountXlm: String(PAYMENT_XLM), + assetCode: "XLM", + password: CUSTOMER_PASSWORD, + description: "POS self-custodial e2e test", + signer, + signMessage, + onStatus: (msg) => console.log(` [status] ${msg}`), }); -if (!prepareRes.ok) throw new Error(`Prepare failed: ${prepareRes.status} ${await prepareRes.text()}`); -const { data: prepare } = await prepareRes.json(); -console.log(` Prepared: ${prepare.merchantUtxos.length} merchant UTXOs (${elapsed()})`); - -// [6] Authenticate customer with provider-platform -console.log("\n[6/10] Authenticating customer with provider-platform..."); -const providerJwt = await authenticateWithProvider(customer); -console.log(` Authenticated (${elapsed()})`); - -// [7] Derive customer UTXO key from password signature -console.log("\n[7/10] Deriving UTXO key from password..."); -const utxoKey = await deriveUtxoKey(CUSTOMER_PASSWORD, customer); -console.log(` UTXO key derived (${elapsed()})`); - -// [8] Derive customer P256 keypairs + build bundle -console.log("\n[8/10] Building self-custodial payment bundle..."); -const ledgerSequence = await getLatestLedger(); -const expiration = ledgerSequence + 1000; - -const customerKeypairCount = 5; -const customerKeypairs = await deriveUtxoKeypairs(utxoKey, customerKeypairCount); - -// Merchant CREATE ops -const merchantAmounts = partitionAmount(PAYMENT_STROOPS, prepare.merchantUtxos.length); -const merchantCreateOps = prepare.merchantUtxos.map( - (u: { utxoPublicKey: string }, i: number) => { - const pubKeyBytes = Uint8Array.from(atob(u.utxoPublicKey), (c) => c.charCodeAt(0)); - return MoonlightOperation.create(pubKeyBytes, merchantAmounts[i]); - }, -); - -// Customer CREATE ops (at customer's password-derived P256 keys) -const customerAmounts = partitionAmount(PAYMENT_STROOPS, customerKeypairCount); -const customerCreateOps = customerKeypairs.map((kp, i) => - MoonlightOperation.create(kp.publicKey, customerAmounts[i]) -); - -// DEPOSIT — customer signs, conditioned on customer CREATEs -const depositOp = await MoonlightOperation.deposit( - customer.publicKey() as Ed25519PublicKey, - PAYMENT_STROOPS + DEPOSIT_FEE_STROOPS, -) - .addConditions(customerCreateOps.map((op) => op.toCondition())) - .signWithEd25519( - customer, - expiration, - privacyChannelId as ContractId, - assetId as ContractId, - NETWORK_PASSPHRASE, - ); - -// SPEND ops — from customer's derived keys, conditioned on merchant CREATEs -const spendOps = []; -for (let i = 0; i < customerKeypairs.length; i++) { - const spendOp = MoonlightOperation.spend(customerKeypairs[i].publicKey); - for (const merchantCreate of merchantCreateOps) { - spendOp.addCondition(merchantCreate.toCondition()); - } - await spendOp.signWithUTXO( - makeUtxoAdapter(customerKeypairs[i]), - privacyChannelId as ContractId, - expiration, - ); - spendOps.push(spendOp); -} - -// Assemble: DEPOSIT, customer CREATEs, SPENDs, merchant CREATEs -const operationsMLXDR = [ - depositOp.toMLXDR(), - ...customerCreateOps.map((op) => op.toMLXDR()), - ...spendOps.map((op) => op.toMLXDR()), - ...merchantCreateOps.map((op) => op.toMLXDR()), -]; -console.log(` Bundle: ${operationsMLXDR.length} operations (${elapsed()})`); - -// [9] Submit via pay-platform -console.log("\n[9/10] Submitting via pay-platform..."); -const submitRes = await payApi("/pay/instant/submit", { - method: "POST", - body: JSON.stringify({ - customerWallet: customer.publicKey(), - merchantWallet: merchant.publicKey(), - amountStroops: PAYMENT_STROOPS.toString(), - description: "POS self-custodial e2e test", - operationsMLXDR, - merchantUtxoIds: prepare.merchantUtxos.map((u: { id: string }) => u.id), - ppUrl: PROVIDER_URL, - ppAuthToken: providerJwt, - channelContractId: privacyChannelId, - }), -}); -if (!submitRes.ok) { - const err = await submitRes.text(); - throw new Error(`Submit failed: ${submitRes.status} ${err}`); -} -const { data: submitData } = await submitRes.json(); -console.log(` Transaction recorded: ${submitData.transactionId} (${elapsed()})`); -console.log(` Bundle ID: ${submitData.bundleId}`); -console.log(` Status: ${submitData.status}`); +console.log(` Transaction: ${result.transactionId} (${elapsed()})`); +console.log(` Status: ${result.status}`); -// [10] Verify merchant balance -console.log("\n[10/10] Verifying merchant balance..."); -const balanceRes = await payApi("/transactions/balance", { +// [5] Verify merchant balance +console.log("\n[5/5] Verifying merchant balance..."); +const balanceRes = await payApi(PAY_API, "/transactions/balance", { headers: { Authorization: `Bearer ${merchantJwt}` }, }); if (!balanceRes.ok) throw new Error(`Balance check failed: ${balanceRes.status}`); diff --git a/setup-keys.sh b/setup-keys.sh index cedd451..708466c 100755 --- a/setup-keys.sh +++ b/setup-keys.sh @@ -8,8 +8,9 @@ set -euo pipefail # reference them in service configs like ADMIN_WALLETS. # # Roles: -# DEPLOYER — deploys Soroban contracts (used by setup-c.sh) -# PAY_ADMIN — configures pay-platform councils (used by setup-pay.sh) +# DEPLOYER — deploys Soroban contracts (used by setup-c.sh) +# PAY_ADMIN — configures pay-platform councils (used by setup-pay.sh) +# PAY_SERVICE — pay-platform's identity for authenticating with provider-platform # # Output: .local-dev-keys file consumed by up.sh and setup-* scripts. @@ -35,6 +36,7 @@ async function deterministicKeypair(seed) { const deployer = await deterministicKeypair('moonlight-local-dev-deployer-admin-v1'); const payAdmin = await deterministicKeypair('moonlight-local-dev-pay-admin-v1'); +const payService = await deterministicKeypair('moonlight-local-dev-pay-service-v1'); const lines = [ '# Generated by setup-keys.sh — deterministic keypairs for local-dev roles.', @@ -48,6 +50,10 @@ const lines = [ '# Pay-platform admin (used by setup-pay.sh, referenced in ADMIN_WALLETS)', 'PAY_ADMIN_PK=' + payAdmin.publicKey(), 'PAY_ADMIN_SK=' + payAdmin.secret(), + '', + '# Pay-platform service keypair (authenticates with provider-platform)', + 'PAY_SERVICE_PK=' + payService.publicKey(), + 'PAY_SERVICE_SK=' + payService.secret(), ]; console.log(lines.join('\n')); diff --git a/setup-pay.sh b/setup-pay.sh index 66e9985..3105295 100755 --- a/setup-pay.sh +++ b/setup-pay.sh @@ -38,12 +38,13 @@ if [ ! -f "$KEYS_FILE" ]; then fi PAY_ADMIN_PK=$(grep "^PAY_ADMIN_PK=" "$KEYS_FILE" | cut -d= -f2) PAY_ADMIN_SK=$(grep "^PAY_ADMIN_SK=" "$KEYS_FILE" | cut -d= -f2) +PAY_SERVICE_PK=$(grep "^PAY_SERVICE_PK=" "$KEYS_FILE" | cut -d= -f2) if [ -z "$PAY_ADMIN_PK" ] || [ -z "$PAY_ADMIN_SK" ]; then echo "ERROR: PAY_ADMIN_PK or PAY_ADMIN_SK missing from $KEYS_FILE" >&2 exit 1 fi -export PAY_ADMIN_PK PAY_ADMIN_SK +export PAY_ADMIN_PK PAY_ADMIN_SK PAY_SERVICE_PK cd "$SCRIPT_DIR" exec "$DENO_BIN" run --allow-all setup-pay.ts "$@" diff --git a/setup-pay.ts b/setup-pay.ts index 38b4b96..ac06657 100644 --- a/setup-pay.ts +++ b/setup-pay.ts @@ -161,7 +161,7 @@ async function main() { const jwt = await walletAuth(payAdmin); console.log(" JWT acquired"); - console.log("\n[5/6] Create council via POST /admin/councils"); + console.log("\n[5/8] Create council via POST /admin/councils"); const councilRes = await fetch(`${PAY_API}/admin/councils`, { method: "POST", headers: { @@ -171,10 +171,15 @@ async function main() { body: JSON.stringify({ name: "Local Council", channelAuthId: state.COUNCIL_ID, - privacyChannelId: state.CHANNEL_ID, - assetId: state.ASSET_ID, networkPassphrase: state.NETWORK_PASSPHRASE, - jurisdictionCodes: "US,GB,DE", + channels: [ + { + assetCode: "XLM", + assetContractId: state.ASSET_ID, + privacyChannelId: state.CHANNEL_ID, + }, + ], + jurisdictions: ["US", "UY", "BR", "AR", "FR"], active: true, }), }); @@ -186,7 +191,7 @@ async function main() { const { data: council } = await councilRes.json(); console.log(` Council created: ${council.id}`); - console.log("\n[6/6] Create PP via POST /admin/councils/:id/pps"); + console.log("\n[6/8] Create PP via POST /admin/councils/:id/pps"); const ppRes = await fetch(`${PAY_API}/admin/councils/${council.id}/pps`, { method: "POST", headers: { @@ -208,6 +213,27 @@ async function main() { const { data: pp } = await ppRes.json(); console.log(` PP created: ${pp.id}`); + // Fund the PAY_SERVICE keypair so it can authenticate with provider-platform + const payServicePk = Deno.env.get("PAY_SERVICE_PK"); + if (payServicePk) { + console.log("\n[7/8] Fund PAY_SERVICE via Friendbot"); + await fundAccount(payServicePk); + console.log(` PAY_SERVICE funded: ${payServicePk}`); + } else { + console.log("\n[7/8] PAY_SERVICE_PK not set — skipping fund"); + } + + console.log("\n[8/8] Verify council config"); + const verifyRes = await fetch(`${PAY_API}/admin/councils/${council.id}`, { + headers: { "Authorization": `Bearer ${jwt}` }, + }); + if (verifyRes.ok) { + const { data: full } = await verifyRes.json(); + console.log(` Channels: ${full.channels?.length ?? 0}`); + console.log(` Jurisdictions: ${full.jurisdictions?.join(", ") ?? "none"}`); + console.log(` PPs: ${full.pps?.length ?? 0}`); + } + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); console.log(`\n=== Pay Platform setup complete in ${elapsed}s ===\n`); console.log(` Council DB ID: ${council.id}`); @@ -215,6 +241,9 @@ async function main() { console.log(` PP DB ID: ${pp.id}`); console.log(` PP public key: ${state.PP_PK}`); console.log(` Provider URL: ${state.PROVIDER_URL}`); + if (payServicePk) { + console.log(` Service key: ${payServicePk}`); + } console.log(""); console.log("Pay-platform now has the council + PP routing config needed"); console.log("for the POS instant payment flow."); diff --git a/up.sh b/up.sh index 65b670e..2070183 100755 --- a/up.sh +++ b/up.sh @@ -349,8 +349,10 @@ cd "$PAY_PLATFORM_PATH" # If setup-keys.sh hasn't run yet, ADMIN_WALLETS is empty and the /admin # endpoints return 403 until it's configured. PAY_ADMIN_PK="" +PAY_SERVICE_SK="" if [ -f "$SCRIPT_DIR/.local-dev-keys" ]; then PAY_ADMIN_PK=$(grep "^PAY_ADMIN_PK=" "$SCRIPT_DIR/.local-dev-keys" | cut -d= -f2) + PAY_SERVICE_SK=$(grep "^PAY_SERVICE_SK=" "$SCRIPT_DIR/.local-dev-keys" | cut -d= -f2) fi cat > .env <