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 | |
| parent | 542cf1d084d189bcdf127c9c2359cced5267295e (diff) | |
| download | coinos-server-b2b9fe837db7308285909ec1b374546194405d00.tar.gz coinos-server-b2b9fe837db7308285909ec1b374546194405d00.zip | |
bolt12 autowithdraw
| -rw-r--r-- | bun.lock | 4 | ||||
| -rw-r--r-- | lib/payments.ts | 35 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | test/forward.test.ts | 150 | ||||
| -rw-r--r-- | test/integration/bolt12.test.ts | 307 | ||||
| -rw-r--r-- | test/preload.ts | 8 |
6 files changed, 494 insertions, 12 deletions
@@ -6,7 +6,7 @@ "name": "coinos", "dependencies": { "@arkade-os/sdk": "^0.3.12", - "@asoltys/clightning-client": "^0.1.10", + "@asoltys/clightning-client": "^0.2.0", "@aws-sdk/client-ses": "^3.984.0", "@cashu/cashu-ts": "^2.9.0", "@coinos/rpc": "^0.0.6", @@ -84,7 +84,7 @@ "@arkade-os/sdk": ["@arkade-os/[email protected]", "", { "dependencies": { "@marcbachmann/cel-js": "7.0.0", "@noble/curves": "2.0.0", "@noble/secp256k1": "3.0.0", "@scure/base": "2.0.0", "@scure/btc-signer": "2.0.1", "bip68": "1.0.4" } }, "sha512-/zE8XiDo6yOCaspMcO3Gua/gLGYmYLRFbqaD1ORkaGI7hH/Gq+m0tkIwArVlwtK50loHK4jvS8clovcaV9CARg=="], - "@asoltys/clightning-client": ["@asoltys/[email protected]", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-Qg0VEvJN1wW4XeW4XnsQH+Vp63JHcVb2YccQEg+wy33BViu2AWFhZzvHhlbucriIOVLmIf7Wc1Me54ivs1sLHA=="], + "@asoltys/clightning-client": ["@asoltys/[email protected]", "", { "dependencies": { "debug": "^4.1.1" } }, "sha512-CYbkyGoS0X1ha/YcFk3sTiI7aRHpPMnib/26WODucfJNrTbWl/9FJ4bpod9a6t9Jbv5OXCa0dYspcPK3kZ71pg=="], "@aws-crypto/sha256-browser": ["@aws-crypto/[email protected]", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], diff --git a/lib/payments.ts b/lib/payments.ts index cc1391fe..3b6a4a86 100644 --- a/lib/payments.ts +++ b/lib/payments.ts @@ -521,13 +521,30 @@ const pay = async ({ aid = undefined, amount, to, user }) => { }); } - const fee = Math.max(5, Math.round(amount * 0.02)); + let fee; + const ourfee = Math.round(amount * (config.fee.lightning || 0)); if (lnurl) { - amount -= fee; + fee = Math.max(5, Math.round(amount * 0.02)); + amount -= fee + ourfee; const { callback } = (await got(lnurl).json()) as any; ({ pr } = (await got(`${callback}?amount=${amount * 1000}`).json()) as any); + } else if (to.startsWith("lno")) { + const { id: source } = await ln.getinfo(); + const { offer_issuer_id } = await ln.decode(to); + const { routes } = await ln.getroutes( + source, offer_issuer_id, amount * 1000, + ["auto.localchans", "auto.sourcefree"], + Math.round(amount * 0.02) * 1000, 6, + ); + fee = routes.length + ? Math.max(5, Math.round((routes[0].path[0].amount_msat - routes[0].amount_msat) / 1000)) + : 0; + amount -= fee + ourfee; + const { invoice } = await ln.fetchinvoice(to, amount * 1000); + pr = invoice; } else if (to.startsWith("ln")) { - amount -= fee; + fee = Math.max(5, Math.round(amount * 0.02)); + amount -= fee + ourfee; pr = to; } @@ -763,7 +780,7 @@ export const sendKeysend = async ({ destination: pubkey, amount_msat: amount * 1000, maxfee: fee * 1000, - retry_for: 10, + retry_for: 20, extratlvs, }); } catch (e) { @@ -796,9 +813,9 @@ export const sendLightning = async ({ payee = invoice_node_id; } - let minfee = 2; const { channels } = await ln.listpeerchannels(); - if (channels.some((c) => c.peer_id === payee)) minfee = 0; + const isDirect = channels.some((c) => c.peer_id === payee); + const minfee = isDirect ? 0 : Math.max(Math.round(amount * 0.005), 5); fee = Math.max(Number.parseInt(fee || 0), minfee); if (fee < 0) fail("Fee cannot be negative"); @@ -829,7 +846,7 @@ export const sendLightning = async ({ // bolt11: pr.replace(/\s/g, "").toLowerCase(), amount_msat: amount_msat ? undefined : amount * 1000, maxfee: fee * 1000, - retry_for: 10, + retry_for: 20, }); try { @@ -1396,8 +1413,8 @@ const finalize = async (r, p) => { nwcNotify(p); const maxfee = p.fee; - const { amount_msat } = await ln.decode(p.hash); - p.fee = Math.round((r.amount_sent_msat - amount_msat) / 1000); + const { amount_msat, invoice_amount_msat } = await ln.decode(p.hash); + p.fee = Math.round((r.amount_sent_msat - (amount_msat || invoice_amount_msat)) / 1000); p.ref = preimage; if (!(await g(`payment:${p.id}`)).ref) { diff --git a/package.json b/package.json index ea8177f1..b960b7ff 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@arkade-os/sdk": "^0.3.12", - "@asoltys/clightning-client": "^0.1.10", + "@asoltys/clightning-client": "^0.2.0", "@aws-sdk/client-ses": "^3.984.0", "@cashu/cashu-ts": "^2.9.0", "@coinos/rpc": "^0.0.6", 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 |
