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 df63db91..11f9cd34 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 @@ -219,5 +220,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`, `alby`. | | 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 6344b31a..d3579359 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`, `alby` 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 (`./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-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" } diff --git a/package.json b/package.json index b25f5241..fbacc1cc 100644 --- a/package.json +++ b/package.json @@ -129,6 +129,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 5a1ed5d9..8a686d35 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 4e8a2075..7c852b74 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -199,9 +199,9 @@ export interface OpenNodePaymentsProcessor { callbackBaseURL: string } -export interface NodelessPaymentsProcessor { - baseURL: string - storeId: string +export interface AlbyPaymentsProcessor { + invoiceExpirySeconds: number + replyTimeoutMs: number } export interface PaymentsProcessors { @@ -210,6 +210,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 cf73ebc0..f8efb4e9 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..51b8b96d --- /dev/null +++ b/src/factories/payments-processors/alby-nwc-payments-processor-factory.ts @@ -0,0 +1,53 @@ +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.') + 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.') + debug('Unable to create Alby NWC payments processor. %o', error) + throw error + } + + try { + new URL(nwcUrl) + } catch { + const error = new Error('ALBY_NWC_URL is not parseable as a URL.') + 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.') + debug('Unable to create Alby NWC payments processor. %o', error) + 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 } +} + +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..2c88d936 --- /dev/null +++ b/src/payments-processors/alby-nwc-payments-processor.ts @@ -0,0 +1,236 @@ +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 +} + +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 + 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 }) + 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() + } + } + + 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: 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) { + debug('Unable to get Alby NWC invoice %s. Reason: %s', invoiceId, error.message) + } else { + debug('Unable to get Alby NWC invoice %s. Reason: %o', invoiceId, 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 amount = toSafeNumber(amountMsats, 'CreateInvoiceRequest.amount') + const transaction = (await this.withReplyTimeout( + client.makeInvoice({ + amount, + 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) { + debug('Unable to request Alby NWC invoice. Reason: %s', error.message) + } else { + debug('Unable to request Alby NWC invoice. Reason: %o', error) + } + throw error + } + } +} 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..7bb9ef2a --- /dev/null +++ b/test/integration/features/invoices/alby-nwc-invoice.feature.ts @@ -0,0 +1,311 @@ +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: [ + { + ...(admission[0] ?? {}), + enabled: true, + amount: ADMISSION_FEE_MSATS, + whitelists: {}, + }, + ], + }, + }, + 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] + + 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.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/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..1c2da6f8 --- /dev/null +++ b/test/unit/factories/payments-processors/alby-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 { 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, + invoiceExpirySeconds: 900, + }, + }, + } 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, + 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' + + 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..8c9700be --- /dev/null +++ b/test/unit/payments-processors/alby-nwc-payments-processor.spec.ts @@ -0,0 +1,222 @@ +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 clock.tickAsync(151) + + 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) + }) + + 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.') + }) +})