diff --git a/.changeset/seven-lines-heal.md b/.changeset/seven-lines-heal.md new file mode 100644 index 00000000..4f4899b7 --- /dev/null +++ b/.changeset/seven-lines-heal.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add NWC (NIP-47) as a payments processor for admission invoices, including configurable invoice expiry and reply timeout handling, compatibility for legacy NWC URI schemes, and docs/env updates. diff --git a/.env.example b/.env.example index 49dfb185..78269d35 100644 --- a/.env.example +++ b/.env.example @@ -41,6 +41,7 @@ WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing. # NODELESS_WEBHOOK_SECRET= # OPENNODE_API_KEY= # LNBITS_API_KEY= +# ALBY_NWC_URL=nostr+walletconnect://?relay=&secret= # --- READ REPLICAS (Optional) --- # READ_REPLICA_ENABLED=false diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 8fc75b0b..5091ea2f 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -57,6 +57,7 @@ The following environment variables can be set: | NOSTR_CONFIG_DIR | Configuration directory | /.nostr/ | | DEBUG | Debugging filter | | | ZEBEDEE_API_KEY | Zebedee Project API Key | | +| NWC_URL | NWC connection URL (`nostr+walletconnect://...`) | | ## I2P @@ -222,5 +223,5 @@ The settings below are listed in alphabetical order by name. Please keep this ta | payments.feeSchedules.admission[].enabled | Enables admission fee. Defaults to false. | | payments.feeSchedules.admission[].whitelists.event_kinds | List of event kinds to waive admission fee. Use `[min, max]` for ranges. | | payments.feeSchedules.admission[].whitelists.pubkeys | List of pubkeys to waive admission fee. | -| payments.processor | Either `zebedee`, `lnbits`, `lnurl`. | +| payments.processor | Either `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `nwc`. | | workers.count | Number of workers to spin up to handle incoming connections. Spin workers as many CPUs are available when set to zero. Defaults to zero. | diff --git a/README.md b/README.md index 8705b869..8ac5c31b 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - Set `payments.enabled` to `true` - Set `payments.feeSchedules.admission.enabled` to `true` - Set `limits.event.pubkey.minBalance` to the minimum balance in msats required to accept events (i.e. `1000000` to require a balance of `1000` sats) - - Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl` + - Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`, `nwc` 2. [ZEBEDEE](https://zebedee.io) - Complete the step "Before you begin" @@ -172,7 +172,20 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal - Set `lnurl.invoiceURL` to your LNURL (e.g. `https://getalby.com/lnurlp/your-username`) - Restart Nostream (`nostream stop` followed by `nostream start`) -7. Ensure payments are required for your public key +7. Nostr Wallet Connect (NIP-47 / NWC) + - Complete the step "Before you begin" + - Create an app connection in your NWC-compatible wallet and copy the generated NWC URL + - Set `NWC_URL` environment variable on your `.env` file + + ``` + NWC_URL={NOSTR_WALLET_CONNECT_URL} + ``` + + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `nwc` + - Restart Nostream (`./scripts/stop` followed by `./scripts/start`) + +8. Ensure payments are required for your public key - Visit https://{YOUR-DOMAIN}/ - You should be presented with a form requesting an admission fee to be paid - Fill out the form and take the necessary steps to pay the invoice diff --git a/package.json b/package.json index 311c7074..675394da 100644 --- a/package.json +++ b/package.json @@ -152,6 +152,7 @@ "node": ">=24.14.1" }, "dependencies": { + "@getalby/sdk": "^5.0.0", "@clack/prompts": "^1.2.0", "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d17d9f74..153bcb60 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@clack/prompts': specifier: ^1.2.0 version: 1.2.0 + '@getalby/sdk': + specifier: ^5.0.0 + version: 5.1.2(typescript@5.7.3) '@noble/secp256k1': specifier: 1.7.1 version: 1.7.1 @@ -506,6 +509,14 @@ packages: engines: {node: '>=4'} deprecated: Support for this package will stop 2025-12-31 + '@getalby/lightning-tools@5.2.1': + resolution: {integrity: sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==} + engines: {node: '>=14'} + + '@getalby/sdk@5.1.2': + resolution: {integrity: sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==} + engines: {node: '>=14'} + '@inquirer/external-editor@1.0.3': resolution: {integrity: sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==} engines: {node: '>=18'} @@ -552,6 +563,23 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} + '@noble/ciphers@0.5.3': + resolution: {integrity: sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==} + + '@noble/curves@1.1.0': + resolution: {integrity: sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==} + + '@noble/curves@1.2.0': + resolution: {integrity: sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==} + + '@noble/hashes@1.3.1': + resolution: {integrity: sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==} + engines: {node: '>= 16'} + + '@noble/hashes@1.3.2': + resolution: {integrity: sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==} + engines: {node: '>= 16'} + '@noble/secp256k1@1.7.1': resolution: {integrity: sha512-hOUk6AyBFmqVrv7k5WAw/LpszxVbj9gGN4JRkIX52fdFAj1UA61KXmZDvqVEm+pOyec3+fIeZB02LYa/pWOArw==} @@ -663,6 +691,15 @@ packages: peerDependencies: '@redis/client': ^1.0.0 + '@scure/base@1.1.1': + resolution: {integrity: sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==} + + '@scure/bip32@1.3.1': + resolution: {integrity: sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==} + + '@scure/bip39@1.2.1': + resolution: {integrity: sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==} + '@sinonjs/commons@2.0.0': resolution: {integrity: sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==} @@ -2278,6 +2315,17 @@ packages: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} engines: {node: '>=0.10.0'} + nostr-tools@2.15.0: + resolution: {integrity: sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==} + peerDependencies: + typescript: '>=5.0.0' + peerDependenciesMeta: + typescript: + optional: true + + nostr-wasm@0.1.0: + resolution: {integrity: sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==} + npm-normalize-package-bin@3.0.1: resolution: {integrity: sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} @@ -3936,6 +3984,15 @@ snapshots: to-pascal-case: 1.0.0 unescape-js: 1.1.4 + '@getalby/lightning-tools@5.2.1': {} + + '@getalby/sdk@5.1.2(typescript@5.7.3)': + dependencies: + '@getalby/lightning-tools': 5.2.1 + nostr-tools: 2.15.0(typescript@5.7.3) + transitivePeerDependencies: + - typescript + '@inquirer/external-editor@1.0.3(@types/node@24.12.2)': dependencies: chardet: 2.1.1 @@ -4002,6 +4059,20 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 + '@noble/ciphers@0.5.3': {} + + '@noble/curves@1.1.0': + dependencies: + '@noble/hashes': 1.3.1 + + '@noble/curves@1.2.0': + dependencies: + '@noble/hashes': 1.3.2 + + '@noble/hashes@1.3.1': {} + + '@noble/hashes@1.3.2': {} + '@noble/secp256k1@1.7.1': {} '@nodelib/fs.scandir@2.1.5': @@ -4135,6 +4206,19 @@ snapshots: dependencies: '@redis/client': 1.4.2 + '@scure/base@1.1.1': {} + + '@scure/bip32@1.3.1': + dependencies: + '@noble/curves': 1.1.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + + '@scure/bip39@1.2.1': + dependencies: + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@sinonjs/commons@2.0.0': dependencies: type-detect: 4.0.8 @@ -5718,6 +5802,20 @@ snapshots: normalize-path@3.0.0: {} + nostr-tools@2.15.0(typescript@5.7.3): + dependencies: + '@noble/ciphers': 0.5.3 + '@noble/curves': 1.2.0 + '@noble/hashes': 1.3.1 + '@scure/base': 1.1.1 + '@scure/bip32': 1.3.1 + '@scure/bip39': 1.2.1 + nostr-wasm: 0.1.0 + optionalDependencies: + typescript: 5.7.3 + + nostr-wasm@0.1.0: {} + npm-normalize-package-bin@3.0.1: {} npm-run-path@4.0.1: diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 4e41716b..6cdfebb7 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -39,6 +39,9 @@ paymentsProcessors: opennode: baseURL: api.opennode.com callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode + nwc: + invoiceExpirySeconds: 900 + replyTimeoutMs: 10000 nip05: # NIP-05 verification of event authors as a spam reduction measure. # mode: 'enabled' requires NIP-05 for publishing (except kind 0), diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 55365ebc..d4463dc9 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -203,9 +203,9 @@ export interface OpenNodePaymentsProcessor { callbackBaseURL: string } -export interface NodelessPaymentsProcessor { - baseURL: string - storeId: string +export interface NwcPaymentsProcessor { + invoiceExpirySeconds: number + replyTimeoutMs: number } export interface PaymentsProcessors { @@ -214,6 +214,7 @@ export interface PaymentsProcessors { lnbits?: LNbitsPaymentsProcessor nodeless?: NodelessPaymentsProcessor opennode?: OpenNodePaymentsProcessor + nwc?: NwcPaymentsProcessor } export interface Local { diff --git a/src/factories/payments-processor-factory.ts b/src/factories/payments-processor-factory.ts index cf73ebc0..1798cc2b 100644 --- a/src/factories/payments-processor-factory.ts +++ b/src/factories/payments-processor-factory.ts @@ -2,6 +2,7 @@ import { createLNbitsPaymentProcessor } from './payments-processors/lnbits-payme import { createLnurlPaymentsProcessor } from './payments-processors/lnurl-payments-processor-factory' import { createLogger } from './logger-factory' import { createNodelessPaymentsProcessor } from './payments-processors/nodeless-payments-processor-factory' +import { createNwcPaymentsProcessor } from './payments-processors/nwc-payments-processor-factory' import { createOpenNodePaymentsProcessor } from './payments-processors/opennode-payments-processor-factory' import { createSettings } from './settings-factory' import { createZebedeePaymentsProcessor } from './payments-processors/zebedee-payments-processor-factory' @@ -29,6 +30,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => { return createNodelessPaymentsProcessor(settings) case 'opennode': return createOpenNodePaymentsProcessor(settings) + case 'nwc': + return createNwcPaymentsProcessor(settings) default: return new NullPaymentsProcessor() } diff --git a/src/factories/payments-processors/nwc-payments-processor-factory.ts b/src/factories/payments-processors/nwc-payments-processor-factory.ts new file mode 100644 index 00000000..468e80d8 --- /dev/null +++ b/src/factories/payments-processors/nwc-payments-processor-factory.ts @@ -0,0 +1,53 @@ +import { createSettings } from '../settings-factory' +import { NwcPaymentsProcessor } from '../../payments-processors/nwc-payments-processor' +import { createLogger } from '../logger-factory' +import { IPaymentsProcessor } from '../../@types/clients' +import { Settings } from '../../@types/settings' + +const logger = createLogger('nwc-payments-processor-factory') + +const getNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: number } => { + const nwcUrl = process.env.NWC_URL + + if (!nwcUrl) { + const error = new Error('NWC_URL must be set.') + logger('Unable to create NWC payments processor. %o', error) + throw error + } + + if (!nwcUrl.startsWith('nostr+walletconnect://') && !nwcUrl.startsWith('nostrwalletconnect://')) { + const error = new Error('NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.') + logger('Unable to create NWC payments processor. %o', error) + throw error + } + + try { + new URL(nwcUrl) + } catch { + const error = new Error('NWC_URL is not parseable as a URL.') + logger('Unable to create NWC payments processor. %o', error) + throw error + } + + const replyTimeoutMs = settings.paymentsProcessors?.nwc?.replyTimeoutMs + if (typeof replyTimeoutMs !== 'number' || replyTimeoutMs <= 0) { + const error = new Error('Setting paymentsProcessors.nwc.replyTimeoutMs must be a positive number.') + logger('Unable to create NWC payments processor. %o', error) + throw error + } + + const invoiceExpirySeconds = settings.paymentsProcessors?.nwc?.invoiceExpirySeconds + if (typeof invoiceExpirySeconds !== 'number' || !Number.isInteger(invoiceExpirySeconds) || invoiceExpirySeconds <= 0) { + const error = new Error('Setting paymentsProcessors.nwc.invoiceExpirySeconds must be a positive integer.') + logger('Unable to create NWC payments processor. %o', error) + throw error + } + + return { nwcUrl, replyTimeoutMs } +} + +export const createNwcPaymentsProcessor = (settings: Settings): IPaymentsProcessor => { + const { nwcUrl, replyTimeoutMs } = getNwcConfig(settings) + + return new NwcPaymentsProcessor(nwcUrl, replyTimeoutMs, createSettings) +} diff --git a/src/payments-processors/nwc-payments-processor.ts b/src/payments-processors/nwc-payments-processor.ts new file mode 100644 index 00000000..a6b3a95a --- /dev/null +++ b/src/payments-processors/nwc-payments-processor.ts @@ -0,0 +1,243 @@ +import { nwc } from '@getalby/sdk' +import { setTimeout as sleep } from 'node:timers/promises' + +import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients' +import { Factory } from '../@types/base' +import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice' +import { Settings } from '../@types/settings' +import { createLogger } from '../factories/logger-factory' + +const logger = createLogger('nwc-payments-processor') + +type NwcTransaction = { + state?: 'settled' | 'pending' | 'expired' | 'failed' | 'accepted' + invoice?: string + payment_hash?: string + amount?: number + description?: string + created_at?: number + settled_at?: number + expires_at?: number +} + +const mapNwcStateToInvoiceStatus = (state?: NwcTransaction['state']): InvoiceStatus => { + switch (state) { + case 'settled': + return InvoiceStatus.COMPLETED + case 'expired': + case 'failed': + return InvoiceStatus.EXPIRED + case 'accepted': + case 'pending': + default: + return InvoiceStatus.PENDING + } +} + +const timestampToDate = (unixSeconds?: number): Date | null => { + if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds) && unixSeconds > 0) { + return new Date(unixSeconds * 1000) + } + + return null +} + +const toSafeNumber = (value: bigint, fieldName: string): number => { + if (value < 0n) { + throw new Error(`${fieldName} must be a non-negative bigint.`) + } + + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error(`${fieldName} exceeds Number.MAX_SAFE_INTEGER.`) + } + + const asNumber = Number(value) + if (!Number.isSafeInteger(asNumber)) { + throw new Error(`${fieldName} is not a safe integer.`) + } + + return asNumber +} + +export class NwcInvoice implements Invoice { + id: string + pubkey: string + bolt11: string + amountRequested: bigint + amountPaid?: bigint + unit: InvoiceUnit + status: InvoiceStatus + description: string + confirmedAt?: Date | null + expiresAt: Date | null + updatedAt: Date + createdAt: Date +} + +export class NwcCreateInvoiceResponse implements CreateInvoiceResponse { + id: string + pubkey: string + bolt11: string + amountRequested: bigint + description: string + unit: InvoiceUnit + status: InvoiceStatus + expiresAt: Date | null + confirmedAt?: Date | null + createdAt: Date + rawResponse?: string +} + +export class NwcPaymentsProcessor implements IPaymentsProcessor { + public constructor( + private nwcUrl: string, + private replyTimeoutMs: number, + private settings: Factory, + ) {} + + private withReplyTimeout = async (operation: Promise): Promise => { + const controller = new AbortController() + const timeout = sleep(this.replyTimeoutMs, undefined, { + ref: false, + signal: controller.signal, + }) + .then(() => { + throw new nwc.Nip47ReplyTimeoutError(`reply timeout after ${this.replyTimeoutMs}ms`, 'INTERNAL') + }) + .catch((error) => { + if ((error as Error).name === 'AbortError') { + return undefined as never + } + + throw error + }) + + try { + return await Promise.race([operation, timeout]) + } finally { + controller.abort() + } + } + + private withClient = async (fn: (client: nwc.NWCClient) => Promise): Promise => { + const client = new nwc.NWCClient({ nostrWalletConnectUrl: this.nwcUrl }) + let caughtError: unknown + + try { + return await fn(client) + } catch (error) { + caughtError = error + throw error + } finally { + if (caughtError instanceof nwc.Nip47ReplyTimeoutError) { + // The SDK can still emit a late response; this wait must not keep the process alive. + await sleep(this.replyTimeoutMs + 100, undefined, { ref: false }) + } + client.close() + } + } + + public async getInvoice(invoiceOrId: string | Invoice): Promise { + const invoiceId = typeof invoiceOrId === 'string' ? invoiceOrId : invoiceOrId.id + logger('get invoice: %s', invoiceId) + + try { + return await this.withClient(async (client) => { + const transaction = (await this.withReplyTimeout( + client.lookupInvoice({ payment_hash: invoiceId }), + )) as NwcTransaction + const status = mapNwcStateToInvoiceStatus(transaction.state) + + const invoice: GetInvoiceResponse = { + id: transaction.payment_hash || invoiceId, + status, + confirmedAt: status === InvoiceStatus.COMPLETED ? (timestampToDate(transaction.settled_at) ?? new Date()) : null, + expiresAt: timestampToDate(transaction.expires_at), + updatedAt: new Date(), + } + + if (typeof invoiceOrId !== 'string') { + invoice.pubkey = invoiceOrId.pubkey + invoice.bolt11 = transaction.invoice || invoiceOrId.bolt11 + invoice.amountRequested = + typeof transaction.amount === 'number' && Number.isFinite(transaction.amount) + ? BigInt(Math.trunc(transaction.amount)) + : invoiceOrId.amountRequested + invoice.amountPaid = status === InvoiceStatus.COMPLETED ? invoice.amountRequested : undefined + invoice.unit = InvoiceUnit.MSATS + invoice.description = transaction.description || invoiceOrId.description + invoice.createdAt = timestampToDate(transaction.created_at) ?? invoiceOrId.createdAt + } else { + if (transaction.invoice) { + invoice.bolt11 = transaction.invoice + } + if (typeof transaction.amount === 'number' && Number.isFinite(transaction.amount)) { + invoice.amountRequested = BigInt(Math.trunc(transaction.amount)) + invoice.amountPaid = status === InvoiceStatus.COMPLETED ? invoice.amountRequested : undefined + invoice.unit = InvoiceUnit.MSATS + } + if (transaction.description) { + invoice.description = transaction.description + } + const createdAt = timestampToDate(transaction.created_at) + if (createdAt) { + invoice.createdAt = createdAt + } + } + + return invoice + }) + } catch (error) { + if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) { + logger('Unable to get NWC invoice %s. Reason: %s', invoiceId, error.message) + } else { + logger('Unable to get NWC invoice %s. Reason: %o', invoiceId, error) + } + throw error + } + } + + public async createInvoice(request: CreateInvoiceRequest): Promise { + logger('create invoice: %o', request) + const { amount: amountMsats, description, requestId: pubkey } = request + + try { + return await this.withClient(async (client) => { + const expirySeconds = this.settings().paymentsProcessors?.nwc?.invoiceExpirySeconds + const amount = toSafeNumber(amountMsats, 'CreateInvoiceRequest.amount') + const transaction = (await this.withReplyTimeout( + client.makeInvoice({ + amount, + description, + expiry: expirySeconds, + }), + )) as NwcTransaction + + const invoice = new NwcCreateInvoiceResponse() + invoice.id = transaction.payment_hash || '' + invoice.pubkey = pubkey + invoice.bolt11 = transaction.invoice || '' + invoice.amountRequested = + typeof transaction.amount === 'number' && Number.isFinite(transaction.amount) + ? BigInt(Math.trunc(transaction.amount)) + : amountMsats + invoice.description = transaction.description || description || '' + invoice.unit = InvoiceUnit.MSATS + invoice.status = mapNwcStateToInvoiceStatus(transaction.state) + invoice.confirmedAt = invoice.status === InvoiceStatus.COMPLETED ? (timestampToDate(transaction.settled_at) ?? new Date()) : null + invoice.expiresAt = timestampToDate(transaction.expires_at) + invoice.createdAt = timestampToDate(transaction.created_at) ?? new Date() + invoice.rawResponse = JSON.stringify(transaction) + + return invoice + }) + } catch (error) { + if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) { + logger('Unable to request NWC invoice. Reason: %s', error.message) + } else { + logger('Unable to request NWC invoice. Reason: %o', error) + } + throw error + } + } +} diff --git a/test/integration/features/invoices/nwc-invoice.feature b/test/integration/features/invoices/nwc-invoice.feature new file mode 100644 index 00000000..d45adba9 --- /dev/null +++ b/test/integration/features/invoices/nwc-invoice.feature @@ -0,0 +1,24 @@ +@nwc-invoice +Feature: NWC invoice integration + + Scenario: creates invoice via HTTP with NWC processor + Given NWC payments are enabled with URI scheme "nostr+walletconnect" + And NWC wallet service make_invoice responds with a pending invoice + When I request an admission invoice for pubkey "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + Then the invoice request response status is 200 + And an NWC invoice is stored as pending for pubkey "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + Scenario: returns 500 on NWC reply timeout + Given NWC payments are enabled with URI scheme "nostr+walletconnect" + And NWC reply timeout is set to 75 milliseconds + And NWC wallet service make_invoice never responds + When I request an admission invoice for pubkey "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + Then the invoice request response status is 500 + And no invoice is stored for pubkey "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + Scenario: accepts legacy nostrwalletconnect URI + Given NWC payments are enabled with URI scheme "nostrwalletconnect" + And NWC wallet service make_invoice responds with a pending invoice + When I request an admission invoice for pubkey "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + Then the invoice request response status is 200 + And an NWC invoice is stored as pending for pubkey "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/test/integration/features/invoices/nwc-invoice.feature.ts b/test/integration/features/invoices/nwc-invoice.feature.ts new file mode 100644 index 00000000..ebcc07f5 --- /dev/null +++ b/test/integration/features/invoices/nwc-invoice.feature.ts @@ -0,0 +1,312 @@ +import WebSocket from 'ws' +import { setTimeout as sleep } from 'node:timers/promises' + +import { After, Given, Then, When, World } from '@cucumber/cucumber' +import axios, { AxiosResponse } from 'axios' +import { expect } from 'chai' +import * as secp256k1 from '@noble/secp256k1' +import { nwc } from '@getalby/sdk' + +import { getMasterDbClient } from '../../../../src/database/client' +import { SettingsStatic } from '../../../../src/utils/settings' + +;(globalThis as any).WebSocket = WebSocket + +const INVOICES_URL = 'http://localhost:18808/invoices' +const ADMISSION_FEE_MSATS = 1000000 + +const randomHex = () => secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey()) + +const buildNwcUrl = (scheme: string, walletPubkey: string, clientSecret: string) => { + const encodedRelay = encodeURIComponent('ws://localhost:18808') + return `${scheme}://${walletPubkey}?relay=${encodedRelay}&secret=${clientSecret}` +} + +Given('NWC payments are enabled with URI scheme {string}', async function (this: World>, scheme: string) { + const settings = SettingsStatic._settings as any + + this.parameters.previousNwcSettings = settings + this.parameters.previousNwcUrl = process.env.NWC_URL + this.parameters.nwcUriScheme = scheme + + const walletSecret = randomHex() + const clientSecret = randomHex() + const clientPubkey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(clientSecret, true).subarray(1)) + const walletPubkey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(walletSecret, true).subarray(1)) + + this.parameters.nwcWalletSecret = walletSecret + this.parameters.nwcClientSecret = clientSecret + this.parameters.nwcClientPubkey = clientPubkey + this.parameters.nwcWalletPubkey = walletPubkey + + const nwcUrl = buildNwcUrl(scheme, walletPubkey, clientSecret) + process.env.NWC_URL = nwcUrl + + const admission = Array.isArray(settings?.payments?.feeSchedules?.admission) + ? settings.payments.feeSchedules.admission + : [] + + SettingsStatic._settings = { + ...settings, + payments: { + ...(settings?.payments ?? {}), + enabled: true, + processor: 'nwc', + feeSchedules: { + ...(settings?.payments?.feeSchedules ?? {}), + admission: [ + { + ...(admission[0] ?? {}), + enabled: true, + amount: ADMISSION_FEE_MSATS, + whitelists: {}, + }, + ], + }, + }, + paymentsProcessors: { + ...(settings?.paymentsProcessors ?? {}), + nwc: { + invoiceExpirySeconds: 900, + replyTimeoutMs: 10000, + ...(settings?.paymentsProcessors?.nwc ?? {}), + }, + }, + } + + const walletService = new nwc.NWCWalletService({ relayUrl: 'ws://localhost:18808' }) + const keypair = new nwc.NWCWalletServiceKeyPair(walletSecret, clientPubkey) + + const dbClient = getMasterDbClient() + await dbClient('users') + .insert([ + { + pubkey: Buffer.from(walletPubkey, 'hex'), + is_admitted: true, + }, + { + pubkey: Buffer.from(clientPubkey, 'hex'), + is_admitted: true, + }, + ]) + .onConflict('pubkey') + .merge({ is_admitted: true }) + + await walletService.publishWalletServiceInfoEvent(walletSecret, ['make_invoice', 'lookup_invoice', 'get_info'], []) + + this.parameters.nwcWalletService = walletService + this.parameters.nwcWalletKeypair = keypair + this.parameters.nwcWalletInvoices = new Map() + this.parameters.nwcInsertedInvoiceIds = [] + this.parameters.nwcTestPubkeys = [walletPubkey, clientPubkey] +}) + +Given('NWC reply timeout is set to {int} milliseconds', function (this: World>, timeoutMs: number) { + const settings = SettingsStatic._settings as any + SettingsStatic._settings = { + ...settings, + paymentsProcessors: { + ...(settings?.paymentsProcessors ?? {}), + nwc: { + ...(settings?.paymentsProcessors?.nwc ?? {}), + replyTimeoutMs: timeoutMs, + }, + }, + } +}) + +Given('NWC wallet service make_invoice responds with a pending invoice', async function (this: World>) { + const walletService = this.parameters.nwcWalletService as nwc.NWCWalletService + const keypair = this.parameters.nwcWalletKeypair as nwc.NWCWalletServiceKeyPair + const invoices = this.parameters.nwcWalletInvoices as Map + + this.parameters.nwcWalletUnsubscribe = await walletService.subscribe(keypair, { + async makeInvoice(request) { + const now = Math.floor(Date.now() / 1000) + const paymentHash = `ph-${request.amount}-${now}` + const invoice = `lnbc${Math.max(1, Math.floor(request.amount / 1000))}n1integration${now}` + const tx = { + type: 'incoming', + state: 'pending', + invoice, + description: request.description ?? '', + description_hash: request.description_hash ?? '', + preimage: '', + payment_hash: paymentHash, + amount: request.amount, + fees_paid: 0, + settled_at: 0, + created_at: now, + expires_at: now + (request.expiry ?? 900), + } + invoices.set(paymentHash, tx) + return { result: tx as any, error: undefined } + }, + async lookupInvoice(request) { + const tx = request.payment_hash ? invoices.get(request.payment_hash) : undefined + if (!tx) { + return { result: undefined, error: { code: 'NOT_FOUND', message: 'invoice not found' } } + } + return { result: tx, error: undefined } + }, + async getInfo() { + return { + result: { + alias: 'nwc-test-wallet', + color: '#000000', + pubkey: this.parameters.nwcWalletPubkey, + network: 'regtest', + block_height: 0, + block_hash: '00', + methods: ['make_invoice', 'lookup_invoice'], + } as any, + error: undefined, + } + }, + }) +}) + +Given('NWC wallet service make_invoice never responds', async function (this: World>) { + const walletService = this.parameters.nwcWalletService as nwc.NWCWalletService + const keypair = this.parameters.nwcWalletKeypair as nwc.NWCWalletServiceKeyPair + + this.parameters.nwcWalletUnsubscribe = await walletService.subscribe(keypair, { + async makeInvoice(request) { + await sleep(120, undefined, { ref: false }) + const now = Math.floor(Date.now() / 1000) + return { + result: { + type: 'incoming', + state: 'pending', + invoice: `lnbc${Math.max(1, Math.floor(request.amount / 1000))}n1late${now}`, + description: request.description ?? '', + description_hash: request.description_hash ?? '', + preimage: '', + payment_hash: `late-ph-${request.amount}-${now}`, + amount: request.amount, + fees_paid: 0, + settled_at: 0, + created_at: now, + expires_at: now + (request.expiry ?? 900), + } as any, + error: undefined, + } + }, + async lookupInvoice() { + return { result: undefined, error: { code: 'NOT_FOUND', message: 'invoice not found' } } + }, + }) +}) +When('I request an admission invoice for pubkey {string}', async function (this: World>, pubkey: string) { + const response: AxiosResponse = await axios.post( + INVOICES_URL, + new URLSearchParams({ + tosAccepted: 'yes', + feeSchedule: 'admission', + pubkey, + }).toString(), + { + headers: { + 'content-type': 'application/x-www-form-urlencoded', + }, + validateStatus: () => true, + }, + ) + + this.parameters.nwcInvoiceHttpResponse = response + this.parameters.nwcTestPubkeys = [...(this.parameters.nwcTestPubkeys ?? []), pubkey] + + if (response.status === 400) { + throw new Error(`Unexpected 400 response body: ${String(response.data)}`) + } +}) + +Then('the invoice request response status is {int}', function (this: World>, statusCode: number) { + const response = this.parameters.nwcInvoiceHttpResponse as AxiosResponse + expect(response.status).to.equal(statusCode) +}) + +Then('an NWC invoice is stored as pending for pubkey {string}', async function (this: World>, pubkey: string) { + const dbClient = getMasterDbClient() + const row = await dbClient('invoices') + .where('pubkey', Buffer.from(pubkey, 'hex')) + .orderBy('created_at', 'desc') + .first('id', 'status', 'unit', 'amount_requested') + + expect(row).to.exist + expect(row.status).to.equal('pending') + expect(row.unit).to.equal('msats') + expect(row.amount_requested).to.equal(ADMISSION_FEE_MSATS.toString()) + + this.parameters.nwcInsertedInvoiceIds = [ + ...(this.parameters.nwcInsertedInvoiceIds ?? []), + row.id, + ] +}) + +Then('no invoice is stored for pubkey {string}', async function (this: World>, pubkey: string) { + const dbClient = getMasterDbClient() + const row = await dbClient('invoices') + .where('pubkey', Buffer.from(pubkey, 'hex')) + .orderBy('created_at', 'desc') + .first('id') + + const response = this.parameters.nwcInvoiceHttpResponse as AxiosResponse + expect(response.status).to.equal(500) + expect(row).to.equal(undefined) + + await sleep(250, undefined, { ref: false }) +}) + +After({ tags: '@nwc-invoice' }, async function (this: World>) { + const unsubscribe = this.parameters.nwcWalletUnsubscribe as (() => Promise) | (() => void) | undefined + if (typeof unsubscribe === 'function') { + await unsubscribe() + } + + const walletService = this.parameters.nwcWalletService as nwc.NWCWalletService | undefined + if (walletService) { + walletService.close() + } + + if (typeof this.parameters.previousNwcUrl === 'undefined') { + delete process.env.NWC_URL + } else { + process.env.NWC_URL = this.parameters.previousNwcUrl + } + + if (this.parameters.previousNwcSettings) { + SettingsStatic._settings = this.parameters.previousNwcSettings + } + + const dbClient = getMasterDbClient() + const insertedInvoiceIds = this.parameters.nwcInsertedInvoiceIds ?? [] + if (insertedInvoiceIds.length > 0) { + await dbClient('invoices').whereIn('id', insertedInvoiceIds).delete() + } + + const testPubkeys = this.parameters.nwcTestPubkeys ?? [] + if (testPubkeys.length > 0) { + await dbClient('users') + .whereIn( + 'pubkey', + testPubkeys.map((p: string) => Buffer.from(p, 'hex')), + ) + .delete() + } + + this.parameters.nwcWalletUnsubscribe = undefined + this.parameters.nwcWalletService = undefined + this.parameters.nwcWalletKeypair = undefined + this.parameters.nwcWalletInvoices = undefined + this.parameters.nwcInvoiceHttpResponse = undefined + this.parameters.nwcInsertedInvoiceIds = [] + this.parameters.nwcTestPubkeys = [] + this.parameters.previousNwcUrl = undefined + this.parameters.previousNwcSettings = undefined + this.parameters.nwcWalletPubkey = undefined + this.parameters.nwcClientPubkey = undefined + this.parameters.nwcWalletSecret = undefined + this.parameters.nwcClientSecret = undefined + this.parameters.nwcUriScheme = undefined +}) diff --git a/test/unit/factories/payments-processors/nwc-payments-processor-factory.spec.ts b/test/unit/factories/payments-processors/nwc-payments-processor-factory.spec.ts new file mode 100644 index 00000000..e0250aa9 --- /dev/null +++ b/test/unit/factories/payments-processors/nwc-payments-processor-factory.spec.ts @@ -0,0 +1,95 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +const { expect } = chai + +import { createNwcPaymentsProcessor } from '../../../../src/factories/payments-processors/nwc-payments-processor-factory' + +describe('createNwcPaymentsProcessor', () => { + let sandbox: sinon.SinonSandbox + const originalUrl = process.env.NWC_URL + + const settings = { + paymentsProcessors: { + nwc: { + replyTimeoutMs: 10_000, + invoiceExpirySeconds: 900, + }, + }, + } as any + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + if (typeof originalUrl === 'string') { + process.env.NWC_URL = originalUrl + } else { + delete process.env.NWC_URL + } + }) + + it('throws when NWC_URL is missing', () => { + delete process.env.NWC_URL + + expect(() => createNwcPaymentsProcessor(settings)).to.throw('NWC_URL must be set.') + }) + + it('throws when NWC_URL is invalid', () => { + process.env.NWC_URL = 'https://example.com/not-nwc' + + expect(() => createNwcPaymentsProcessor(settings)).to.throw('NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.') + }) + + it('throws when settings.paymentsProcessors.nwc.replyTimeoutMs is invalid', () => { + process.env.NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + expect(() => + createNwcPaymentsProcessor({ + paymentsProcessors: { + nwc: { + replyTimeoutMs: 0, + invoiceExpirySeconds: 900, + }, + }, + } as any) + ).to.throw('Setting paymentsProcessors.nwc.replyTimeoutMs must be a positive number.') + }) + + it('throws when settings.paymentsProcessors.nwc.invoiceExpirySeconds is invalid', () => { + process.env.NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + expect(() => + createNwcPaymentsProcessor({ + paymentsProcessors: { + nwc: { + replyTimeoutMs: 10_000, + invoiceExpirySeconds: 0, + }, + }, + } as any) + ).to.throw('Setting paymentsProcessors.nwc.invoiceExpirySeconds must be a positive integer.') + }) + + it('creates the processor when config is valid', () => { + process.env.NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + const result = createNwcPaymentsProcessor(settings) + + expect(result).to.have.property('createInvoice') + expect(result).to.have.property('getInvoice') + }) + + it('accepts legacy nostrwalletconnect URI scheme', () => { + process.env.NWC_URL = 'nostrwalletconnect://wallet?relay=wss://relay&secret=abc' + + const result = createNwcPaymentsProcessor(settings) + + expect(result).to.have.property('createInvoice') + expect(result).to.have.property('getInvoice') + }) +}) diff --git a/test/unit/payments-processors/nwc-payments-processor.spec.ts b/test/unit/payments-processors/nwc-payments-processor.spec.ts new file mode 100644 index 00000000..a4706137 --- /dev/null +++ b/test/unit/payments-processors/nwc-payments-processor.spec.ts @@ -0,0 +1,214 @@ +import chai from 'chai' +import chaiAsPromised from 'chai-as-promised' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +chai.use(chaiAsPromised) +const { expect } = chai + +import { nwc } from '@getalby/sdk' +import { NwcPaymentsProcessor } from '../../../src/payments-processors/nwc-payments-processor' +import { InvoiceStatus } from '../../../src/@types/invoice' + +describe('NwcPaymentsProcessor', () => { + let sandbox: sinon.SinonSandbox + let makeInvoiceStub: sinon.SinonStub + let lookupInvoiceStub: sinon.SinonStub + let closeStub: sinon.SinonStub + + const settings = () => ({ + paymentsProcessors: { + nwc: { + invoiceExpirySeconds: 900, + replyTimeoutMs: 10_000, + }, + }, + }) as any + + beforeEach(() => { + sandbox = sinon.createSandbox() + makeInvoiceStub = sandbox.stub() + lookupInvoiceStub = sandbox.stub() + closeStub = sandbox.stub() + + sandbox.stub(nwc, 'NWCClient').callsFake(() => { + return { + makeInvoice: makeInvoiceStub, + lookupInvoice: lookupInvoiceStub, + close: closeStub, + } as any + }) + }) + + afterEach(() => { + sandbox.restore() + }) + + it('maps makeInvoice response to CreateInvoiceResponse', async () => { + makeInvoiceStub.resolves({ + payment_hash: 'payment-hash-1', + invoice: 'lnbc1abc', + amount: 21000, + description: 'Admission fee', + state: 'pending', + created_at: 1710000000, + expires_at: 1710000900, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + const result = await processor.createInvoice({ + amount: 21000n, + description: 'Admission fee', + requestId: 'pubkey123', + }) + + expect(result.id).to.equal('payment-hash-1') + expect(result.bolt11).to.equal('lnbc1abc') + expect(result.amountRequested).to.equal(21000n) + expect(result.status).to.equal(InvoiceStatus.PENDING) + expect(result.pubkey).to.equal('pubkey123') + expect(closeStub).to.have.been.calledOnce + }) + + it('maps settled lookup invoice to completed', async () => { + lookupInvoiceStub.resolves({ + payment_hash: 'payment-hash-2', + invoice: 'lnbc1def', + amount: 21000, + description: 'Admission fee', + state: 'settled', + created_at: 1710000000, + settled_at: 1710000100, + expires_at: 1710000900, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + const result = await processor.getInvoice('payment-hash-2') + + expect(result.id).to.equal('payment-hash-2') + expect(result.status).to.equal(InvoiceStatus.COMPLETED) + expect(result.confirmedAt).to.be.instanceOf(Date) + expect(closeStub).to.have.been.calledOnce + }) + + it('maps failed lookup invoice to expired', async () => { + lookupInvoiceStub.resolves({ + payment_hash: 'payment-hash-3', + state: 'failed', + created_at: 1710000000, + expires_at: 1710000900, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + const result = await processor.getInvoice('payment-hash-3') + + expect(result.status).to.equal(InvoiceStatus.EXPIRED) + }) + + it('maps accepted lookup invoice to pending', async () => { + lookupInvoiceStub.resolves({ + payment_hash: 'payment-hash-4', + state: 'accepted', + created_at: 1710000000, + expires_at: 1710000900, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + const result = await processor.getInvoice('payment-hash-4') + + expect(result.status).to.equal(InvoiceStatus.PENDING) + }) + + it('rethrows SDK errors and still closes client', async () => { + makeInvoiceStub.rejects(new Error('wallet unavailable')) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + await expect( + processor.createInvoice({ amount: 1n, description: 'x', requestId: 'p' }) + ).to.be.rejectedWith('wallet unavailable') + + expect(closeStub).to.have.been.calledOnce + }) + + it('applies configured replyTimeoutMs to makeInvoice requests', async () => { + makeInvoiceStub.returns(new Promise(() => {})) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 5, settings) + + const pending = processor.createInvoice({ + amount: 1000n, + description: 'Timeout test', + requestId: 'pubkey-timeout', + }) + + await expect(pending).to.be.rejectedWith('reply timeout after 5ms') + expect(closeStub).to.have.been.calledOnce + }) + + it('passes invoiceExpirySeconds to makeInvoice and maps expiresAt', async () => { + makeInvoiceStub.resolves({ + payment_hash: 'payment-hash-expiry', + invoice: 'lnbc1expiry', + amount: 1000, + description: 'Expiry test', + state: 'pending', + created_at: 1710000000, + expires_at: 1710000300, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + const result = await processor.createInvoice({ + amount: 1000n, + description: 'Expiry test', + requestId: 'pubkey-expiry', + }) + + expect(makeInvoiceStub).to.have.been.calledOnceWithExactly({ + amount: 1000, + description: 'Expiry test', + expiry: 900, + }) + expect(result.expiresAt?.toISOString()).to.equal('2024-03-09T16:05:00.000Z') + }) + + it('does not wait for the reply timeout when operation succeeds first', async () => { + makeInvoiceStub.resolves({ + payment_hash: 'payment-hash-fast', + invoice: 'lnbc1fast', + amount: 1000, + description: 'Fast op', + state: 'pending', + created_at: 1710000000, + expires_at: 1710000300, + }) + + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + await processor.createInvoice({ + amount: 1000n, + description: 'Fast op', + requestId: 'pubkey-fast', + }) + + expect(closeStub).to.have.been.calledOnce + }) + + it('throws when createInvoice amount exceeds Number.MAX_SAFE_INTEGER', async () => { + const processor = new NwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + await expect( + processor.createInvoice({ + amount: BigInt(Number.MAX_SAFE_INTEGER) + 1n, + description: 'Unsafe amount', + requestId: 'pubkey-unsafe', + }) + ).to.be.rejectedWith('CreateInvoiceRequest.amount exceeds Number.MAX_SAFE_INTEGER.') + }) +})