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/integration | |
| parent | 542cf1d084d189bcdf127c9c2359cced5267295e (diff) | |
| download | coinos-server-b2b9fe837db7308285909ec1b374546194405d00.tar.gz coinos-server-b2b9fe837db7308285909ec1b374546194405d00.zip | |
bolt12 autowithdraw
Diffstat (limited to 'test/integration')
| -rw-r--r-- | test/integration/bolt12.test.ts | 307 |
1 files changed, 307 insertions, 0 deletions
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); +}); |
