From 4e9c0c5198c7884cfd97cb4495f6156632e3124a Mon Sep 17 00:00:00 2001 From: Adam Soltys Date: Thu, 5 Feb 2026 16:44:47 -0800 Subject: payment forwarding --- test/forward.test.ts | 369 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 369 insertions(+) create mode 100644 test/forward.test.ts (limited to 'test/forward.test.ts') 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); + }); +}); -- cgit v1.2.3