diff options
| author | Adam Soltys <[email protected]> | 2026-02-13 14:14:02 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-13 14:14:02 -0800 |
| commit | 542cf1d084d189bcdf127c9c2359cced5267295e (patch) | |
| tree | d9774ea67ae174b83a6e654eced1c10c9e3559a7 /test | |
| parent | 6de76d3a76f8b91b0ffa88bf175bb821234e84bd (diff) | |
| download | coinos-server-542cf1d084d189bcdf127c9c2359cced5267295e.tar.gz coinos-server-542cf1d084d189bcdf127c9c2359cced5267295e.zip | |
fix onchain confirmations
Diffstat (limited to 'test')
| -rw-r--r-- | test/onchain.test.ts | 649 | ||||
| -rw-r--r-- | test/preload.ts | 47 |
2 files changed, 692 insertions, 4 deletions
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", () => { |
