diff options
| author | Adam Soltys <[email protected]> | 2026-02-13 15:33:37 -0800 |
|---|---|---|
| committer | Adam Soltys <[email protected]> | 2026-02-13 15:33:37 -0800 |
| commit | b2b9fe837db7308285909ec1b374546194405d00 (patch) | |
| tree | be9dbc10c7f0699b40ac2ed2534869e6b9e772e7 /test | |
| parent | 542cf1d084d189bcdf127c9c2359cced5267295e (diff) | |
| download | coinos-server-b2b9fe837db7308285909ec1b374546194405d00.tar.gz coinos-server-b2b9fe837db7308285909ec1b374546194405d00.zip | |
bolt12 autowithdraw
Diffstat (limited to 'test')
| -rw-r--r-- | test/forward.test.ts | 150 | ||||
| -rw-r--r-- | test/integration/bolt12.test.ts | 307 | ||||
| -rw-r--r-- | test/preload.ts | 8 |
3 files changed, 465 insertions, 0 deletions
diff --git a/test/forward.test.ts b/test/forward.test.ts index 1e0cd566..7f548b67 100644 --- a/test/forward.test.ts +++ b/test/forward.test.ts @@ -11,6 +11,9 @@ const mockTbCredit = tbCredit as any; const mockWarn = warn as any; const mockCallWebhook = callWebhook as any; const mockLnXpay = ln.xpay as any; +const mockLnFetchinvoice = ln.fetchinvoice as any; +const mockLnDecode = ln.decode as any; +const mockLnGetroutes = ln.getroutes as any; import { completePayment } from "$lib/payments"; import routes from "$routes/payments"; @@ -77,12 +80,23 @@ const resetAll = () => { mockWarn.mockClear(); mockCallWebhook.mockClear(); mockLnXpay.mockClear(); + mockLnFetchinvoice.mockClear(); + mockLnDecode.mockClear(); + mockLnGetroutes.mockClear(); + + mockLnGetroutes.mockImplementation(async () => ({ routes: [] })); mockLnXpay.mockImplementation(async () => ({ amount_sent_msat: 1_000_000, payment_preimage: "preimage-abc123", })); + mockLnDecode.mockImplementation(async () => ({ + type: "bolt11", + amount_msat: 1_000_000, + payee: "test-payee", + })); + // Seed rates and limits s.kvStore["rates"] = JSON.stringify({ USD: 50000, CAD: 68000 }); s.kvStore["limit"] = JSON.stringify(100_000_000); @@ -361,3 +375,139 @@ describe("arkSync", () => { expect(updated.confirmed).toBe(true); }); }); + +// ===================================================================== +// Tests: BOLT12 offer autowithdraw +// ===================================================================== + +const bolt12Offer = "lno1qgsqvgnwgcg35z6ee2h3yczraddm72xrfua9uve2rlrm9deu7xyfzrcgqyqs"; +const bolt12Invoice = "lni1qqg86n2pddz86n2ptszien5gqp3zzqmpkv93rjd"; + +describe("BOLT12 autowithdraw", () => { + beforeEach(resetAll); + + test("fetches invoice from BOLT12 offer and sends via Lightning", async () => { + mockLnFetchinvoice.mockImplementation(async () => ({ + invoice: bolt12Invoice, + })); + + mockLnDecode.mockImplementation(async (s: string) => + s.startsWith("lno") + ? { type: "bolt12 offer", offer_issuer_id: "bolt12-payee" } + : { type: "bolt12 invoice", invoice_amount_msat: 980_000, invoice_node_id: "bolt12-payee" }, + ); + + const user = makeUser({ + autowithdraw: true, + threshold: 500, + reserve: 0, + destination: bolt12Offer, + }); + seedUser(user); + + const inv = makeInvoice({ type: "lightning", amount: 1000 }); + const p = { amount: 1000, confirmed: true, type: "lightning", uid: custodialUid, created: Date.now() }; + + await completePayment(inv, p, user); + + // decode called first with the offer to get node id + expect(mockLnDecode).toHaveBeenCalled(); + expect(mockLnDecode.mock.calls[0][0]).toBe(bolt12Offer); + + // getroutes called to estimate routing fee + expect(mockLnGetroutes).toHaveBeenCalled(); + + // fetchinvoice called with the offer and amount in msats + expect(mockLnFetchinvoice).toHaveBeenCalled(); + const [offer, amountMsat] = mockLnFetchinvoice.mock.calls[0]; + expect(offer).toBe(bolt12Offer); + expect(amountMsat).toBeGreaterThan(0); + + // xpay called with the fetched bolt12 invoice + expect(mockLnXpay).toHaveBeenCalled(); + }); + + test("forwards BOLT12 offer via invoice forward field", async () => { + mockLnFetchinvoice.mockImplementation(async () => ({ + invoice: bolt12Invoice, + })); + + mockLnDecode.mockImplementation(async (s: string) => + s.startsWith("lno") + ? { type: "bolt12 offer", offer_issuer_id: "bolt12-payee" } + : { type: "bolt12 invoice", invoice_amount_msat: 980_000, invoice_node_id: "bolt12-payee" }, + ); + + const user = makeUser(); + const inv = makeInvoice({ forward: bolt12Offer }); + const p = { amount: 1000, confirmed: true, type: "lightning", uid: custodialUid, created: Date.now() }; + + await completePayment(inv, p, user); + + expect(mockLnFetchinvoice).toHaveBeenCalled(); + expect(mockLnXpay).toHaveBeenCalled(); + expect(inv.forwarded).toBe(true); + }); + + test("regular ln invoice is NOT passed through fetchinvoice", async () => { + const user = makeUser({ + autowithdraw: true, + threshold: 500, + reserve: 0, + destination: bolt11, + }); + seedUser(user); + + const inv = makeInvoice({ type: "lightning", amount: 1000 }); + const p = { amount: 1000, confirmed: true, type: "lightning", uid: custodialUid, created: Date.now() }; + + await completePayment(inv, p, user); + + expect(mockLnFetchinvoice).not.toHaveBeenCalled(); + expect(mockLnXpay).toHaveBeenCalled(); + }); + + test("accounts for routing fee from getroutes on multi-hop", async () => { + // Mock balance is 10_000_000; autowithdraw sends balance - reserve + const balance = 10_000_000; + const routingFee = 50; + + mockLnDecode.mockImplementation(async (s: string) => + s.startsWith("lno") + ? { type: "bolt12 offer", offer_issuer_id: "bolt12-payee" } + : { type: "bolt12 invoice", invoice_amount_msat: null, invoice_node_id: "bolt12-payee" }, + ); + + // Simulate a route with 50 sat routing fee (getroutes response format) + mockLnGetroutes.mockImplementation(async () => ({ + routes: [{ + amount_msat: balance * 1000, + path: [{ amount_msat: (balance + routingFee) * 1000 }], + }], + })); + + mockLnFetchinvoice.mockImplementation(async () => ({ + invoice: bolt12Invoice, + })); + + const user = makeUser({ + autowithdraw: true, + threshold: 500, + reserve: 0, + destination: bolt12Offer, + }); + seedUser(user); + + const inv = makeInvoice({ type: "lightning", amount: 1000 }); + const p = { amount: 1000, confirmed: true, type: "lightning", uid: custodialUid, created: Date.now() }; + + await completePayment(inv, p, user); + + // fetchinvoice amount should exclude routing fee and ourfee + const [, amountMsat] = mockLnFetchinvoice.mock.calls[0]; + const ourfee = Math.round(balance * 0.001); + expect(amountMsat).toBe((balance - routingFee - ourfee) * 1000); + + expect(mockLnXpay).toHaveBeenCalled(); + }); +}); diff --git a/test/integration/bolt12.test.ts b/test/integration/bolt12.test.ts new file mode 100644 index 00000000..06929c6c --- /dev/null +++ b/test/integration/bolt12.test.ts @@ -0,0 +1,307 @@ +process.env.INTEGRATION = "1"; + +import { describe, test, expect, beforeAll } from "bun:test"; + +// ===================================================================== +// Helpers +// ===================================================================== + +const APP = "http://localhost:3119"; + +const exec = async (cmd: string): Promise<string> => { + const proc = Bun.spawn(["bash", "-c", cmd], { + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const code = await proc.exited; + if (code !== 0) { + const stderr = await new Response(proc.stderr).text(); + throw new Error(`exec failed (${code}): ${stderr || stdout}`); + } + return stdout.trim(); +}; + +const clExec = async (container: string, ...args: string[]): Promise<any> => { + const escaped = args.map((a) => `'${a.replace(/'/g, "'\\''")}'`).join(" "); + const result = await exec( + `docker exec ${container} lightning-cli ${escaped}`, + ); + try { + return JSON.parse(result); + } catch { + return result; + } +}; + +const mine = async (n: number) => { + const addr = await exec( + "docker exec bc bitcoin-cli -regtest -rpcwallet=coinos getnewaddress", + ); + await exec( + `docker exec bc bitcoin-cli -regtest generatetoaddress ${n} ${addr}`, + ); +}; + +const waitFor = async <T>( + fn: () => Promise<T>, + timeout = 30000, +): Promise<T> => { + const start = Date.now(); + let lastError: any; + while (Date.now() - start < timeout) { + try { + const result = await fn(); + if (result) return result; + } catch (e) { + lastError = e; + } + await Bun.sleep(500); + } + throw new Error(`waitFor timed out: ${lastError?.message || "no result"}`); +}; + +const register = async (username: string, password: string) => { + const res = await fetch(`${APP}/register`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ user: { username, password } }), + }); + if (!res.ok) throw new Error(`register failed: ${await res.text()}`); + return res.json(); +}; + +const api = async (path: string, token: string, opts: any = {}) => { + const res = await fetch(`${APP}${path}`, { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + ...opts.headers, + }, + ...opts, + }); + return res.json(); +}; + +const updateUser = (token: string, settings: any) => + api("/user", token, { + method: "POST", + body: JSON.stringify(settings), + }); + +const getMe = (token: string) => api("/me", token); + +const createInvoice = (token: string, invoice: any) => + api("/invoice", token, { + method: "POST", + body: JSON.stringify({ invoice }), + }); + +const sendInternal = (token: string, username: string, amount: number) => + api("/send", token, { + method: "POST", + body: JSON.stringify({ username, amount }), + }); + +const getPayments = (token: string) => api("/payments", token); + +// ===================================================================== +// Test state +// ===================================================================== + +let funderToken: string; +let withdrawerToken: string; +let funderUser: any; +let withdrawerUser: any; + +// ===================================================================== +// Setup: register users, fund the funder via lightning +// ===================================================================== + +const ts = Date.now(); +const funderName = `intfunder${ts}`; +const withdrawerName = `intwdraw${ts}`; + +beforeAll(async () => { + // Verify containers are running + try { + await exec("docker exec cl lightning-cli getinfo"); + await exec("docker exec clb lightning-cli getinfo"); + await exec("docker exec clc lightning-cli getinfo"); + } catch { + throw new Error( + "Lightning containers not running. Start with: docker compose up -d cl clb clc", + ); + } + + // Register test users + const funder = await register(funderName, "testpass123"); + funderToken = funder.token; + funderUser = funder; + + const withdrawer = await register(withdrawerName, "testpass123"); + withdrawerToken = withdrawer.token; + withdrawerUser = withdrawer; + + // Fund the funder: generate a lightning invoice and pay from clb + const inv = await createInvoice(funderToken, { + amount: 500_000, + type: "lightning", + }); + + await clExec("clb", "pay", inv.hash); + + // Wait for the payment to be credited + await waitFor(async () => { + const me = await getMe(funderToken); + return me.balance >= 500_000 ? me : null; + }); +}, 60000); + +// ===================================================================== +// Tests +// ===================================================================== + +describe("BOLT12 autowithdraw", () => { + test("autowithdraw to direct peer (clb)", async () => { + // Create a BOLT12 offer on clb + const offer = await clExec("clb", "offer", "any", "integration-test"); + expect(offer.bolt12).toBeTruthy(); + + // Get clb's initial balance + const clbFundsBefore = await clExec("clb", "listfunds"); + const clbBalanceBefore = clbFundsBefore.channels.reduce( + (s: number, c: any) => s + c.our_amount_msat, + 0, + ); + + // Configure withdrawer with autowithdraw to clb's offer + await updateUser(withdrawerToken, { + autowithdraw: "lightning", + threshold: 1000, + reserve: 0, + destination: offer.bolt12, + }); + + // Send 100k sats from funder to withdrawer (triggers autowithdraw) + const sendAmount = 100_000; + const sendResult = await sendInternal( + funderToken, + withdrawerName, + sendAmount, + ); + expect(sendResult.amount).toBe(-sendAmount); + + // Wait for autowithdraw to complete — withdrawer balance should drop near 0 + const finalMe = await waitFor(async () => { + const me = await getMe(withdrawerToken); + return me.balance < 1000 ? me : null; + }, 20000); + + expect(finalMe.balance).toBeLessThan(1000); + + // clb should have received the payment + const clbFundsAfter = await clExec("clb", "listfunds"); + const clbBalanceAfter = clbFundsAfter.channels.reduce( + (s: number, c: any) => s + c.our_amount_msat, + 0, + ); + expect(clbBalanceAfter).toBeGreaterThan(clbBalanceBefore); + + // Check payment records on withdrawer + const payments = await getPayments(withdrawerToken); + const withdrawal = payments.payments?.find( + (p: any) => p.amount < 0 && p.type === "lightning", + ); + expect(withdrawal).toBeTruthy(); + expect(withdrawal.fee).toBeGreaterThanOrEqual(0); + }, 30000); + + test("autowithdraw routes through clb to clc (multi-hop)", async () => { + // Create a BOLT12 offer on clc + const offer = await clExec("clc", "offer", "any", "multihop-test"); + expect(offer.bolt12).toBeTruthy(); + + // Configure withdrawer with autowithdraw to clc's offer + await updateUser(withdrawerToken, { + autowithdraw: "lightning", + threshold: 1000, + reserve: 0, + destination: offer.bolt12, + }); + + // Get clc's balance before + const clcFundsBefore = await clExec("clc", "listfunds"); + const clcBalanceBefore = clcFundsBefore.channels.reduce( + (s: number, c: any) => s + c.our_amount_msat, + 0, + ); + + // Send 50k sats from funder to withdrawer + const sendAmount = 50_000; + await sendInternal(funderToken, withdrawerName, sendAmount); + + // Wait for autowithdraw to complete + const finalMe = await waitFor(async () => { + const me = await getMe(withdrawerToken); + return me.balance < 1000 ? me : null; + }, 30000); + + expect(finalMe.balance).toBeLessThan(1000); + + // clc should have received the payment (routed cl→clb→clc) + const clcFundsAfter = await clExec("clc", "listfunds"); + const clcBalanceAfter = clcFundsAfter.channels.reduce( + (s: number, c: any) => s + c.our_amount_msat, + 0, + ); + expect(clcBalanceAfter).toBeGreaterThan(clcBalanceBefore); + + // Check payment record has routing fee + const payments = await getPayments(withdrawerToken); + const withdrawal = payments.payments?.find( + (p: any) => p.amount < 0 && p.type === "lightning", + ); + expect(withdrawal).toBeTruthy(); + // Multi-hop should have non-zero routing fee + expect(withdrawal.fee).toBeGreaterThan(0); + }, 45000); + + test("finalize() refunds unused routing budget", async () => { + // Create offer on clb (direct peer — minimal actual routing cost) + const offer = await clExec("clb", "offer", "any", "refund-test"); + + await updateUser(withdrawerToken, { + autowithdraw: "lightning", + threshold: 1000, + reserve: 0, + destination: offer.bolt12, + }); + + // Send 20k sats + const sendAmount = 20_000; + await sendInternal(funderToken, withdrawerName, sendAmount); + + // Wait for autowithdraw + await waitFor(async () => { + const me = await getMe(withdrawerToken); + return me.balance < 1000 ? me : null; + }, 20000); + + // Get the withdrawal payment record + const payments = await getPayments(withdrawerToken); + const withdrawal = payments.payments?.find( + (p: any) => p.amount < 0 && p.type === "lightning", + ); + expect(withdrawal).toBeTruthy(); + + // For a direct peer, actual routing fee should be 0 or very small + // The pre-allocated budget (2% or getroutes estimate) should have been refunded + // via finalize(), so p.fee reflects the actual cost, not the budget + expect(withdrawal.fee).toBeLessThan(Math.abs(withdrawal.amount) * 0.02); + + // Verify the refund happened — user should have gotten back the difference + // between the pre-allocated fee budget and the actual fee + expect(withdrawal.ref).toBeTruthy(); // preimage set by finalize() + }, 30000); +}); diff --git a/test/preload.ts b/test/preload.ts index 9541dacd..a3d3cbb9 100644 --- a/test/preload.ts +++ b/test/preload.ts @@ -3,6 +3,11 @@ 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 +if (process.env.INTEGRATION) { + // Skip all mocking — use real services + globalThis.__testStore = { kvStore: {}, listStore: {}, setStore: {} }; +} else { + let kvStore: Record<string, string> = {}; let listStore: Record<string, string[]> = {}; let setStore: Record<string, Set<string>> = {}; @@ -183,6 +188,7 @@ mock.module("$lib/ln", () => ({ listfunds: mock(async () => ({ channels: [] })), keysend: mock(async () => ({})), fetchinvoice: mock(async () => ({})), + getroutes: mock(async () => ({ routes: [] })), sendinvoice: mock(async () => ({})), }, })); @@ -303,3 +309,5 @@ mock.module("$lib/utils", () => { time: () => ({ start: () => {}, end: () => {} }), }; }); + +} // end INTEGRATION skip |
