aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-05 16:44:47 -0800
committerAdam Soltys <[email protected]>2026-02-06 09:00:10 -0800
commit4e9c0c5198c7884cfd97cb4495f6156632e3124a (patch)
treece8144806d7f88a7c3084eca3fd9669f13df54af
parent635ca5ad96c791bdd229f50c138b4f3292ae29ad (diff)
downloadcoinos-server-4e9c0c5198c7884cfd97cb4495f6156632e3124a.tar.gz
coinos-server-4e9c0c5198c7884cfd97cb4495f6156632e3124a.zip
payment forwarding
-rw-r--r--bunfig.toml2
-rw-r--r--index.ts1
-rw-r--r--lib/invoices.ts2
-rw-r--r--lib/payments.ts23
-rw-r--r--routes/payments.ts26
-rw-r--r--test/forward.test.ts369
-rw-r--r--test/preload.ts262
7 files changed, 676 insertions, 9 deletions
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 00000000..786a3774
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./test/preload.ts"]
diff --git a/index.ts b/index.ts
index 26e332c1..31c48f3e 100644
--- a/index.ts
+++ b/index.ts
@@ -96,6 +96,7 @@ app.post("/gateway", payments.gateway);
app.post("/replace", auth, payments.replace);
app.get("/decode/:bolt11", payments.decode);
app.post("/fetchinvoice", payments.fetchinvoice);
+app.get("/ark/address", payments.arkAddress);
app.post("/ark/receive", auth, payments.arkReceive);
app.post("/ark/sync", auth, payments.arkSync);
diff --git a/lib/invoices.ts b/lib/invoices.ts
index 4a9d5c89..2d9d82ad 100644
--- a/lib/invoices.ts
+++ b/lib/invoices.ts
@@ -36,6 +36,7 @@ export const generate = async ({ invoice, user }) => {
type = PaymentType.lightning,
webhook,
secret,
+ forward,
} = invoice;
amount = Number.parseInt(amount || 0);
@@ -168,6 +169,7 @@ export const generate = async ({ invoice, user }) => {
type,
uid: user.id,
webhook,
+ forward,
};
if (type === "liquid") {
diff --git a/lib/payments.ts b/lib/payments.ts
index 011807d5..526ae5a2 100644
--- a/lib/payments.ts
+++ b/lib/payments.ts
@@ -315,7 +315,28 @@ export const completePayment = async (inv, p, user) => {
const { id, autowithdraw, threshold, reserve, destination, username } = user;
let withdrawal;
if (p.confirmed) {
- if (autowithdraw) {
+ if (inv.forward && !inv.forwarded) {
+ try {
+ // Skip forward if destination is the same user's own invoice
+ try {
+ const destInv = await getInvoice(inv.forward);
+ if (destInv?.uid === id) {
+ inv.forwarded = true;
+ await s(`invoice:${inv.id}`, inv);
+ callWebhook(inv, p);
+ return;
+ }
+ } catch {}
+
+ inv.forwarded = true;
+ await s(`invoice:${inv.id}`, inv);
+ const w = await pay({ amount: p.amount, to: inv.forward, user });
+ callWebhook(inv, p);
+ return w;
+ } catch (e) {
+ warn(username, "forward failed", inv.forward, e.message);
+ }
+ } else if (autowithdraw && p.type !== "ark") {
try {
const to = destination.trim();
const balance = await getBalance(id);
diff --git a/routes/payments.ts b/routes/payments.ts
index e3cd8e5e..a93a1882 100644
--- a/routes/payments.ts
+++ b/routes/payments.ts
@@ -1,6 +1,6 @@
import config from "$config";
import api from "$lib/api";
-import { sendArk } from "$lib/ark";
+import { getArkAddress, sendArk } from "$lib/ark";
import { requirePin } from "$lib/auth";
import { archive, db, g, gf, s } from "$lib/db";
import { getTx } from "$lib/esplora";
@@ -21,7 +21,7 @@ import {
sendOnchain,
} from "$lib/payments";
import { emit } from "$lib/sockets";
-import { fundDebit, getBalance, getCredit, tbConfirm } from "$lib/tb";
+import { fundDebit, getBalance, getCredit, tbConfirm, tbCredit } from "$lib/tb";
import { PaymentType } from "$lib/types";
import {
SATS,
@@ -63,7 +63,7 @@ export default {
const recipient = invoice ? await getUser(invoice.uid) : undefined;
if (payreq) {
if (invoice && recipient.username !== "mint") {
- if (invoice.aid === user.id) fail("Cannot send to self");
+ if (invoice.aid === (aid || user.id)) fail("Cannot send to self");
hash = payreq;
if (!amount) ({ amount } = invoice);
} else {
@@ -809,6 +809,10 @@ export default {
}
},
+ async arkAddress(_, res) {
+ res.send(await getArkAddress());
+ },
+
async arkReceive(req, res) {
try {
const { body, user } = req;
@@ -821,13 +825,16 @@ export default {
if (invoice.uid !== user?.id) fail("Unauthorized");
invoice.received += amount;
- const { aid, type, currency } = invoice;
+ let { aid, type, currency } = invoice;
const rates = await g("rates");
const rate = rates[currency];
+ // Always credit custodial balance (Ark funds received by server)
+ await tbCredit(uid, uid, type, amount, false);
+
const p = {
id: v4(),
- aid: aid,
+ aid: uid,
iid,
amount,
hash,
@@ -842,10 +849,10 @@ export default {
const m = db.multi();
m.set(`invoice:${invoice.id}`, JSON.stringify(invoice))
.set(`payment:${p.id}`, JSON.stringify(p))
- .lPush(`${aid || uid}:payments`, p.id)
- .set(`${aid || uid}:payments:last`, p.created);
+ .lPush(`${uid}:payments`, p.id)
+ .set(`${uid}:payments:last`, p.created);
- if (hash) m.set(`payment:${aid || uid}:${hash}`, p.id);
+ if (hash) m.set(`payment:${aid}:${hash}`, p.id);
await m.exec();
@@ -878,6 +885,9 @@ export default {
continue;
}
+ // Skip received/change VTXOs — handled by arkReceive
+ if (tx.amount > 0) continue;
+
const p = {
id: v4(),
aid,
diff --git a/test/forward.test.ts b/test/forward.test.ts
new file mode 100644
index 00000000..55e88a3e
--- /dev/null
+++ b/test/forward.test.ts
@@ -0,0 +1,369 @@
+import { describe, test, expect, beforeEach } from "bun:test";
+
+// Mocks are loaded via test/preload.ts (bunfig.toml [test].preload)
+// Access the mock functions we need for assertions
+import { tbCredit } from "$lib/tb";
+import { warn } from "$lib/logging";
+import { callWebhook } from "$lib/webhooks";
+import ln from "$lib/ln";
+
+const mockTbCredit = tbCredit as any;
+const mockWarn = warn as any;
+const mockCallWebhook = callWebhook as any;
+const mockLnXpay = ln.xpay as any;
+
+import { completePayment } from "$lib/payments";
+import routes from "$routes/payments";
+
+// =====================================================================
+// Helpers
+// =====================================================================
+
+const store = () => globalThis.__testStore;
+
+const custodialUid = "custodial-uid";
+const arkAccountId = "ark-account-123";
+const bolt11 = "lnbcrt10u1pntestqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq";
+
+const makeUser = (overrides = {}) => ({
+ id: custodialUid,
+ username: "testuser",
+ currency: "USD",
+ language: "en",
+ autowithdraw: false,
+ threshold: 0,
+ reserve: 0,
+ destination: "",
+ ...overrides,
+});
+
+const makeInvoice = (overrides: any = {}) => ({
+ id: "inv-001",
+ uid: custodialUid,
+ aid: custodialUid,
+ type: "ark",
+ currency: "USD",
+ amount: 1000,
+ received: 0,
+ pending: 0,
+ rate: 50000,
+ forward: null as string | null,
+ forwarded: false,
+ hash: "inv-hash-001",
+ memo: "",
+ tip: null,
+ webhook: null,
+ secret: null,
+ ...overrides,
+});
+
+const seedInvoice = (inv: any) => {
+ store().kvStore[`invoice:${inv.id}`] = JSON.stringify(inv);
+ store().kvStore[`invoice:${inv.hash}`] = inv.id;
+};
+
+const seedUser = (user: any) => {
+ store().kvStore[`user:${user.username}`] = JSON.stringify(user);
+ store().kvStore[`user:${user.id}`] = JSON.stringify(user);
+};
+
+const resetAll = () => {
+ const s = store();
+ for (const k of Object.keys(s.kvStore)) delete s.kvStore[k];
+ for (const k of Object.keys(s.listStore)) delete s.listStore[k];
+ for (const k of Object.keys(s.setStore)) delete s.setStore[k];
+
+ mockTbCredit.mockClear();
+ mockWarn.mockClear();
+ mockCallWebhook.mockClear();
+ mockLnXpay.mockClear();
+
+ mockLnXpay.mockImplementation(async () => ({
+ amount_sent_msat: 1_000_000,
+ payment_preimage: "preimage-abc123",
+ }));
+
+ // Seed rates and limits
+ s.kvStore["rates"] = JSON.stringify({ USD: 50000, CAD: 68000 });
+ s.kvStore["limit"] = JSON.stringify(100_000_000);
+ s.kvStore["lightning:limit"] = JSON.stringify(100_000_000);
+ s.kvStore["internal:limit"] = JSON.stringify(100_000_000);
+ s.kvStore["freeze"] = JSON.stringify(false);
+ s.kvStore["hardfreeze"] = JSON.stringify(false);
+
+ seedUser(makeUser());
+};
+
+const makeRes = () => {
+ let sent: any;
+ return {
+ send: (data: any) => { sent = data; },
+ code: (code: number) => ({
+ send: (data: any) => { sent = data; },
+ }),
+ getSent: () => sent,
+ };
+};
+
+// =====================================================================
+// Tests: completePayment forward logic
+// =====================================================================
+
+describe("completePayment", () => {
+ beforeEach(resetAll);
+
+ test("forwards payment and debit stays in custodial", async () => {
+ const user = makeUser();
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 });
+ const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() };
+
+ const w: any = await completePayment(inv, p, user);
+
+ expect(w).toBeTruthy();
+ expect(w.amount).toBeLessThan(0);
+ expect(w.type).toBe("lightning");
+
+ // Debit stays in custodial list
+ expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id);
+
+ expect(inv.forwarded).toBe(true);
+ expect(mockCallWebhook).toHaveBeenCalled();
+ });
+
+ test("forwards and debit stays in custodial when aid matches user", async () => {
+ const user = makeUser();
+ const inv = makeInvoice({ aid: custodialUid, forward: bolt11 });
+ const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() };
+
+ const w: any = await completePayment(inv, p, user);
+
+ expect(w).toBeTruthy();
+ expect(w.amount).toBeLessThan(0);
+ expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(w.id);
+ expect(inv.forwarded).toBe(true);
+ });
+
+ test("handles forward failure gracefully", async () => {
+ const user = makeUser();
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 });
+ const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() };
+
+ mockLnXpay.mockImplementationOnce(async () => { throw new Error("route not found"); });
+
+ const w = await completePayment(inv, p, user);
+
+ expect(w).toBeUndefined();
+ expect(mockWarn).toHaveBeenCalled();
+ expect(mockWarn.mock.calls.some((c: any[]) => c.includes("forward failed"))).toBe(true);
+ });
+
+ test("skips forward when already forwarded", async () => {
+ const user = makeUser();
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11, forwarded: true });
+ const p = { amount: 1000, confirmed: true, type: "ark", uid: custodialUid, created: Date.now() };
+
+ const w = await completePayment(inv, p, user);
+
+ expect(w).toBeUndefined();
+ expect(mockLnXpay).not.toHaveBeenCalled();
+ });
+
+ test("skips forward when payment not confirmed", async () => {
+ const user = makeUser();
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 });
+ const p = { amount: 1000, confirmed: false, type: "ark", uid: custodialUid, created: Date.now() };
+
+ const w = await completePayment(inv, p, user);
+
+ expect(w).toBeUndefined();
+ expect(mockLnXpay).not.toHaveBeenCalled();
+ });
+});
+
+// =====================================================================
+// Tests: arkReceive route handler
+// =====================================================================
+
+describe("arkReceive", () => {
+ beforeEach(resetAll);
+
+ test("credits custodial and forwards to Lightning", async () => {
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 });
+ seedInvoice(inv);
+
+ const req = {
+ body: { iid: inv.id, amount: 1000, hash: "ark-txid-999" },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkReceive(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ // Response is the custodial credit record
+ expect(sent).toBeTruthy();
+ expect(sent.amount).toBe(1000);
+ expect(sent.aid).toBe(custodialUid);
+
+ // tbCredit called for deposit
+ expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false);
+
+ // Ark txid mapped for dedup
+ expect(store().kvStore[`payment:${arkAccountId}:ark-txid-999`]).toBeTruthy();
+
+ // Credit record in custodial list
+ expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id);
+
+ // Forward triggered Lightning send
+ expect(mockLnXpay).toHaveBeenCalled();
+ });
+
+ test("credits custodial without forward", async () => {
+ const inv = makeInvoice({ aid: arkAccountId });
+ seedInvoice(inv);
+
+ const req = {
+ body: { iid: inv.id, amount: 1000, hash: "ark-txid-888" },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkReceive(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent).toBeTruthy();
+ expect(sent.amount).toBe(1000);
+ expect(sent.aid).toBe(custodialUid);
+
+ // Always credits custodial TB
+ expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false);
+ // Record in custodial list
+ expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id);
+ });
+
+ test("credits custodial even when forward fails", async () => {
+ const inv = makeInvoice({ aid: arkAccountId, forward: bolt11 });
+ seedInvoice(inv);
+
+ mockLnXpay.mockImplementationOnce(async () => { throw new Error("no route"); });
+
+ const req = {
+ body: { iid: inv.id, amount: 1000, hash: "ark-txid-777" },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkReceive(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ // Credit still lands in custodial even though forward failed
+ expect(sent).toBeTruthy();
+ expect(sent.amount).toBe(1000);
+ expect(sent.aid).toBe(custodialUid);
+
+ expect(mockTbCredit).toHaveBeenCalledWith(custodialUid, custodialUid, inv.type, 1000, false);
+ expect(store().listStore[`${custodialUid}:payments`] ?? []).toContain(sent.id);
+ });
+
+ test("rejects invalid amount", async () => {
+ const inv = makeInvoice();
+ seedInvoice(inv);
+
+ const req = {
+ body: { iid: inv.id, amount: 0, hash: "ark-txid-000" },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkReceive(req as any, res as any);
+ // Should error - check that send wasn't called with a valid payment
+ const sent = (res as any).getSent();
+ expect(sent).toBeDefined();
+ });
+
+ test("rejects unauthorized user", async () => {
+ const inv = makeInvoice({ uid: "other-user-id" });
+ seedInvoice(inv);
+
+ const req = {
+ body: { iid: inv.id, amount: 1000, hash: "ark-txid-666" },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkReceive(req as any, res as any);
+ const sent = (res as any).getSent();
+ expect(sent).toBeDefined();
+ });
+});
+
+// =====================================================================
+// Tests: arkSync skips change VTXOs
+// =====================================================================
+
+describe("arkSync", () => {
+ beforeEach(resetAll);
+
+ test("skips received/change VTXOs (positive amounts)", async () => {
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [
+ { hash: "change-vtxo-hash", amount: 9000, settled: true, createdAt: Date.now() },
+ ],
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(0);
+ expect(store().listStore[`${arkAccountId}:payments`] ?? []).toHaveLength(0);
+ });
+
+ test("records sent transactions (negative amounts)", async () => {
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [
+ { hash: "send-tx-hash", amount: -1000, settled: true, createdAt: Date.now() },
+ ],
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(1);
+ expect((store().listStore[`${arkAccountId}:payments`] ?? []).length).toBe(1);
+ });
+
+ test("deduplicates known transactions", async () => {
+ // Pre-store a known tx hash
+ store().kvStore[`payment:${arkAccountId}:known-hash`] = JSON.stringify("existing-id");
+ store().kvStore["payment:existing-id"] = JSON.stringify({ id: "existing-id", confirmed: false });
+
+ const req = {
+ body: {
+ aid: arkAccountId,
+ transactions: [
+ { hash: "known-hash", amount: -1000, settled: true, createdAt: Date.now() },
+ ],
+ },
+ user: makeUser(),
+ };
+ const res = makeRes();
+
+ await routes.arkSync(req as any, res as any);
+ const sent = (res as any).getSent();
+
+ expect(sent.synced).toBe(0);
+ // Existing record should be updated to confirmed
+ const updated = JSON.parse(store().kvStore["payment:existing-id"]);
+ expect(updated.confirmed).toBe(true);
+ });
+});
diff --git a/test/preload.ts b/test/preload.ts
new file mode 100644
index 00000000..0704561a
--- /dev/null
+++ b/test/preload.ts
@@ -0,0 +1,262 @@
+import { mock } from "bun:test";
+
+// This file is loaded before test files via bunfig.toml [test].preload
+// Mocks must be set up here to intercept transitive imports
+
+let kvStore: Record<string, string> = {};
+let listStore: Record<string, string[]> = {};
+let setStore: Record<string, Set<string>> = {};
+
+globalThis.__testStore = { kvStore, listStore, setStore };
+
+const makeMulti = () => {
+ const ops: Array<() => Promise<any>> = [];
+ const chain: any = {
+ set: (k: string, v: string) => {
+ ops.push(async () => { globalThis.__testStore.kvStore[k] = v; });
+ return chain;
+ },
+ lPush: (k: string, v: string) => {
+ ops.push(async () => {
+ const ls = globalThis.__testStore.listStore;
+ if (!ls[k]) ls[k] = [];
+ ls[k].unshift(v);
+ });
+ return chain;
+ },
+ del: (k: string) => {
+ ops.push(async () => { delete globalThis.__testStore.kvStore[k]; });
+ return chain;
+ },
+ lRem: (k: string, _count: number, v: string) => {
+ ops.push(async () => {
+ const ls = globalThis.__testStore.listStore;
+ if (ls[k]) {
+ const idx = ls[k].indexOf(v);
+ if (idx >= 0) ls[k].splice(idx, 1);
+ }
+ });
+ return chain;
+ },
+ sRem: (k: string, v: string) => {
+ ops.push(async () => globalThis.__testStore.setStore[k]?.delete(v));
+ return chain;
+ },
+ exec: async () => {
+ for (const op of ops) await op();
+ return [];
+ },
+ };
+ return chain;
+};
+
+mock.module("$lib/db", () => {
+ const kv = () => globalThis.__testStore.kvStore;
+ const ls = () => globalThis.__testStore.listStore;
+ const ss = () => globalThis.__testStore.setStore;
+ const g = async (k: string) => {
+ const v = kv()[k] ?? null;
+ if (v === null) return null;
+ try { return JSON.parse(v); } catch { return v; }
+ };
+ const s = (k: string, v: any) => {
+ if (k === "user:null" || k === "user:undefined") throw new Error("null user");
+ kv()[k] = JSON.stringify(v);
+ };
+ const gf = async (k: string) => {
+ const v = kv()[k] ?? null;
+ if (v === null) return null;
+ try { return JSON.parse(v); } catch { return v; }
+ };
+ return {
+ db: {
+ get: async (k: string) => kv()[k] ?? null,
+ set: async (k: string, v: string) => { kv()[k] = v; return "OK"; },
+ del: async (k: string) => { delete kv()[k]; return 1; },
+ exists: async (k: string) => (kv()[k] !== undefined ? 1 : 0),
+ lPush: async (k: string, v: string) => {
+ if (!ls()[k]) ls()[k] = [];
+ ls()[k].unshift(v);
+ return ls()[k].length;
+ },
+ lRem: async (k: string, _count: number, v: string) => {
+ if (!ls()[k]) return 0;
+ const idx = ls()[k].indexOf(v);
+ if (idx >= 0) { ls()[k].splice(idx, 1); return 1; }
+ return 0;
+ },
+ lRange: async (k: string, start: number, end: number) => {
+ if (!ls()[k]) return [];
+ if (end === -1) return ls()[k].slice(start);
+ return ls()[k].slice(start, end + 1);
+ },
+ lPos: async (k: string, v: string) => {
+ if (!ls()[k]) return null;
+ const idx = ls()[k].indexOf(v);
+ return idx >= 0 ? idx : null;
+ },
+ sAdd: async (k: string, ...vals: string[]) => {
+ if (!ss()[k]) ss()[k] = new Set();
+ for (const v of vals) ss()[k].add(v);
+ return vals.length;
+ },
+ sRem: async (k: string, v: string) => ss()[k]?.delete(v) ? 1 : 0,
+ sIsMember: async (k: string, v: string) => ss()[k]?.has(v) ?? false,
+ sMembers: async (k: string) => (ss()[k] ? [...ss()[k]] : []),
+ incrBy: async (k: string, n: number) => {
+ const v = Number.parseInt(kv()[k] || "0") + n;
+ kv()[k] = String(v);
+ return v;
+ },
+ watch: async () => {},
+ multi: makeMulti,
+ expire: async () => 1,
+ setNX: async (k: string, v: string) => {
+ if (kv()[k]) return false;
+ kv()[k] = v;
+ return true;
+ },
+ zScore: async () => null,
+ zAdd: async () => 1,
+ zCard: async () => 0,
+ zRemRangeByRank: async () => 0,
+ keys: async () => [],
+ type: async () => "none",
+ },
+ g, s, gf,
+ ga: async () => null,
+ archive: { lRange: async () => [] },
+ };
+});
+
+mock.module("$config", () => ({
+ default: {
+ bitcoin: { host: "localhost", wallet: "test", user: "u", password: "p", network: "regtest", port: 18443 },
+ liquid: { host: "localhost", wallet: "test", user: "u", password: "p", btc: "test-asset", port: 7040 },
+ lightning: "/dev/null",
+ fee: { bitcoin: 0.004, liquid: 0.001, lightning: 0.001 },
+ adminpass: "test",
+ ark: { arkPrivateKey: "0000", arkServerUrl: "http://localhost" },
+ nostr: "ws://localhost:7777",
+ tigerbeetle: { cluster_id: 0n, replica_addresses: ["localhost:3000"] },
+ vapid: { pk: "test", sk: "test" },
+ support: "[email protected]",
+ txWebhookSecret: "test",
+ },
+}));
+
+mock.module("$lib/tb", () => ({
+ getBalance: mock(async () => 10_000_000),
+ getCredit: mock(async () => 0),
+ tbDebit: mock(async () => 0),
+ tbCredit: mock(async () => undefined),
+ tbRefund: mock(async () => undefined),
+ tbReverse: mock(async () => undefined),
+ tbConfirm: mock(async () => undefined),
+ tbSetBalance: mock(async () => undefined),
+ tbSetPending: mock(async () => undefined),
+ fundDebit: mock(async () => ({ err: null })),
+ initTigerBeetle: mock(async () => {}),
+}));
+
+mock.module("$lib/ln", () => ({
+ default: {
+ decode: mock(async () => ({ type: "bolt11", amount_msat: 1_000_000, payee: "test-payee" })),
+ listpeerchannels: mock(async () => ({ channels: [] })),
+ listpays: mock(async () => ({ pays: [] })),
+ xpay: mock(async () => ({ amount_sent_msat: 1_000_000, payment_preimage: "preimage-abc" })),
+ getinfo: mock(async () => ({ id: "our-node-id" })),
+ listinvoices: mock(async () => ({ invoices: [] })),
+ listfunds: mock(async () => ({ channels: [] })),
+ keysend: mock(async () => ({})),
+ fetchinvoice: mock(async () => ({})),
+ sendinvoice: mock(async () => ({})),
+ },
+}));
+
+mock.module("$lib/logging", () => ({ l: () => {}, warn: mock(() => {}), err: () => {} }));
+mock.module("$lib/notifications", () => ({ notify: () => {}, nwcNotify: () => {} }));
+mock.module("$lib/webhooks", () => ({ callWebhook: mock(() => {}) }));
+mock.module("$lib/sockets", () => ({ emit: () => {}, sendHeartbeat: () => {} }));
+mock.module("$lib/square", () => ({ squarePayment: () => {} }));
+mock.module("$lib/nostr", () => ({
+ handleZap: async () => {}, publish: async () => {},
+ serverPubkey: "m", serverPubkey2: "m", serverSecret: "m", serverSecret2: "m",
+}));
+mock.module("$lib/mqtt", () => ({ default: { publish: () => {} } }));
+mock.module("$lib/ark", () => ({
+ getArkAddress: async () => "ark-addr", sendArk: async () => "ark-txid", getArkBalance: async () => 0,
+}));
+mock.module("$lib/ecash", () => ({ request: async () => ({}) }));
+mock.module("$lib/lightning", () => ({
+ replay: async () => ({}), fixBolt12: () => {}, listenForLightning: () => {},
+}));
+mock.module("$lib/mail", () => ({ mail: async () => {}, templates: {} }));
+mock.module("$lib/auth", () => ({
+ requirePin: async () => {}, auth: (_r: any, _s: any, n: any) => n(), optional: (_r: any, _s: any, n: any) => n(), admin: (_r: any, _s: any, n: any) => n(),
+}));
+mock.module("$lib/invoices", () => ({
+ generate: mock(async ({ invoice, user }: any) => ({
+ id: "gen-inv", hash: "gen-hash", uid: user?.id, received: 0, pending: 0, ...invoice,
+ })),
+}));
+mock.module("$lib/api", () => ({ default: { bitcoin: "http://localhost", liquid: "http://localhost" } }));
+mock.module("$lib/store", () => ({ default: { rates: { USD: 50000 } } }));
+mock.module("@coinos/rpc", () => ({
+ default: () => new Proxy({}, { get: () => async () => ({}) }),
+}));
+
+mock.module("$lib/utils", () => {
+ const SATS = 100_000_000;
+ const kv = () => globalThis.__testStore.kvStore;
+ return {
+ SATS,
+ btc: (n: number) => Number.parseFloat((n / SATS).toFixed(8)),
+ fail: (msg: string) => { throw new Error(msg); },
+ fmt: (n: number) => String(n),
+ formatReceipt: () => {},
+ getInvoice: async (hash: string) => {
+ const raw = kv()[`invoice:${hash}`] ?? null;
+ if (!raw) return null;
+ let iid;
+ try { iid = JSON.parse(raw); } catch { iid = raw; }
+ if (iid?.id) iid = iid.id;
+ else if (iid?.hash) iid = iid.hash;
+ const raw2 = kv()[`invoice:${iid}`] ?? null;
+ if (!raw2) return null;
+ try { return JSON.parse(raw2); } catch { return raw2; }
+ },
+ getPayment: async (id: string) => {
+ const raw = kv()[`payment:${id}`];
+ if (!raw) return null;
+ try {
+ const parsed = JSON.parse(raw);
+ if (typeof parsed === "string") {
+ const p = kv()[`payment:${parsed}`];
+ return p ? JSON.parse(p) : null;
+ }
+ return parsed;
+ } catch { return null; }
+ },
+ getUser: async (username: string) => {
+ const raw = kv()[`user:${username}`];
+ return raw ? JSON.parse(raw) : null;
+ },
+ getAccount: async () => null,
+ link: (id: string) => `http://test/${id}`,
+ sats: (n: number) => Math.round(n * SATS),
+ sleep: async () => {},
+ t: () => ({ insufficientFunds: "Insufficient funds" }),
+ bail: (res: any, msg: string) => res.code(500).send(msg),
+ bip21: () => "",
+ fields: [],
+ nada: () => {},
+ fiat: (n: number, r: number) => (n * r) / SATS,
+ f: (s: any) => String(s),
+ pick: (O: any, K: string[]) => K.reduce((o: any, k: string) => ((o[k] = O[k]), o), {}),
+ prod: false,
+ uniq: (a: any[], k: any) => [...new Map(a.map((x: any) => [k(x), x])).values()],
+ wait: async () => {},
+ time: () => ({ start: () => {}, end: () => {} }),
+ };
+});