aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-13 14:14:02 -0800
committerAdam Soltys <[email protected]>2026-02-13 14:14:02 -0800
commit542cf1d084d189bcdf127c9c2359cced5267295e (patch)
treed9774ea67ae174b83a6e654eced1c10c9e3559a7
parent6de76d3a76f8b91b0ffa88bf175bb821234e84bd (diff)
downloadcoinos-server-542cf1d084d189bcdf127c9c2359cced5267295e.tar.gz
coinos-server-542cf1d084d189bcdf127c9c2359cced5267295e.zip
fix onchain confirmations
-rw-r--r--index.ts3
-rw-r--r--lib/payments.ts38
-rw-r--r--routes/payments.ts37
-rw-r--r--test/onchain.test.ts649
-rw-r--r--test/preload.ts47
5 files changed, 717 insertions, 57 deletions
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", () => {