Skip to content

Commit 3779b7f

Browse files
committed
feat: add Alby NWC payments processor (#323)
1 parent 22d971a commit 3779b7f

12 files changed

Lines changed: 557 additions & 5 deletions

File tree

.changeset/seven-lines-heal.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"nostream": minor
3+
---
4+
5+
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.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ WORKER_COUNT=2 # Defaults to CPU count. Use 1 or 2 for local testing.
4141
# NODELESS_WEBHOOK_SECRET=
4242
# OPENNODE_API_KEY=
4343
# LNBITS_API_KEY=
44+
# ALBY_NWC_URL=nostr+walletconnect://<wallet-service-pubkey>?relay=<wss-relay-url>&secret=<secret>
4445

4546
# --- READ REPLICAS (Optional) ---
4647
# READ_REPLICA_ENABLED=false

CONFIGURATION.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ The following environment variables can be set:
5757
| NOSTR_CONFIG_DIR | Configuration directory | <project_root>/.nostr/ |
5858
| DEBUG | Debugging filter | |
5959
| ZEBEDEE_API_KEY | Zebedee Project API Key | |
60+
| ALBY_NWC_URL | Alby NWC connection URL (`nostr+walletconnect://...`) | |
6061

6162
## I2P
6263

@@ -154,3 +155,4 @@ The settings below are listed in alphabetical order by name. Please keep this ta
154155
| limits.admissionCheck.rateLimits[].rate | Maximum number of admission checks during period. |
155156
| limits.admissionCheck.ipWhitelist | List of IPs (IPv4 or IPv6) to ignore rate limits. |
156157
| 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. |
158+
| payments.processor | One of `zebedee`, `lnbits`, `lnurl`, `nodeless`, `opennode`, `alby`. |

README.md

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
9898
- Set `payments.enabled` to `true`
9999
- Set `payments.feeSchedules.admission.enabled` to `true`
100100
- 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)
101-
- Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`
101+
- Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`, `alby`
102102

