Skip to content

Commit c7f2a1a

Browse files
committed
test: add deterministic Alby NWC integration coverage
1 parent 0b845ff commit c7f2a1a

4 files changed

Lines changed: 339 additions & 0 deletions

File tree

src/payments-processors/alby-nwc-payments-processor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,10 +115,17 @@ export class AlbyNwcPaymentsProcessor implements IPaymentsProcessor {
115115

116116
private withClient = async <T>(fn: (client: nwc.NWCClient) => Promise<T>): Promise<T> => {
117117
const client = new nwc.NWCClient({ nostrWalletConnectUrl: this.nwcUrl })
118+
let caughtError: unknown
118119

119120
try {
120121
return await fn(client)
122+
} catch (error) {
123+
caughtError = error
124+
throw error
121125
} finally {
126+
if (caughtError instanceof nwc.Nip47ReplyTimeoutError) {
127+
await new Promise((resolve) => setTimeout(resolve, this.replyTimeoutMs + 100))
128+
}
122129
client.close()
123130
}
124131
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
@alby-nwc-invoice
2+
Feature: Alby NWC invoice integration
3+
4+
Scenario: creates invoice via HTTP with Alby processor
5+
Given Alby NWC payments are enabled with URI scheme "nostr+walletconnect"
6+
And Alby NWC wallet service make_invoice responds with a pending invoice
7+
When I request an admission invoice for pubkey "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
8+
Then the invoice request response status is 200
9+
And an Alby invoice is stored as pending for pubkey "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
10+
11+
Scenario: returns 500 on Alby reply timeout
12+
Given Alby NWC payments are enabled with URI scheme "nostr+walletconnect"
13+
And Alby NWC reply timeout is set to 75 milliseconds
14+
And Alby NWC wallet service make_invoice never responds
15+
When I request an admission invoice for pubkey "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
16+
Then the invoice request response status is 500
17+
And no invoice is stored for pubkey "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
18+
19+
Scenario: accepts legacy nostrwalletconnect URI
20+
Given Alby NWC payments are enabled with URI scheme "nostrwalletconnect"
21+
And Alby NWC wallet service make_invoice responds with a pending invoice
22+
When I request an admission invoice for pubkey "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
23+
Then the invoice request response status is 200
24+
And an Alby invoice is stored as pending for pubkey "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
Lines changed: 307 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,307 @@
1+
import WebSocket from 'ws'
2+
3+
import { After, Given, Then, When, World } from '@cucumber/cucumber'
4+
import axios, { AxiosResponse } from 'axios'
5+
import { expect } from 'chai'
6+
import * as secp256k1 from '@noble/secp256k1'
7+
import { nwc } from '@getalby/sdk'
8+
9+
import { getMasterDbClient } from '../../../../src/database/client'
10+
import { SettingsStatic } from '../../../../src/utils/settings'
11+
12+
;(globalThis as any).WebSocket = WebSocket
13+
14+
const INVOICES_URL = 'http://localhost:18808/invoices'
15+
const ADMISSION_FEE_MSATS = 1000000
16+
17+
const randomHex = () => secp256k1.utils.bytesToHex(secp256k1.utils.randomPrivateKey())
18+
19+
const buildNwcUrl = (scheme: string, walletPubkey: string, clientSecret: string) => {
20+
const encodedRelay = encodeURIComponent('ws://localhost:18808')
21+
return `${scheme}://${walletPubkey}?relay=${encodedRelay}&secret=${clientSecret}`
22+
}
23+
24+
Given('Alby NWC payments are enabled with URI scheme {string}', async function (this: World<Record<string, any>>, scheme: string) {
25+
const settings = SettingsStatic._settings as any
26+
27+
this.parameters.previousAlbyNwcSettings = settings
28+
this.parameters.previousAlbyNwcUrl = process.env.ALBY_NWC_URL
29+
this.parameters.albyNwcUriScheme = scheme
30+
31+
const walletSecret = randomHex()
32+
const clientSecret = randomHex()
33+
const clientPubkey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(clientSecret, true).subarray(1))
34+
const walletPubkey = secp256k1.utils.bytesToHex(secp256k1.getPublicKey(walletSecret, true).subarray(1))
35+
36+
this.parameters.albyWalletSecret = walletSecret
37+
this.parameters.albyClientSecret = clientSecret
38+
this.parameters.albyClientPubkey = clientPubkey
39+
this.parameters.albyWalletPubkey = walletPubkey
40+
41+
const nwcUrl = buildNwcUrl(scheme, walletPubkey, clientSecret)
42+
process.env.ALBY_NWC_URL = nwcUrl
43+
44+
const admission = Array.isArray(settings?.payments?.feeSchedules?.admission)
45+
? settings.payments.feeSchedules.admission
46+
: []
47+
48+
SettingsStatic._settings = {
49+
...settings,
50+
payments: {
51+
...(settings?.payments ?? {}),
52+
enabled: true,
53+
processor: 'alby',
54+
feeSchedules: {
55+
...(settings?.payments?.feeSchedules ?? {}),
56+
admission: [
57+
{
58+
enabled: true,
59+
amount: ADMISSION_FEE_MSATS,
60+
whitelists: {},
61+
...(admission[0] ?? {}),
62+
},
63+
],
64+
},
65+
},
66+
paymentsProcessors: {
67+
...(settings?.paymentsProcessors ?? {}),
68+
alby: {
69+
invoiceExpirySeconds: 900,
70+
replyTimeoutMs: 10000,
71+
...(settings?.paymentsProcessors?.alby ?? {}),
72+
},
73+
},
74+
}
75+
76+
const walletService = new nwc.NWCWalletService({ relayUrl: 'ws://localhost:18808' })
77+
const keypair = new nwc.NWCWalletServiceKeyPair(walletSecret, clientPubkey)
78+
79+
const dbClient = getMasterDbClient()
80+
await dbClient('users')
81+
.insert([
82+
{
83+
pubkey: Buffer.from(walletPubkey, 'hex'),
84+
is_admitted: true,
85+
},
86+
{
87+
pubkey: Buffer.from(clientPubkey, 'hex'),
88+
is_admitted: true,
89+
},
90+
])
91+
.onConflict('pubkey')
92+
.merge({ is_admitted: true })
93+
94+
await walletService.publishWalletServiceInfoEvent(walletSecret, ['make_invoice', 'lookup_invoice', 'get_info'], [])
95+
96+
this.parameters.albyWalletService = walletService
97+
this.parameters.albyWalletKeypair = keypair
98+
this.parameters.albyWalletInvoices = new Map<string, any>()
99+
this.parameters.albyInsertedInvoiceIds = []
100+
this.parameters.albyTestPubkeys = [walletPubkey, clientPubkey]
101+
})
102+
103+
Given('Alby NWC reply timeout is set to {int} milliseconds', function (this: World<Record<string, any>>, timeoutMs: number) {
104+
const settings = SettingsStatic._settings as any
105+
SettingsStatic._settings = {
106+
...settings,
107+
paymentsProcessors: {
108+
...(settings?.paymentsProcessors ?? {}),
109+
alby: {
110+
...(settings?.paymentsProcessors?.alby ?? {}),
111+
replyTimeoutMs: timeoutMs,
112+
},
113+
},
114+
}
115+
})
116+
117+
Given('Alby NWC wallet service make_invoice responds with a pending invoice', async function (this: World<Record<string, any>>) {
118+
const walletService = this.parameters.albyWalletService as nwc.NWCWalletService
119+
const keypair = this.parameters.albyWalletKeypair as nwc.NWCWalletServiceKeyPair
120+
const invoices = this.parameters.albyWalletInvoices as Map<string, any>
121+
122+
this.parameters.albyWalletUnsubscribe = await walletService.subscribe(keypair, {
123+
async makeInvoice(request) {
124+
const now = Math.floor(Date.now() / 1000)
125+
const paymentHash = `ph-${request.amount}-${now}`
126+
const invoice = `lnbc${Math.max(1, Math.floor(request.amount / 1000))}n1integration${now}`
127+
const tx = {
128+
type: 'incoming',
129+
state: 'pending',
130+
invoice,
131+
description: request.description ?? '',
132+
description_hash: request.description_hash ?? '',
133+
preimage: '',
134+
payment_hash: paymentHash,
135+
amount: request.amount,
136+
fees_paid: 0,
137+
settled_at: 0,
138+
created_at: now,
139+
expires_at: now + (request.expiry ?? 900),
140+
}
141+
invoices.set(paymentHash, tx)
142+
return { result: tx as any, error: undefined }
143+
},
144+
async lookupInvoice(request) {
145+
const tx = request.payment_hash ? invoices.get(request.payment_hash) : undefined
146+
if (!tx) {
147+
return { result: undefined, error: { code: 'NOT_FOUND', message: 'invoice not found' } }
148+
}
149+
return { result: tx, error: undefined }
150+
},
151+
async getInfo() {
152+
return {
153+
result: {
154+
alias: 'alby-test-wallet',
155+
color: '#000000',
156+
pubkey: this.parameters.albyWalletPubkey,
157+
network: 'regtest',
158+
block_height: 0,
159+
block_hash: '00',
160+
methods: ['make_invoice', 'lookup_invoice'],
161+
} as any,
162+
error: undefined,
163+
}
164+
},
165+
})
166+
})
167+
168+
Given('Alby NWC wallet service make_invoice never responds', async function (this: World<Record<string, any>>) {
169+
const walletService = this.parameters.albyWalletService as nwc.NWCWalletService
170+
const keypair = this.parameters.albyWalletKeypair as nwc.NWCWalletServiceKeyPair
171+
172+
this.parameters.albyWalletUnsubscribe = await walletService.subscribe(keypair, {
173+
async makeInvoice(request) {
174+
await new Promise((resolve) => setTimeout(resolve, 120))
175+
const now = Math.floor(Date.now() / 1000)
176+
return {
177+
result: {
178+
type: 'incoming',
179+
state: 'pending',
180+
invoice: `lnbc${Math.max(1, Math.floor(request.amount / 1000))}n1late${now}`,
181+
description: request.description ?? '',
182+
description_hash: request.description_hash ?? '',
183+
preimage: '',
184+
payment_hash: `late-ph-${request.amount}-${now}`,
185+
amount: request.amount,
186+
fees_paid: 0,
187+
settled_at: 0,
188+
created_at: now,
189+
expires_at: now + (request.expiry ?? 900),
190+
} as any,
191+
error: undefined,
192+
}
193+
},
194+
async lookupInvoice() {
195+
return { result: undefined, error: { code: 'NOT_FOUND', message: 'invoice not found' } }
196+
},
197+
})
198+
})
199+
When('I request an admission invoice for pubkey {string}', async function (this: World<Record<string, any>>, pubkey: string) {
200+
const response: AxiosResponse = await axios.post(
201+
INVOICES_URL,
202+
new URLSearchParams({
203+
tosAccepted: 'yes',
204+
feeSchedule: 'admission',
205+
pubkey,
206+
}).toString(),
207+
{
208+
headers: {
209+
'content-type': 'application/x-www-form-urlencoded',
210+
},
211+
validateStatus: () => true,
212+
},
213+
)
214+
215+
this.parameters.albyInvoiceHttpResponse = response
216+
this.parameters.albyTestPubkeys = [...(this.parameters.albyTestPubkeys ?? []), pubkey]
217+
})
218+
219+
Then('the invoice request response status is {int}', function (this: World<Record<string, any>>, statusCode: number) {
220+
const response = this.parameters.albyInvoiceHttpResponse as AxiosResponse
221+
expect(response.status).to.equal(statusCode)
222+
})
223+
224+
Then('an Alby invoice is stored as pending for pubkey {string}', async function (this: World<Record<string, any>>, pubkey: string) {
225+
const dbClient = getMasterDbClient()
226+
const row = await dbClient('invoices')
227+
.where('pubkey', Buffer.from(pubkey, 'hex'))
228+
.orderBy('created_at', 'desc')
229+
.first('id', 'status', 'unit', 'amount_requested')
230+
231+
expect(row).to.exist
232+
expect(row.status).to.equal('pending')
233+
expect(row.unit).to.equal('msats')
234+
expect(row.amount_requested).to.equal(ADMISSION_FEE_MSATS.toString())
235+
236+
this.parameters.albyInsertedInvoiceIds = [
237+
...(this.parameters.albyInsertedInvoiceIds ?? []),
238+
row.id,
239+
]
240+
})
241+
242+
Then('no invoice is stored for pubkey {string}', async function (this: World<Record<string, any>>, pubkey: string) {
243+
const dbClient = getMasterDbClient()
244+
const row = await dbClient('invoices')
245+
.where('pubkey', Buffer.from(pubkey, 'hex'))
246+
.orderBy('created_at', 'desc')
247+
.first('id')
248+
249+
const response = this.parameters.albyInvoiceHttpResponse as AxiosResponse
250+
expect(response.status).to.equal(500)
251+
expect(row).to.equal(undefined)
252+
253+
await new Promise((resolve) => setTimeout(resolve, 250))
254+
})
255+
256+
After({ tags: '@alby-nwc-invoice' }, async function (this: World<Record<string, any>>) {
257+
const unsubscribe = this.parameters.albyWalletUnsubscribe as (() => Promise<void>) | (() => void) | undefined
258+
if (typeof unsubscribe === 'function') {
259+
await unsubscribe()
260+
}
261+
262+
const walletService = this.parameters.albyWalletService as nwc.NWCWalletService | undefined
263+
if (walletService) {
264+
walletService.close()
265+
}
266+
267+
if (typeof this.parameters.previousAlbyNwcUrl === 'undefined') {
268+
delete process.env.ALBY_NWC_URL
269+
} else {
270+
process.env.ALBY_NWC_URL = this.parameters.previousAlbyNwcUrl
271+
}
272+
273+
if (this.parameters.previousAlbyNwcSettings) {
274+
SettingsStatic._settings = this.parameters.previousAlbyNwcSettings
275+
}
276+
277+
const dbClient = getMasterDbClient()
278+
const insertedInvoiceIds = this.parameters.albyInsertedInvoiceIds ?? []
279+
if (insertedInvoiceIds.length > 0) {
280+
await dbClient('invoices').whereIn('id', insertedInvoiceIds).delete()
281+
}
282+
283+
const testPubkeys = this.parameters.albyTestPubkeys ?? []
284+
if (testPubkeys.length > 0) {
285+
await dbClient('users')
286+
.whereIn(
287+
'pubkey',
288+
testPubkeys.map((p: string) => Buffer.from(p, 'hex')),
289+
)
290+
.delete()
291+
}
292+
293+
this.parameters.albyWalletUnsubscribe = undefined
294+
this.parameters.albyWalletService = undefined
295+
this.parameters.albyWalletKeypair = undefined
296+
this.parameters.albyWalletInvoices = undefined
297+
this.parameters.albyInvoiceHttpResponse = undefined
298+
this.parameters.albyInsertedInvoiceIds = []
299+
this.parameters.albyTestPubkeys = []
300+
this.parameters.previousAlbyNwcUrl = undefined
301+
this.parameters.previousAlbyNwcSettings = undefined
302+
this.parameters.albyWalletPubkey = undefined
303+
this.parameters.albyClientPubkey = undefined
304+
this.parameters.albyWalletSecret = undefined
305+
this.parameters.albyClientSecret = undefined
306+
this.parameters.albyNwcUriScheme = undefined
307+
})

test/unit/payments-processors/alby-nwc-payments-processor.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ describe('AlbyNwcPaymentsProcessor', () => {
151151
})
152152

153153
await clock.tickAsync(51)
154+
await clock.tickAsync(151)
154155

155156
await expect(pending).to.be.rejectedWith('reply timeout after 50ms')
156157
expect(closeStub).to.have.been.calledOnce

0 commit comments

Comments
 (0)