From b0ba584a9d627e95e9da76d890a84a6078eb4bbe Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 26 Mar 2026 16:27:56 +0300 Subject: [PATCH 1/8] feat(e2e): add NWC contract tests for get_*, hold, multi, and notifications --- e2e/nwc-cancel-hold-invoice.test.ts | 59 ++++++++++++ e2e/nwc-get-balance.test.ts | 30 ++++++ e2e/nwc-get-budget.test.ts | 42 +++++++++ e2e/nwc-get-info.test.ts | 31 +++++++ e2e/nwc-make-hold-invoice.test.ts | 56 +++++++++++ e2e/nwc-multi-pay-invoice.test.ts | 72 +++++++++++++++ e2e/nwc-multi-pay-keysend.test.ts | 66 +++++++++++++ ...nwc-notifications-payment-received.test.ts | 92 +++++++++++++++++++ e2e/nwc-settle-hold-invoice.test.ts | 70 ++++++++++++++ 9 files changed, 518 insertions(+) create mode 100644 e2e/nwc-cancel-hold-invoice.test.ts create mode 100644 e2e/nwc-get-balance.test.ts create mode 100644 e2e/nwc-get-budget.test.ts create mode 100644 e2e/nwc-get-info.test.ts create mode 100644 e2e/nwc-make-hold-invoice.test.ts create mode 100644 e2e/nwc-multi-pay-invoice.test.ts create mode 100644 e2e/nwc-multi-pay-keysend.test.ts create mode 100644 e2e/nwc-notifications-payment-received.test.ts create mode 100644 e2e/nwc-settle-hold-invoice.test.ts diff --git a/e2e/nwc-cancel-hold-invoice.test.ts b/e2e/nwc-cancel-hold-invoice.test.ts new file mode 100644 index 0000000..18b3768 --- /dev/null +++ b/e2e/nwc-cancel-hold-invoice.test.ts @@ -0,0 +1,59 @@ +import { createHash, randomBytes } from "crypto"; +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for cancel_hold_invoice using the NWC faucet. + * Requires network access. + */ +describe("NWC cancel_hold_invoice", () => { + const AMOUNT_MSATS = 100_000; // 100 sats + const BALANCE_SATS = 10_000; + + test( + "cancels hold invoice when supported, otherwise returns NOT_IMPLEMENTED", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + const preimageHex = randomBytes(32).toString("hex"); + const paymentHash = createHash("sha256") + .update(Buffer.from(preimageHex, "hex")) + .digest("hex"); + + try { + const infoResult = await nwc.getInfo(); + const hasHoldMethods = + infoResult.methods.includes("make_hold_invoice") && + infoResult.methods.includes("cancel_hold_invoice"); + + if (!hasHoldMethods) { + await expect( + nwc.cancelHoldInvoice({ payment_hash: paymentHash }), + ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); + return; + } + + const holdInvoiceResult = await nwc.makeHoldInvoice({ + amount: AMOUNT_MSATS, + payment_hash: paymentHash, + description: "E2E cancel_hold_invoice test", + }); + expect(holdInvoiceResult.invoice).toMatch(/^ln/); + + const cancelResult = await nwc.cancelHoldInvoice({ + payment_hash: holdInvoiceResult.payment_hash, + }); + expect(cancelResult).toEqual({}); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect(["NOT_IMPLEMENTED", "NOT_FOUND"]).toContain( + (error as Nip47WalletError).code, + ); + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); diff --git a/e2e/nwc-get-balance.test.ts b/e2e/nwc-get-balance.test.ts new file mode 100644 index 0000000..a12122c --- /dev/null +++ b/e2e/nwc-get-balance.test.ts @@ -0,0 +1,30 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for get_balance using the NWC faucet. + * Requires network access. + */ +describe("NWC get_balance", () => { + const BALANCE_SATS = 10_000; + const BALANCE_MSATS = BALANCE_SATS * 1000; + + test( + "returns wallet balance in msats", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + + try { + const balanceResult = await nwc.getBalance(); + + expect(balanceResult.balance).toBeDefined(); + expect(typeof balanceResult.balance).toBe("number"); + expect(balanceResult.balance).toBe(BALANCE_MSATS); + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); diff --git a/e2e/nwc-get-budget.test.ts b/e2e/nwc-get-budget.test.ts new file mode 100644 index 0000000..40853b3 --- /dev/null +++ b/e2e/nwc-get-budget.test.ts @@ -0,0 +1,42 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for get_budget using the NWC faucet. + * Requires network access. + */ +describe("NWC get_budget", () => { + const BALANCE_SATS = 10_000; + + test( + "returns budget details or reports unsupported method", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + + try { + const budgetResult = await nwc.getBudget(); + const hasBudgetFields = + "used_budget" in budgetResult && + "total_budget" in budgetResult && + "renewal_period" in budgetResult; + + if (hasBudgetFields) { + expect(typeof budgetResult.used_budget).toBe("number"); + expect(typeof budgetResult.total_budget).toBe("number"); + expect(typeof budgetResult.renewal_period).toBe("string"); + return; + } + + expect(budgetResult).toEqual({}); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); diff --git a/e2e/nwc-get-info.test.ts b/e2e/nwc-get-info.test.ts new file mode 100644 index 0000000..26cc7d5 --- /dev/null +++ b/e2e/nwc-get-info.test.ts @@ -0,0 +1,31 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for get_info using the NWC faucet. + * Requires network access. + */ +describe("NWC get_info", () => { + const BALANCE_SATS = 10_000; + + test( + "returns wallet pubkey and supported methods", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + + try { + const infoResult = await nwc.getInfo(); + + expect(infoResult.pubkey).toBeDefined(); + expect(typeof infoResult.pubkey).toBe("string"); + + expect(Array.isArray(infoResult.methods)).toBe(true); + expect(infoResult.methods.length).toBeGreaterThan(0); + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); diff --git a/e2e/nwc-make-hold-invoice.test.ts b/e2e/nwc-make-hold-invoice.test.ts new file mode 100644 index 0000000..4cea6cb --- /dev/null +++ b/e2e/nwc-make-hold-invoice.test.ts @@ -0,0 +1,56 @@ +import { createHash, randomBytes } from "crypto"; +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for make_hold_invoice using the NWC faucet. + * Requires network access. + */ +describe("NWC make_hold_invoice", () => { + const AMOUNT_MSATS = 100_000; // 100 sats + const BALANCE_SATS = 10_000; + + test( + "creates hold invoice when supported, otherwise returns NOT_IMPLEMENTED", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + const preimageHex = randomBytes(32).toString("hex"); + const paymentHash = createHash("sha256") + .update(Buffer.from(preimageHex, "hex")) + .digest("hex"); + + try { + const infoResult = await nwc.getInfo(); + + if (!infoResult.methods.includes("make_hold_invoice")) { + await expect( + nwc.makeHoldInvoice({ + amount: AMOUNT_MSATS, + payment_hash: paymentHash, + description: "E2E make_hold_invoice unsupported-path check", + }), + ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); + return; + } + + const holdInvoiceResult = await nwc.makeHoldInvoice({ + amount: AMOUNT_MSATS, + payment_hash: paymentHash, + description: "E2E make_hold_invoice test", + }); + + expect(holdInvoiceResult.invoice).toBeDefined(); + expect(holdInvoiceResult.invoice).toMatch(/^ln/); + expect(holdInvoiceResult.payment_hash).toBe(paymentHash); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); diff --git a/e2e/nwc-multi-pay-invoice.test.ts b/e2e/nwc-multi-pay-invoice.test.ts new file mode 100644 index 0000000..294aa63 --- /dev/null +++ b/e2e/nwc-multi-pay-invoice.test.ts @@ -0,0 +1,72 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for multi_pay_invoice using the NWC faucet. + * Requires network access. + */ +describe("NWC multi_pay_invoice", () => { + const AMOUNT_MSATS = 100_000; // 100 sats + const BALANCE_SATS = 10_000; + + let sender: { nwcUrl: string }; + let receiver: { nwcUrl: string }; + + beforeAll(async () => { + receiver = await createTestWallet(BALANCE_SATS); + sender = await createTestWallet(BALANCE_SATS); + }, 60_000); + + test( + "pays multiple invoices when supported, otherwise returns NOT_IMPLEMENTED", + async () => { + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + + try { + const senderInfo = await senderClient.getInfo(); + + if (!senderInfo.methods.includes("multi_pay_invoice")) { + await expect( + senderClient.multiPayInvoice({ + invoices: [{ invoice: "lnbc1invalidinvoice" }], + }), + ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); + return; + } + + const firstInvoice = await receiverClient.makeInvoice({ + amount: AMOUNT_MSATS, + description: "E2E multi_pay_invoice #1", + }); + const secondInvoice = await receiverClient.makeInvoice({ + amount: AMOUNT_MSATS, + description: "E2E multi_pay_invoice #2", + }); + + const multiPayResult = await senderClient.multiPayInvoice({ + invoices: [ + { invoice: firstInvoice.invoice }, + { invoice: secondInvoice.invoice }, + ], + }); + + expect(Array.isArray(multiPayResult.invoices)).toBe(true); + expect(multiPayResult.invoices.length).toBe(2); + expect(multiPayResult.errors).toEqual([]); + expect(multiPayResult.invoices[0].preimage).toBeDefined(); + expect(multiPayResult.invoices[1].preimage).toBeDefined(); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + } finally { + receiverClient.close(); + senderClient.close(); + } + }, + 90_000, + ); +}); diff --git a/e2e/nwc-multi-pay-keysend.test.ts b/e2e/nwc-multi-pay-keysend.test.ts new file mode 100644 index 0000000..bb576a9 --- /dev/null +++ b/e2e/nwc-multi-pay-keysend.test.ts @@ -0,0 +1,66 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for multi_pay_keysend using the NWC faucet. + * Requires network access. + */ +describe("NWC multi_pay_keysend", () => { + const AMOUNT_MSATS = 50_000; // 50 sats + const BALANCE_SATS = 10_000; + + let sender: { nwcUrl: string }; + let receiver: { nwcUrl: string }; + + beforeAll(async () => { + receiver = await createTestWallet(BALANCE_SATS); + sender = await createTestWallet(BALANCE_SATS); + }, 60_000); + + test( + "sends multiple keysends when supported, otherwise returns NOT_IMPLEMENTED", + async () => { + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + + try { + const senderInfo = await senderClient.getInfo(); + + if (!senderInfo.methods.includes("multi_pay_keysend")) { + await expect( + senderClient.multiPayKeysend({ + keysends: [{ amount: AMOUNT_MSATS, pubkey: "invalidpubkey" }], + }), + ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); + return; + } + + const receiverInfo = await receiverClient.getInfo(); + expect(receiverInfo.pubkey).toBeDefined(); + + const multiPayResult = await senderClient.multiPayKeysend({ + keysends: [ + { amount: AMOUNT_MSATS, pubkey: receiverInfo.pubkey }, + { amount: AMOUNT_MSATS, pubkey: receiverInfo.pubkey }, + ], + }); + + expect(Array.isArray(multiPayResult.keysends)).toBe(true); + expect(multiPayResult.keysends.length).toBe(2); + expect(multiPayResult.errors).toEqual([]); + expect(multiPayResult.keysends[0].preimage).toBeDefined(); + expect(multiPayResult.keysends[1].preimage).toBeDefined(); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + } finally { + receiverClient.close(); + senderClient.close(); + } + }, + 90_000, + ); +}); diff --git a/e2e/nwc-notifications-payment-received.test.ts b/e2e/nwc-notifications-payment-received.test.ts new file mode 100644 index 0000000..28c53a4 --- /dev/null +++ b/e2e/nwc-notifications-payment-received.test.ts @@ -0,0 +1,92 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { + Nip47Notification, + Nip47NotificationType, + Nip47WalletError, +} from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for notifications subscription using the NWC faucet. + * Requires network access. + */ +describe("NWC notifications", () => { + const AMOUNT_MSATS = 100_000; // 100 sats + const BALANCE_SATS = 10_000; + + test( + "receives payment_received notification when supported", + async () => { + const receiver = await createTestWallet(BALANCE_SATS); + const sender = await createTestWallet(BALANCE_SATS); + + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + let unsubscribe: (() => void) | undefined; + + try { + const receiverInfo = await receiverClient.getInfo(); + const supportsPaymentReceived = + receiverInfo.notifications?.includes("payment_received") ?? false; + + if (!supportsPaymentReceived) { + expect( + receiverInfo.notifications?.includes("payment_received") ?? false, + ).toBe(false); + return; + } + + const invoiceResult = await receiverClient.makeInvoice({ + amount: AMOUNT_MSATS, + description: "E2E notifications payment_received test", + }); + + const receivedNotification = new Promise( + (resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error("Timed out waiting for payment notification")); + }, 20_000); + + receiverClient + .subscribeNotifications( + (notification) => { + if (notification.notification.invoice !== invoiceResult.invoice) { + return; + } + clearTimeout(timeout); + resolve(notification); + }, + ["payment_received" as Nip47NotificationType], + ) + .then((unsub) => { + unsubscribe = unsub; + }) + .catch((error) => { + clearTimeout(timeout); + reject(error); + }); + }, + ); + + await senderClient.payInvoice({ invoice: invoiceResult.invoice }); + + const notification = await receivedNotification; + expect(notification.notification_type).toBe("payment_received"); + expect(notification.notification.invoice).toBe(invoiceResult.invoice); + } catch (error) { + if (error instanceof Nip47WalletError) { + expect(error.code).toBe("NOT_IMPLEMENTED"); + } else { + throw error; + } + } finally { + unsubscribe?.(); + receiverClient.close(); + senderClient.close(); + } + }, + 90_000, + ); +}); diff --git a/e2e/nwc-settle-hold-invoice.test.ts b/e2e/nwc-settle-hold-invoice.test.ts new file mode 100644 index 0000000..258bdf6 --- /dev/null +++ b/e2e/nwc-settle-hold-invoice.test.ts @@ -0,0 +1,70 @@ +import { createHash, randomBytes } from "crypto"; +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for settle_hold_invoice using the NWC faucet. + * Requires network access. + */ +describe("NWC settle_hold_invoice", () => { + const AMOUNT_MSATS = 100_000; // 100 sats + const BALANCE_SATS = 10_000; + + test( + "settles hold invoice flow when supported, otherwise returns NOT_IMPLEMENTED", + async () => { + const receiver = await createTestWallet(BALANCE_SATS); + const sender = await createTestWallet(BALANCE_SATS); + + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + + const preimageHex = randomBytes(32).toString("hex"); + const paymentHash = createHash("sha256") + .update(Buffer.from(preimageHex, "hex")) + .digest("hex"); + + try { + const receiverInfo = await receiverClient.getInfo(); + const hasHoldMethods = + receiverInfo.methods.includes("make_hold_invoice") && + receiverInfo.methods.includes("settle_hold_invoice"); + + if (!hasHoldMethods) { + await expect( + receiverClient.settleHoldInvoice({ preimage: preimageHex }), + ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); + return; + } + + const holdInvoice = await receiverClient.makeHoldInvoice({ + amount: AMOUNT_MSATS, + payment_hash: paymentHash, + description: "E2E settle_hold_invoice test", + }); + expect(holdInvoice.invoice).toMatch(/^ln/); + + const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice }); + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const settleResult = await receiverClient.settleHoldInvoice({ + preimage: preimageHex, + }); + expect(settleResult).toEqual({}); + + const payResult = await payPromise; + expect(payResult.preimage).toBe(preimageHex); + } catch (error) { + expect(error).toBeInstanceOf(Nip47WalletError); + expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + } finally { + receiverClient.close(); + senderClient.close(); + } + }, + 90_000, + ); +}); From 63e73e5e044203d0e53e00d9430ed55c768d6c42 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Wed, 15 Apr 2026 11:12:11 +0300 Subject: [PATCH 2/8] test(e2e): harden NWC contract tests against faucet and timing flakes Made-with: Cursor --- e2e/nwc-get-balance.test.ts | 5 ++- ...nwc-notifications-payment-received.test.ts | 42 +++++++++---------- e2e/nwc-settle-hold-invoice.test.ts | 27 ++++++------ 3 files changed, 40 insertions(+), 34 deletions(-) diff --git a/e2e/nwc-get-balance.test.ts b/e2e/nwc-get-balance.test.ts index a12122c..bfb267b 100644 --- a/e2e/nwc-get-balance.test.ts +++ b/e2e/nwc-get-balance.test.ts @@ -20,7 +20,10 @@ describe("NWC get_balance", () => { expect(balanceResult.balance).toBeDefined(); expect(typeof balanceResult.balance).toBe("number"); - expect(balanceResult.balance).toBe(BALANCE_MSATS); + // Faucet balances are approximate; avoid exact equality with the requested amount. + expect(balanceResult.balance).toBeGreaterThanOrEqual( + Math.floor(BALANCE_MSATS * 0.9), + ); } finally { nwc.close(); } diff --git a/e2e/nwc-notifications-payment-received.test.ts b/e2e/nwc-notifications-payment-received.test.ts index 28c53a4..9898581 100644 --- a/e2e/nwc-notifications-payment-received.test.ts +++ b/e2e/nwc-notifications-payment-received.test.ts @@ -43,36 +43,36 @@ describe("NWC notifications", () => { description: "E2E notifications payment_received test", }); - const receivedNotification = new Promise( + const notification = await new Promise( (resolve, reject) => { const timeout = setTimeout(() => { reject(new Error("Timed out waiting for payment notification")); }, 20_000); - receiverClient - .subscribeNotifications( - (notification) => { - if (notification.notification.invoice !== invoiceResult.invoice) { - return; - } - clearTimeout(timeout); - resolve(notification); - }, - ["payment_received" as Nip47NotificationType], - ) - .then((unsub) => { - unsubscribe = unsub; - }) - .catch((error) => { + void (async () => { + try { + unsubscribe = await receiverClient.subscribeNotifications( + (notification) => { + if ( + notification.notification.invoice !== invoiceResult.invoice + ) { + return; + } + clearTimeout(timeout); + resolve(notification); + }, + ["payment_received" as Nip47NotificationType], + ); + await senderClient.payInvoice({ + invoice: invoiceResult.invoice, + }); + } catch (error) { clearTimeout(timeout); reject(error); - }); + } + })(); }, ); - - await senderClient.payInvoice({ invoice: invoiceResult.invoice }); - - const notification = await receivedNotification; expect(notification.notification_type).toBe("payment_received"); expect(notification.notification.invoice).toBe(invoiceResult.invoice); } catch (error) { diff --git a/e2e/nwc-settle-hold-invoice.test.ts b/e2e/nwc-settle-hold-invoice.test.ts index 258bdf6..c15c077 100644 --- a/e2e/nwc-settle-hold-invoice.test.ts +++ b/e2e/nwc-settle-hold-invoice.test.ts @@ -1,6 +1,5 @@ import { createHash, randomBytes } from "crypto"; import { NWCClient } from "../src/nwc/NWCClient"; -import { Nip47WalletError } from "../src/nwc/types"; import { createTestWallet } from "./helpers"; /** @@ -47,19 +46,23 @@ describe("NWC settle_hold_invoice", () => { }); expect(holdInvoice.invoice).toMatch(/^ln/); - const payPromise = senderClient.payInvoice({ invoice: holdInvoice.invoice }); - await new Promise((resolve) => setTimeout(resolve, 1500)); - - const settleResult = await receiverClient.settleHoldInvoice({ - preimage: preimageHex, + const payPromise = senderClient.payInvoice({ + invoice: holdInvoice.invoice, }); - expect(settleResult).toEqual({}); + try { + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const settleResult = await receiverClient.settleHoldInvoice({ + preimage: preimageHex, + }); + expect(settleResult).toEqual({}); - const payResult = await payPromise; - expect(payResult.preimage).toBe(preimageHex); - } catch (error) { - expect(error).toBeInstanceOf(Nip47WalletError); - expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + const payResult = await payPromise; + expect(payResult.preimage).toBe(preimageHex); + } catch (error) { + await payPromise.catch(() => {}); + throw error; + } } finally { receiverClient.close(); senderClient.close(); From 331500565a67ed9a75548a647951378947ddd430 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Wed, 15 Apr 2026 14:29:51 +0300 Subject: [PATCH 3/8] test(e2e): harden hold invoice and notification contract tests --- e2e/nwc-cancel-hold-invoice.test.ts | 65 +++++++++++++++---- ...nwc-notifications-payment-received.test.ts | 20 ++---- e2e/nwc-settle-hold-invoice.test.ts | 2 +- 3 files changed, 58 insertions(+), 29 deletions(-) diff --git a/e2e/nwc-cancel-hold-invoice.test.ts b/e2e/nwc-cancel-hold-invoice.test.ts index 18b3768..d5c0634 100644 --- a/e2e/nwc-cancel-hold-invoice.test.ts +++ b/e2e/nwc-cancel-hold-invoice.test.ts @@ -6,54 +6,91 @@ import { createTestWallet } from "./helpers"; /** * E2E test for cancel_hold_invoice using the NWC faucet. * Requires network access. + * + * Another wallet must start paying the hold before cancel is valid (same idea + * as settle_hold_invoice). After cancel, pay_invoice rejects — that rejection + * must be observed synchronously on the promise, otherwise Node/Jest treat it + * as an unhandled rejection before the next await runs. */ describe("NWC cancel_hold_invoice", () => { const AMOUNT_MSATS = 100_000; // 100 sats const BALANCE_SATS = 10_000; test( - "cancels hold invoice when supported, otherwise returns NOT_IMPLEMENTED", + "cancels hold invoice when supported, otherwise NOT_IMPLEMENTED", async () => { - const { nwcUrl } = await createTestWallet(BALANCE_SATS); - const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + const receiver = await createTestWallet(BALANCE_SATS); + const sender = await createTestWallet(BALANCE_SATS); + + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + const preimageHex = randomBytes(32).toString("hex"); const paymentHash = createHash("sha256") .update(Buffer.from(preimageHex, "hex")) .digest("hex"); + let payPromise: Promise | undefined; + let payRejectionDrained: Promise | undefined; + try { - const infoResult = await nwc.getInfo(); + const infoResult = await receiverClient.getInfo(); const hasHoldMethods = infoResult.methods.includes("make_hold_invoice") && infoResult.methods.includes("cancel_hold_invoice"); if (!hasHoldMethods) { await expect( - nwc.cancelHoldInvoice({ payment_hash: paymentHash }), + receiverClient.cancelHoldInvoice({ payment_hash: paymentHash }), ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); return; } - const holdInvoiceResult = await nwc.makeHoldInvoice({ + const holdInvoiceResult = await receiverClient.makeHoldInvoice({ amount: AMOUNT_MSATS, payment_hash: paymentHash, description: "E2E cancel_hold_invoice test", }); expect(holdInvoiceResult.invoice).toMatch(/^ln/); - const cancelResult = await nwc.cancelHoldInvoice({ - payment_hash: holdInvoiceResult.payment_hash, + payPromise = senderClient.payInvoice({ + invoice: holdInvoiceResult.invoice, + }); + // Register rejection handler synchronously so cancel does not surface as + // an unhandled rejection before the next await runs. + payRejectionDrained = payPromise.catch(() => {}); + + await new Promise((resolve) => setTimeout(resolve, 1500)); + + const cancelResult = await receiverClient.cancelHoldInvoice({ + payment_hash: paymentHash, }); expect(cancelResult).toEqual({}); - } catch (error) { - expect(error).toBeInstanceOf(Nip47WalletError); - expect(["NOT_IMPLEMENTED", "NOT_FOUND"]).toContain( - (error as Nip47WalletError).code, + + const payOutcome = await payPromise.then( + () => ({ settled: true as const }), + (e) => ({ settled: false as const, e }), ); + if (payOutcome.settled) { + throw new Error("Expected pay_invoice to fail after hold cancel"); + } + if (!(payOutcome.e instanceof Nip47WalletError)) { + throw payOutcome.e; + } + expect(payOutcome.e.message).toMatch(/hold|canceled|cancel/i); } finally { - nwc.close(); + if (payPromise !== undefined) { + await payPromise.catch(() => {}); + } + if (payRejectionDrained !== undefined) { + await payRejectionDrained; + } + receiverClient.close(); + senderClient.close(); } }, - 60_000, + 90_000, ); }); diff --git a/e2e/nwc-notifications-payment-received.test.ts b/e2e/nwc-notifications-payment-received.test.ts index 9898581..b311bc8 100644 --- a/e2e/nwc-notifications-payment-received.test.ts +++ b/e2e/nwc-notifications-payment-received.test.ts @@ -2,7 +2,6 @@ import { NWCClient } from "../src/nwc/NWCClient"; import { Nip47Notification, Nip47NotificationType, - Nip47WalletError, } from "../src/nwc/types"; import { createTestWallet } from "./helpers"; @@ -49,17 +48,15 @@ describe("NWC notifications", () => { reject(new Error("Timed out waiting for payment notification")); }, 20_000); - void (async () => { + const subscribeAndPay = async () => { try { unsubscribe = await receiverClient.subscribeNotifications( - (notification) => { - if ( - notification.notification.invoice !== invoiceResult.invoice - ) { + (n) => { + if (n.notification.invoice !== invoiceResult.invoice) { return; } clearTimeout(timeout); - resolve(notification); + resolve(n); }, ["payment_received" as Nip47NotificationType], ); @@ -70,17 +67,12 @@ describe("NWC notifications", () => { clearTimeout(timeout); reject(error); } - })(); + }; + subscribeAndPay(); }, ); expect(notification.notification_type).toBe("payment_received"); expect(notification.notification.invoice).toBe(invoiceResult.invoice); - } catch (error) { - if (error instanceof Nip47WalletError) { - expect(error.code).toBe("NOT_IMPLEMENTED"); - } else { - throw error; - } } finally { unsubscribe?.(); receiverClient.close(); diff --git a/e2e/nwc-settle-hold-invoice.test.ts b/e2e/nwc-settle-hold-invoice.test.ts index c15c077..4dc348e 100644 --- a/e2e/nwc-settle-hold-invoice.test.ts +++ b/e2e/nwc-settle-hold-invoice.test.ts @@ -11,7 +11,7 @@ describe("NWC settle_hold_invoice", () => { const BALANCE_SATS = 10_000; test( - "settles hold invoice flow when supported, otherwise returns NOT_IMPLEMENTED", + "settles hold invoice flow when supported, otherwise NOT_IMPLEMENTED", async () => { const receiver = await createTestWallet(BALANCE_SATS); const sender = await createTestWallet(BALANCE_SATS); From f1274412b6ffb228c253dfbf2c4cf870cede824e Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Wed, 15 Apr 2026 14:30:04 +0300 Subject: [PATCH 4/8] test(e2e): broaden contract e2e for reads, multi-pay, lookup, and sign_message --- e2e/nwc-faucet.test.ts | 5 ++- e2e/nwc-get-budget.test.ts | 8 ++-- e2e/nwc-get-info.test.ts | 22 +++++++--- ...wc-list-transactions-after-payment.test.ts | 40 +++++++++++++++++ e2e/nwc-lookup-invoice.test.ts | 28 ++++++++++++ e2e/nwc-multi-pay-invoice.test.ts | 6 +-- e2e/nwc-multi-pay-keysend.test.ts | 6 +-- e2e/nwc-sign-message.test.ts | 44 +++++++++++++++++++ 8 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 e2e/nwc-sign-message.test.ts diff --git a/e2e/nwc-faucet.test.ts b/e2e/nwc-faucet.test.ts index aee5341..e4011b6 100644 --- a/e2e/nwc-faucet.test.ts +++ b/e2e/nwc-faucet.test.ts @@ -18,7 +18,10 @@ describe("NWC faucet integration", () => { const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); try { const result = await nwc.getBalance(); - expect(result.balance).toBe(EXPECTED_BALANCE_MSATS); + // Faucet balances are approximate; avoid exact equality with requested amount. + expect(result.balance).toBeGreaterThanOrEqual( + Math.floor(EXPECTED_BALANCE_MSATS * 0.9), + ); } finally { nwc.close(); } diff --git a/e2e/nwc-get-budget.test.ts b/e2e/nwc-get-budget.test.ts index 40853b3..2a2a835 100644 --- a/e2e/nwc-get-budget.test.ts +++ b/e2e/nwc-get-budget.test.ts @@ -10,7 +10,7 @@ describe("NWC get_budget", () => { const BALANCE_SATS = 10_000; test( - "returns budget details or reports unsupported method", + "returns budget details, empty object, or NOT_IMPLEMENTED", async () => { const { nwcUrl } = await createTestWallet(BALANCE_SATS); const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); @@ -31,8 +31,10 @@ describe("NWC get_budget", () => { expect(budgetResult).toEqual({}); } catch (error) { - expect(error).toBeInstanceOf(Nip47WalletError); - expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); + if (error instanceof Nip47WalletError && error.code === "NOT_IMPLEMENTED") { + return; + } + throw error; } finally { nwc.close(); } diff --git a/e2e/nwc-get-info.test.ts b/e2e/nwc-get-info.test.ts index 26cc7d5..fbd0642 100644 --- a/e2e/nwc-get-info.test.ts +++ b/e2e/nwc-get-info.test.ts @@ -9,19 +9,29 @@ describe("NWC get_info", () => { const BALANCE_SATS = 10_000; test( - "returns wallet pubkey and supported methods", + "returns wallet metadata and supported methods", async () => { const { nwcUrl } = await createTestWallet(BALANCE_SATS); const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); try { - const infoResult = await nwc.getInfo(); + const info = await nwc.getInfo(); - expect(infoResult.pubkey).toBeDefined(); - expect(typeof infoResult.pubkey).toBe("string"); + expect(typeof info.alias).toBe("string"); + // NIP-47 implementations may return x-only (64 hex) or compressed secp256k1 (66 hex, 02/03 prefix). + expect(info.pubkey).toMatch(/^(02|03)[0-9a-f]{64}$|^[0-9a-f]{64}$/i); + expect(typeof info.color).toBe("string"); + expect(typeof info.network).toBe("string"); + expect(info.network.length).toBeGreaterThan(0); + expect(typeof info.block_height).toBe("number"); + expect(info.block_height).toBeGreaterThanOrEqual(0); + expect(typeof info.block_hash).toBe("string"); + expect(info.block_hash.length).toBeGreaterThan(0); - expect(Array.isArray(infoResult.methods)).toBe(true); - expect(infoResult.methods.length).toBeGreaterThan(0); + expect(Array.isArray(info.methods)).toBe(true); + expect(info.methods.length).toBeGreaterThan(0); + expect(info.methods).toContain("get_info"); + expect(info.methods).toContain("get_balance"); } finally { nwc.close(); } diff --git a/e2e/nwc-list-transactions-after-payment.test.ts b/e2e/nwc-list-transactions-after-payment.test.ts index 6213ac9..28bdc69 100644 --- a/e2e/nwc-list-transactions-after-payment.test.ts +++ b/e2e/nwc-list-transactions-after-payment.test.ts @@ -56,4 +56,44 @@ describe("NWC list_transactions after pay_invoice", () => { }, 60_000, ); + + test( + "returns an incoming settled transaction on the receiver wallet", + async () => { + const receiverClient = new NWCClient({ + nostrWalletConnectUrl: receiver.nwcUrl, + }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + + try { + const invoiceResult = await receiverClient.makeInvoice({ + amount: AMOUNT_MSATS, + description: "E2E list_transactions incoming", + }); + expect(invoiceResult.invoice).toBeDefined(); + + await senderClient.payInvoice({ invoice: invoiceResult.invoice }); + + const listResult = await receiverClient.listTransactions({ + limit: 20, + type: "incoming", + }); + + expect(Array.isArray(listResult.transactions)).toBe(true); + + const matchingTx = listResult.transactions.find( + (tx) => tx.invoice === invoiceResult.invoice, + ); + + expect(matchingTx).toBeDefined(); + expect(matchingTx?.type).toBe("incoming"); + expect(matchingTx?.state).toBe("settled"); + expect(matchingTx?.amount).toBe(AMOUNT_MSATS); + } finally { + receiverClient.close(); + senderClient.close(); + } + }, + 60_000, + ); }); diff --git a/e2e/nwc-lookup-invoice.test.ts b/e2e/nwc-lookup-invoice.test.ts index b937ca7..204ba0f 100644 --- a/e2e/nwc-lookup-invoice.test.ts +++ b/e2e/nwc-lookup-invoice.test.ts @@ -44,4 +44,32 @@ describe("NWC lookup_invoice", () => { }, 60_000, ); + + test( + "finds paid invoice by payment_hash only", + async () => { + const receiverClient = new NWCClient({ nostrWalletConnectUrl: receiver.nwcUrl }); + const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); + + try { + const invoiceResult = await receiverClient.makeInvoice({ + amount: AMOUNT_MSATS, + description: "E2E lookup by payment_hash", + }); + expect(invoiceResult.payment_hash).toBeDefined(); + + await senderClient.payInvoice({ invoice: invoiceResult.invoice }); + + const byHash = await receiverClient.lookupInvoice({ + payment_hash: invoiceResult.payment_hash, + }); + expect(byHash.payment_hash).toBe(invoiceResult.payment_hash); + expect(byHash.invoice).toBe(invoiceResult.invoice); + } finally { + receiverClient.close(); + senderClient.close(); + } + }, + 60_000, + ); }); diff --git a/e2e/nwc-multi-pay-invoice.test.ts b/e2e/nwc-multi-pay-invoice.test.ts index 294aa63..68818ac 100644 --- a/e2e/nwc-multi-pay-invoice.test.ts +++ b/e2e/nwc-multi-pay-invoice.test.ts @@ -1,5 +1,4 @@ import { NWCClient } from "../src/nwc/NWCClient"; -import { Nip47WalletError } from "../src/nwc/types"; import { createTestWallet } from "./helpers"; /** @@ -19,7 +18,7 @@ describe("NWC multi_pay_invoice", () => { }, 60_000); test( - "pays multiple invoices when supported, otherwise returns NOT_IMPLEMENTED", + "pays multiple invoices when supported", async () => { const receiverClient = new NWCClient({ nostrWalletConnectUrl: receiver.nwcUrl, @@ -59,9 +58,6 @@ describe("NWC multi_pay_invoice", () => { expect(multiPayResult.errors).toEqual([]); expect(multiPayResult.invoices[0].preimage).toBeDefined(); expect(multiPayResult.invoices[1].preimage).toBeDefined(); - } catch (error) { - expect(error).toBeInstanceOf(Nip47WalletError); - expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); } finally { receiverClient.close(); senderClient.close(); diff --git a/e2e/nwc-multi-pay-keysend.test.ts b/e2e/nwc-multi-pay-keysend.test.ts index bb576a9..9f6be13 100644 --- a/e2e/nwc-multi-pay-keysend.test.ts +++ b/e2e/nwc-multi-pay-keysend.test.ts @@ -1,5 +1,4 @@ import { NWCClient } from "../src/nwc/NWCClient"; -import { Nip47WalletError } from "../src/nwc/types"; import { createTestWallet } from "./helpers"; /** @@ -19,7 +18,7 @@ describe("NWC multi_pay_keysend", () => { }, 60_000); test( - "sends multiple keysends when supported, otherwise returns NOT_IMPLEMENTED", + "sends multiple keysends when supported", async () => { const receiverClient = new NWCClient({ nostrWalletConnectUrl: receiver.nwcUrl, @@ -53,9 +52,6 @@ describe("NWC multi_pay_keysend", () => { expect(multiPayResult.errors).toEqual([]); expect(multiPayResult.keysends[0].preimage).toBeDefined(); expect(multiPayResult.keysends[1].preimage).toBeDefined(); - } catch (error) { - expect(error).toBeInstanceOf(Nip47WalletError); - expect((error as Nip47WalletError).code).toBe("NOT_IMPLEMENTED"); } finally { receiverClient.close(); senderClient.close(); diff --git a/e2e/nwc-sign-message.test.ts b/e2e/nwc-sign-message.test.ts new file mode 100644 index 0000000..6fc4a39 --- /dev/null +++ b/e2e/nwc-sign-message.test.ts @@ -0,0 +1,44 @@ +import { NWCClient } from "../src/nwc/NWCClient"; +import { Nip47WalletError } from "../src/nwc/types"; +import { createTestWallet } from "./helpers"; + +/** + * E2E test for sign_message using the NWC faucet. + * Requires network access. + * + * Faucet connections may list the method but lack the scope; wallets then + * return RESTRICTED rather than NOT_IMPLEMENTED. + */ +describe("NWC sign_message", () => { + const BALANCE_SATS = 10_000; + + test( + "returns a signature when allowed, otherwise RESTRICTED or NOT_IMPLEMENTED", + async () => { + const { nwcUrl } = await createTestWallet(BALANCE_SATS); + const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl }); + + try { + const message = "e2e sign_message"; + try { + const result = await nwc.signMessage({ message }); + expect(result.message).toBe(message); + expect(result.signature).toBeDefined(); + expect(typeof result.signature).toBe("string"); + expect(result.signature.length).toBeGreaterThan(0); + } catch (error) { + if ( + error instanceof Nip47WalletError && + (error.code === "NOT_IMPLEMENTED" || error.code === "RESTRICTED") + ) { + return; + } + throw error; + } + } finally { + nwc.close(); + } + }, + 60_000, + ); +}); From 2d4e1c6753f30e5f9e54f5f576ab46d90e780389 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 16 Apr 2026 09:56:00 +0300 Subject: [PATCH 5/8] test(e2e): harden cancel_hold_invoice contract test --- e2e/nwc-cancel-hold-invoice.test.ts | 55 +++++++++++++++++++++++------ 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/e2e/nwc-cancel-hold-invoice.test.ts b/e2e/nwc-cancel-hold-invoice.test.ts index d5c0634..74e2a44 100644 --- a/e2e/nwc-cancel-hold-invoice.test.ts +++ b/e2e/nwc-cancel-hold-invoice.test.ts @@ -17,7 +17,7 @@ describe("NWC cancel_hold_invoice", () => { const BALANCE_SATS = 10_000; test( - "cancels hold invoice when supported, otherwise NOT_IMPLEMENTED", + "cancels hold invoice when supported, otherwise NOT_IMPLEMENTED or RESTRICTED", async () => { const receiver = await createTestWallet(BALANCE_SATS); const sender = await createTestWallet(BALANCE_SATS); @@ -42,10 +42,20 @@ describe("NWC cancel_hold_invoice", () => { infoResult.methods.includes("cancel_hold_invoice"); if (!hasHoldMethods) { - await expect( - receiverClient.cancelHoldInvoice({ payment_hash: paymentHash }), - ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); - return; + try { + await receiverClient.cancelHoldInvoice({ payment_hash: paymentHash }); + throw new Error( + "Expected cancel_hold_invoice to fail when hold methods are unavailable", + ); + } catch (error) { + if ( + error instanceof Nip47WalletError && + (error.code === "NOT_IMPLEMENTED" || error.code === "RESTRICTED") + ) { + return; + } + throw error; + } } const holdInvoiceResult = await receiverClient.makeHoldInvoice({ @@ -54,6 +64,7 @@ describe("NWC cancel_hold_invoice", () => { description: "E2E cancel_hold_invoice test", }); expect(holdInvoiceResult.invoice).toMatch(/^ln/); + expect(holdInvoiceResult.payment_hash).toBe(paymentHash); payPromise = senderClient.payInvoice({ invoice: holdInvoiceResult.invoice, @@ -62,12 +73,34 @@ describe("NWC cancel_hold_invoice", () => { // an unhandled rejection before the next await runs. payRejectionDrained = payPromise.catch(() => {}); - await new Promise((resolve) => setTimeout(resolve, 1500)); - - const cancelResult = await receiverClient.cancelHoldInvoice({ - payment_hash: paymentHash, - }); - expect(cancelResult).toEqual({}); + // Pay must reach an in-flight hold before cancel is valid; shared infra + // timing varies, so retry cancel until success or a definitive error. + const cancelDeadlineMs = Date.now() + 25_000; + const cancelPollMs = 500; + for (;;) { + try { + const cancelResult = await receiverClient.cancelHoldInvoice({ + payment_hash: paymentHash, + }); + expect(cancelResult).toEqual({}); + break; + } catch (error) { + if (error instanceof Nip47WalletError) { + if ( + error.code === "NOT_IMPLEMENTED" || + error.code === "RESTRICTED" + ) { + throw error; + } + } else { + throw error; + } + if (Date.now() >= cancelDeadlineMs) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, cancelPollMs)); + } + } const payOutcome = await payPromise.then( () => ({ settled: true as const }), From 188cf80109c44e4036df6875d746af699498f0f8 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 16 Apr 2026 19:53:30 +0300 Subject: [PATCH 6/8] test(e2e): remove multi pay NWC tests --- e2e/nwc-multi-pay-invoice.test.ts | 68 ------------------------------- e2e/nwc-multi-pay-keysend.test.ts | 62 ---------------------------- 2 files changed, 130 deletions(-) delete mode 100644 e2e/nwc-multi-pay-invoice.test.ts delete mode 100644 e2e/nwc-multi-pay-keysend.test.ts diff --git a/e2e/nwc-multi-pay-invoice.test.ts b/e2e/nwc-multi-pay-invoice.test.ts deleted file mode 100644 index 68818ac..0000000 --- a/e2e/nwc-multi-pay-invoice.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { NWCClient } from "../src/nwc/NWCClient"; -import { createTestWallet } from "./helpers"; - -/** - * E2E test for multi_pay_invoice using the NWC faucet. - * Requires network access. - */ -describe("NWC multi_pay_invoice", () => { - const AMOUNT_MSATS = 100_000; // 100 sats - const BALANCE_SATS = 10_000; - - let sender: { nwcUrl: string }; - let receiver: { nwcUrl: string }; - - beforeAll(async () => { - receiver = await createTestWallet(BALANCE_SATS); - sender = await createTestWallet(BALANCE_SATS); - }, 60_000); - - test( - "pays multiple invoices when supported", - async () => { - const receiverClient = new NWCClient({ - nostrWalletConnectUrl: receiver.nwcUrl, - }); - const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); - - try { - const senderInfo = await senderClient.getInfo(); - - if (!senderInfo.methods.includes("multi_pay_invoice")) { - await expect( - senderClient.multiPayInvoice({ - invoices: [{ invoice: "lnbc1invalidinvoice" }], - }), - ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); - return; - } - - const firstInvoice = await receiverClient.makeInvoice({ - amount: AMOUNT_MSATS, - description: "E2E multi_pay_invoice #1", - }); - const secondInvoice = await receiverClient.makeInvoice({ - amount: AMOUNT_MSATS, - description: "E2E multi_pay_invoice #2", - }); - - const multiPayResult = await senderClient.multiPayInvoice({ - invoices: [ - { invoice: firstInvoice.invoice }, - { invoice: secondInvoice.invoice }, - ], - }); - - expect(Array.isArray(multiPayResult.invoices)).toBe(true); - expect(multiPayResult.invoices.length).toBe(2); - expect(multiPayResult.errors).toEqual([]); - expect(multiPayResult.invoices[0].preimage).toBeDefined(); - expect(multiPayResult.invoices[1].preimage).toBeDefined(); - } finally { - receiverClient.close(); - senderClient.close(); - } - }, - 90_000, - ); -}); diff --git a/e2e/nwc-multi-pay-keysend.test.ts b/e2e/nwc-multi-pay-keysend.test.ts deleted file mode 100644 index 9f6be13..0000000 --- a/e2e/nwc-multi-pay-keysend.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { NWCClient } from "../src/nwc/NWCClient"; -import { createTestWallet } from "./helpers"; - -/** - * E2E test for multi_pay_keysend using the NWC faucet. - * Requires network access. - */ -describe("NWC multi_pay_keysend", () => { - const AMOUNT_MSATS = 50_000; // 50 sats - const BALANCE_SATS = 10_000; - - let sender: { nwcUrl: string }; - let receiver: { nwcUrl: string }; - - beforeAll(async () => { - receiver = await createTestWallet(BALANCE_SATS); - sender = await createTestWallet(BALANCE_SATS); - }, 60_000); - - test( - "sends multiple keysends when supported", - async () => { - const receiverClient = new NWCClient({ - nostrWalletConnectUrl: receiver.nwcUrl, - }); - const senderClient = new NWCClient({ nostrWalletConnectUrl: sender.nwcUrl }); - - try { - const senderInfo = await senderClient.getInfo(); - - if (!senderInfo.methods.includes("multi_pay_keysend")) { - await expect( - senderClient.multiPayKeysend({ - keysends: [{ amount: AMOUNT_MSATS, pubkey: "invalidpubkey" }], - }), - ).rejects.toMatchObject({ code: "NOT_IMPLEMENTED" }); - return; - } - - const receiverInfo = await receiverClient.getInfo(); - expect(receiverInfo.pubkey).toBeDefined(); - - const multiPayResult = await senderClient.multiPayKeysend({ - keysends: [ - { amount: AMOUNT_MSATS, pubkey: receiverInfo.pubkey }, - { amount: AMOUNT_MSATS, pubkey: receiverInfo.pubkey }, - ], - }); - - expect(Array.isArray(multiPayResult.keysends)).toBe(true); - expect(multiPayResult.keysends.length).toBe(2); - expect(multiPayResult.errors).toEqual([]); - expect(multiPayResult.keysends[0].preimage).toBeDefined(); - expect(multiPayResult.keysends[1].preimage).toBeDefined(); - } finally { - receiverClient.close(); - senderClient.close(); - } - }, - 90_000, - ); -}); From 2106144ef81bfd21f4011604021a29ffb60ee03d Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 16 Apr 2026 19:55:49 +0300 Subject: [PATCH 7/8] test(e2e): run cancel_hold_invoice flow without feature gating --- e2e/nwc-cancel-hold-invoice.test.ts | 40 ++++------------------------- 1 file changed, 5 insertions(+), 35 deletions(-) diff --git a/e2e/nwc-cancel-hold-invoice.test.ts b/e2e/nwc-cancel-hold-invoice.test.ts index 74e2a44..1efaf6b 100644 --- a/e2e/nwc-cancel-hold-invoice.test.ts +++ b/e2e/nwc-cancel-hold-invoice.test.ts @@ -16,9 +16,7 @@ describe("NWC cancel_hold_invoice", () => { const AMOUNT_MSATS = 100_000; // 100 sats const BALANCE_SATS = 10_000; - test( - "cancels hold invoice when supported, otherwise NOT_IMPLEMENTED or RESTRICTED", - async () => { + test("cancels hold invoice and pay_invoice fails afterward", async () => { const receiver = await createTestWallet(BALANCE_SATS); const sender = await createTestWallet(BALANCE_SATS); @@ -36,28 +34,6 @@ describe("NWC cancel_hold_invoice", () => { let payRejectionDrained: Promise | undefined; try { - const infoResult = await receiverClient.getInfo(); - const hasHoldMethods = - infoResult.methods.includes("make_hold_invoice") && - infoResult.methods.includes("cancel_hold_invoice"); - - if (!hasHoldMethods) { - try { - await receiverClient.cancelHoldInvoice({ payment_hash: paymentHash }); - throw new Error( - "Expected cancel_hold_invoice to fail when hold methods are unavailable", - ); - } catch (error) { - if ( - error instanceof Nip47WalletError && - (error.code === "NOT_IMPLEMENTED" || error.code === "RESTRICTED") - ) { - return; - } - throw error; - } - } - const holdInvoiceResult = await receiverClient.makeHoldInvoice({ amount: AMOUNT_MSATS, payment_hash: paymentHash, @@ -102,17 +78,11 @@ describe("NWC cancel_hold_invoice", () => { } } - const payOutcome = await payPromise.then( - () => ({ settled: true as const }), - (e) => ({ settled: false as const, e }), + const payError = await payPromise.catch((e) => e); + expect(payError).toBeInstanceOf(Nip47WalletError); + expect((payError as Nip47WalletError).message).toMatch( + /hold|canceled|cancel/i, ); - if (payOutcome.settled) { - throw new Error("Expected pay_invoice to fail after hold cancel"); - } - if (!(payOutcome.e instanceof Nip47WalletError)) { - throw payOutcome.e; - } - expect(payOutcome.e.message).toMatch(/hold|canceled|cancel/i); } finally { if (payPromise !== undefined) { await payPromise.catch(() => {}); From 4ab32cc1e2808f4b1a9d0e36ec6a80d9991989c6 Mon Sep 17 00:00:00 2001 From: Dmitriy E Date: Thu, 16 Apr 2026 19:58:21 +0300 Subject: [PATCH 8/8] test(e2e): drop superficial get_info metadata assertions --- e2e/nwc-get-info.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/e2e/nwc-get-info.test.ts b/e2e/nwc-get-info.test.ts index fbd0642..464c6e1 100644 --- a/e2e/nwc-get-info.test.ts +++ b/e2e/nwc-get-info.test.ts @@ -17,10 +17,6 @@ describe("NWC get_info", () => { try { const info = await nwc.getInfo(); - expect(typeof info.alias).toBe("string"); - // NIP-47 implementations may return x-only (64 hex) or compressed secp256k1 (66 hex, 02/03 prefix). - expect(info.pubkey).toMatch(/^(02|03)[0-9a-f]{64}$|^[0-9a-f]{64}$/i); - expect(typeof info.color).toBe("string"); expect(typeof info.network).toBe("string"); expect(info.network.length).toBeGreaterThan(0); expect(typeof info.block_height).toBe("number");