103103
2. [ZEBEDEE](https://zebedee.io)
104104
- Complete the step "Before you begin"
@@ -173,7 +173,20 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
173173
- Set `lnurl.invoiceURL` to your LNURL (e.g. `https://getalby.com/lnurlp/your-username`)
174174
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
175175
176-
7. Ensure payments are required for your public key
176+
7. [Alby Wallet API (NIP-47 / NWC)](https://getalby.com/)
177+
- Complete the step "Before you begin"
178+
- Create an app connection in your wallet and copy the generated NWC URL
179+
- Set `ALBY_NWC_URL` environment variable on your `.env` file
180+
181+
```
182+
ALBY_NWC_URL={NOSTR_WALLET_CONNECT_URL}
183+
```
184+
185+
- On your `.nostr/settings.yaml` file make the following changes:
186+
- Set `payments.processor` to `alby`
187+
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
188+
189+
8. Ensure payments are required for your public key
177190
- Visit https://{YOUR-DOMAIN}/
178191
- You should be presented with a form requesting an admission fee to be paid
179192
- Fill out the form and take the necessary steps to pay the invoice

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@
128128
"node": ">=24.14.1"
129129
},
130130
"dependencies": {
131+
"@getalby/sdk": "^5.0.0",
131132
"@noble/secp256k1": "1.7.1",
132133
"accepts": "^1.3.8",
133134
"axios": "^1.15.0",

resources/default-settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ paymentsProcessors:
3535
opennode:
3636
baseURL: api.opennode.com
3737
callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode
38+
alby:
39+
invoiceExpirySeconds: 900
40+
replyTimeoutMs: 10000
3841
nip05:
3942
# NIP-05 verification of event authors as a spam reduction measure.
4043
# mode: 'enabled' requires NIP-05 for publishing (except kind 0),

src/@types/settings.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,9 @@ export interface OpenNodePaymentsProcessor {
198198
callbackBaseURL: string
199199
}
200200

201-
export interface NodelessPaymentsProcessor {
202-
baseURL: string
203-
storeId: string
201+
export interface AlbyPaymentsProcessor {
202+
invoiceExpirySeconds: number
203+
replyTimeoutMs: number
204204
}
205205

206206
export interface PaymentsProcessors {
@@ -209,6 +209,7 @@ export interface PaymentsProcessors {
209209
lnbits?: LNbitsPaymentsProcessor
210210
nodeless?: NodelessPaymentsProcessor
211211
opennode?: OpenNodePaymentsProcessor
212+
alby?: AlbyPaymentsProcessor
212213
}
213214

214215
export interface Local {

src/factories/payments-processor-factory.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { createAlbyNwcPaymentsProcessor } from './payments-processors/alby-nwc-payments-processor-factory'
12
import { createLNbitsPaymentProcessor } from './payments-processors/lnbits-payments-processor-factory'
23
import { createLnurlPaymentsProcessor } from './payments-processors/lnurl-payments-processor-factory'
34
import { createLogger } from './logger-factory'
@@ -29,6 +30,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
2930
return createNodelessPaymentsProcessor(settings)
3031
case 'opennode':
3132
return createOpenNodePaymentsProcessor(settings)
33+
case 'alby':
34+
return createAlbyNwcPaymentsProcessor(settings)
3235
default:
3336
return new NullPaymentsProcessor()
3437
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { createSettings } from '../settings-factory'
2+
import { AlbyNwcPaymentsProcessor } from '../../payments-processors/alby-nwc-payments-processor'
3+
import { IPaymentsProcessor } from '../../@types/clients'
4+
import { Settings } from '../../@types/settings'
5+
6+
const getAlbyNwcConfig = (settings: Settings): { nwcUrl: string; replyTimeoutMs: number } => {
7+
const nwcUrl = process.env.ALBY_NWC_URL
8+
9+
if (!nwcUrl) {
10+
const error = new Error('ALBY_NWC_URL must be set.')
11+
console.error('Unable to create Alby NWC payments processor.', error)
12+
throw error
13+
}
14+
15+
if (!nwcUrl.startsWith('nostr+walletconnect://') && !nwcUrl.startsWith('nostrwalletconnect://')) {
16+
const error = new Error('ALBY_NWC_URL must be a valid nostr+walletconnect:// or nostrwalletconnect:// URI.')
17+
console.error('Unable to create Alby NWC payments processor.', error)
18+
throw error
19+
}
20+
21+
try {
22+
new URL(nwcUrl)
23+
} catch {
24+
const error = new Error('ALBY_NWC_URL is not parseable as a URL.')
25+
console.error('Unable to create Alby NWC payments processor.', error)
26+
throw error
27+
}
28+
29+
const replyTimeoutMs = settings.paymentsProcessors?.alby?.replyTimeoutMs
30+
if (typeof replyTimeoutMs !== 'number' || replyTimeoutMs <= 0) {
31+
const error = new Error('Setting paymentsProcessors.alby.replyTimeoutMs must be a positive number.')
32+
console.error('Unable to create Alby NWC payments processor.', error)
33+
throw error
34+
}
35+
36+
return { nwcUrl, replyTimeoutMs }
37+
}
38+
39+
export const createAlbyNwcPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
40+
const { nwcUrl, replyTimeoutMs } = getAlbyNwcConfig(settings)
41+
42+
return new AlbyNwcPaymentsProcessor(nwcUrl, replyTimeoutMs, createSettings)
43+
}
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
import { nwc } from '@getalby/sdk'
2+
3+
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
4+
import { Factory } from '../@types/base'
5+
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
6+
import { Settings } from '../@types/settings'
7+
import { createLogger } from '../factories/logger-factory'
8+
9+
const debug = createLogger('alby-nwc-payments-processor')
10+
11+
type NwcTransaction = {
12+
state?: 'settled' | 'pending' | 'expired' | 'failed' | 'accepted'
13+
invoice?: string
14+
payment_hash?: string
15+
amount?: number
16+
description?: string
17+
created_at?: number
18+
settled_at?: number
19+
expires_at?: number
20+
}
21+
22+
const mapNwcStateToInvoiceStatus = (state?: NwcTransaction['state']): InvoiceStatus => {
23+
switch (state) {
24+
case 'settled':
25+
return InvoiceStatus.COMPLETED
26+
case 'expired':
27+
case 'failed':
28+
return InvoiceStatus.EXPIRED
29+
case 'accepted':
30+
case 'pending':
31+
default:
32+
return InvoiceStatus.PENDING
33+
}
34+
}
35+
36+
const timestampToDate = (unixSeconds?: number): Date | null => {
37+
if (typeof unixSeconds === 'number' && Number.isFinite(unixSeconds) && unixSeconds > 0) {
38+
return new Date(unixSeconds * 1000)
39+
}
40+
41+
return null
42+
}
43+
44+
export class AlbyNwcInvoice implements Invoice {
45+
id: string
46+
pubkey: string
47+
bolt11: string
48+
amountRequested: bigint
49+
amountPaid?: bigint
50+
unit: InvoiceUnit
51+
status: InvoiceStatus
52+
description: string
53+
confirmedAt?: Date | null
54+
expiresAt: Date | null
55+
updatedAt: Date
56+
createdAt: Date
57+
}
58+
59+
export class AlbyNwcCreateInvoiceResponse implements CreateInvoiceResponse {
60+
id: string
61+
pubkey: string
62+
bolt11: string
63+
amountRequested: bigint
64+
description: string
65+
unit: InvoiceUnit
66+
status: InvoiceStatus
67+
expiresAt: Date | null
68+
confirmedAt?: Date | null
69+
createdAt: Date
70+
rawResponse?: string
71+
}
72+
73+
export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor {
74+
public constructor(
75+
private nwcUrl: string,
76+
private replyTimeoutMs: number,
77+
private settings: Factory<Settings>,
78+
) {}
79+
80+
private withReplyTimeout = async <T>(operation: Promise<T>): Promise<T> => {
81+
let timeoutId: ReturnType<typeof setTimeout> | undefined
82+
83+
try {
84+
return await Promise.race([
85+
operation,
86+
new Promise<never>((_, reject) => {
87+
timeoutId = setTimeout(() => {
88+
reject(new nwc.Nip47ReplyTimeoutError(`reply timeout after ${this.replyTimeoutMs}ms`, 'INTERNAL'))
89+
}, this.replyTimeoutMs)
90+
}),
91+
])
92+
} finally {
93+
if (timeoutId) {
94+
clearTimeout(timeoutId)
95+
}
96+
}
97+
}
98+
99+
private withClient = async <T>(fn: (client: nwc.NWCClient) => Promise<T>): Promise<T> => {
100+
const client = new nwc.NWCClient({ nostrWalletConnectUrl: this.nwcUrl })
101+
102+
try {
103+
return await fn(client)
104+
} finally {
105+
client.close()
106+
}
107+
}
108+
109+
public async getInvoice(invoiceOrId: string | Invoice): Promise<GetInvoiceResponse> {
110+
const invoiceId = typeof invoiceOrId === 'string' ? invoiceOrId : invoiceOrId.id
111+
debug('get invoice: %s', invoiceId)
112+
113+
try {
114+
return await this.withClient(async (client) => {
115+
const transaction = (await this.withReplyTimeout(
116+
client.lookupInvoice({ payment_hash: invoiceId }),
117+
)) as NwcTransaction
118+
const status = mapNwcStateToInvoiceStatus(transaction.state)
119+
120+
const invoice = new AlbyNwcInvoice()
121+
invoice.id = transaction.payment_hash || invoiceId
122+
invoice.pubkey = typeof invoiceOrId === 'string' ? '' : invoiceOrId.pubkey
123+
invoice.bolt11 = transaction.invoice || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.bolt11)
124+
invoice.amountRequested =
125+
typeof transaction.amount === 'number' && Number.isFinite(transaction.amount)
126+
? BigInt(Math.trunc(transaction.amount))
127+
: typeof invoiceOrId === 'string'
128+
? 0n
129+
: invoiceOrId.amountRequested
130+
invoice.amountPaid = status === InvoiceStatus.COMPLETED ? invoice.amountRequested : undefined
131+
invoice.unit = InvoiceUnit.MSATS
132+
invoice.status = status
133+
invoice.description = transaction.description || (typeof invoiceOrId === 'string' ? '' : invoiceOrId.description)
134+
invoice.confirmedAt = status === InvoiceStatus.COMPLETED ? (timestampToDate(transaction.settled_at) ?? new Date()) : null
135+
invoice.expiresAt = timestampToDate(transaction.expires_at)
136+
invoice.createdAt = timestampToDate(transaction.created_at) ?? new Date()
137+
invoice.updatedAt = new Date()
138+
139+
return invoice
140+
})
141+
} catch (error) {
142+
if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) {
143+
console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error.message)
144+
} else {
145+
console.error(`Unable to get Alby NWC invoice ${invoiceId}. Reason:`, error)
146+
}
147+
throw error
148+
}
149+
}
150+
151+
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
152+
debug('create invoice: %o', request)
153+
const { amount: amountMsats, description, requestId: pubkey } = request
154+
155+
try {
156+
return await this.withClient(async (client) => {
157+
const expirySeconds = this.settings().paymentsProcessors?.alby?.invoiceExpirySeconds
158+
const transaction = (await this.withReplyTimeout(
159+
client.makeInvoice({
160+
amount: Number(amountMsats),
161+
description,
162+
expiry: expirySeconds,
163+
}),
164+
)) as NwcTransaction
165+
166+
const invoice = new AlbyNwcCreateInvoiceResponse()
167+
invoice.id = transaction.payment_hash || ''
168+
invoice.pubkey = pubkey
169+
invoice.bolt11 = transaction.invoice || ''
170+
invoice.amountRequested =
171+
typeof transaction.amount === 'number' && Number.isFinite(transaction.amount)
172+
? BigInt(Math.trunc(transaction.amount))
173+
: amountMsats
174+
invoice.description = transaction.description || description || ''
175+
invoice.unit = InvoiceUnit.MSATS
176+
invoice.status = mapNwcStateToInvoiceStatus(transaction.state)
177+
invoice.confirmedAt = invoice.status === InvoiceStatus.COMPLETED ? (timestampToDate(transaction.settled_at) ?? new Date()) : null
178+
invoice.expiresAt = timestampToDate(transaction.expires_at)
179+
invoice.createdAt = timestampToDate(transaction.created_at) ?? new Date()
180+
invoice.rawResponse = JSON.stringify(transaction)
181+
182+
return invoice
183+
})
184+
} catch (error) {
185+
if (error instanceof nwc.Nip47WalletError || error instanceof nwc.Nip47ReplyTimeoutError) {
186+
console.error('Unable to request Alby NWC invoice. Reason:', error.message)
187+
} else {
188+
console.error('Unable to request Alby NWC invoice. Reason:', error)
189+
}
190+
throw error
191+
}
192+
}
193+
}

0 commit comments

Comments
 (0)