Skip to content
99 changes: 99 additions & 0 deletions e2e/nwc-cancel-hold-invoice.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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.
*
* 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 and pay_invoice fails afterward", 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");

let payPromise: Promise<unknown> | undefined;
let payRejectionDrained: Promise<unknown> | undefined;

try {
const holdInvoiceResult = await receiverClient.makeHoldInvoice({
amount: AMOUNT_MSATS,
payment_hash: paymentHash,
description: "E2E cancel_hold_invoice test",
});
expect(holdInvoiceResult.invoice).toMatch(/^ln/);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
expect(holdInvoiceResult.payment_hash).toBe(paymentHash);

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(() => {});

// 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 payError = await payPromise.catch((e) => e);
expect(payError).toBeInstanceOf(Nip47WalletError);
expect((payError as Nip47WalletError).message).toMatch(
/hold|canceled|cancel/i,
);
} finally {
if (payPromise !== undefined) {
await payPromise.catch(() => {});
}
if (payRejectionDrained !== undefined) {
await payRejectionDrained;
}
receiverClient.close();
senderClient.close();
}
},
90_000,
);
});
5 changes: 4 additions & 1 deletion e2e/nwc-faucet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
33 changes: 33 additions & 0 deletions e2e/nwc-get-balance.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
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");
// Faucet balances are approximate; avoid exact equality with the requested amount.
expect(balanceResult.balance).toBeGreaterThanOrEqual(
Math.floor(BALANCE_MSATS * 0.9),
);
} finally {
nwc.close();
}
},
60_000,
);
});
44 changes: 44 additions & 0 deletions e2e/nwc-get-budget.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
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, empty object, or NOT_IMPLEMENTED",
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) {
if (error instanceof Nip47WalletError && error.code === "NOT_IMPLEMENTED") {
return;
}
throw error;
} finally {
nwc.close();
}
},
60_000,
);
});
37 changes: 37 additions & 0 deletions e2e/nwc-get-info.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
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 metadata and supported methods",
async () => {
const { nwcUrl } = await createTestWallet(BALANCE_SATS);
const nwc = new NWCClient({ nostrWalletConnectUrl: nwcUrl });

try {
const info = await nwc.getInfo();

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(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();
}
},
60_000,
);
});
40 changes: 40 additions & 0 deletions e2e/nwc-list-transactions-after-payment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
28 changes: 28 additions & 0 deletions e2e/nwc-lookup-invoice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
});
56 changes: 56 additions & 0 deletions e2e/nwc-make-hold-invoice.test.ts
Original file line number Diff line number Diff line change
@@ -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,
);
});
Loading
Loading