From 7f3998eb311d790deafaf43fb6cfae0a89d2c13c Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 2 Jun 2026 16:00:04 -0300 Subject: [PATCH 1/7] feat(utxo): on-demand backend UTXO derivation + delete-my-account + receive precheck Replace the 100-key signup pre-pool with a single encrypted delegation root stored on the user's pay_accounts row. The backend derives merchant UTXO public keys on demand by walking the privacy channel's utxo_balances view, so the receive-side is no longer bounded by the batch the user generated at signup. - pay_accounts.encrypted_delegation_key (migration 0008). receive_utxos table dropped along with its enum + repository. - core/crypto/utxo-derivation.ts: server-side per-index P-256 keypair derivation from a 32-byte root (HKDF + SHA-256 + P-256). - core/service/utxo/utxo-balance.ts: chain walk, free-index finder, and receive-side precheck (validateReceiveDestinations). Walk terminates on 3 consecutive -1 (never-created) returns from the chain. - POST /account/delegation-key (one-shot encrypted-at-rest handover via encrypt-sk.ts + SERVICE_AUTH_SECRET). - DELETE /account/me (hard delete; transactions FK cascades). - GET /account/:walletPublicKey/public (public profile lookup; replaces the old /utxo/receive/:wallet/available endpoint). - pay/instant/prepare derives on demand; pay/instant/execute runs the precheck before submitting; merchantUtxoIndexes replaces the DB row ids. - transactions/balance switches from SUM(transactions) to chain-derived walk. - 6 unit tests covering the chain walk + precheck. --- deno.json | 2 +- src/core/channel-client/index.ts | 52 +++++ src/core/crypto/utxo-derivation.ts | 77 +++++++ src/core/service/utxo/utxo-balance.ts | 195 ++++++++++++++++++ src/core/service/utxo/utxo-balance_test.ts | 185 +++++++++++++++++ src/http/v1/account/delegation-key.ts | 102 +++++++++ src/http/v1/account/delete.ts | 42 ++++ src/http/v1/account/public.ts | 53 +++++ src/http/v1/account/routes.ts | 17 ++ src/http/v1/pay/instant-execute.ts | 116 +++++------ src/http/v1/pay/instant-prepare.ts | 90 ++++---- src/http/v1/pay/instant-submit.ts | 48 +---- src/http/v1/transaction/balance.ts | 85 +++++++- src/http/v1/utxo/available.ts | 76 ------- src/http/v1/utxo/post.ts | 87 -------- src/http/v1/utxo/routes.ts | 20 -- src/http/v1/v1.routes.ts | 3 - src/persistence/drizzle/entity/index.ts | 1 - .../drizzle/entity/pay-account.entity.ts | 1 + .../drizzle/entity/receive-utxo.entity.ts | 38 ---- .../drizzle/migration/meta/_journal.json | 7 + .../repository/pay-account.repository.ts | 8 + .../repository/receive-utxo.repository.ts | 72 ------- 23 files changed, 923 insertions(+), 454 deletions(-) create mode 100644 src/core/channel-client/index.ts create mode 100644 src/core/crypto/utxo-derivation.ts create mode 100644 src/core/service/utxo/utxo-balance.ts create mode 100644 src/core/service/utxo/utxo-balance_test.ts create mode 100644 src/http/v1/account/delegation-key.ts create mode 100644 src/http/v1/account/delete.ts create mode 100644 src/http/v1/account/public.ts delete mode 100644 src/http/v1/utxo/available.ts delete mode 100644 src/http/v1/utxo/post.ts delete mode 100644 src/http/v1/utxo/routes.ts delete mode 100644 src/persistence/drizzle/entity/receive-utxo.entity.ts delete mode 100644 src/persistence/drizzle/repository/receive-utxo.repository.ts diff --git a/deno.json b/deno.json index 914eecd..e4662c2 100644 --- a/deno.json +++ b/deno.json @@ -1,6 +1,6 @@ { "name": "@moonlight-protocol/pay-platform", - "version": "0.5.18", + "version": "0.5.19", "license": "MIT", "exports": "./src/main.ts", "tasks": { diff --git a/src/core/channel-client/index.ts b/src/core/channel-client/index.ts new file mode 100644 index 0000000..b0ab45f --- /dev/null +++ b/src/core/channel-client/index.ts @@ -0,0 +1,52 @@ +/** + * PrivacyChannel client factory + cache. + * + * Mirrors provider-platform's channel-client. Caches one PrivacyChannel per + * channelContractId so on-chain reads (utxo_balances et al) skip the + * constructor cost on every request. + */ +import { NetworkConfig } from "@colibri/core"; +import { PrivacyChannel } from "@moonlight/moonlight-sdk"; +import { STELLAR_NETWORK_PASSPHRASE, STELLAR_RPC_URL } from "@/config/env.ts"; + +const MAX_CACHE_SIZE = 100; +const channelCache = new Map(); + +function buildNetworkConfig(): NetworkConfig { + const horizonUrl = STELLAR_RPC_URL.includes("/soroban/rpc") + ? STELLAR_RPC_URL.replace("/soroban/rpc", "") + : STELLAR_RPC_URL; + return NetworkConfig.CustomNet({ + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + rpcUrl: STELLAR_RPC_URL, + horizonUrl, + allowHttp: STELLAR_RPC_URL.startsWith("http://"), + }); +} + +export function getChannelClient( + channelContractId: string, + channelAuthId: string, + assetContractId: string, +): PrivacyChannel { + const cached = channelCache.get(channelContractId); + if (cached) return cached; + + if (channelCache.size >= MAX_CACHE_SIZE) { + const oldestKey = channelCache.keys().next().value; + if (oldestKey !== undefined) channelCache.delete(oldestKey); + } + + const client = new PrivacyChannel( + buildNetworkConfig(), + channelContractId as `C${string}`, + channelAuthId as `C${string}`, + assetContractId as `C${string}`, + ); + channelCache.set(channelContractId, client); + return client; +} + +export function clearChannelCache(): void { + channelCache.clear(); +} diff --git a/src/core/crypto/utxo-derivation.ts b/src/core/crypto/utxo-derivation.ts new file mode 100644 index 0000000..9a0376b --- /dev/null +++ b/src/core/crypto/utxo-derivation.ts @@ -0,0 +1,77 @@ +/** + * Server-side UTXO key derivation for Moonlight Pay accounts. + * + * The client computes `utxoRoot` once at signup: + * utxoRoot = HKDF-SHA256( + * IKM = masterSeed, + * salt = utf8("moonlight-pay"), + * info = utf8("moonlight-pay-utxo-v1"), + * L = 32, + * ) + * and ships it encrypted (AES-256-GCM via encrypt-sk.ts) to pay-platform. + * + * Pay-platform decrypts the root on demand and derives the keypair at the + * given index here. The masterSeed never reaches the backend. + * + * seed_i = SHA-256(utxoRoot ‖ utf8(i.toString())) + * expanded_i = HKDF-SHA256(IKM=seed_i, salt=∅, info="moonlight-p256", L=48) + * priv_i = expanded_i[0:32] + * pub_i = p256·G·priv_i (uncompressed 65-byte: 0x04 ‖ X ‖ Y) + */ +import { p256 } from "@noble/curves/p256"; + +export const UTXO_ROOT_HKDF_SALT = "moonlight-pay"; +export const UTXO_ROOT_HKDF_INFO = "moonlight-pay-utxo-v1"; +const P256_HKDF_INFO = "moonlight-p256"; + +export interface UtxoKeypair { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +export async function deriveUtxoKeypair( + utxoRoot: Uint8Array, + index: number, +): Promise { + const indexBytes = new TextEncoder().encode(index.toString()); + const seedInput = new Uint8Array(utxoRoot.length + indexBytes.length); + seedInput.set(utxoRoot); + seedInput.set(indexBytes, utxoRoot.length); + + const seed = new Uint8Array( + await crypto.subtle.digest("SHA-256", seedInput), + ); + + 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(P256_HKDF_INFO), + }, + expandKey, + 384, + ); + + const privateKey = new Uint8Array(expanded).slice(0, 32); + const publicKey = p256.ProjectivePoint.fromPrivateKey(privateKey) + .toRawBytes(false); + + return { publicKey: new Uint8Array(publicKey), privateKey }; +} + +export async function deriveUtxoPublicKey( + utxoRoot: Uint8Array, + index: number, +): Promise { + return (await deriveUtxoKeypair(utxoRoot, index)).publicKey; +} diff --git a/src/core/service/utxo/utxo-balance.ts b/src/core/service/utxo/utxo-balance.ts new file mode 100644 index 0000000..af92a4d --- /dev/null +++ b/src/core/service/utxo/utxo-balance.ts @@ -0,0 +1,195 @@ +/** + * On-chain UTXO balance helpers. + * + * Calls the privacy-channel contract's `utxo_balances` view. Per + * `soroban-core/modules/utxo-core/src/core.rs`: + * - balance > 0 → UTXO is funded and unspent + * - balance == 0 → UTXO was created and later spent (cannot be reused) + * - balance < 0 → no record exists (free to create) + * + * With a monotonic "always pick the lowest free index" assignment rule, the + * funded-or-spent region is a contiguous prefix `0..N` and every index past + * the prefix is free. We terminate the walk after seeing 3 consecutive + * free indexes — defensive padding against transient race conditions. + */ +import { Buffer } from "buffer"; +import { + ChannelReadMethods, + type PrivacyChannel, +} from "@moonlight/moonlight-sdk"; +import type { Logger } from "@/utils/logger/index.ts"; +import { deriveUtxoPublicKey } from "@/core/crypto/utxo-derivation.ts"; + +const DEFAULT_BATCH_SIZE = 50; +const FREE_RUN_TERMINATOR = 3; + +export interface UtxoIndexBalance { + index: number; + balance: bigint; +} + +export async function fetchUtxoBalances( + channelClient: PrivacyChannel, + publicKeys: Uint8Array[], + deps: { log: Logger }, +): Promise { + const log = deps.log.scope("fetchUtxoBalances"); + if (publicKeys.length === 0) return []; + log.debug("count", publicKeys.length); + + const result = await channelClient.read({ + method: ChannelReadMethods.utxo_balances, + methodArgs: { utxos: publicKeys.map((pk) => Buffer.from(pk)) }, + }); + + return (result as Array).map((b) => BigInt(b)); +} + +export async function fetchUtxoBalance( + channelClient: PrivacyChannel, + publicKey: Uint8Array, + deps: { log: Logger }, +): Promise { + const [b] = await fetchUtxoBalances(channelClient, [publicKey], deps); + return b ?? -1n; +} + +async function deriveKeyBatch( + utxoRoot: Uint8Array, + startIndex: number, + batchSize: number, +): Promise { + const out: Uint8Array[] = []; + for (let k = 0; k < batchSize; k++) { + out.push(await deriveUtxoPublicKey(utxoRoot, startIndex + k)); + } + return out; +} + +/** + * Walk derivation indexes from 0, summing positive balances. Stops after + * FREE_RUN_TERMINATOR (3) consecutive free (-1) entries, confirming we are + * past the funded prefix. Per-index records returned for the caller's use + * (e.g. surfacing the spent UTXO history). + */ +export async function walkFundedBalances( + channelClient: PrivacyChannel, + utxoRoot: Uint8Array, + deps: { log: Logger }, + opts?: { batchSize?: number }, +): Promise<{ totalStroops: bigint; perIndex: UtxoIndexBalance[] }> { + const batchSize = opts?.batchSize ?? DEFAULT_BATCH_SIZE; + const log = deps.log.scope("walkFundedBalances"); + const perIndex: UtxoIndexBalance[] = []; + let total = 0n; + let i = 0; + let freeRun = 0; + + while (freeRun < FREE_RUN_TERMINATOR) { + const keys = await deriveKeyBatch(utxoRoot, i, batchSize); + const balances = await fetchUtxoBalances(channelClient, keys, deps); + for (let k = 0; k < balances.length; k++) { + const balance = balances[k]; + const index = i + k; + perIndex.push({ index, balance }); + if (balance < 0n) { + freeRun++; + if (freeRun >= FREE_RUN_TERMINATOR) break; + } else { + freeRun = 0; + if (balance > 0n) total += balance; + } + } + i += batchSize; + } + + log.debug("totalStroops", total.toString()); + log.debug("scannedUpTo", i); + return { totalStroops: total, perIndex }; +} + +/** + * Receive-side precheck. For each of `proposedIndexes`, derive the public + * key and query on-chain balance. If every proposed index is still free + * (-1), return them as-is. If any is non-free (funded or spent), walk + * forward and substitute with the next free index. Returns the validated + * indexes paired with the public keys the caller should target. + */ +export async function validateReceiveDestinations( + channelClient: PrivacyChannel, + utxoRoot: Uint8Array, + proposedIndexes: number[], + deps: { log: Logger }, +): Promise<{ indexes: number[]; publicKeys: Uint8Array[] }> { + const log = deps.log.scope("validateReceiveDestinations"); + if (proposedIndexes.length === 0) return { indexes: [], publicKeys: [] }; + + const proposedKeys = await Promise.all( + proposedIndexes.map((i) => deriveUtxoPublicKey(utxoRoot, i)), + ); + const balances = await fetchUtxoBalances(channelClient, proposedKeys, deps); + + const allFree = balances.every((b) => b < 0n); + if (allFree) { + log.debug("preflight", "all proposed indexes free"); + return { indexes: [...proposedIndexes], publicKeys: proposedKeys }; + } + + log.event("preflight rejected — re-deriving free destinations"); + const freshIndexes = await findFreeUtxoIndexes( + channelClient, + utxoRoot, + proposedIndexes.length, + { log }, + ); + const freshKeys = await Promise.all( + freshIndexes.map((i) => deriveUtxoPublicKey(utxoRoot, i)), + ); + return { indexes: freshIndexes, publicKeys: freshKeys }; +} + +/** + * Walk derivation indexes from 0 and collect the first `count` free + * (-1) indexes. Use for picking receive destinations. + */ +export async function findFreeUtxoIndexes( + channelClient: PrivacyChannel, + utxoRoot: Uint8Array, + count: number, + deps: { log: Logger }, + opts?: { batchSize?: number }, +): Promise { + const batchSize = opts?.batchSize ?? DEFAULT_BATCH_SIZE; + const log = deps.log.scope("findFreeUtxoIndexes"); + const out: number[] = []; + let i = 0; + let freeRun = 0; + + while (out.length < count) { + const keys = await deriveKeyBatch(utxoRoot, i, batchSize); + const balances = await fetchUtxoBalances(channelClient, keys, deps); + for (let k = 0; k < balances.length; k++) { + const index = i + k; + if (balances[k] < 0n) { + out.push(index); + freeRun++; + if (out.length >= count) break; + } else { + freeRun = 0; + } + } + // Past the funded prefix every subsequent index is free, no need to + // chain-query further — fill the remainder synthetically. + if (freeRun >= FREE_RUN_TERMINATOR && out.length < count) { + let nextIndex = out[out.length - 1] + 1; + while (out.length < count) { + out.push(nextIndex++); + } + break; + } + i += batchSize; + } + + log.debug("freeIndexes", out.join(",")); + return out; +} diff --git a/src/core/service/utxo/utxo-balance_test.ts b/src/core/service/utxo/utxo-balance_test.ts new file mode 100644 index 0000000..1d42787 --- /dev/null +++ b/src/core/service/utxo/utxo-balance_test.ts @@ -0,0 +1,185 @@ +import { assertEquals, assertNotEquals } from "@std/assert"; +import type { Buffer } from "buffer"; +import { + fetchUtxoBalances, + findFreeUtxoIndexes, + validateReceiveDestinations, + walkFundedBalances, +} from "./utxo-balance.ts"; +import { deriveUtxoPublicKey } from "@/core/crypto/utxo-derivation.ts"; + +function makeUtxoRoot(seed: number): Uint8Array { + const buf = new Uint8Array(32); + for (let i = 0; i < 32; i++) buf[i] = (seed + i) & 0xff; + return buf; +} + +const silentLog = { + scope: () => silentLog, + info: () => {}, + debug: () => {}, + event: () => {}, + error: () => {}, + // deno-lint-ignore no-explicit-any +} as any; + +interface MockReadCall { + method: unknown; + // deno-lint-ignore no-explicit-any + methodArgs: any; +} + +function mockChannelClient(balances: Map) { + const calls: MockReadCall[] = []; + const client = { + read(args: MockReadCall): Promise { + calls.push(args); + const utxos = args.methodArgs.utxos as Buffer[]; + const out = utxos.map((buf) => { + const key = Array.from(buf).map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return balances.has(key) ? balances.get(key)! : -1n; + }); + return Promise.resolve(out); + }, + // deno-lint-ignore no-explicit-any + } as any; + return { client, calls }; +} + +function hexOf(bytes: Uint8Array): string { + return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +Deno.test("fetchUtxoBalances forwards bigints from the SDK", async () => { + const utxoRoot = makeUtxoRoot(1); + const pubA = await deriveUtxoPublicKey(utxoRoot, 0); + const pubB = await deriveUtxoPublicKey(utxoRoot, 1); + const balances = new Map([ + [hexOf(pubA), 100n], + [hexOf(pubB), 0n], + ]); + const { client } = mockChannelClient(balances); + + const result = await fetchUtxoBalances(client, [pubA, pubB], { + log: silentLog, + }); + + assertEquals(result, [100n, 0n]); +}); + +Deno.test("walkFundedBalances sums positives, ignores spent (0), terminates on 3 free", async () => { + const utxoRoot = makeUtxoRoot(2); + const pub0 = await deriveUtxoPublicKey(utxoRoot, 0); + const pub1 = await deriveUtxoPublicKey(utxoRoot, 1); + const pub2 = await deriveUtxoPublicKey(utxoRoot, 2); + const balances = new Map([ + [hexOf(pub0), 500n], // funded + [hexOf(pub1), 0n], // spent — not counted + [hexOf(pub2), 200n], // funded + // indexes 3+ are -1 (free) by default + ]); + const { client, calls } = mockChannelClient(balances); + + const { totalStroops, perIndex } = await walkFundedBalances( + client, + utxoRoot, + { log: silentLog }, + { batchSize: 6 }, + ); + + assertEquals(totalStroops, 700n); + assertEquals(perIndex[0].balance, 500n); + assertEquals(perIndex[1].balance, 0n); + assertEquals(perIndex[2].balance, 200n); + assertEquals(perIndex[3].balance, -1n); + assertEquals(perIndex[4].balance, -1n); + assertEquals(perIndex[5].balance, -1n); + assertEquals(calls.length, 1); +}); + +Deno.test("findFreeUtxoIndexes skips funded + spent, picks first free indexes", async () => { + const utxoRoot = makeUtxoRoot(3); + const pub0 = await deriveUtxoPublicKey(utxoRoot, 0); + const pub1 = await deriveUtxoPublicKey(utxoRoot, 1); + const balances = new Map([ + [hexOf(pub0), 50n], // funded + [hexOf(pub1), 0n], // spent + // 2,3,4,5,... → -1 (free) + ]); + const { client } = mockChannelClient(balances); + + const indexes = await findFreeUtxoIndexes( + client, + utxoRoot, + 3, + { log: silentLog }, + { batchSize: 6 }, + ); + + assertEquals(indexes, [2, 3, 4]); +}); + +Deno.test("validateReceiveDestinations passes through when all proposed are free", async () => { + const utxoRoot = makeUtxoRoot(4); + const { client, calls } = mockChannelClient(new Map()); + + const { indexes, publicKeys } = await validateReceiveDestinations( + client, + utxoRoot, + [7, 8, 9], + { log: silentLog }, + ); + + assertEquals(indexes, [7, 8, 9]); + assertEquals(publicKeys.length, 3); + // Single chain query for the precheck; no fallback walk needed. + assertEquals(calls.length, 1); +}); + +Deno.test("validateReceiveDestinations rejects a non-zero-balance destination and re-derives", async () => { + const utxoRoot = makeUtxoRoot(5); + const proposed = [0, 1, 2]; + const proposedKeys = await Promise.all( + proposed.map((i) => deriveUtxoPublicKey(utxoRoot, i)), + ); + + // Index 1 has been funded between prepare and execute; the precheck must + // reject the proposed set and substitute fresh free indexes. + const balances = new Map([ + [hexOf(proposedKeys[1]), 42n], + ]); + const { client } = mockChannelClient(balances); + + const { indexes, publicKeys } = await validateReceiveDestinations( + client, + utxoRoot, + proposed, + { log: silentLog }, + ); + + assertEquals(publicKeys.length, proposed.length); + // The replacement set must NOT include the funded index 1. + for (const idx of indexes) assertNotEquals(idx, 1); +}); + +Deno.test("validateReceiveDestinations rejects a spent (balance == 0) destination", async () => { + const utxoRoot = makeUtxoRoot(6); + const proposed = [4, 5]; + const proposedKeys = await Promise.all( + proposed.map((i) => deriveUtxoPublicKey(utxoRoot, i)), + ); + const balances = new Map([ + [hexOf(proposedKeys[0]), 0n], // spent — must reject + ]); + const { client } = mockChannelClient(balances); + + const { indexes } = await validateReceiveDestinations( + client, + utxoRoot, + proposed, + { log: silentLog }, + ); + + for (const idx of indexes) assertNotEquals(idx, 4); +}); diff --git a/src/http/v1/account/delegation-key.ts b/src/http/v1/account/delegation-key.ts new file mode 100644 index 0000000..1bddd78 --- /dev/null +++ b/src/http/v1/account/delegation-key.ts @@ -0,0 +1,102 @@ +import { type Context, Status } from "@oak/oak"; +import { drizzleClient } from "@/persistence/drizzle/config.ts"; +import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import { encryptSk } from "@/core/crypto/encrypt-sk.ts"; +import { SERVICE_AUTH_SECRET } from "@/config/env.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; + +const accountRepo = new PayAccountRepository(drizzleClient); + +const UTXO_ROOT_BYTES = 32; +const UTXO_ROOT_BASE64_LEN = 44; // 32-byte payload, base64-encoded + +/** + * POST /api/v1/account/delegation-key + * + * Hand-off of the user's UTXO derivation root. The client computes + * `utxoRoot = HKDF-SHA256(masterSeed, salt="moonlight-pay", + * info="moonlight-pay-utxo-v1", 32)` once at signup and sends the 32-byte + * root over TLS. The platform encrypts it with SERVICE_AUTH_SECRET and + * stores the ciphertext on the user's pay_accounts row. The masterSeed + * never leaves the device. + * + * Body: { utxoRoot: string } // 32 raw bytes, base64-encoded + * + * Idempotent: a subsequent call overwrites the stored ciphertext (used + * when the user wipes + re-derives). + */ +export function handlePostDelegationKey( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("postDelegationKey"); + + return async (ctx) => { + log.info("postDelegationKey"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + + const body = await ctx.request.body.json().catch(() => ({})); + const { utxoRoot } = body; + + if ( + typeof utxoRoot !== "string" || utxoRoot.length !== UTXO_ROOT_BASE64_LEN + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: `utxoRoot must be ${UTXO_ROOT_BYTES} bytes, base64-encoded`, + }; + return; + } + + let rootBytes: Uint8Array; + try { + rootBytes = Uint8Array.from(atob(utxoRoot), (c) => c.charCodeAt(0)); + } catch { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "utxoRoot is not valid base64" }; + return; + } + if (rootBytes.length !== UTXO_ROOT_BYTES) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: `utxoRoot must decode to exactly ${UTXO_ROOT_BYTES} bytes`, + }; + return; + } + + const existing = await accountRepo.findByPublicKey(walletPublicKey); + if (!existing) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } + + const encryptedDelegationKey = await encryptSk( + utxoRoot, + SERVICE_AUTH_SECRET, + ); + const updated = await accountRepo.update(walletPublicKey, { + encryptedDelegationKey, + }); + if (!updated) { + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to store delegation key" }; + return; + } + + log.event("delegation key stored"); + ctx.response.status = Status.NoContent; + } catch (error) { + if (error instanceof SyntaxError) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { message: "Invalid request body" }; + return; + } + log.error(error, "failed to store delegation key"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to store delegation key" }; + } + }; +} diff --git a/src/http/v1/account/delete.ts b/src/http/v1/account/delete.ts new file mode 100644 index 0000000..688972a --- /dev/null +++ b/src/http/v1/account/delete.ts @@ -0,0 +1,42 @@ +import { type Context, Status } from "@oak/oak"; +import { drizzleClient } from "@/persistence/drizzle/config.ts"; +import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import type { Logger } from "@/utils/logger/index.ts"; +import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; + +const accountRepo = new PayAccountRepository(drizzleClient); + +/** + * DELETE /api/v1/account/me + * + * Hard-deletes the authenticated user's pay_accounts row. The transactions + * FK cascades (`transaction.entity.ts:37` — onDelete: "cascade"), so the + * user's payment log is wiped along with the account. No tombstone. + */ +export function handleDeleteMe( + deps: { log: Logger }, +): (ctx: Context) => Promise { + const log = deps.log.scope("deleteMe"); + + return async (ctx) => { + log.info("deleteMe"); + try { + const session = ctx.state.session as JwtSessionData; + const walletPublicKey = session.sub; + + const removed = await accountRepo.deleteByPublicKey(walletPublicKey); + if (!removed) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } + + log.event("pay account deleted"); + ctx.response.status = Status.NoContent; + } catch (error) { + log.error(error, "failed to delete account"); + ctx.response.status = Status.InternalServerError; + ctx.response.body = { message: "Failed to delete account" }; + } + }; +} diff --git a/src/http/v1/account/public.ts b/src/http/v1/account/public.ts new file mode 100644 index 0000000..0721da5 --- /dev/null +++ b/src/http/v1/account/public.ts @@ -0,0 +1,53 @@ +import { type RouterContext, Status } from "@oak/oak"; +import { drizzleClient } from "@/persistence/drizzle/config.ts"; +import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import type { Logger } from "@/utils/logger/index.ts"; + +const accountRepo = new PayAccountRepository(drizzleClient); + +/** + * GET /api/v1/account/:walletPublicKey/public + * + * Public profile lookup. Used by the POS view to confirm a merchant exists + * and is set up to receive (has a delegation key registered), and to + * render their display name. + * + * 200 { data: { walletPublicKey, displayName, jurisdictionCountryCode } } + * 404 — no account for that wallet + * 503 — account exists but no delegation key set yet ("not set up") + */ +export function handleGetPublic( + deps: { log: Logger }, +): (ctx: RouterContext) => Promise { + const log = deps.log.scope("getAccountPublic"); + + return async (ctx) => { + log.info("getAccountPublic"); + const walletPublicKey = ctx.params.walletPublicKey; + log.debug("walletPublicKey", walletPublicKey); + + const account = await accountRepo.findByPublicKey(walletPublicKey); + if (!account) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } + + if (!account.encryptedDelegationKey) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "Merchant has not finished onboarding", + }; + return; + } + + ctx.response.status = Status.OK; + ctx.response.body = { + data: { + walletPublicKey: account.walletPublicKey, + displayName: account.displayName, + jurisdictionCountryCode: account.jurisdictionCountryCode, + }, + }; + }; +} diff --git a/src/http/v1/account/routes.ts b/src/http/v1/account/routes.ts index ebffadf..e79dc04 100644 --- a/src/http/v1/account/routes.ts +++ b/src/http/v1/account/routes.ts @@ -3,17 +3,34 @@ import type { Logger } from "@/utils/logger/index.ts"; import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; import { handlePostAccount } from "./post.ts"; import { handleGetMe, handlePatchMe } from "./me.ts"; +import { handleDeleteMe } from "./delete.ts"; import { handlePostOpex } from "./opex.ts"; +import { handlePostDelegationKey } from "./delegation-key.ts"; +import { handleGetPublic } from "./public.ts"; export function buildAccountRouter(deps: { log: Logger }): Router { const accountRouter = new Router(); accountRouter.post("/account", jwtMiddleware(deps), handlePostAccount(deps)); accountRouter.get("/account/me", jwtMiddleware(deps), handleGetMe(deps)); accountRouter.patch("/account/me", jwtMiddleware(deps), handlePatchMe(deps)); + accountRouter.delete( + "/account/me", + jwtMiddleware(deps), + handleDeleteMe(deps), + ); accountRouter.post( "/account/opex", jwtMiddleware(deps), handlePostOpex(deps), ); + accountRouter.post( + "/account/delegation-key", + jwtMiddleware(deps), + handlePostDelegationKey(deps), + ); + accountRouter.get( + "/account/:walletPublicKey/public", + handleGetPublic(deps), + ); return accountRouter; } diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index 69dae63..eec578a 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -12,11 +12,12 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilRepository } from "@/persistence/drizzle/repository/council.repository.ts"; import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; import { CouncilPpRepository } from "@/persistence/drizzle/repository/council-pp.repository.ts"; -import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; import { decryptSk } from "@/core/crypto/encrypt-sk.ts"; import { getProviderJwt } from "@/core/service/provider-auth.ts"; +import { getChannelClient } from "@/core/channel-client/index.ts"; +import { validateReceiveDestinations } from "@/core/service/utxo/utxo-balance.ts"; import { SERVICE_AUTH_SECRET, STELLAR_NETWORK_PASSPHRASE, @@ -28,29 +29,9 @@ import { withSpan } from "@/core/tracing.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); const ppRepo = new CouncilPpRepository(drizzleClient); -const utxoRepo = new ReceiveUtxoRepository(drizzleClient); const txRepo = new TransactionRepository(drizzleClient); const accountRepo = new PayAccountRepository(drizzleClient); -/** - * POST /api/v1/pay/instant/execute - * - * Instant payment flow: the customer has sent a standard Stellar payment - * to the merchant's OpEx address. Pay-platform: - * 1. Verifies the payment on-chain - * 2. Deposits (SAC transfer) from OpEx to the privacy channel - * 3. Builds the MLXDR bundle (CREATE + SPEND) and submits to provider-platform - * 4. Records the transaction - * - * Body: { - * customerPaymentHash — Stellar tx hash of customer's payment to OpEx - * merchantWallet — merchant's Moonlight Pay wallet - * amountStroops — amount the customer sent (in stroops) - * assetCode? — defaults to "XLM" - * description? — optional payment description - * merchantUtxoIds — reserved UTXO IDs from prepare - * } - */ export function handleExecuteInstant( deps: { log: Logger }, ): (ctx: Context) => Promise { @@ -59,7 +40,6 @@ export function handleExecuteInstant( return (ctx) => withSpan("P_ExecuteInstant", async (span) => { log.info("executeInstant"); - let merchantUtxoIds: string[] | undefined; try { const body = await ctx.request.body.json().catch(() => ({})); @@ -69,8 +49,8 @@ export function handleExecuteInstant( amountStroops: amountStr, assetCode: requestedAsset, description, + merchantUtxoIndexes, } = body; - merchantUtxoIds = body.merchantUtxoIds; if (!customerPaymentHash || !merchantWallet || !amountStr) { ctx.response.status = Status.BadRequest; @@ -80,6 +60,17 @@ export function handleExecuteInstant( }; return; } + if ( + !Array.isArray(merchantUtxoIndexes) || + merchantUtxoIndexes.length === 0 || + !merchantUtxoIndexes.every((i: unknown) => typeof i === "number") + ) { + ctx.response.status = Status.BadRequest; + ctx.response.body = { + message: "merchantUtxoIndexes must be a non-empty array of numbers", + }; + return; + } const assetCode = requestedAsset || "XLM"; const amountStroops = BigInt(amountStr); @@ -88,12 +79,6 @@ export function handleExecuteInstant( span.setAttribute("amount.stroops", amountStroops.toString()); span.setAttribute("customer.payment_hash", customerPaymentHash); - log.debug("merchantWallet", merchantWallet); - log.debug("assetCode", assetCode); - log.debug("amountStroops", amountStroops.toString()); - log.debug("customerPaymentHash", customerPaymentHash); - - // ─── 1. Look up merchant and OpEx ────────────────────── const merchant = await accountRepo.findByPublicKey(merchantWallet); if (!merchant) { ctx.response.status = Status.NotFound; @@ -107,9 +92,15 @@ export function handleExecuteInstant( }; return; } + if (!merchant.encryptedDelegationKey) { + ctx.response.status = Status.UnprocessableEntity; + ctx.response.body = { + message: "Merchant has not finished onboarding", + }; + return; + } const feePct = merchant.feePct ? Number(merchant.feePct) : 0; - // ─── 2. Find council + channel + PP ──────────────────── const councils = await councilRepo.findByJurisdiction( merchant.jurisdictionCountryCode, ); @@ -129,7 +120,6 @@ export function handleExecuteInstant( if (!selectedCouncil || !selectedChannel) { ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { message: `No ${assetCode} channel available` }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } span.setAttribute("council.id", selectedCouncil.id); @@ -139,13 +129,11 @@ export function handleExecuteInstant( if (pps.length === 0) { ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { message: "No privacy provider available" }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } const pp = pps[Math.floor(Math.random() * pps.length)]; span.setAttribute("pp.id", pp.id); - // ─── 3. Verify customer payment on-chain ─────────────── log.event("verifying customer payment on-chain"); const horizonUrl = STELLAR_RPC_URL.includes("/soroban/rpc") ? STELLAR_RPC_URL.replace("/soroban/rpc", "") @@ -159,7 +147,6 @@ export function handleExecuteInstant( ctx.response.body = { message: "Customer payment not found on-chain", }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } const txOps = await txRes.json(); @@ -182,7 +169,6 @@ export function handleExecuteInstant( ctx.response.body = { message: "No payment to OpEx address found in transaction", }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } const paidAmount = paymentOp.amount ?? paymentOp.starting_balance ?? @@ -194,19 +180,37 @@ export function handleExecuteInstant( message: `Insufficient payment: expected ${amountStroops}, got ${paidStroops}`, }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } log.event("customer payment verified"); - // ─── 4. Calculate net amount ─────────────────────────── const feeStroops = amountStroops * BigInt(Math.round(feePct * 100)) / 10000n; const netStroops = amountStroops - feeStroops; span.setAttribute("net.stroops", netStroops.toString()); span.setAttribute("fee.stroops", feeStroops.toString()); - // ─── 5. Decrypt OpEx SK and deposit into channel ─────── + const utxoRootBase64 = await decryptSk( + merchant.encryptedDelegationKey, + SERVICE_AUTH_SECRET, + ); + const utxoRoot = Uint8Array.from( + atob(utxoRootBase64), + (c) => c.charCodeAt(0), + ); + + const channelClient = getChannelClient( + selectedChannel.privacyChannelId, + selectedCouncil.channelAuthId, + selectedChannel.assetContractId, + ); + const merchantDestinations = await validateReceiveDestinations( + channelClient, + utxoRoot, + merchantUtxoIndexes, + { log }, + ); + log.event("depositing OpEx to privacy channel"); const opexSk = await decryptSk( merchant.encryptedOpexSk, @@ -254,27 +258,17 @@ export function handleExecuteInstant( } await new Promise((r) => setTimeout(r, 2000)); } - - log.debug("txHash", depositResult.hash); log.event("deposit confirmed on-chain"); - // ─── 6. Build MLXDR bundle ───────────────────────────── - const merchantUtxos = await utxoRepo.findByIds( - Array.isArray(merchantUtxoIds) ? merchantUtxoIds : [], - ); - const merchantAmounts = partitionAmount( netStroops, - merchantUtxos.length, + merchantDestinations.publicKeys.length, ); - const merchantCreateOps = merchantUtxos.map((u, i) => - MoonlightOperation.create( - Uint8Array.from(atob(u.utxoPublicKey), (c) => c.charCodeAt(0)), - merchantAmounts[i], - ) + const merchantCreateOps = merchantDestinations.publicKeys.map((pk, i) => + MoonlightOperation.create(pk, merchantAmounts[i]) ); - const tempCount = merchantUtxos.length; + const tempCount = merchantDestinations.publicKeys.length; const tempKeypairs: Array< { publicKey: Uint8Array; privateKey: Uint8Array } > = []; @@ -338,7 +332,6 @@ export function handleExecuteInstant( ...merchantCreateOps.map((op) => op.toMLXDR()), ]; - // ─── 7. Submit bundle to provider-platform ───────────── log.event("submitting bundle to provider-platform"); const providerJwt = await getProviderJwt(pp.url, { log }); const bundleRes = await fetch( @@ -368,7 +361,6 @@ export function handleExecuteInstant( ctx.response.body = { message: "Payment processing failed — provider rejected the bundle", }; - if (merchantUtxoIds) await utxoRepo.release(merchantUtxoIds); return; } @@ -376,11 +368,6 @@ export function handleExecuteInstant( const bundleId = bundleData?.data?.operationsBundleId ?? null; if (bundleId) span.setAttribute("bundle.id", bundleId); - // ─── 8. Record transactions ──────────────────────────── - if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { - await utxoRepo.markSpent(merchantUtxoIds); - } - const inTx = await txRepo.create({ walletPublicKey: merchantWallet, direction: "IN", @@ -394,10 +381,6 @@ export function handleExecuteInstant( completedAt: new Date(), }); - log.debug("netStroops", netStroops.toString()); - log.debug("feeStroops", feeStroops.toString()); - log.debug("bundleId", bundleId); - log.debug("txId", inTx.id); log.event("instant payment completed"); ctx.response.body = { @@ -411,17 +394,10 @@ export function handleExecuteInstant( log.error(error, "failed to execute instant payment"); ctx.response.status = Status.InternalServerError; ctx.response.body = { message: "Failed to process payment" }; - if (merchantUtxoIds) { - try { - await utxoRepo.release(merchantUtxoIds); - } catch { /* best effort */ } - } } }); } -// ─── Helpers ─────────────────────────────────────────────────── - function partitionAmount(total: bigint, parts: number): bigint[] { if (parts <= 0) return []; if (parts === 1) return [total]; diff --git a/src/http/v1/pay/instant-prepare.ts b/src/http/v1/pay/instant-prepare.ts index b847d53..d52a505 100644 --- a/src/http/v1/pay/instant-prepare.ts +++ b/src/http/v1/pay/instant-prepare.ts @@ -3,26 +3,37 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilRepository } from "@/persistence/drizzle/repository/council.repository.ts"; import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; import { CouncilPpRepository } from "@/persistence/drizzle/repository/council-pp.repository.ts"; -import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import { decryptSk } from "@/core/crypto/encrypt-sk.ts"; +import { deriveUtxoPublicKey } from "@/core/crypto/utxo-derivation.ts"; +import { getChannelClient } from "@/core/channel-client/index.ts"; +import { findFreeUtxoIndexes } from "@/core/service/utxo/utxo-balance.ts"; +import { + SERVICE_AUTH_SECRET, + STELLAR_NETWORK_PASSPHRASE, +} from "@/config/env.ts"; import type { Logger } from "@/utils/logger/index.ts"; -import { STELLAR_NETWORK_PASSPHRASE } from "@/config/env.ts"; import { withSpan } from "@/core/tracing.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); const ppRepo = new CouncilPpRepository(drizzleClient); -const utxoRepo = new ReceiveUtxoRepository(drizzleClient); const accountRepo = new PayAccountRepository(drizzleClient); +const MERCHANT_UTXO_DESTINATIONS = 5; + +function bytesToBase64(bytes: Uint8Array): string { + return btoa(String.fromCharCode(...bytes)); +} + /** * POST /api/v1/pay/instant/prepare * * Body: { merchantWallet, amountXlm, customerWallet, assetCode?, payerJurisdiction? } * - * Returns the council config (including the channel for the requested asset), - * a privacy provider URL, and the merchant's receive UTXO public keys so - * the frontend can build the deposit operation. + * Returns the council/channel config, a privacy provider URL, and the + * merchant's next available receive UTXO public keys derived on demand + * from the encrypted delegation key. */ export function handlePrepareInstant( deps: { log: Logger }, @@ -53,12 +64,9 @@ export function handlePrepareInstant( span.setAttribute("merchant.public_key", merchantWallet); span.setAttribute("customer.public_key", customerWallet); - log.debug("merchantWallet", merchantWallet); - log.debug("customerWallet", customerWallet); const assetCode = requestedAsset || "XLM"; span.setAttribute("asset.code", assetCode); - log.debug("assetCode", assetCode); const amount = parseFloat(amountXlm); if (isNaN(amount) || amount <= 0) { @@ -69,15 +77,20 @@ export function handlePrepareInstant( return; } - // Look up the merchant const merchant = await accountRepo.findByPublicKey(merchantWallet); if (!merchant) { ctx.response.status = Status.NotFound; ctx.response.body = { message: "Merchant not found" }; return; } + if (!merchant.encryptedDelegationKey) { + ctx.response.status = Status.ServiceUnavailable; + ctx.response.body = { + message: "Merchant has not finished onboarding", + }; + return; + } - // Find a council covering the merchant's jurisdiction let councils; if (payerJurisdiction) { councils = await councilRepo.findByJurisdictionPair( @@ -105,7 +118,6 @@ export function handlePrepareInstant( } } - // Find a council that has the requested asset channel let selectedCouncil = null; let selectedChannel = null; for (const c of councils) { @@ -128,11 +140,9 @@ export function handlePrepareInstant( }; return; } - span.setAttribute("council.id", selectedCouncil.id); span.setAttribute("channel.id", selectedChannel.id); - // Pick a privacy provider within the council const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); if (pps.length === 0) { ctx.response.status = Status.ServiceUnavailable; @@ -144,26 +154,40 @@ export function handlePrepareInstant( const pp = pps[Math.floor(Math.random() * pps.length)]; span.setAttribute("pp.id", pp.id); - // Get merchant's available receive UTXOs (5 for privacy distribution) - const merchantUtxos = await utxoRepo.findAvailable(merchantWallet, 5); - if (merchantUtxos.length === 0) { - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: "Merchant has no available receive addresses", - }; - return; - } - - // Reserve the UTXOs so they're not used by concurrent payments - await utxoRepo.reserve(merchantUtxos.map((u) => u.id)); + const utxoRootBase64 = await decryptSk( + merchant.encryptedDelegationKey, + SERVICE_AUTH_SECRET, + ); + const utxoRoot = Uint8Array.from( + atob(utxoRootBase64), + (c) => c.charCodeAt(0), + ); + + const channelClient = getChannelClient( + selectedChannel.privacyChannelId, + selectedCouncil.channelAuthId, + selectedChannel.assetContractId, + ); + + const freeIndexes = await findFreeUtxoIndexes( + channelClient, + utxoRoot, + MERCHANT_UTXO_DESTINATIONS, + { log }, + ); + + const merchantUtxos = await Promise.all( + freeIndexes.map(async (index) => ({ + utxoPublicKey: bytesToBase64( + await deriveUtxoPublicKey(utxoRoot, index), + ), + derivationIndex: index, + })), + ); const amountStroops = BigInt(Math.round(amount * 1e7)); span.setAttribute("amount.stroops", amountStroops.toString()); - log.debug("amountStroops", amountStroops.toString()); - log.debug("councilId", selectedCouncil.id); - log.debug("channelId", selectedChannel.id); - log.debug("ppId", pp.id); log.event("instant payment prepared"); ctx.response.body = { @@ -187,11 +211,7 @@ export function handlePrepareInstant( publicKey: merchant.opexPublicKey ?? null, feePct: merchant.feePct ? Number(merchant.feePct) : null, }, - merchantUtxos: merchantUtxos.map((u) => ({ - id: u.id, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), + merchantUtxos, amountStroops: amountStroops.toString(), }, }; diff --git a/src/http/v1/pay/instant-submit.ts b/src/http/v1/pay/instant-submit.ts index 978eb14..1c6ef72 100644 --- a/src/http/v1/pay/instant-submit.ts +++ b/src/http/v1/pay/instant-submit.ts @@ -3,7 +3,6 @@ import { drizzleClient } from "@/persistence/drizzle/config.ts"; import { CouncilRepository } from "@/persistence/drizzle/repository/council.repository.ts"; import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; import { CouncilPpRepository } from "@/persistence/drizzle/repository/council-pp.repository.ts"; -import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; import { getProviderJwt } from "@/core/service/provider-auth.ts"; @@ -13,32 +12,25 @@ import { withSpan } from "@/core/tracing.ts"; const councilRepo = new CouncilRepository(drizzleClient); const channelRepo = new CouncilChannelRepository(drizzleClient); const ppRepo = new CouncilPpRepository(drizzleClient); -const utxoRepo = new ReceiveUtxoRepository(drizzleClient); const txRepo = new TransactionRepository(drizzleClient); const accountRepo = new PayAccountRepository(drizzleClient); /** * POST /api/v1/pay/instant/submit * + * Self-custodial flow: the customer has built the full MLXDR bundle locally + * using the merchant's UTXO public keys returned by /instant/prepare. + * pay-platform forwards the bundle to provider-platform after looking up + * the council/channel/PP for the merchant's jurisdiction. + * * Body: { * customerWallet, * merchantWallet, * amountStroops, * assetCode, * description?, - * operationsMLXDR, — all operations built by the frontend - * merchantUtxoIds, — IDs to mark as SPENT + * operationsMLXDR, * } - * - * The frontend builds ALL operations (deposit + temp creates + temp spends + - * merchant creates) because it holds the customer's signing context. - * - * Pay-platform's job: - * 1. Look up the council + channel + PP from the asset code and merchant jurisdiction - * 2. Authenticate with provider-platform server-side (PAY_SERVICE_SK) - * 3. Submit the bundle to provider-platform - * 4. Record the transaction - * 5. Mark merchant UTXOs as SPENT */ export function handleSubmitInstant( deps: { log: Logger }, @@ -57,7 +49,6 @@ export function handleSubmitInstant( assetCode: requestedAsset, description, operationsMLXDR, - merchantUtxoIds, } = body; if ( @@ -77,12 +68,6 @@ export function handleSubmitInstant( span.setAttribute("asset.code", assetCode); span.setAttribute("amount.stroops", amountStroops.toString()); - log.debug("merchantWallet", merchantWallet); - log.debug("customerWallet", customerWallet); - log.debug("assetCode", assetCode); - log.debug("amountStroops", amountStroops.toString()); - - // Look up merchant to get jurisdiction const merchant = await accountRepo.findByPublicKey(merchantWallet); if (!merchant) { ctx.response.status = Status.NotFound; @@ -90,7 +75,6 @@ export function handleSubmitInstant( return; } - // Find a council covering the merchant's jurisdiction with the requested asset const councils = await councilRepo.findByJurisdiction( merchant.jurisdictionCountryCode, ); @@ -108,38 +92,28 @@ export function handleSubmitInstant( break; } } - if (!selectedCouncil || !selectedChannel) { ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { message: `No ${assetCode} channel available for this merchant`, }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); - } return; } span.setAttribute("council.id", selectedCouncil.id); span.setAttribute("channel.id", selectedChannel.id); - // Pick a PP const pps = await ppRepo.findActiveByCouncilId(selectedCouncil.id); if (pps.length === 0) { ctx.response.status = Status.ServiceUnavailable; ctx.response.body = { message: "No privacy provider available" }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); - } return; } const pp = pps[Math.floor(Math.random() * pps.length)]; span.setAttribute("pp.id", pp.id); - // Authenticate with provider-platform server-side log.event("authenticating with provider-platform"); const providerJwt = await getProviderJwt(pp.url, { log }); - // Submit the bundle to provider-platform log.event("submitting bundle to provider-platform"); const bundleRes = await fetch( `${pp.url}/api/v1/providers/${pp.publicKey}/entity/bundles`, @@ -168,9 +142,6 @@ export function handleSubmitInstant( ctx.response.body = { message: "Payment processing failed — provider rejected the bundle", }; - if (Array.isArray(merchantUtxoIds)) { - await utxoRepo.release(merchantUtxoIds); - } return; } @@ -179,12 +150,6 @@ export function handleSubmitInstant( bundleData?.operationsBundleId ?? null; if (bundleId) span.setAttribute("bundle.id", bundleId); - // Mark merchant UTXOs as SPENT - if (Array.isArray(merchantUtxoIds) && merchantUtxoIds.length > 0) { - await utxoRepo.markSpent(merchantUtxoIds); - } - - // Record merchant IN transaction const inTx = await txRepo.create({ walletPublicKey: merchantWallet, direction: "IN", @@ -198,7 +163,6 @@ export function handleSubmitInstant( completedAt: new Date(), }); - // Record customer OUT transaction only if they have a pay-platform account let outTxId: string | null = null; const customerAccount = await accountRepo.findByPublicKey( customerWallet, diff --git a/src/http/v1/transaction/balance.ts b/src/http/v1/transaction/balance.ts index da3ab50..00ca407 100644 --- a/src/http/v1/transaction/balance.ts +++ b/src/http/v1/transaction/balance.ts @@ -1,15 +1,32 @@ import { type Context, Status } from "@oak/oak"; import { drizzleClient } from "@/persistence/drizzle/config.ts"; -import { TransactionRepository } from "@/persistence/drizzle/repository/transaction.repository.ts"; +import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; +import { CouncilRepository } from "@/persistence/drizzle/repository/council.repository.ts"; +import { CouncilChannelRepository } from "@/persistence/drizzle/repository/council-channel.repository.ts"; +import { decryptSk } from "@/core/crypto/encrypt-sk.ts"; +import { SERVICE_AUTH_SECRET } from "@/config/env.ts"; +import { getChannelClient } from "@/core/channel-client/index.ts"; +import { walkFundedBalances } from "@/core/service/utxo/utxo-balance.ts"; import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; import type { Logger } from "@/utils/logger/index.ts"; -const txRepo = new TransactionRepository(drizzleClient); +const accountRepo = new PayAccountRepository(drizzleClient); +const councilRepo = new CouncilRepository(drizzleClient); +const channelRepo = new CouncilChannelRepository(drizzleClient); + +const DEFAULT_ASSET_CODE = "XLM"; /** * GET /api/v1/transactions/balance * - * Returns the authenticated user's balance in stroops and XLM. + * Returns the authenticated user's on-chain balance for their jurisdiction's + * XLM channel — the sum of every UTXO funded under the user's delegation + * key. The walk terminates after 3 consecutive free (-1) indexes per + * core/service/utxo/utxo-balance.ts. + * + * Returns 0 when: + * - the user has not yet handed over a delegation key, or + * - no council/XLM channel covers their jurisdiction. */ export function handleGetBalance( deps: { log: Logger }, @@ -20,16 +37,66 @@ export function handleGetBalance( log.info("getBalance"); try { const session = ctx.state.session as JwtSessionData; - log.debug("accountId", session.sub); + const account = await accountRepo.findByPublicKey(session.sub); + if (!account) { + ctx.response.status = Status.NotFound; + ctx.response.body = { message: "Account not found" }; + return; + } + if (!account.encryptedDelegationKey) { + ctx.response.body = { + data: { balanceStroops: "0", balanceXlm: "0.0000000" }, + }; + return; + } + + const councils = await councilRepo.findByJurisdiction( + account.jurisdictionCountryCode, + ); + let selectedCouncil = null; + let selectedChannel = null; + for (const c of councils) { + const ch = await channelRepo.findByCouncilIdAndAsset( + c.id, + DEFAULT_ASSET_CODE, + ); + if (ch) { + selectedCouncil = c; + selectedChannel = ch; + break; + } + } + if (!selectedCouncil || !selectedChannel) { + ctx.response.body = { + data: { balanceStroops: "0", balanceXlm: "0.0000000" }, + }; + return; + } + + const utxoRootBase64 = await decryptSk( + account.encryptedDelegationKey, + SERVICE_AUTH_SECRET, + ); + const utxoRoot = Uint8Array.from( + atob(utxoRootBase64), + (ch) => ch.charCodeAt(0), + ); - log.event("fetching account balance"); - const balanceStroops = await txRepo.getBalance(session.sub); - log.debug("balanceStroops", balanceStroops.toString()); + const channelClient = getChannelClient( + selectedChannel.privacyChannelId, + selectedCouncil.channelAuthId, + selectedChannel.assetContractId, + ); + const { totalStroops } = await walkFundedBalances( + channelClient, + utxoRoot, + { log }, + ); ctx.response.body = { data: { - balanceStroops: balanceStroops.toString(), - balanceXlm: (Number(balanceStroops) / 1e7).toFixed(7), + balanceStroops: totalStroops.toString(), + balanceXlm: (Number(totalStroops) / 1e7).toFixed(7), }, }; log.event("balance response assembled"); diff --git a/src/http/v1/utxo/available.ts b/src/http/v1/utxo/available.ts deleted file mode 100644 index 1d80f97..0000000 --- a/src/http/v1/utxo/available.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { type RouterContext, Status } from "@oak/oak"; -import { drizzleClient } from "@/persistence/drizzle/config.ts"; -import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; -import { PayAccountRepository } from "@/persistence/drizzle/repository/pay-account.repository.ts"; -import type { Logger } from "@/utils/logger/index.ts"; - -const utxoRepo = new ReceiveUtxoRepository(drizzleClient); -const accountRepo = new PayAccountRepository(drizzleClient); - -/** - * GET /api/v1/utxo/receive/:walletPublicKey/available - * - * Returns available receive UTXO public keys for a merchant. - * Used by POS to build CREATE operations targeting the merchant's addresses. - * - * Public endpoint — no auth required. The POS customer needs to know - * where to send without being authenticated as the merchant. - * - * Query params: - * count — number of UTXOs to return (default 5, max 20) - */ -export function handleGetAvailable( - deps: { log: Logger }, -): (ctx: RouterContext) => Promise { - const log = deps.log.scope("getAvailableUtxos"); - - return async (ctx) => { - log.info("getAvailableUtxos"); - const walletPublicKey = ctx.params.walletPublicKey; - const countParam = ctx.request.url.searchParams.get("count"); - const count = Math.min( - Math.max(parseInt(countParam ?? "5", 10) || 5, 1), - 20, - ); - - log.debug("walletPublicKey", walletPublicKey); - log.debug("count", count); - - log.event("looking up merchant account"); - const account = await accountRepo.findByPublicKey(walletPublicKey); - if (!account) { - log.event("merchant account not found"); - ctx.response.status = Status.NotFound; - ctx.response.body = { message: "Account not found" }; - return; - } - - log.event("fetching available receive UTXOs"); - const available = await utxoRepo.findAvailable(walletPublicKey, count); - log.debug("availableCount", available.length); - if (available.length === 0) { - log.event("no receive addresses available"); - ctx.response.status = Status.ServiceUnavailable; - ctx.response.body = { - message: "No receive addresses available for this merchant", - }; - return; - } - - ctx.response.body = { - data: { - merchant: { - walletPublicKey: account.walletPublicKey, - displayName: account.displayName, - jurisdictionCountryCode: account.jurisdictionCountryCode, - }, - utxos: available.map((u) => ({ - id: u.id, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), - }, - }; - log.event("available UTXOs response assembled"); - }; -} diff --git a/src/http/v1/utxo/post.ts b/src/http/v1/utxo/post.ts deleted file mode 100644 index 8e9dabc..0000000 --- a/src/http/v1/utxo/post.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { type Context, Status } from "@oak/oak"; -import { drizzleClient } from "@/persistence/drizzle/config.ts"; -import { ReceiveUtxoRepository } from "@/persistence/drizzle/repository/receive-utxo.repository.ts"; -import type { Logger } from "@/utils/logger/index.ts"; -import type { JwtSessionData } from "@/http/middleware/auth/index.ts"; - -const utxoRepo = new ReceiveUtxoRepository(drizzleClient); - -/** - * POST /api/v1/utxo/receive - * - * Stores pre-generated receive UTXO public keys for the authenticated user. - * Called by the moonlight-pay frontend at onboarding after deriving keys - * from HKDF(master_seed, salt=email). - * - * Body: { utxos: Array<{ utxoPublicKey: string, derivationIndex: number }> } - * - * Idempotent: if the user already has UTXOs, returns 200 with the count. - */ -export function handlePostUtxos( - deps: { log: Logger }, -): (ctx: Context) => Promise { - const log = deps.log.scope("postUtxos"); - - return async (ctx) => { - log.info("postUtxos"); - try { - const session = ctx.state.session as JwtSessionData; - const walletPublicKey = session.sub; - log.debug("walletPublicKey", walletPublicKey); - - const existing = await utxoRepo.countByWallet(walletPublicKey); - if (existing > 0) { - ctx.response.status = Status.OK; - ctx.response.body = { - message: "Receive UTXOs already generated", - data: { count: existing }, - }; - return; - } - - const body = await ctx.request.body.json().catch(() => ({})); - const { utxos } = body; - - if (!Array.isArray(utxos) || utxos.length === 0) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { message: "utxos array is required" }; - return; - } - - for (const u of utxos) { - if ( - typeof u.utxoPublicKey !== "string" || - typeof u.derivationIndex !== "number" - ) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: - "Each utxo must have utxoPublicKey (string) and derivationIndex (number)", - }; - return; - } - } - - const rows = await utxoRepo.bulkCreate( - utxos.map((u: { utxoPublicKey: string; derivationIndex: number }) => ({ - walletPublicKey, - utxoPublicKey: u.utxoPublicKey, - derivationIndex: u.derivationIndex, - })), - ); - - log.debug("count", rows.length); - log.event("receive UTXOs stored"); - - ctx.response.status = Status.Created; - ctx.response.body = { - message: "Receive UTXOs stored", - data: { count: rows.length }, - }; - } catch (error) { - log.error(error, "failed to store receive UTXOs"); - ctx.response.status = Status.InternalServerError; - ctx.response.body = { message: "Failed to store receive UTXOs" }; - } - }; -} diff --git a/src/http/v1/utxo/routes.ts b/src/http/v1/utxo/routes.ts deleted file mode 100644 index 79570d2..0000000 --- a/src/http/v1/utxo/routes.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Router } from "@oak/oak"; -import type { Logger } from "@/utils/logger/index.ts"; -import { jwtMiddleware } from "@/http/middleware/auth/index.ts"; -import { handlePostUtxos } from "@/http/v1/utxo/post.ts"; -import { handleGetAvailable } from "@/http/v1/utxo/available.ts"; - -export function buildUtxoRouter(deps: { log: Logger }): Router { - const utxoRouter = new Router(); - - /** POST /utxo/receive — store pre-generated receive UTXOs (called at onboarding). */ - utxoRouter.post("/utxo/receive", jwtMiddleware(deps), handlePostUtxos(deps)); - - /** GET /utxo/receive/:walletPublicKey/available — fetch available receive UTXOs for a merchant (used by POS). */ - utxoRouter.get( - "/utxo/receive/:walletPublicKey/available", - handleGetAvailable(deps), - ); - - return utxoRouter; -} diff --git a/src/http/v1/v1.routes.ts b/src/http/v1/v1.routes.ts index 90474db..d40e6ef 100644 --- a/src/http/v1/v1.routes.ts +++ b/src/http/v1/v1.routes.ts @@ -5,7 +5,6 @@ import { buildWaitlistRouter } from "@/http/v1/waitlist/routes.ts"; import { buildAuthRouter } from "@/http/v1/auth/routes.ts"; import { buildAccountRouter } from "@/http/v1/account/routes.ts"; import { buildAdminRouter } from "@/http/v1/admin/routes.ts"; -import { buildUtxoRouter } from "@/http/v1/utxo/routes.ts"; import { buildTransactionRouter } from "@/http/v1/transaction/routes.ts"; import { buildPayRouter } from "@/http/v1/pay/routes.ts"; @@ -15,7 +14,6 @@ export function buildApiRouter(deps: { log: Logger }): Router { const authRouter = buildAuthRouter(deps); const accountRouter = buildAccountRouter(deps); const adminRouter = buildAdminRouter(deps); - const utxoRouter = buildUtxoRouter(deps); const payRouter = buildPayRouter(deps); const transactionRouter = buildTransactionRouter(deps); const waitlistRouter = buildWaitlistRouter(deps); @@ -32,7 +30,6 @@ export function buildApiRouter(deps: { log: Logger }): Router { accountRouter.allowedMethods(), ); apiRouter.use("/api/v1", adminRouter.routes(), adminRouter.allowedMethods()); - apiRouter.use("/api/v1", utxoRouter.routes(), utxoRouter.allowedMethods()); apiRouter.use( "/api/v1", transactionRouter.routes(), diff --git a/src/persistence/drizzle/entity/index.ts b/src/persistence/drizzle/entity/index.ts index 7adc0a9..46137d2 100644 --- a/src/persistence/drizzle/entity/index.ts +++ b/src/persistence/drizzle/entity/index.ts @@ -3,6 +3,5 @@ export * from "./council.entity.ts"; export * from "./council-channel.entity.ts"; export * from "./council-jurisdiction.entity.ts"; export * from "./council-pp.entity.ts"; -export * from "./receive-utxo.entity.ts"; export * from "./transaction.entity.ts"; export * from "./waitlist-request.entity.ts"; diff --git a/src/persistence/drizzle/entity/pay-account.entity.ts b/src/persistence/drizzle/entity/pay-account.entity.ts index 51e7a05..7cea927 100644 --- a/src/persistence/drizzle/entity/pay-account.entity.ts +++ b/src/persistence/drizzle/entity/pay-account.entity.ts @@ -19,6 +19,7 @@ export const payAccount = pgTable("pay_accounts", { opexPublicKey: text("opex_public_key"), encryptedOpexSk: text("encrypted_opex_sk"), feePct: numeric("fee_pct", { precision: 5, scale: 2 }), + encryptedDelegationKey: text("encrypted_delegation_key"), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), ...createBaseColumns(), }); diff --git a/src/persistence/drizzle/entity/receive-utxo.entity.ts b/src/persistence/drizzle/entity/receive-utxo.entity.ts deleted file mode 100644 index a470067..0000000 --- a/src/persistence/drizzle/entity/receive-utxo.entity.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { integer, pgEnum, pgTable, text, uuid } from "drizzle-orm/pg-core"; -import { createBaseColumns } from "@/persistence/drizzle/entity/base.entity.ts"; -import { payAccount } from "@/persistence/drizzle/entity/pay-account.entity.ts"; - -/** - * UTXO availability status. - * AVAILABLE — ready to be assigned to an incoming payment. - * RESERVED — assigned to a pending payment, waiting for confirmation. - * SPENT — payment confirmed, UTXO has been used. - */ -export const receiveUtxoStatusEnum = pgEnum("receive_utxo_status", [ - "AVAILABLE", - "RESERVED", - "SPENT", -]); - -/** - * receive_utxos: pre-generated P256 public keys for receiving payments. - * - * Generated at onboarding from HKDF(master_seed, salt=email) → StellarDerivator. - * Only public keys are stored — private keys are derived client-side and - * never leave the user's device. - */ -export const receiveUtxo = pgTable("receive_utxos", { - id: uuid("id").defaultRandom().primaryKey(), - walletPublicKey: text("wallet_public_key") - .notNull() - .references(() => payAccount.walletPublicKey, { onDelete: "cascade" }), - /** The P256 (secp256r1) public key in base64 — used in CREATE operations. */ - utxoPublicKey: text("utxo_public_key").notNull(), - /** Derivation index used to generate this key. Allows re-derivation. */ - derivationIndex: integer("derivation_index").notNull(), - status: receiveUtxoStatusEnum("status").notNull().default("AVAILABLE"), - ...createBaseColumns(), -}); - -export type ReceiveUtxo = typeof receiveUtxo.$inferSelect; -export type NewReceiveUtxo = typeof receiveUtxo.$inferInsert; diff --git a/src/persistence/drizzle/migration/meta/_journal.json b/src/persistence/drizzle/migration/meta/_journal.json index c501ae7..d7e8a04 100644 --- a/src/persistence/drizzle/migration/meta/_journal.json +++ b/src/persistence/drizzle/migration/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1778073633927, "tag": "0007_add_waitlist_requests", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1780000000000, + "tag": "0008_drop_receive_utxos_add_delegation_key", + "breakpoints": true } ] } diff --git a/src/persistence/drizzle/repository/pay-account.repository.ts b/src/persistence/drizzle/repository/pay-account.repository.ts index fd77601..cc0c118 100644 --- a/src/persistence/drizzle/repository/pay-account.repository.ts +++ b/src/persistence/drizzle/repository/pay-account.repository.ts @@ -46,4 +46,12 @@ export class PayAccountRepository { .set({ lastSeenAt: new Date() }) .where(eq(payAccount.walletPublicKey, walletPublicKey)); } + + async deleteByPublicKey(walletPublicKey: string): Promise { + const rows = await this.db + .delete(payAccount) + .where(eq(payAccount.walletPublicKey, walletPublicKey)) + .returning({ walletPublicKey: payAccount.walletPublicKey }); + return rows.length > 0; + } } diff --git a/src/persistence/drizzle/repository/receive-utxo.repository.ts b/src/persistence/drizzle/repository/receive-utxo.repository.ts deleted file mode 100644 index fa0afb4..0000000 --- a/src/persistence/drizzle/repository/receive-utxo.repository.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { and, eq, inArray, sql } from "drizzle-orm"; -import { - type NewReceiveUtxo, - type ReceiveUtxo, - receiveUtxo, -} from "@/persistence/drizzle/entity/receive-utxo.entity.ts"; -import type { DrizzleClient } from "@/persistence/drizzle/config.ts"; - -export class ReceiveUtxoRepository { - constructor(private readonly db: DrizzleClient) {} - - bulkCreate(rows: NewReceiveUtxo[]): Promise { - if (rows.length === 0) return Promise.resolve([]); - return this.db.insert(receiveUtxo).values(rows).returning(); - } - - async countByWallet(walletPublicKey: string): Promise { - const [row] = await this.db - .select({ count: sql`count(*)::int` }) - .from(receiveUtxo) - .where(eq(receiveUtxo.walletPublicKey, walletPublicKey)); - return row?.count ?? 0; - } - - findAvailable( - walletPublicKey: string, - limit: number, - ): Promise { - return this.db - .select() - .from(receiveUtxo) - .where( - and( - eq(receiveUtxo.walletPublicKey, walletPublicKey), - eq(receiveUtxo.status, "AVAILABLE"), - ), - ) - .limit(limit); - } - - findByIds(ids: string[]): Promise { - if (ids.length === 0) return Promise.resolve([]); - return this.db - .select() - .from(receiveUtxo) - .where(inArray(receiveUtxo.id, ids)); - } - - async reserve(ids: string[]): Promise { - if (ids.length === 0) return; - await this.db - .update(receiveUtxo) - .set({ status: "RESERVED", updatedAt: new Date() }) - .where(inArray(receiveUtxo.id, ids)); - } - - async markSpent(ids: string[]): Promise { - if (ids.length === 0) return; - await this.db - .update(receiveUtxo) - .set({ status: "SPENT", updatedAt: new Date() }) - .where(inArray(receiveUtxo.id, ids)); - } - - async release(ids: string[]): Promise { - if (ids.length === 0) return; - await this.db - .update(receiveUtxo) - .set({ status: "AVAILABLE", updatedAt: new Date() }) - .where(inArray(receiveUtxo.id, ids)); - } -} From 1f5e7e09c59342887917515b865c376465018ea7 Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 2 Jun 2026 16:49:37 -0300 Subject: [PATCH 2/7] fix(utxo): include migration 0008 SQL (force-add past global *.sql ignore) The user's global ~/.gitignore.global excludes *.sql, so the new migration file was silently dropped from the previous commit. _journal.json points at it, so the integration suite's runMigrations() crashes with NotFound. --- .../0008_drop_receive_utxos_add_delegation_key.sql | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 src/persistence/drizzle/migration/0008_drop_receive_utxos_add_delegation_key.sql diff --git a/src/persistence/drizzle/migration/0008_drop_receive_utxos_add_delegation_key.sql b/src/persistence/drizzle/migration/0008_drop_receive_utxos_add_delegation_key.sql new file mode 100644 index 0000000..ae3facf --- /dev/null +++ b/src/persistence/drizzle/migration/0008_drop_receive_utxos_add_delegation_key.sql @@ -0,0 +1,10 @@ +-- Flip the receive-UTXO model: +-- * Drop the pre-pool table + its enum. +-- * Add the encrypted delegation key column on pay_accounts so the backend +-- can derive merchant UTXO keypairs on demand. + +DROP TABLE IF EXISTS "receive_utxos"; +--> statement-breakpoint +DROP TYPE IF EXISTS "receive_utxo_status"; +--> statement-breakpoint +ALTER TABLE "pay_accounts" ADD COLUMN "encrypted_delegation_key" text; From 14e4132983ace38b828eb5c32a40a2b24ea79c13 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 3 Jun 2026 15:54:27 -0300 Subject: [PATCH 3/7] fix(instant-execute): drop temp-hop, mirror browser-wallet deposit shape MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deposit + 5-temp-CREATE + 5-SPEND + 5-merchant-CREATE pattern made provider-platform's fee classifier compute a NEGATIVE bundle fee (totalInflows = deposit = netStroops, totalOutflows = 2 * netStroops), so the executor's totalFee was -netStroops and MoonlightOperation.create rejected it with "Amount too low". Switch to browser-wallet's deposit pattern (lib/client/deposit.ts + browser-wallet/src/background/handlers/private/deposit.ts): deposit(opex, netStroops + BUNDLE_FEE) + N merchant CREATEs summing to netStroops The on-chain SAC transfer to the channel sends netStroops + BUNDLE_FEE so inflows/outflows align with the bundle's deposit op. Fee classifier sees inflows = deposit, outflows = merchant creates, fee = BUNDLE_FEE (positive). BUNDLE_FEE = 500_000 stroops (0.05 XLM), matches lifecycle DEPOSIT_FEE. Also hardcode feePct = 0 — merchant-fee concept is being reworked separately. Removes the now-unused deriveP256Keypair + buildPkcs8P256 helpers. --- src/http/v1/pay/instant-execute.ts | 146 +++-------------------------- 1 file changed, 14 insertions(+), 132 deletions(-) diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index eec578a..18fcde0 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -99,7 +99,8 @@ export function handleExecuteInstant( }; return; } - const feePct = merchant.feePct ? Number(merchant.feePct) : 0; + // Merchant fee concept is being reworked — hardcode to 0 for now. + const feePct = 0; const councils = await councilRepo.findByJurisdiction( merchant.jurisdictionCountryCode, @@ -223,6 +224,11 @@ export function handleExecuteInstant( allowHttp: STELLAR_RPC_URL.startsWith("http://"), }); + // The SAC transfer to the channel must match the bundle's deposit op + // (netStroops + BUNDLE_FEE). Otherwise the channel rejects the bundle + // for inflow/outflow mismatch. + const BUNDLE_FEE = 500_000n; + const depositTotalStroops = netStroops + BUNDLE_FEE; const opexAccount = await server.getAccount(opexKeypair.publicKey()); const sacContract = new Contract(selectedChannel.assetContractId); const depositTx = new TransactionBuilder(opexAccount, { @@ -234,7 +240,7 @@ export function handleExecuteInstant( "transfer", new Address(opexKeypair.publicKey()).toScVal(), new Address(selectedChannel.privacyChannelId).toScVal(), - nativeToScVal(netStroops, { type: "i128" }), + nativeToScVal(depositTotalStroops, { type: "i128" }), ), ) .setTimeout(300) @@ -260,6 +266,10 @@ export function handleExecuteInstant( } log.event("deposit confirmed on-chain"); + // Bundle is shaped like browser-wallet's deposit flow: + // deposit(opex, netStroops + BUNDLE_FEE) + merchant CREATEs(netStroops) + // No temp-hop. provider-platform's classifier sees inflows = deposit, + // outflows = merchant creates, fee = BUNDLE_FEE (positive). const merchantAmounts = partitionAmount( netStroops, merchantDestinations.publicKeys.length, @@ -268,67 +278,13 @@ export function handleExecuteInstant( MoonlightOperation.create(pk, merchantAmounts[i]) ); - const tempCount = merchantDestinations.publicKeys.length; - const tempKeypairs: Array< - { publicKey: Uint8Array; privateKey: Uint8Array } - > = []; - for (let i = 0; i < tempCount; i++) { - const seed = crypto.getRandomValues(new Uint8Array(32)); - tempKeypairs.push(await deriveP256Keypair(seed)); - } - - const tempAmounts = partitionAmount(netStroops, tempCount); - const tempCreateOps = tempKeypairs.map((kp, i) => - MoonlightOperation.create(kp.publicKey, tempAmounts[i]) - ); - - const expirationLedger = 999999999; - const depositOp = MoonlightOperation.deposit( opexKeypair.publicKey() as `G${string}`, - netStroops, - ).addConditions(tempCreateOps.map((op) => op.toCondition())); - - 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()); - } - // deno-lint-ignore no-explicit-any - const utxoAdapter: any = { - publicKey: tempKeypairs[i].publicKey, - signPayload: async (hash: Uint8Array) => { - const hashBuf = new ArrayBuffer(hash.length); - new Uint8Array(hashBuf).set(hash); - const pkcs8 = buildPkcs8P256(tempKeypairs[i].privateKey); - const key = await crypto.subtle.importKey( - "pkcs8", - pkcs8, - { name: "ECDSA", namedCurve: "P-256" }, - false, - ["sign"], - ); - const sig = await crypto.subtle.sign( - { name: "ECDSA", hash: "SHA-256" }, - key, - hashBuf, - ); - return new Uint8Array(sig); - }, - }; - await spendOp.signWithUTXO( - utxoAdapter, - selectedChannel.privacyChannelId as `C${string}`, - expirationLedger, - ); - spendOps.push(spendOp); - } + depositTotalStroops, + ).addConditions(merchantCreateOps.map((op) => op.toCondition())); const operationsMLXDR = [ depositOp.toMLXDR(), - ...tempCreateOps.map((op) => op.toMLXDR()), - ...spendOps.map((op) => op.toMLXDR()), ...merchantCreateOps.map((op) => op.toMLXDR()), ]; @@ -414,77 +370,3 @@ function partitionAmount(total: bigint, parts: number): bigint[] { return result; } -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 { p256 } = await import("@noble/curves/p256"); - const publicKey = p256.ProjectivePoint.fromPrivateKey(privateKeyBytes) - .toRawBytes(false); - - return { publicKey: new Uint8Array(publicKey), privateKey: privateKeyBytes }; -} - -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; -} From 5415597432fc27640d4449f72902b0e920eed094 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 3 Jun 2026 16:44:07 -0300 Subject: [PATCH 4/7] feat: remove merchant-fee concept end-to-end Drop fee_pct column from pay_accounts (migration 0009), remove feePct from /account/opex body + response, /pay/instant/prepare opex sub-object, and instant-execute amount accounting (replaces feeStroops /netStroops with the customer-paid amountStroops). The bundle's fee is the fixed BUNDLE_FEE the executor pays the PP, not a merchant cut. Sign the deposit op with the OpEx Ed25519 key (browser-wallet pattern) so the chain accepts the bundle. --- src/http/v1/account/opex.ts | 15 +++------------ src/http/v1/account/post.ts | 2 -- src/http/v1/pay/instant-execute.ts | 16 ++++++++++++++-- src/http/v1/pay/instant-prepare.ts | 1 - .../drizzle/entity/pay-account.entity.ts | 3 +-- .../drizzle/migration/0009_drop_fee_pct.sql | 5 +++++ .../drizzle/migration/meta/_journal.json | 7 +++++++ 7 files changed, 30 insertions(+), 19 deletions(-) create mode 100644 src/persistence/drizzle/migration/0009_drop_fee_pct.sql diff --git a/src/http/v1/account/opex.ts b/src/http/v1/account/opex.ts index 6e32325..5b4bbd3 100644 --- a/src/http/v1/account/opex.ts +++ b/src/http/v1/account/opex.ts @@ -15,7 +15,7 @@ const accountRepo = new PayAccountRepository(drizzleClient); * the keypair deterministically from the master seed, funds it, and sends * the secret key here for server-side storage. * - * Body: { secretKey, publicKey, feePct } + * Body: { secretKey, publicKey } */ export function handlePostOpex( deps: { log: Logger }, @@ -30,7 +30,7 @@ export function handlePostOpex( log.debug("walletPublicKey", walletPublicKey); const body = await ctx.request.body.json().catch(() => ({})); - const { secretKey, publicKey, feePct } = body; + const { secretKey, publicKey } = body; if (typeof secretKey !== "string" || !secretKey.startsWith("S")) { ctx.response.status = Status.BadRequest; @@ -42,13 +42,6 @@ export function handlePostOpex( ctx.response.body = { message: "Invalid publicKey" }; return; } - if (typeof feePct !== "number" || feePct < 0 || feePct > 100) { - ctx.response.status = Status.BadRequest; - ctx.response.body = { - message: "feePct must be a number between 0 and 100", - }; - return; - } const account = await accountRepo.findByPublicKey(walletPublicKey); if (!account) { @@ -64,17 +57,15 @@ export function handlePostOpex( await accountRepo.update(walletPublicKey, { opexPublicKey: publicKey, encryptedOpexSk: encrypted, - feePct: String(feePct), }); log.debug("opexPublicKey", publicKey); - log.debug("feePct", feePct); log.event("OpEx account registered"); ctx.response.status = Status.OK; ctx.response.body = { message: "OpEx account registered", - data: { opexPublicKey: publicKey, feePct }, + data: { opexPublicKey: publicKey }, }; } catch (error) { log.error(error, "failed to register OpEx account"); diff --git a/src/http/v1/account/post.ts b/src/http/v1/account/post.ts index b25cadb..4fe1323 100644 --- a/src/http/v1/account/post.ts +++ b/src/http/v1/account/post.ts @@ -106,7 +106,6 @@ function formatAccount(row: { jurisdictionCountryCode: string; displayName: string | null; opexPublicKey: string | null; - feePct: string | null; lastSeenAt: Date | null; createdAt: Date; updatedAt: Date; @@ -117,7 +116,6 @@ function formatAccount(row: { jurisdictionCountryCode: row.jurisdictionCountryCode, displayName: row.displayName, opexPublicKey: row.opexPublicKey, - feePct: row.feePct ? Number(row.feePct) : null, lastSeenAt: row.lastSeenAt?.toISOString() ?? null, createdAt: row.createdAt.toISOString(), updatedAt: row.updatedAt.toISOString(), diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index 18fcde0..425d110 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -278,10 +278,22 @@ export function handleExecuteInstant( MoonlightOperation.create(pk, merchantAmounts[i]) ); - const depositOp = MoonlightOperation.deposit( + // Sign the deposit op with the OpEx Ed25519 key — browser-wallet's + // deposit pattern requires the depositor's signature on the deposit + // op (separate from the SAC transfer that moves XLM). + const expirationLedger = 999_999_999; + const depositOp = await MoonlightOperation.deposit( opexKeypair.publicKey() as `G${string}`, depositTotalStroops, - ).addConditions(merchantCreateOps.map((op) => op.toCondition())); + ) + .addConditions(merchantCreateOps.map((op) => op.toCondition())) + .signWithEd25519( + opexKeypair, + expirationLedger, + selectedChannel.privacyChannelId as `C${string}`, + selectedChannel.assetContractId as `C${string}`, + networkPassphrase, + ); const operationsMLXDR = [ depositOp.toMLXDR(), diff --git a/src/http/v1/pay/instant-prepare.ts b/src/http/v1/pay/instant-prepare.ts index d52a505..9cbcb85 100644 --- a/src/http/v1/pay/instant-prepare.ts +++ b/src/http/v1/pay/instant-prepare.ts @@ -209,7 +209,6 @@ export function handlePrepareInstant( }, opex: { publicKey: merchant.opexPublicKey ?? null, - feePct: merchant.feePct ? Number(merchant.feePct) : null, }, merchantUtxos, amountStroops: amountStroops.toString(), diff --git a/src/persistence/drizzle/entity/pay-account.entity.ts b/src/persistence/drizzle/entity/pay-account.entity.ts index 7cea927..d67d35f 100644 --- a/src/persistence/drizzle/entity/pay-account.entity.ts +++ b/src/persistence/drizzle/entity/pay-account.entity.ts @@ -1,4 +1,4 @@ -import { numeric, pgTable, text, timestamp } from "drizzle-orm/pg-core"; +import { pgTable, text, timestamp } from "drizzle-orm/pg-core"; import { createBaseColumns } from "@/persistence/drizzle/entity/base.entity.ts"; /** @@ -18,7 +18,6 @@ export const payAccount = pgTable("pay_accounts", { displayName: text("display_name"), opexPublicKey: text("opex_public_key"), encryptedOpexSk: text("encrypted_opex_sk"), - feePct: numeric("fee_pct", { precision: 5, scale: 2 }), encryptedDelegationKey: text("encrypted_delegation_key"), lastSeenAt: timestamp("last_seen_at", { withTimezone: true }), ...createBaseColumns(), diff --git a/src/persistence/drizzle/migration/0009_drop_fee_pct.sql b/src/persistence/drizzle/migration/0009_drop_fee_pct.sql new file mode 100644 index 0000000..d3bff8e --- /dev/null +++ b/src/persistence/drizzle/migration/0009_drop_fee_pct.sql @@ -0,0 +1,5 @@ +-- Merchant-fee concept removed from the instant-payment flow. Drop the +-- fee_pct column from pay_accounts; the bundle's fee is a fixed +-- BUNDLE_FEE applied server-side at execute time, not per-merchant. + +ALTER TABLE "pay_accounts" DROP COLUMN IF EXISTS "fee_pct"; diff --git a/src/persistence/drizzle/migration/meta/_journal.json b/src/persistence/drizzle/migration/meta/_journal.json index d7e8a04..723222e 100644 --- a/src/persistence/drizzle/migration/meta/_journal.json +++ b/src/persistence/drizzle/migration/meta/_journal.json @@ -64,6 +64,13 @@ "when": 1780000000000, "tag": "0008_drop_receive_utxos_add_delegation_key", "breakpoints": true + }, + { + "idx": 9, + "version": "7", + "when": 1780100000000, + "tag": "0009_drop_fee_pct", + "breakpoints": true } ] } From fd657fefd8fce079d8631c59c2148184ffba1302 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 3 Jun 2026 16:56:34 -0300 Subject: [PATCH 5/7] fix(instant-execute): query latest ledger for deposit-op expiration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hardcoded expirationLedger = 999_999_999 was too far ahead of the chain's current ledger sequence (~3M on local), causing the channel contract to reject the bundle: HostError: Error(Auth, InvalidInput) "signature expiration is too late" Match lifecycle's lib/client/deposit.ts pattern — fetch latestLedger from the RPC server and use `latestLedger + 1000` as the deposit op expiration. --- src/http/v1/pay/instant-execute.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index 425d110..ade19a6 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -281,7 +281,10 @@ export function handleExecuteInstant( // Sign the deposit op with the OpEx Ed25519 key — browser-wallet's // deposit pattern requires the depositor's signature on the deposit // op (separate from the SAC transfer that moves XLM). - const expirationLedger = 999_999_999; + // Expiration must be `<= latestLedger + maxOffset` per the channel + // contract — match lifecycle's lib/client/deposit.ts: latest + 1000. + const latestLedger = await server.getLatestLedger(); + const expirationLedger = latestLedger.sequence + 1000; const depositOp = await MoonlightOperation.deposit( opexKeypair.publicKey() as `G${string}`, depositTotalStroops, From a5ef77ff1d38b8745ea3e3f11f57d8eaf3813da8 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 3 Jun 2026 17:31:32 -0300 Subject: [PATCH 6/7] fix(instant-execute): wait for bundle COMPLETED before returning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit provider-platform's mempool → executor → verifier pipeline is async (5 s executor tick + chain confirm + 10 s verifier tick), so the bundle status doesn't reach COMPLETED until ~10-20 s after the POST returns. Returning to the caller before then yielded "payment completed" status but the merchant's chain-derived balance was still 0. Poll GET /providers/:pp/entity/bundles/:id every 5 s up to 120 s (matches local-dev's lib/client/bundle.ts:waitForBundle pattern). Only record the IN transaction and return COMPLETED once the bundle actually settles. FAILED / EXPIRED bundles surface as errors. Also drops the stale feeStroops / netStroops / feePct references left over from the merchant-fee removal — the transaction row uses the amountStroops directly (feeStroops defaults to 0). --- src/http/v1/pay/instant-execute.ts | 61 ++++++++++++++++++++++-------- 1 file changed, 46 insertions(+), 15 deletions(-) diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index ade19a6..d053284 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -99,9 +99,6 @@ export function handleExecuteInstant( }; return; } - // Merchant fee concept is being reworked — hardcode to 0 for now. - const feePct = 0; - const councils = await councilRepo.findByJurisdiction( merchant.jurisdictionCountryCode, ); @@ -185,12 +182,6 @@ export function handleExecuteInstant( } log.event("customer payment verified"); - const feeStroops = amountStroops * BigInt(Math.round(feePct * 100)) / - 10000n; - const netStroops = amountStroops - feeStroops; - span.setAttribute("net.stroops", netStroops.toString()); - span.setAttribute("fee.stroops", feeStroops.toString()); - const utxoRootBase64 = await decryptSk( merchant.encryptedDelegationKey, SERVICE_AUTH_SECRET, @@ -225,10 +216,10 @@ export function handleExecuteInstant( }); // The SAC transfer to the channel must match the bundle's deposit op - // (netStroops + BUNDLE_FEE). Otherwise the channel rejects the bundle + // (amountStroops + BUNDLE_FEE). Otherwise the channel rejects the bundle // for inflow/outflow mismatch. const BUNDLE_FEE = 500_000n; - const depositTotalStroops = netStroops + BUNDLE_FEE; + const depositTotalStroops = amountStroops + BUNDLE_FEE; const opexAccount = await server.getAccount(opexKeypair.publicKey()); const sacContract = new Contract(selectedChannel.assetContractId); const depositTx = new TransactionBuilder(opexAccount, { @@ -267,11 +258,11 @@ export function handleExecuteInstant( log.event("deposit confirmed on-chain"); // Bundle is shaped like browser-wallet's deposit flow: - // deposit(opex, netStroops + BUNDLE_FEE) + merchant CREATEs(netStroops) + // deposit(opex, amountStroops + BUNDLE_FEE) + merchant CREATEs(amountStroops) // No temp-hop. provider-platform's classifier sees inflows = deposit, // outflows = merchant creates, fee = BUNDLE_FEE (positive). const merchantAmounts = partitionAmount( - netStroops, + amountStroops, merchantDestinations.publicKeys.length, ); const merchantCreateOps = merchantDestinations.publicKeys.map((pk, i) => @@ -339,13 +330,53 @@ export function handleExecuteInstant( const bundleId = bundleData?.data?.operationsBundleId ?? null; if (bundleId) span.setAttribute("bundle.id", bundleId); + // Wait for the bundle to actually settle on chain. provider-platform's + // mempool → executor → verifier pipeline is async (5 s executor tick, + // 10 s verifier tick), so the bundle status doesn't reach COMPLETED + // until well after the POST returns. Until it COMPLETEs, the merchant's + // chain-derived balance is 0 — returning here without waiting means + // the caller sees "payment completed" but on-chain state is empty. + // Pattern matches local-dev's lib/client/bundle.ts:waitForBundle. + if (bundleId) { + log.event("waiting for bundle settlement"); + const bundlePollUrl = + `${pp.url}/api/v1/providers/${pp.publicKey}/entity/bundles/${bundleId}`; + const pollDeadline = Date.now() + 120_000; + let settled = false; + while (Date.now() < pollDeadline) { + await new Promise((r) => setTimeout(r, 5_000)); + const pollRes = await fetch(bundlePollUrl, { + headers: { "Authorization": `Bearer ${providerJwt}` }, + }); + if (!pollRes.ok) { + if (pollRes.status === 429) continue; + throw new Error( + `Bundle poll failed: ${pollRes.status} ${await pollRes.text()}`, + ); + } + const pollData = await pollRes.json().catch(() => ({})); + const bundleStatus = pollData?.data?.status; + log.debug("bundleStatus", String(bundleStatus)); + if (bundleStatus === "COMPLETED") { + settled = true; + break; + } + if (bundleStatus === "FAILED" || bundleStatus === "EXPIRED") { + throw new Error(`Bundle ${bundleId} ${bundleStatus}`); + } + } + if (!settled) { + throw new Error(`Bundle ${bundleId} did not settle within 120 s`); + } + log.event("bundle settled on chain"); + } + const inTx = await txRepo.create({ walletPublicKey: merchantWallet, direction: "IN", status: "COMPLETED", method: "CRYPTO_INSTANT", - amountStroops: netStroops, - feeStroops, + amountStroops: amountStroops, counterparty: null, description: description ?? null, bundleId, From 4ff3f21ee110909b09f0b03aa985d8ba0ee60a6d Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 3 Jun 2026 18:03:37 -0300 Subject: [PATCH 7/7] chore: deno fmt --- src/http/v1/pay/instant-execute.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/http/v1/pay/instant-execute.ts b/src/http/v1/pay/instant-execute.ts index d053284..cf14b30 100644 --- a/src/http/v1/pay/instant-execute.ts +++ b/src/http/v1/pay/instant-execute.ts @@ -415,4 +415,3 @@ function partitionAmount(total: bigint, parts: number): bigint[] { result.push(remaining); return result; } -