aboutsummaryrefslogtreecommitdiffstats
path: root/test/forward.test.ts
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-02-13 15:33:37 -0800
committerAdam Soltys <[email protected]>2026-02-13 15:33:37 -0800
commitb2b9fe837db7308285909ec1b374546194405d00 (patch)
treebe9dbc10c7f0699b40ac2ed2534869e6b9e772e7 /test/forward.test.ts
parent542cf1d084d189bcdf127c9c2359cced5267295e (diff)
downloadcoinos-server-b2b9fe837db7308285909ec1b374546194405d00.tar.gz
coinos-server-b2b9fe837db7308285909ec1b374546194405d00.zip
bolt12 autowithdraw
Diffstat (limited to 'test/forward.test.ts')
-rw-r--r--test/forward.test.ts150
1 files changed, 150 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();
+ });
+});