From 3779b7f76d2ef2946bb48ff031e31e9029094b8b Mon Sep 17 00:00:00 2001 From: justxd22 Date: Sun, 19 Apr 2026 22:05:38 +0200 Subject: [PATCH 1/6] feat: add Alby NWC payments processor (#323) --- .changeset/seven-lines-heal.md | 5 + .env.example | 1 + CONFIGURATION.md | 2 + README.md | 17 +- package.json | 1 + resources/default-settings.yaml | 3 + src/@types/settings.ts | 7 +- src/factories/payments-processor-factory.ts | 3 + .../alby-nwc-payments-processor-factory.ts | 43 ++++ .../alby-nwc-payments-processor.ts | 193 ++++++++++++++++ ...lby-nwc-payments-processor-factory.spec.ts | 78 +++++++ .../alby-nwc-payments-processor.spec.ts | 209 ++++++++++++++++++ 12 files changed, 557 insertions(+), 5 deletions(-) create mode 100644 .changeset/seven-lines-heal.md create mode 100644 src/factories/payments-processors/alby-nwc-payments-processor-factory.ts create mode 100644 src/payments-processors/alby-nwc-payments-processor.ts create mode 100644 test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts create mode 100644 test/unit/payments-processors/alby-nwc-payments-processor.spec.ts diff --git a/.changeset/seven-lines-heal.md b/.changeset/seven-lines-heal.md new file mode 100644 index 00000000..d15ae245 --- /dev/null +++ b/.changeset/seven-lines-heal.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +Add Alby 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 7f335c89..c5efbe8c 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 927da453..bcb92cbb 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 | | +| ALBY_NWC_URL | Alby NWC connection URL (`nostr+walletconnect://...`) | | ## I2P @@ -154,3 +155,4 @@ The settings below are listed in alphabetical order by name. Please keep this ta | limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. | | limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. | | limits.rateLimiter.strategy | Rate limiting strategy. Either `ewma` or `sliding_window`. Defaults to `ewma`. When using `ewma`, the `period` field in each rate limit serves as the half-life for the exponential decay function. Note: when switching from `sliding_window` to `ewma`, consider increasing `rate` values slightly as EWMA penalizes bursty behavior more aggressively. | +| payments.processor | One of `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `alby`. | diff --git a/README.md b/README.md index 2b53b333..e12ed285 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,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`, `alby` 2. [ZEBEDEE](https://zebedee.io) - Complete the step "Before you begin" @@ -173,7 +173,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 (`./scripts/stop` followed by `./scripts/start`) -7. Ensure payments are required for your public key +7. [Alby Wallet API (NIP-47 / NWC)](https://getalby.com/) + - Complete the step "Before you begin" + - Create an app connection in your wallet and copy the generated NWC URL + - Set `ALBY_NWC_URL` environment variable on your `.env` file + + ``` + ALBY_NWC_URL={NOSTR_WALLET_CONNECT_URL} + ``` + + - On your `.nostr/settings.yaml` file make the following changes: + - Set `payments.processor` to `alby` + - 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 06945be1..19443590 100644 --- a/package.json +++ b/package.json @@ -128,6 +128,7 @@ "node": ">=24.14.1" }, "dependencies": { + "@getalby/sdk": "^5.0.0", "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 7f0ea0ef..05d1805c 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -35,6 +35,9 @@ paymentsProcessors: opennode: baseURL: api.opennode.com callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode + alby: + 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 8c36fe8a..9c94b16a 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -198,9 +198,9 @@ export interface OpenNodePaymentsProcessor { callbackBaseURL: string } -export interface NodelessPaymentsProcessor { - baseURL: string - storeId: string +export interface AlbyPaymentsProcessor { + invoiceExpirySeconds: number + replyTimeoutMs: number } export interface PaymentsProcessors { @@ -209,6 +209,7 @@ export interface PaymentsProcessors { lnbits?: LNbitsPaymentsProcessor nodeless?: NodelessPaymentsProcessor opennode?: OpenNodePaymentsProcessor + alby?: AlbyPaymentsProcessor } export interface Local { diff --git a/src/factories/payments-processor-factory.ts b/src/factories/payments-processor-factory.ts index b26f045f..441b33fe 100644 --- a/src/factories/payments-processor-factory.ts +++ b/src/factories/payments-processor-factory.ts @@ -1,3 +1,4 @@ +import { createAlbyNwcPaymentsProcessor } from './payments-processors/alby-nwc-payments-processor-factory' import { createLNbitsPaymentProcessor } from './payments-processors/lnbits-payments-processor-factory' import { createLnurlPaymentsProcessor } from './payments-processors/lnurl-payments-processor-factory' import { createLogger } from './logger-factory' @@ -29,6 +30,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => { return createNodelessPaymentsProcessor(settings) case 'opennode': return createOpenNodePaymentsProcessor(settings) + case 'alby': + return createAlbyNwcPaymentsProcessor(settings) default: return new NullPaymentsProcessor() } diff --git a/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts new file mode 100644 index 00000000..60ed9b61 --- /dev/null +++ b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts @@ -0,0 +1,43 @@ +import { createSettings } from '../settings-factory' +import { AlbyNwcPaymentsProcessor } from '../../payments-processors/alby-nwc-payments-processor' +import { IPaymentsProcessor } from '../../@types/clients' +import { Settings } from '../../@types/settings' + +const getAlbyNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: number } => { + const nwcUrl = process.env.ALBY_NWC_URL + + if (!nwcUrl) { + const error = new Error('ALBY_NWC_URL must be set.') + console.error('Unable to create Alby NWC payments processor.', error) + throw error + } + + if (!nwcUrl.startsWith('nostr+walletconnect://') && !nwcUrl.startsWith('nostrwalletconnect://')) { + const error = new Error('ALBY_NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.') + console.error('Unable to create Alby NWC payments processor.', error) + throw error + } + + try { + new URL(nwcUrl) + } catch { + const error = new Error('ALBY_NWC_URL is not parseable as a URL.') + console.error('Unable to create Alby NWC payments processor.', error) + throw error + } + + const replyTimeoutMs = settings.paymentsProcessors?.alby?.replyTimeoutMs + if (typeof replyTimeoutMs !== 'number' || replyTimeoutMs <= 0) { + const error = new Error('Setting paymentsProcessors.alby.replyTimeoutMs must be a positive number.') + console.error('Unable to create Alby NWC payments processor.', error) + throw error + } + + return { nwcUrl, replyTimeoutMs } +} + +export const createAlbyNwcPaymentsProcessor = (settings: Settings): IPaymentsProcessor => { + const { nwcUrl, replyTimeoutMs } = getAlbyNwcConfig(settings) + + return new AlbyNwcPaymentsProcessor(nwcUrl, replyTimeoutMs, createSettings) +} diff --git a/src/payments-processors/alby-nwc-payments-processor.ts b/src/payments-processors/alby-nwc-payments-processor.ts new file mode 100644 index 00000000..d0c66bf7 --- /dev/null +++ b/src/payments-processors/alby-nwc-payments-processor.ts @@ -0,0 +1,193 @@ +import { nwc } from '@getalby/sdk' + +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 debug = createLogger('alby-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 +} + +export class AlbyNwcInvoice 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 AlbyNwcCreateInvoiceResponse 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 AlbyNwcPaymentsProcessor implements IPaymentsProcessor { + public constructor( + private nwcUrl: string, + private replyTimeoutMs: number, + private settings: Factory, + ) {} + + private withReplyTimeout = async (operation: Promise): Promise => { + let timeoutId: ReturnType | undefined + + try { + return await Promise.race([ + operation, + new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject(new nwc.Nip47ReplyTimeoutError(`reply timeout after ${this.replyTimeoutMs}ms`, 'INTERNAL')) + }, this.replyTimeoutMs) + }), + ]) + } finally { + if (timeoutId) { + clearTimeout(timeoutId) + } + } + } + + private withClient = async (fn: (client: nwc.NWCClient) => Promise): Promise => { + const client = new nwc.NWCClient({ nostrWalletConnectUrl: this.nwcUrl }) + + try { + return await fn(client) + } finally { + client.close() + } + } + + public async getInvoice(invoiceOrId: string | Invoice): Promise { + const invoiceId = typeof invoiceOrId === 'string' ? invoiceOrId : invoiceOrId.id + debug('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 = new AlbyNwcInvoice() + invoice.id = transaction.payment_hash || invoiceId + invoice.pubkey = typeof invoiceOrId === 'string' ? '' : invoiceOrId.pubkey + invoice.bolt11 = transaction.invoice || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.bolt11) + invoice.amountRequested = + typeof transaction.amount === 'number' && Number.isFinite(transaction.amount) + ? BigInt(Math.trunc(transaction.amount)) + : typeof invoiceOrId === 'string' + ? 0n + : invoiceOrId.amountRequested + invoice.amountPaid = status === InvoiceStatus.COMPLETED ? invoice.amountRequested : undefined + invoice.unit = InvoiceUnit.MSATS + invoice.status = status + invoice.description = transaction.description || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.description) + invoice.confirmedAt = 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.updatedAt = new Date() + + return invoice + }) + } catch (error) { + if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) { + console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error.message) + } else { + console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error) + } + throw error + } + } + + public async createInvoice(request: CreateInvoiceRequest): Promise { + debug('create invoice: %o', request) + const { amount: amountMsats, description, requestId: pubkey } = request + + try { + return await this.withClient(async (client) => { + const expirySeconds = this.settings().paymentsProcessors?.alby?.invoiceExpirySeconds + const transaction = (await this.withReplyTimeout( + client.makeInvoice({ + amount: Number(amountMsats), + description, + expiry: expirySeconds, + }), + )) as NwcTransaction + + const invoice = new AlbyNwcCreateInvoiceResponse() + 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) { + console.error('Unable to request Alby NWC invoice. Reason:', error.message) + } else { + console.error('Unable to request Alby NWC invoice. Reason:', error) + } + throw error + } + } +} diff --git a/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts b/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts new file mode 100644 index 00000000..f3ab83bc --- /dev/null +++ b/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts @@ -0,0 +1,78 @@ +import chai from 'chai' +import sinon from 'sinon' +import sinonChai from 'sinon-chai' + +chai.use(sinonChai) +const { expect } = chai + +import { createAlbyNwcPaymentsProcessor } from '../../../../src/factories/payments-processors/alby-nwc-payments-processor-factory' + +describe('createAlbyNwcPaymentsProcessor', () => { + let sandbox: sinon.SinonSandbox + const originalUrl = process.env.ALBY_NWC_URL + + const settings = { + paymentsProcessors: { + alby: { + replyTimeoutMs: 10_000, + }, + }, + } as any + + beforeEach(() => { + sandbox = sinon.createSandbox() + }) + + afterEach(() => { + sandbox.restore() + if (typeof originalUrl === 'string') { + process.env.ALBY_NWC_URL = originalUrl + } else { + delete process.env.ALBY_NWC_URL + } + }) + + it('throws when ALBY_NWC_URL is missing', () => { + delete process.env.ALBY_NWC_URL + + expect(() => createAlbyNwcPaymentsProcessor(settings)).to.throw('ALBY_NWC_URL must be set.') + }) + + it('throws when ALBY_NWC_URL is invalid', () => { + process.env.ALBY_NWC_URL = 'https://example.com/not-nwc' + + expect(() => createAlbyNwcPaymentsProcessor(settings)).to.throw('ALBY_NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.') + }) + + it('throws when settings.paymentsProcessors.alby.replyTimeoutMs is invalid', () => { + process.env.ALBY_NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + expect(() => + createAlbyNwcPaymentsProcessor({ + paymentsProcessors: { + alby: { + replyTimeoutMs: 0, + }, + }, + } as any) + ).to.throw('Setting paymentsProcessors.alby.replyTimeoutMs must be a positive number.') + }) + + it('creates the processor when config is valid', () => { + process.env.ALBY_NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + const result = createAlbyNwcPaymentsProcessor(settings) + + expect(result).to.have.property('createInvoice') + expect(result).to.have.property('getInvoice') + }) + + it('accepts legacy nostrwalletconnect URI scheme', () => { + process.env.ALBY_NWC_URL = 'nostrwalletconnect://wallet?relay=wss://relay&secret=abc' + + const result = createAlbyNwcPaymentsProcessor(settings) + + expect(result).to.have.property('createInvoice') + expect(result).to.have.property('getInvoice') + }) +}) diff --git a/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts new file mode 100644 index 00000000..21f9d4a9 --- /dev/null +++ b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts @@ -0,0 +1,209 @@ +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 { AlbyNwcPaymentsProcessor } from '../../../src/payments-processors/alby-nwc-payments-processor' +import { InvoiceStatus } from '../../../src/@types/invoice' + +describe('AlbyNwcPaymentsProcessor', () => { + let sandbox: sinon.SinonSandbox + let makeInvoiceStub: sinon.SinonStub + let lookupInvoiceStub: sinon.SinonStub + let closeStub: sinon.SinonStub + let clock: sinon.SinonFakeTimers + + const settings = () => ({ + paymentsProcessors: { + alby: { + invoiceExpirySeconds: 900, + replyTimeoutMs: 10_000, + }, + }, + }) as any + + beforeEach(() => { + sandbox = sinon.createSandbox() + clock = sinon.useFakeTimers() + makeInvoiceStub = sandbox.stub() + lookupInvoiceStub = sandbox.stub() + closeStub = sandbox.stub() + + sandbox.stub(nwc, 'NWCClient').callsFake(() => { + return { + makeInvoice: makeInvoiceStub, + lookupInvoice: lookupInvoiceStub, + close: closeStub, + } as any + }) + }) + + afterEach(() => { + clock.restore() + 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 AlbyNwcPaymentsProcessor('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 AlbyNwcPaymentsProcessor('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 AlbyNwcPaymentsProcessor('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 AlbyNwcPaymentsProcessor('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 AlbyNwcPaymentsProcessor('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 AlbyNwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 50, settings) + + const pending = processor.createInvoice({ + amount: 1000n, + description: 'Timeout test', + requestId: 'pubkey-timeout', + }) + + await clock.tickAsync(51) + + await expect(pending).to.be.rejectedWith('reply timeout after 50ms') + 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 AlbyNwcPaymentsProcessor('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('clears timeout timer when operation succeeds before timeout', async () => { + const clearTimeoutSpy = sandbox.spy(global, 'clearTimeout') + + 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 AlbyNwcPaymentsProcessor('nostr+walletconnect://wallet?relay=wss://relay&secret=abc', 10_000, settings) + + await processor.createInvoice({ + amount: 1000n, + description: 'Fast op', + requestId: 'pubkey-fast', + }) + + expect(clearTimeoutSpy.called).to.equal(true) + }) +}) From 632b24d83f6ee47d21706ec89c04349102d31666 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Sun, 19 Apr 2026 23:14:17 +0200 Subject: [PATCH 2/6] fix: pump packages lock --- package-lock.json | 173 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 170 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index cb999924..0ee96f94 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "2.1.1", "license": "MIT", "dependencies": { + "@getalby/sdk": "^5.0.0", "@noble/secp256k1": "1.7.1", "accepts": "^1.3.8", "axios": "^1.15.0", @@ -97,6 +98,7 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1573,6 +1575,7 @@ "integrity": "sha512-Kxap9uP5jD8tHUZVjTWgzxemi/0uOsbGjd4LBOSxcJoOCRbESFwemUzilJuzNTB8pcTQUh8D5oudUyxfkJOKmA==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@cucumber/messages": ">=17.1.1" } @@ -1643,6 +1646,36 @@ "node": ">=4" } }, + "node_modules/@getalby/lightning-tools": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/@getalby/lightning-tools/-/lightning-tools-5.2.1.tgz", + "integrity": "sha512-dxOmJLJAh6qJ8rsbA5/Bwj7MSI9X3RkxxqmCedl5rfP+yKwNSdfu8i4EiCZN/tk2hNBJb8GHSCcPRNZfwfmEHg==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, + "node_modules/@getalby/sdk": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@getalby/sdk/-/sdk-5.1.2.tgz", + "integrity": "sha512-yUF9LhuvdIFOwjV1aG0ryzfwDiGBFk/CRLkRvrrM9dsE38SUjKsf1FDga5jxsKMu80nWcPZR9TiGGASWedoYPA==", + "license": "MIT", + "dependencies": { + "@getalby/lightning-tools": "^5.2.0", + "nostr-tools": "2.15.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "lightning", + "url": "lightning:hello@getalby.com" + } + }, "node_modules/@inquirer/external-editor": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.3.tgz", @@ -2117,6 +2150,51 @@ "node": ">= 4.0.0" } }, + "node_modules/@noble/ciphers": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.5.3.tgz", + "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.2.0.tgz", + "integrity": "sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.2" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves/node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.1.tgz", + "integrity": "sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@noble/secp256k1": { "version": "1.7.1", "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.1.tgz", @@ -2308,6 +2386,7 @@ "integrity": "sha512-YfcB2QrX+Wx1o6LD1G2Y2fhDhOix/bAY/oAnMpHoNLsKkWIRbt1oKLkIFvxBMzLwAEPqnYWguJrYC+J6i4ywbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bole": "^5.0.0", "ndjson": "^2.0.0" @@ -2472,6 +2551,7 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-1.4.2.tgz", "integrity": "sha512-oUdEjE0I7JS5AyaAjkD3aOXn9NhO7XKyPyXEyrgFDu++VrVBHUPnV6dgEya9TcMuj5nIJRuCzCm8ZP+c9zCHPw==", "license": "MIT", + "peer": true, "dependencies": { "cluster-key-slot": "1.1.1", "generic-pool": "3.9.0", @@ -2517,6 +2597,57 @@ "@redis/client": "^1.0.0" } }, + "node_modules/@scure/base": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-1.1.1.tgz", + "integrity": "sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, + "node_modules/@scure/bip32": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@scure/bip32/-/bip32-1.3.1.tgz", + "integrity": "sha512-osvveYtyzdEVbt3OfwwXFr4P2iVBL5u1Q3q4ONBfDY/UpOuXmOlbgwc1xECEboY8wIays8Yt6onaWMUdUbfl0A==", + "license": "MIT", + "dependencies": { + "@noble/curves": "~1.1.0", + "@noble/hashes": "~1.3.1", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip32/node_modules/@noble/curves": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.1.0.tgz", + "integrity": "sha512-091oBExgENk/kGj3AZmtBDMpxQPDtxQABR2B9lb1JbVTs6ytdzZNwvhxQ4MWasRNEzlbEH8jCWFCwhF/Obj5AA==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.3.1" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@scure/bip39": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-1.2.1.tgz", + "integrity": "sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "~1.3.0", + "@scure/base": "~1.1.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinonjs/commons": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", @@ -2731,6 +2862,7 @@ "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3327,6 +3459,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3535,6 +3668,7 @@ "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "assertion-error": "^1.1.0", "check-error": "^1.0.3", @@ -3908,6 +4042,7 @@ "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", @@ -7160,6 +7295,35 @@ "node": ">=0.10.0" } }, + "node_modules/nostr-tools": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.15.0.tgz", + "integrity": "sha512-Jj/+UFbu3JbTAWP4ipPFNuyD4W5eVRBNAP+kmnoRCYp3bLmTrlQ0Qhs5O1xSQJTFpjdZqoS0zZOUKdxUdjc+pw==", + "license": "Unlicense", + "dependencies": { + "@noble/ciphers": "^0.5.1", + "@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" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/nostr-wasm": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/nostr-wasm/-/nostr-wasm-0.1.0.tgz", + "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", + "license": "MIT" + }, "node_modules/npm-normalize-package-bin": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", @@ -7905,6 +8069,7 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.9.0.tgz", "integrity": "sha512-ZJM+qkEbtOHRuXjmvBtOgNOXOtLSbxiMiUVMgE4rV6Zwocy03RicCVvDXgx8l4Biwo8/qORUnEqn2fdQzV7KCg==", "license": "MIT", + "peer": true, "dependencies": { "buffer-writer": "2.0.0", "packet-reader": "1.0.0", @@ -8818,8 +8983,7 @@ "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "dev": true, - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/regexp-match-indices": { "version": "1.0.2", @@ -10135,6 +10299,7 @@ "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -10405,8 +10570,9 @@ "version": "5.7.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10945,6 +11111,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-3.22.4.tgz", "integrity": "sha512-iC+8Io04lddc+mVqQ9AZ7OQ2MrUKGN+oIQyq1vemgt46jwCwLfhq7/pwnBnNXXXZb8VTVLKwp9EDkx+ryxIWmg==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } From 323ae9357e98a201669fd2c7f2af9177ebb5691e Mon Sep 17 00:00:00 2001 From: justxd22 Date: Sun, 19 Apr 2026 23:30:53 +0200 Subject: [PATCH 3/6] fix: satisfy lint rules --- .../alby-nwc-payments-processor-factory.ts | 11 +++++++---- .../alby-nwc-payments-processor.ts | 8 ++++---- .../integration/features/rate-limiter/rate-limiter.ts | 7 +++++-- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts index 60ed9b61..a5c425ca 100644 --- a/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts +++ b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts @@ -1,20 +1,23 @@ import { createSettings } from '../settings-factory' import { AlbyNwcPaymentsProcessor } from '../../payments-processors/alby-nwc-payments-processor' +import { createLogger } from '../logger-factory' import { IPaymentsProcessor } from '../../@types/clients' import { Settings } from '../../@types/settings' +const debug = createLogger('alby-nwc-payments-processor-factory') + const getAlbyNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: number } => { const nwcUrl = process.env.ALBY_NWC_URL if (!nwcUrl) { const error = new Error('ALBY_NWC_URL must be set.') - console.error('Unable to create Alby NWC payments processor.', error) + debug('Unable to create Alby NWC payments processor. %o', error) throw error } if (!nwcUrl.startsWith('nostr+walletconnect://') && !nwcUrl.startsWith('nostrwalletconnect://')) { const error = new Error('ALBY_NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.') - console.error('Unable to create Alby NWC payments processor.', error) + debug('Unable to create Alby NWC payments processor. %o', error) throw error } @@ -22,14 +25,14 @@ const getAlbyNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: new URL(nwcUrl) } catch { const error = new Error('ALBY_NWC_URL is not parseable as a URL.') - console.error('Unable to create Alby NWC payments processor.', error) + debug('Unable to create Alby NWC payments processor. %o', error) throw error } const replyTimeoutMs = settings.paymentsProcessors?.alby?.replyTimeoutMs if (typeof replyTimeoutMs !== 'number' || replyTimeoutMs <= 0) { const error = new Error('Setting paymentsProcessors.alby.replyTimeoutMs must be a positive number.') - console.error('Unable to create Alby NWC payments processor.', error) + debug('Unable to create Alby NWC payments processor. %o', error) throw error } diff --git a/src/payments-processors/alby-nwc-payments-processor.ts b/src/payments-processors/alby-nwc-payments-processor.ts index d0c66bf7..78a6729b 100644 --- a/src/payments-processors/alby-nwc-payments-processor.ts +++ b/src/payments-processors/alby-nwc-payments-processor.ts @@ -140,9 +140,9 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor { }) } catch (error) { if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) { - console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error.message) + debug('Unable to get Alby NWC invoice %s. Reason: %s', invoiceId, error.message) } else { - console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error) + debug('Unable to get Alby NWC invoice %s. Reason: %o', invoiceId, error) } throw error } @@ -183,9 +183,9 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor { }) } catch (error) { if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) { - console.error('Unable to request Alby NWC invoice. Reason:', error.message) + debug('Unable to request Alby NWC invoice. Reason: %s', error.message) } else { - console.error('Unable to request Alby NWC invoice. Reason:', error) + debug('Unable to request Alby NWC invoice. Reason: %o', error) } throw error } diff --git a/test/integration/features/rate-limiter/rate-limiter.ts b/test/integration/features/rate-limiter/rate-limiter.ts index ab6b7104..5737a440 100644 --- a/test/integration/features/rate-limiter/rate-limiter.ts +++ b/test/integration/features/rate-limiter/rate-limiter.ts @@ -43,8 +43,11 @@ When(/(\w+) sends a text_note event expecting to be rate limited/, async functio await new Promise((resolve, reject) => { ws.send(JSON.stringify(['EVENT', event]), (err) => { - if (err) reject(err) - else resolve() + if (err) { + reject(err) + } else { + resolve() + } }) }) From bc28b51b222c468fc8ca05cc12aa1e1f149f3916 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Sun, 19 Apr 2026 23:50:22 +0200 Subject: [PATCH 4/6] fix: enforce safe invoice params, guard bigint-to-number --- .../alby-nwc-payments-processor-factory.ts | 7 ++ .../alby-nwc-payments-processor.ts | 72 ++++++++++++++----- ...lby-nwc-payments-processor-factory.spec.ts | 17 +++++ .../alby-nwc-payments-processor.spec.ts | 12 ++++ 4 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts index a5c425ca..51b8b96d 100644 --- a/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts +++ b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts @@ -36,6 +36,13 @@ const getAlbyNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: throw error } + const invoiceExpirySeconds = settings.paymentsProcessors?.alby?.invoiceExpirySeconds + if (typeof invoiceExpirySeconds !== 'number' || !Number.isInteger(invoiceExpirySeconds) || invoiceExpirySeconds <= 0) { + const error = new Error('Setting paymentsProcessors.alby.invoiceExpirySeconds must be a positive integer.') + debug('Unable to create Alby NWC payments processor. %o', error) + throw error + } + return { nwcUrl, replyTimeoutMs } } diff --git a/src/payments-processors/alby-nwc-payments-processor.ts b/src/payments-processors/alby-nwc-payments-processor.ts index 78a6729b..161305fc 100644 --- a/src/payments-processors/alby-nwc-payments-processor.ts +++ b/src/payments-processors/alby-nwc-payments-processor.ts @@ -41,6 +41,23 @@ const timestampToDate = (unixSeconds?: number): Date | null => { 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 AlbyNwcInvoice implements Invoice { id: string pubkey: string @@ -117,24 +134,42 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor { )) as NwcTransaction const status = mapNwcStateToInvoiceStatus(transaction.state) - const invoice = new AlbyNwcInvoice() - invoice.id = transaction.payment_hash || invoiceId - invoice.pubkey = typeof invoiceOrId === 'string' ? '' : invoiceOrId.pubkey - invoice.bolt11 = transaction.invoice || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.bolt11) - invoice.amountRequested = - typeof transaction.amount === 'number' && Number.isFinite(transaction.amount) - ? BigInt(Math.trunc(transaction.amount)) - : typeof invoiceOrId === 'string' - ? 0n + 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.status = status - invoice.description = transaction.description || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.description) - invoice.confirmedAt = 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.updatedAt = new Date() + 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 }) @@ -155,9 +190,10 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor { try { return await this.withClient(async (client) => { const expirySeconds = this.settings().paymentsProcessors?.alby?.invoiceExpirySeconds + const amount = toSafeNumber(amountMsats, 'CreateInvoiceRequest.amount') const transaction = (await this.withReplyTimeout( client.makeInvoice({ - amount: Number(amountMsats), + amount, description, expiry: expirySeconds, }), diff --git a/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts b/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts index f3ab83bc..1c2da6f8 100644 --- a/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts +++ b/test/unit/factories/payments-processors/alby-nwc-payments-processor-factory.spec.ts @@ -15,6 +15,7 @@ describe('createAlbyNwcPaymentsProcessor', () => { paymentsProcessors: { alby: { replyTimeoutMs: 10_000, + invoiceExpirySeconds: 900, }, }, } as any @@ -52,12 +53,28 @@ describe('createAlbyNwcPaymentsProcessor', () => { paymentsProcessors: { alby: { replyTimeoutMs: 0, + invoiceExpirySeconds: 900, }, }, } as any) ).to.throw('Setting paymentsProcessors.alby.replyTimeoutMs must be a positive number.') }) + it('throws when settings.paymentsProcessors.alby.invoiceExpirySeconds is invalid', () => { + process.env.ALBY_NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' + + expect(() => + createAlbyNwcPaymentsProcessor({ + paymentsProcessors: { + alby: { + replyTimeoutMs: 10_000, + invoiceExpirySeconds: 0, + }, + }, + } as any) + ).to.throw('Setting paymentsProcessors.alby.invoiceExpirySeconds must be a positive integer.') + }) + it('creates the processor when config is valid', () => { process.env.ALBY_NWC_URL = 'nostr+walletconnect://wallet?relay=wss://relay&secret=abc' diff --git a/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts index 21f9d4a9..101e981d 100644 --- a/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts +++ b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts @@ -206,4 +206,16 @@ describe('AlbyNwcPaymentsProcessor', () => { expect(clearTimeoutSpy.called).to.equal(true) }) + + it('throws when createInvoice amount exceeds Number.MAX_SAFE_INTEGER', async () => { + const processor = new AlbyNwcPaymentsProcessor('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.') + }) }) From c7f2a1a8dbe7bee2ad0597b91b51701802b60c94 Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 20 Apr 2026 15:19:31 +0200 Subject: [PATCH 5/6] test: add deterministic Alby NWC integration coverage --- .../alby-nwc-payments-processor.ts | 7 + .../invoices/alby-nwc-invoice.feature | 24 ++ .../invoices/alby-nwc-invoice.feature.ts | 307 ++++++++++++++++++ .../alby-nwc-payments-processor.spec.ts | 1 + 4 files changed, 339 insertions(+) create mode 100644 test/integration/features/invoices/alby-nwc-invoice.feature create mode 100644 test/integration/features/invoices/alby-nwc-invoice.feature.ts diff --git a/src/payments-processors/alby-nwc-payments-processor.ts b/src/payments-processors/alby-nwc-payments-processor.ts index 161305fc..2c88d936 100644 --- a/src/payments-processors/alby-nwc-payments-processor.ts +++ b/src/payments-processors/alby-nwc-payments-processor.ts @@ -115,10 +115,17 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor { 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) { + await new Promise((resolve) => setTimeout(resolve, this.replyTimeoutMs + 100)) + } client.close() } } diff --git a/test/integration/features/invoices/alby-nwc-invoice.feature b/test/integration/features/invoices/alby-nwc-invoice.feature new file mode 100644 index 00000000..b4252e9d --- /dev/null +++ b/test/integration/features/invoices/alby-nwc-invoice.feature @@ -0,0 +1,24 @@ +@alby-nwc-invoice +Feature: Alby NWC invoice integration + + Scenario: creates invoice via HTTP with Alby processor + Given Alby NWC payments are enabled with URI scheme "nostr+walletconnect" + And Alby 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 Alby invoice is stored as pending for pubkey "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + + Scenario: returns 500 on Alby reply timeout + Given Alby NWC payments are enabled with URI scheme "nostr+walletconnect" + And Alby NWC reply timeout is set to 75 milliseconds + And Alby 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 Alby NWC payments are enabled with URI scheme "nostrwalletconnect" + And Alby 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 Alby invoice is stored as pending for pubkey "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" diff --git a/test/integration/features/invoices/alby-nwc-invoice.feature.ts b/test/integration/features/invoices/alby-nwc-invoice.feature.ts new file mode 100644 index 00000000..3eae4612 --- /dev/null +++ b/test/integration/features/invoices/alby-nwc-invoice.feature.ts @@ -0,0 +1,307 @@ +import WebSocket from 'ws' + +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('Alby NWC payments are enabled with URI scheme {string}', async function (this: World>, scheme: string) { + const settings = SettingsStatic._settings as any + + this.parameters.previousAlbyNwcSettings = settings + this.parameters.previousAlbyNwcUrl = process.env.ALBY_NWC_URL + this.parameters.albyNwcUriScheme = 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.albyWalletSecret = walletSecret + this.parameters.albyClientSecret = clientSecret + this.parameters.albyClientPubkey = clientPubkey + this.parameters.albyWalletPubkey = walletPubkey + + const nwcUrl = buildNwcUrl(scheme, walletPubkey, clientSecret) + process.env.ALBY_NWC_URL = nwcUrl + + const admission = Array.isArray(settings?.payments?.feeSchedules?.admission) + ? settings.payments.feeSchedules.admission + : [] + + SettingsStatic._settings = { + ...settings, + payments: { + ...(settings?.payments ?? {}), + enabled: true, + processor: 'alby', + feeSchedules: { + ...(settings?.payments?.feeSchedules ?? {}), + admission: [ + { + enabled: true, + amount: ADMISSION_FEE_MSATS, + whitelists: {}, + ...(admission[0] ?? {}), + }, + ], + }, + }, + paymentsProcessors: { + ...(settings?.paymentsProcessors ?? {}), + alby: { + invoiceExpirySeconds: 900, + replyTimeoutMs: 10000, + ...(settings?.paymentsProcessors?.alby ?? {}), + }, + }, + } + + 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.albyWalletService = walletService + this.parameters.albyWalletKeypair = keypair + this.parameters.albyWalletInvoices = new Map() + this.parameters.albyInsertedInvoiceIds = [] + this.parameters.albyTestPubkeys = [walletPubkey, clientPubkey] +}) + +Given('Alby 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 ?? {}), + alby: { + ...(settings?.paymentsProcessors?.alby ?? {}), + replyTimeoutMs: timeoutMs, + }, + }, + } +}) + +Given('Alby NWC wallet service make_invoice responds with a pending invoice', async function (this: World>) { + const walletService = this.parameters.albyWalletService as nwc.NWCWalletService + const keypair = this.parameters.albyWalletKeypair as nwc.NWCWalletServiceKeyPair + const invoices = this.parameters.albyWalletInvoices as Map + + this.parameters.albyWalletUnsubscribe = 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: 'alby-test-wallet', + color: '#000000', + pubkey: this.parameters.albyWalletPubkey, + network: 'regtest', + block_height: 0, + block_hash: '00', + methods: ['make_invoice', 'lookup_invoice'], + } as any, + error: undefined, + } + }, + }) +}) + +Given('Alby NWC wallet service make_invoice never responds', async function (this: World>) { + const walletService = this.parameters.albyWalletService as nwc.NWCWalletService + const keypair = this.parameters.albyWalletKeypair as nwc.NWCWalletServiceKeyPair + + this.parameters.albyWalletUnsubscribe = await walletService.subscribe(keypair, { + async makeInvoice(request) { + await new Promise((resolve) => setTimeout(resolve, 120)) + 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.albyInvoiceHttpResponse = response + this.parameters.albyTestPubkeys = [...(this.parameters.albyTestPubkeys ?? []), pubkey] +}) + +Then('the invoice request response status is {int}', function (this: World>, statusCode: number) { + const response = this.parameters.albyInvoiceHttpResponse as AxiosResponse + expect(response.status).to.equal(statusCode) +}) + +Then('an Alby 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.albyInsertedInvoiceIds = [ + ...(this.parameters.albyInsertedInvoiceIds ?? []), + 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.albyInvoiceHttpResponse as AxiosResponse + expect(response.status).to.equal(500) + expect(row).to.equal(undefined) + + await new Promise((resolve) => setTimeout(resolve, 250)) +}) + +After({ tags: '@alby-nwc-invoice' }, async function (this: World>) { + const unsubscribe = this.parameters.albyWalletUnsubscribe as (() => Promise) | (() => void) | undefined + if (typeof unsubscribe === 'function') { + await unsubscribe() + } + + const walletService = this.parameters.albyWalletService as nwc.NWCWalletService | undefined + if (walletService) { + walletService.close() + } + + if (typeof this.parameters.previousAlbyNwcUrl === 'undefined') { + delete process.env.ALBY_NWC_URL + } else { + process.env.ALBY_NWC_URL = this.parameters.previousAlbyNwcUrl + } + + if (this.parameters.previousAlbyNwcSettings) { + SettingsStatic._settings = this.parameters.previousAlbyNwcSettings + } + + const dbClient = getMasterDbClient() + const insertedInvoiceIds = this.parameters.albyInsertedInvoiceIds ?? [] + if (insertedInvoiceIds.length > 0) { + await dbClient('invoices').whereIn('id', insertedInvoiceIds).delete() + } + + const testPubkeys = this.parameters.albyTestPubkeys ?? [] + if (testPubkeys.length > 0) { + await dbClient('users') + .whereIn( + 'pubkey', + testPubkeys.map((p: string) => Buffer.from(p, 'hex')), + ) + .delete() + } + + this.parameters.albyWalletUnsubscribe = undefined + this.parameters.albyWalletService = undefined + this.parameters.albyWalletKeypair = undefined + this.parameters.albyWalletInvoices = undefined + this.parameters.albyInvoiceHttpResponse = undefined + this.parameters.albyInsertedInvoiceIds = [] + this.parameters.albyTestPubkeys = [] + this.parameters.previousAlbyNwcUrl = undefined + this.parameters.previousAlbyNwcSettings = undefined + this.parameters.albyWalletPubkey = undefined + this.parameters.albyClientPubkey = undefined + this.parameters.albyWalletSecret = undefined + this.parameters.albyClientSecret = undefined + this.parameters.albyNwcUriScheme = undefined +}) diff --git a/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts index 101e981d..8c9700be 100644 --- a/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts +++ b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts @@ -151,6 +151,7 @@ describe('AlbyNwcPaymentsProcessor', () => { }) await clock.tickAsync(51) + await clock.tickAsync(151) await expect(pending).to.be.rejectedWith('reply timeout after 50ms') expect(closeStub).to.have.been.calledOnce From 31875bb04e42027450d834de8a3d3f17e4e65d9e Mon Sep 17 00:00:00 2001 From: justxd22 Date: Mon, 20 Apr 2026 16:28:12 +0200 Subject: [PATCH 6/6] fix: integration test late setup --- .../features/invoices/alby-nwc-invoice.feature.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/features/invoices/alby-nwc-invoice.feature.ts b/test/integration/features/invoices/alby-nwc-invoice.feature.ts index 3eae4612..7bb9ef2a 100644 --- a/test/integration/features/invoices/alby-nwc-invoice.feature.ts +++ b/test/integration/features/invoices/alby-nwc-invoice.feature.ts @@ -55,10 +55,10 @@ Given('Alby NWC payments are enabled with URI scheme {string}', async function ( ...(settings?.payments?.feeSchedules ?? {}), admission: [ { + ...(admission[0] ?? {}), enabled: true, amount: ADMISSION_FEE_MSATS, whitelists: {}, - ...(admission[0] ?? {}), }, ], }, @@ -214,6 +214,10 @@ When('I request an admission invoice for pubkey {string}', async function (this: this.parameters.albyInvoiceHttpResponse = response this.parameters.albyTestPubkeys = [...(this.parameters.albyTestPubkeys ?? []), 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) {