From 542cf1d084d189bcdf127c9c2359cced5267295e Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Fri, 13 Feb 2026 14:14:02 -0800 Subject: fix onchain confirmations --- index.ts | 3 +- lib/payments.ts | 38 ++- routes/payments.ts | 37 +-- test/onchain.test.ts | 649 +++++++++++++++++++++++++++++++++++++++++++++++++++ test/preload.ts | 47 +++- 5 files changed, 717 insertions(+), 57 deletions(-) create mode 100644 test/onchain.test.ts diff --git a/index.ts b/index.ts index c626a961..507e62f4 100644 --- a/index.ts +++ b/index.ts @@ -6,7 +6,7 @@ import { startHealthCheck } from "$lib/health"; import { getLocations } from "$lib/locations"; import { migrateAccounts, migrateBalancesToTB, migrateToMicrosats } from "$lib/migrate"; import nwc from "$lib/nwc"; -import { check } from "$lib/payments"; +import { catchUp, check } from "$lib/payments"; import { getFx } from "$lib/rates"; import { sendHeartbeat } from "$lib/sockets"; import { initTigerBeetle } from "$lib/tb"; @@ -34,6 +34,7 @@ try { check(); startHealthCheck(); startZmq(); + catchUp(); migrateAccounts().then((n) => n && console.log(`Migrated ${n} accounts`)); migrateBalancesToTB().then((n) => n && console.log(`Migrated ${n} balances to TB`)); migrateToMicrosats().then((n) => n && console.log(`Migrated ${n} users to microsats`)); diff --git a/lib/payments.ts b/lib/payments.ts index 99f07cc8..cc1391fe 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -462,6 +462,12 @@ export const processWatchedTx = async (tx) => { } if (output.value < 300) continue; + + const lockKey = `lock:${txid}:${vout}`; + const locked = await db.setNX(lockKey, "1"); + if (!locked) continue; + await db.expire(lockKey, 60); + await credit({ hash: address, amount: output.value, @@ -1147,29 +1153,16 @@ export const build = async ({ export const catchUp = async () => { try { - // Platform wallets (custodial bitcoin + liquid) - const txns = []; - for (const [type, n] of Object.entries({ bitcoin: bc, liquid: lq })) { - txns.push( - ...(await n.listTransactions("*", 10)).filter((tx) => { - tx.type = type; - return tx.category === "receive" && tx.confirmations > 0; - }), - ); - } - - for (const { txid, type } of txns) { + // Check watched addresses for any missed bitcoin transactions + const watched = await db.sMembers("watching"); + for (const address of watched) { try { - if (await db.zScore("seen", txid)) continue; - await got.post(`http://localhost:${process.env.PORT || 3119}/confirm`, { - json: { txid, wallet: config[type].wallet, type }, - }); - - await db.zAdd("seen", { score: Date.now(), value: txid }); - if ((await db.zCard("seen")) > 10000) - await db.zRemRangeByRank("seen", 0, 0); + const txs = await getAddressTxs(address); + for (const tx of txs) { + await processWatchedTx(tx); + } } catch (e) { - err("problem confirming", e.message); + err("catchUp address check failed", address, e.message); } } @@ -1199,9 +1192,8 @@ export const catchUp = async () => { } } } - } catch (e) { - err("problem syncing", e.message); + err("problem catching up", e.message); } }; diff --git a/routes/payments.ts b/routes/payments.ts index 4377a15d..52e9057e 100644 --- a/routes/payments.ts +++ b/routes/payments.ts @@ -458,39 +458,22 @@ export default { body: { txid, wallet, type }, } = req; + if (type !== PaymentType.liquid) return res.send({}); + try { const node = rpc({ ...config[type], wallet }); const { confirmations, details } = await node.getTransaction(txid); - const hot = wallet === config[type].wallet; - let aid; - if (!hot) aid = wallet; for (const { address, amount, asset, category, vout } of details) { if (!address) continue; - if (type === PaymentType.liquid && asset !== config.liquid.btc) - continue; - - if (category === "send") { - const p = await getPayment(txid); - if (!p) continue; + if (asset !== config.liquid.btc) continue; - if (confirmations) { - p.confirmed = true; - await s(`payment:${p.id}`, p); - if (aid) await db.sRem(`inflight:${aid}`, p.id); - } else { - if (aid) await db.sAdd(`inflight:${aid}`, p.id); - } - - emit(p.uid, "payment", p); - continue; - } + if (category === "send") continue; const p = await getPayment(`${txid}:${vout}`); if (!p) { const invoice = await getInvoice(address); - if (!hot && aid !== invoice?.aid) continue; if (sats(amount) < 300) continue; const lockKey = `lock:${txid}:${vout}`; @@ -503,28 +486,24 @@ export default { amount: sats(amount), ref: `${txid}:${vout}`, type, - aid, }); } else if (confirmations >= 1) { - const id = `payment:${txid}:${vout}`; - const p = await getPayment(`${txid}:${vout}`); - if (!p) return db.sAdd("missed", id); - if (p.confirmed) return; + if (p.confirmed) continue; const invoice = await getInvoice(address); - const { id: iid } = invoice; + if (!invoice) continue; p.confirmed = true; invoice.received += Number.parseInt(invoice.pending); invoice.pending = 0; - l("confirming", id, p.id, p.amount); + l("confirming", p.id, p.amount); await tbConfirm(p.aid || p.uid, p.amount); await db .multi() - .set(`invoice:${iid}`, JSON.stringify(invoice)) + .set(`invoice:${invoice.id}`, JSON.stringify(invoice)) .set(`payment:${p.id}`, JSON.stringify(p)) .exec(); diff --git a/test/onchain.test.ts b/test/onchain.test.ts new file mode 100644 index 00000000..eae77544 --- /dev/null +++ b/test/onchain.test.ts @@ -0,0 +1,649 @@ +import { describe, test, expect, beforeEach } from "bun:test"; + +import { tbCredit, tbConfirm } from "$lib/tb"; +import { emit } from "$lib/sockets"; +import { callWebhook } from "$lib/webhooks"; + +const mockTbCredit = tbCredit as any; +const mockTbConfirm = tbConfirm as any; +const mockEmit = emit as any; +const mockCallWebhook = callWebhook as any; + +import { processWatchedTx, credit, catchUp } from "$lib/payments"; +import routes from "$routes/payments"; +import { PaymentType } from "$lib/types"; + +// ===================================================================== +// Helpers +// ===================================================================== + +const store = () => globalThis.__testStore; + +const uid = "user-uid-001"; +const address = "bcrt1qtest123"; +const lqAddress = "el1qqtest456"; + +const makeUser = (overrides = {}) => ({ + id: uid, + username: "testuser", + currency: "USD", + language: "en", + autowithdraw: false, + threshold: 0, + reserve: 0, + destination: "", + ...overrides, +}); + +const makeInvoice = (overrides: any = {}) => ({ + id: "inv-001", + uid, + aid: uid, + type: PaymentType.bitcoin, + currency: "USD", + amount: 50000, + received: 0, + pending: 0, + rate: 50000, + forward: null as string | null, + forwarded: false, + hash: address, + 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}`] = JSON.stringify(inv.id); +}; + +const seedUser = (user: any) => { + store().kvStore[`user:${user.username}`] = JSON.stringify(user); + store().kvStore[`user:${user.id}`] = JSON.stringify(user); +}; + +const seedPayment = (p: any) => { + store().kvStore[`payment:${p.id}`] = JSON.stringify(p); + if (p.ref) { + const [txid, vout] = p.ref.split(":"); + store().kvStore[`payment:${txid}:${vout}`] = JSON.stringify(p.id); + } +}; + +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(); + mockTbConfirm.mockClear(); + mockCallWebhook.mockClear(); + + // Seed rates and limits + s.kvStore["rates"] = JSON.stringify({ USD: 50000, CAD: 68000 }); + s.kvStore["limit"] = JSON.stringify(100_000_000); + s.kvStore["bitcoin:limit"] = JSON.stringify(100_000_000); + s.kvStore["liquid: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); + + // Reset overrides + delete s.rpcOverride; + delete s.esploraOverride; + + seedUser(makeUser()); +}; + +const makeRes = () => { + let sent: any; + return { + send: (data: any) => { sent = data; }, + code: (code: number) => ({ + send: (data: any) => { sent = data; }, + }), + getSent: () => sent, + }; +}; + +// ===================================================================== +// Tests: processWatchedTx — Bitcoin receive via ZMQ +// ===================================================================== + +describe("processWatchedTx", () => { + beforeEach(resetAll); + + test("creates pending payment for unconfirmed tx to watched address", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + await processWatchedTx({ + txid: "btctx001", + vout: [{ scriptpubkey_address: address, value: 50000 }], + status: { confirmed: false }, + }); + + // Payment record created + const pid = JSON.parse(store().kvStore[`payment:btctx001:0`]); + expect(pid).toBeTruthy(); + const p = JSON.parse(store().kvStore[`payment:${pid}`]); + expect(p.amount).toBe(50000); + expect(p.confirmed).toBe(false); + expect(p.type).toBe(PaymentType.bitcoin); + + // Invoice pending updated + const updatedInv = JSON.parse(store().kvStore[`invoice:${inv.id}`]); + expect(updatedInv.pending).toBe(50000); + + // TB credit called with pending=true + expect(mockTbCredit).toHaveBeenCalledWith(uid, uid, PaymentType.bitcoin, 50000, true); + + // Address still being watched (not confirmed yet) + expect(store().setStore["watching"].has(address)).toBe(true); + }); + + test("confirms pending payment when tx is confirmed", async () => { + const inv = makeInvoice({ pending: 50000 }); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + // Seed existing pending payment + const pendingPayment = { + id: "pay-pending-001", + aid: uid, + uid, + amount: 50000, + confirmed: false, + type: PaymentType.bitcoin, + ref: "btctx001:0", + }; + seedPayment(pendingPayment); + + await processWatchedTx({ + txid: "btctx001", + vout: [{ scriptpubkey_address: address, value: 50000 }], + status: { confirmed: true }, + }); + + // Payment now confirmed + const p = JSON.parse(store().kvStore[`payment:${pendingPayment.id}`]); + expect(p.confirmed).toBe(true); + + // TB confirm called + expect(mockTbConfirm).toHaveBeenCalledWith(uid, 50000); + + // Invoice received updated, pending cleared + const updatedInv = JSON.parse(store().kvStore[`invoice:${inv.id}`]); + expect(updatedInv.received).toBe(50000); + expect(updatedInv.pending).toBe(0); + + // Address removed from watching + expect(store().setStore["watching"].has(address)).toBe(false); + }); + + test("creates and immediately confirms payment for already-confirmed tx", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + await processWatchedTx({ + txid: "btctx002", + vout: [{ scriptpubkey_address: address, value: 75000 }], + status: { confirmed: true }, + }); + + // Payment was created then confirmed + const pid = JSON.parse(store().kvStore[`payment:btctx002:0`]); + const p = JSON.parse(store().kvStore[`payment:${pid}`]); + expect(p.confirmed).toBe(true); + + // TB credit (pending) + confirm both called + expect(mockTbCredit).toHaveBeenCalled(); + expect(mockTbConfirm).toHaveBeenCalled(); + + // Address removed from watching + expect(store().setStore["watching"].has(address)).toBe(false); + }); + + test("skips outputs below dust threshold (300 sats)", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + await processWatchedTx({ + txid: "btctx003", + vout: [{ scriptpubkey_address: address, value: 299 }], + status: { confirmed: false }, + }); + + expect(store().kvStore[`payment:btctx003:0`]).toBeUndefined(); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("skips addresses not in watching set", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + // No address in watching set + + await processWatchedTx({ + txid: "btctx004", + vout: [{ scriptpubkey_address: address, value: 50000 }], + status: { confirmed: false }, + }); + + expect(store().kvStore[`payment:btctx004:0`]).toBeUndefined(); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("deduplicates with setNX lock", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + // Pre-set the lock + store().kvStore[`lock:btctx005:0`] = "1"; + + await processWatchedTx({ + txid: "btctx005", + vout: [{ scriptpubkey_address: address, value: 50000 }], + status: { confirmed: false }, + }); + + // No payment created because lock was already held + expect(store().kvStore[`payment:btctx005:0`]).toBeUndefined(); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("skips already-confirmed payment on duplicate notification", async () => { + const inv = makeInvoice({ pending: 0, received: 50000 }); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + const confirmedPayment = { + id: "pay-confirmed-001", + aid: uid, + uid, + amount: 50000, + confirmed: true, + type: PaymentType.bitcoin, + ref: "btctx006:0", + }; + seedPayment(confirmedPayment); + + await processWatchedTx({ + txid: "btctx006", + vout: [{ scriptpubkey_address: address, value: 50000 }], + status: { confirmed: true }, + }); + + // TB confirm should NOT be called again + expect(mockTbConfirm).not.toHaveBeenCalled(); + }); + + test("handles multiple outputs in same tx", async () => { + const address2 = "bcrt1qtest456"; + const inv1 = makeInvoice(); + const inv2 = makeInvoice({ id: "inv-002", hash: address2 }); + seedInvoice(inv1); + seedInvoice(inv2); + store().setStore["watching"] = new Set([address, address2]); + + await processWatchedTx({ + txid: "btctx007", + vout: [ + { scriptpubkey_address: address, value: 30000 }, + { scriptpubkey_address: address2, value: 20000 }, + ], + status: { confirmed: false }, + }); + + const pid1 = JSON.parse(store().kvStore[`payment:btctx007:0`]); + const pid2 = JSON.parse(store().kvStore[`payment:btctx007:1`]); + expect(pid1).toBeTruthy(); + expect(pid2).toBeTruthy(); + + const p1 = JSON.parse(store().kvStore[`payment:${pid1}`]); + const p2 = JSON.parse(store().kvStore[`payment:${pid2}`]); + expect(p1.amount).toBe(30000); + expect(p2.amount).toBe(20000); + }); +}); + +// ===================================================================== +// Tests: /confirm — Liquid receive via walletnotify +// ===================================================================== + +describe("confirm (liquid)", () => { + beforeEach(resetAll); + + test("ignores non-liquid types", async () => { + const req = { + body: { txid: "btctx100", wallet: "test", type: PaymentType.bitcoin }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect((res as any).getSent()).toEqual({}); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("creates pending payment for unconfirmed liquid receive", async () => { + const inv = makeInvoice({ type: PaymentType.liquid, hash: lqAddress }); + seedInvoice(inv); + + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 0, + details: [{ + address: lqAddress, + amount: 0.0005, + asset: "test-asset", + category: "receive", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx001", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect((res as any).getSent()).toEqual({}); + + // Payment should be created via credit() + const pid = JSON.parse(store().kvStore[`payment:lqtx001:0`]); + expect(pid).toBeTruthy(); + const p = JSON.parse(store().kvStore[`payment:${pid}`]); + expect(p.type).toBe(PaymentType.liquid); + expect(p.confirmed).toBe(false); + expect(mockTbCredit).toHaveBeenCalled(); + }); + + test("confirms existing pending liquid payment", async () => { + const inv = makeInvoice({ type: PaymentType.liquid, hash: lqAddress, pending: 50000 }); + seedInvoice(inv); + + const pendingPayment = { + id: "lq-pay-001", + aid: uid, + uid, + amount: 50000, + confirmed: false, + type: PaymentType.liquid, + ref: "lqtx002:0", + }; + seedPayment(pendingPayment); + + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 2, + details: [{ + address: lqAddress, + amount: 0.0005, + asset: "test-asset", + category: "receive", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx002", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + + // Payment confirmed + const p = JSON.parse(store().kvStore[`payment:${pendingPayment.id}`]); + expect(p.confirmed).toBe(true); + + // TB confirm called + expect(mockTbConfirm).toHaveBeenCalledWith(uid, 50000); + + // Invoice updated + const updatedInv = JSON.parse(store().kvStore[`invoice:${inv.id}`]); + expect(updatedInv.received).toBe(50000); + expect(updatedInv.pending).toBe(0); + }); + + test("skips send category", async () => { + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 1, + details: [{ + address: lqAddress, + amount: 0.0005, + asset: "test-asset", + category: "send", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx003", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect(mockTbCredit).not.toHaveBeenCalled(); + expect(mockTbConfirm).not.toHaveBeenCalled(); + }); + + test("skips non-btc asset", async () => { + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 0, + details: [{ + address: lqAddress, + amount: 0.001, + asset: "wrong-asset-id", + category: "receive", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx004", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("deduplicates with setNX lock", async () => { + const inv = makeInvoice({ type: PaymentType.liquid, hash: lqAddress }); + seedInvoice(inv); + + // Pre-set the lock + store().kvStore[`lock:lqtx005:0`] = "1"; + + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 0, + details: [{ + address: lqAddress, + amount: 0.0005, + asset: "test-asset", + category: "receive", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx005", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect(mockTbCredit).not.toHaveBeenCalled(); + }); + + test("skips already-confirmed payment", async () => { + const inv = makeInvoice({ type: PaymentType.liquid, hash: lqAddress }); + seedInvoice(inv); + + const confirmedPayment = { + id: "lq-pay-002", + aid: uid, + uid, + amount: 50000, + confirmed: true, + type: PaymentType.liquid, + ref: "lqtx006:0", + }; + seedPayment(confirmedPayment); + + store().rpcOverride = { + getTransaction: async () => ({ + confirmations: 3, + details: [{ + address: lqAddress, + amount: 0.0005, + asset: "test-asset", + category: "receive", + vout: 0, + }], + }), + }; + + const req = { + body: { txid: "lqtx006", wallet: "test", type: PaymentType.liquid }, + }; + const res = makeRes(); + + await routes.confirm(req as any, res as any); + expect(mockTbConfirm).not.toHaveBeenCalled(); + }); +}); + +// ===================================================================== +// Tests: catchUp — recovery after server downtime +// ===================================================================== + +describe("catchUp", () => { + beforeEach(resetAll); + + test("picks up missed unconfirmed tx for watched address", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + store().esploraOverride = { + addressTxs: { + [address]: [{ + txid: "missed-tx-001", + vout: [{ scriptpubkey_address: address, value: 40000 }], + status: { confirmed: false }, + }], + }, + }; + + await catchUp(); + + const pid = JSON.parse(store().kvStore[`payment:missed-tx-001:0`]); + expect(pid).toBeTruthy(); + const p = JSON.parse(store().kvStore[`payment:${pid}`]); + expect(p.amount).toBe(40000); + expect(p.confirmed).toBe(false); + expect(mockTbCredit).toHaveBeenCalled(); + + delete store().esploraOverride; + }); + + test("picks up and confirms already-confirmed tx for watched address", async () => { + const inv = makeInvoice(); + seedInvoice(inv); + store().setStore["watching"] = new Set([address]); + + store().esploraOverride = { + addressTxs: { + [address]: [{ + txid: "missed-tx-002", + vout: [{ scriptpubkey_address: address, value: 60000 }], + status: { confirmed: true }, + }], + }, + }; + + await catchUp(); + + const pid = JSON.parse(store().kvStore[`payment:missed-tx-002:0`]); + const p = JSON.parse(store().kvStore[`payment:${pid}`]); + expect(p.confirmed).toBe(true); + expect(mockTbCredit).toHaveBeenCalled(); + expect(mockTbConfirm).toHaveBeenCalled(); + + delete store().esploraOverride; + }); + + test("confirms inflight non-custodial payment", async () => { + const aid = "noncustodial-001"; + + const inflightPayment = { + id: "inflight-pay-001", + aid, + uid, + hash: "btctx-inflight", + amount: 25000, + confirmed: false, + type: PaymentType.bitcoin, + }; + store().kvStore[`payment:${inflightPayment.id}`] = JSON.stringify(inflightPayment); + store().setStore[`inflight:${aid}`] = new Set([inflightPayment.id]); + + store().esploraOverride = { + txStatus: { "btctx-inflight": { confirmed: true } }, + }; + + await catchUp(); + + const p = JSON.parse(store().kvStore[`payment:${inflightPayment.id}`]); + expect(p.confirmed).toBe(true); + + // Removed from inflight set + expect(store().setStore[`inflight:${aid}`].has(inflightPayment.id)).toBe(false); + + delete store().esploraOverride; + }); + + test("cleans up already-confirmed inflight entries", async () => { + const aid = "noncustodial-002"; + + const confirmedPayment = { + id: "inflight-pay-002", + aid, + uid, + hash: "btctx-already-done", + amount: 15000, + confirmed: true, + type: PaymentType.bitcoin, + }; + store().kvStore[`payment:${confirmedPayment.id}`] = JSON.stringify(confirmedPayment); + store().setStore[`inflight:${aid}`] = new Set([confirmedPayment.id]); + + await catchUp(); + + // Should be removed from inflight without calling getTxStatus + expect(store().setStore[`inflight:${aid}`].has(confirmedPayment.id)).toBe(false); + // No TB calls needed + expect(mockTbConfirm).not.toHaveBeenCalled(); + }); + + test("does nothing with empty watching set and no inflight", async () => { + await catchUp(); + expect(mockTbCredit).not.toHaveBeenCalled(); + expect(mockTbConfirm).not.toHaveBeenCalled(); + }); +}); diff --git a/test/preload.ts b/test/preload.ts index 151c3c64..9541dacd 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -124,8 +124,17 @@ mock.module("$lib/db", () => { zAdd: async () => 1, zCard: async () => 0, zRemRangeByRank: async () => 0, - keys: async () => [], - type: async () => "none", + keys: async (pattern: string) => { + const ss = globalThis.__testStore.setStore; + const prefix = pattern.replace("*", ""); + return Object.keys(ss).filter((k) => k.startsWith(prefix)); + }, + type: async (k: string) => { + if (globalThis.__testStore.setStore[k]) return "set"; + if (globalThis.__testStore.listStore[k]) return "list"; + if (globalThis.__testStore.kvStore[k] !== undefined) return "string"; + return "none"; + }, }, g, s, gf, ga: async () => null, @@ -181,7 +190,29 @@ mock.module("$lib/ln", () => ({ 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/sockets", () => ({ emit: mock(() => {}), sendHeartbeat: () => {} })); +mock.module("$lib/esplora", () => ({ + btcNetwork: { bech32: "bcrt", pubKeyHash: 0x6f, scriptHash: 0xc4, wif: 0xef }, + hdVersions: { private: 0x04358394, public: 0x043587cf }, + getAddressTxs: mock(async (address: string) => { + const txs = globalThis.__testStore.esploraOverride?.addressTxs?.[address]; + return txs || []; + }), + getTxStatus: mock(async (txid: string) => { + const status = globalThis.__testStore.esploraOverride?.txStatus?.[txid]; + return status || { confirmed: false }; + }), + getAddressUtxos: mock(async () => []), + getUtxos: mock(async () => []), + getTxHex: mock(async () => ""), + getTx: mock(async () => ({})), + broadcastTx: mock(async () => ({})), + getFeeEstimates: mock(async () => ({})), + deriveAddress: mock(() => ({ address: "bcrt1qmock" })), + deriveAddresses: mock(() => []), + parseDescriptor: mock(() => ({})), + findLastUsedIndex: mock(async () => -1), +})); mock.module("$lib/square", () => ({ squarePayment: () => {} })); mock.module("$lib/nostr", () => ({ handleZap: async () => {}, publish: async () => {}, @@ -207,7 +238,15 @@ mock.module("$lib/invoices", () => ({ 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 () => ({}) }), + default: () => new Proxy({}, { + get: (_target, prop) => async (...args: any[]) => { + const override = globalThis.__testStore.rpcOverride; + if (override && typeof override[prop] === "function") { + return override[prop](...args); + } + return {}; + }, + }), })); mock.module("$lib/utils", () => { -- cgit v1.2.3