Skip to content

Commit df1a364

Browse files
authored
feat: implement opennode payments processor (#315)
1 parent 7331f95 commit df1a364

39 files changed

+646
-405
lines changed

README.md

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

100100
2. [ZEBEDEE](https://zebedee.io)
101101
- Complete the step "Before you begin"
@@ -113,9 +113,9 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
113113
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
114114
- Read the in-depth guide for more information: [Set Up a Paid Nostr Relay with ZEBEDEE API](https://docs.zebedee.io/docs/guides/nostr-relay)
115115
116-
3. [Nodeless.io](https://nodeless.io)
116+
3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731)
117117
- Complete the step "Before you begin"
118-
- Sign up for a new account at https://nodeless.io, create a new store and take note of the store ID
118+
- [Sign up](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731) for a new account, create a new store and take note of the store ID
119119
- Go to Profile > API Tokens and generate a new key and take note of it
120120
- Create a store webhook with your Nodeless callback URL (e.g. `https://{YOUR_DOMAIN_HERE}/callbacks/nodeless`) and make sure to enable all of the events. Grab the generated store webhook secret
121121
- Set `NODELESS_API_KEY` and `NODELESS_WEBHOOK_SECRET` environment variables with generated API key and webhook secret, respectively
@@ -130,9 +130,24 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
130130
- Set `paymentsProcessors.nodeless.storeId` to your store ID
131131
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
132132
133-
4. [LNBITS](https://lnbits.com/)
133+
4. [OpenNode](https://www.opennode.com/)
134+
- Complete the step "Before you begin"
135+
- Sign up for a new account and get verified
136+
- Go to Developers > Integrations and setup two-factor authentication
137+
- Create a new API Key with Invoices permission
138+
- Set `OPENNODE_API_KEY` environment variable on your `.env` file
139+
140+
```
141+
OPENNODE_API_KEY={YOUR_OPENNODE_API_KEY}
142+
```
143+
144+
- On your `.nostr/settings.yaml` file make the following changes:
145+
- Set `payments.processor` to `opennode`
146+
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
147+
148+
5. [LNBITS](https://lnbits.com/)
134149
- Complete the step "Before you begin"
135-
- Create a new wallet on you public LNbits instance
150+
- Create a new wallet on you public LNbits instance
136151
- [Demo](https://legend.lnbits.com/) server must not be used for production
137152
- Your instance must be accessible from the internet and have a valid SSL/TLS certificate
138153
- Get wallet Invoice/read key (in Api docs section of your wallet)

docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,16 @@ services:
4646
TOR_CONTROL_PORT: 9051
4747
TOR_PASSWORD: nostr_ts_relay
4848
HIDDEN_SERVICE_PORT: 80
49+
# Payments Processors
50+
# Zebedee
51+
ZEBEDEE_API_KEY: ${ZEBEDEE_API_KEY}
4952
# Nodeless.io
5053
NODELESS_API_KEY: ${NODELESS_API_KEY}
5154
NODELESS_WEBHOOK_SECRET: ${NODELESS_WEBHOOK_SECRET}
55+
# OpenNode
56+
OPENNODE_API_KEY: ${OPENNODE_API_KEY}
57+
# Lnbits
58+
LNBITS_API_KEY: ${LNBITS_API_KEY}
5259
# Enable DEBUG for troubleshooting. Examples:
5360
# DEBUG: "primary:*"
5461
# DEBUG: "worker:*"

resources/default-settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ paymentsProcessors:
3232
nodeless:
3333
baseURL: https://nodeless.io
3434
storeId: your-nodeless-io-store-id
35+
opennode:
36+
baseURL: api.opennode.com
37+
callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode
3538
network:
3639
maxPayloadSize: 524288
3740
# Comment the next line if using CloudFlare proxy

src/@types/settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ export interface LNbitsPaymentsProcessor {
167167
callbackBaseURL: string
168168
}
169169

170+
export interface OpenNodePaymentsProcessor {
171+
baseURL: string
172+
callbackBaseURL: string
173+
}
174+
170175
export interface NodelessPaymentsProcessor {
171176
baseURL: string
172177
storeId: string
@@ -177,6 +182,7 @@ export interface PaymentsProcessors {
177182
zebedee?: ZebedeePaymentsProcessor
178183
lnbits?: LNbitsPaymentsProcessor
179184
nodeless?: NodelessPaymentsProcessor
185+
opennode?: OpenNodePaymentsProcessor
180186
}
181187

182188
export interface Local {

src/app/maintenance-worker.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,6 @@ export class MaintenanceWorker implements IRunnable {
4545
let successful = 0
4646

4747
for (const invoice of invoices) {
48-
debug('invoice %s: %o', invoice.id, invoice)
4948
try {
5049
debug('getting invoice %s from payment processor: %o', invoice.id, invoice)
5150
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)

src/controllers/callbacks/lnbits-callback-controller.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { Request, Response } from 'express'
22

3+
import { deriveFromSecret, hmacSha256 } from '../../utils/secret'
34
import { Invoice, InvoiceStatus } from '../../@types/invoice'
45
import { createLogger } from '../../factories/logger-factory'
6+
import { createSettings } from '../../factories/settings-factory'
7+
import { getRemoteAddress } from '../../utils/http'
58
import { IController } from '../../@types/controllers'
69
import { IInvoiceRepository } from '../../@types/repositories'
710
import { IPaymentsService } from '../../@types/services'
@@ -22,6 +25,37 @@ export class LNbitsCallbackController implements IController {
2225
debug('request headers: %o', request.headers)
2326
debug('request body: %o', request.body)
2427

28+
const settings = createSettings()
29+
const remoteAddress = getRemoteAddress(request, settings)
30+
const paymentProcessor = settings.payments?.processor ?? 'null'
31+
32+
if (paymentProcessor !== 'lnbits') {
33+
debug('denied request from %s to /callbacks/lnbits which is not the current payment processor', remoteAddress)
34+
response
35+
.status(403)
36+
.send('Forbidden')
37+
return
38+
}
39+
40+
let validationPassed = false
41+
42+
if (typeof request.query.hmac === 'string' && request.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) {
43+
const split = request.query.hmac.split(':')
44+
if (hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') === split[1]) {
45+
if (parseInt(split[0]) > Date.now()) {
46+
validationPassed = true
47+
}
48+
}
49+
}
50+
51+
if (!validationPassed) {
52+
debug('unauthorized request from %s to /callbacks/lnbits', remoteAddress)
53+
response
54+
.status(403)
55+
.send('Forbidden')
56+
return
57+
}
58+
2559
const body = request.body
2660
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
2761
response

src/controllers/callbacks/nodeless-callback-controller.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import { Request, Response } from 'express'
33

44
import { Invoice, InvoiceStatus } from '../../@types/invoice'
55
import { createLogger } from '../../factories/logger-factory'
6+
import { createSettings } from '../../factories/settings-factory'
67
import { fromNodelessInvoice } from '../../utils/transform'
8+
import { hmacSha256 } from '../../utils/secret'
79
import { IController } from '../../@types/controllers'
810
import { IPaymentsService } from '../../@types/services'
911

@@ -22,6 +24,28 @@ export class NodelessCallbackController implements IController {
2224
debug('callback request headers: %o', request.headers)
2325
debug('callback request body: %O', request.body)
2426

27+
const settings = createSettings()
28+
const paymentProcessor = settings.payments?.processor
29+
30+
const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex')
31+
const actual = request.headers['nodeless-signature']
32+
33+
if (expected !== actual) {
34+
console.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
35+
response
36+
.status(403)
37+
.send('Forbidden')
38+
return
39+
}
40+
41+
if (paymentProcessor !== 'nodeless') {
42+
debug('denied request from %s to /callbacks/nodeless which is not the current payment processor')
43+
response
44+
.status(403)
45+
.send('Forbidden')
46+
return
47+
}
48+
2549
const nodelessInvoice = applySpec({
2650
id: prop('uuid'),
2751
status: prop('status'),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Request, Response } from 'express'
2+
3+
import { Invoice, InvoiceStatus } from '../../@types/invoice'
4+
import { createLogger } from '../../factories/logger-factory'
5+
import { fromOpenNodeInvoice } from '../../utils/transform'
6+
import { IController } from '../../@types/controllers'
7+
import { IPaymentsService } from '../../@types/services'
8+
9+
const debug = createLogger('opennode-callback-controller')
10+
11+
export class OpenNodeCallbackController implements IController {
12+
public constructor(
13+
private readonly paymentsService: IPaymentsService,
14+
) {}
15+
16+
// TODO: Validate
17+
public async handleRequest(
18+
request: Request,
19+
response: Response,
20+
) {
21+
debug('request headers: %o', request.headers)
22+
debug('request body: %O', request.body)
23+
24+
const invoice = fromOpenNodeInvoice(request.body)
25+
26+
debug('invoice', invoice)
27+
28+
let updatedInvoice: Invoice
29+
try {
30+
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
31+
} catch (error) {
32+
console.error(`Unable to persist invoice ${invoice.id}`, error)
33+
34+
throw error
35+
}
36+
37+
if (
38+
updatedInvoice.status !== InvoiceStatus.COMPLETED
39+
&& !updatedInvoice.confirmedAt
40+
) {
41+
response
42+
.status(200)
43+
.send()
44+
45+
return
46+
}
47+
48+
invoice.amountPaid = invoice.amountRequested
49+
updatedInvoice.amountPaid = invoice.amountRequested
50+
51+
try {
52+
await this.paymentsService.confirmInvoice({
53+
id: invoice.id,
54+
amountPaid: updatedInvoice.amountRequested,
55+
confirmedAt: updatedInvoice.confirmedAt,
56+
})
57+
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
58+
} catch (error) {
59+
console.error(`Unable to confirm invoice ${invoice.id}`, error)
60+
61+
throw error
62+
}
63+
64+
response
65+
.status(200)
66+
.setHeader('content-type', 'text/plain; charset=utf8')
67+
.send('OK')
68+
}
69+
}

src/controllers/callbacks/zebedee-callback-controller.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { Request, Response } from 'express'
22

3+
import { Invoice, InvoiceStatus } from '../../@types/invoice'
34
import { createLogger } from '../../factories/logger-factory'
5+
import { createSettings } from '../../factories/settings-factory'
46
import { fromZebedeeInvoice } from '../../utils/transform'
7+
import { getRemoteAddress } from '../../utils/http'
58
import { IController } from '../../@types/controllers'
6-
import { InvoiceStatus } from '../../@types/invoice'
79
import { IPaymentsService } from '../../@types/services'
810

911
const debug = createLogger('zebedee-callback-controller')
@@ -21,23 +23,44 @@ export class ZebedeeCallbackController implements IController {
2123
debug('request headers: %o', request.headers)
2224
debug('request body: %O', request.body)
2325

26+
const settings = createSettings()
27+
28+
const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
29+
const remoteAddress = getRemoteAddress(request, settings)
30+
const paymentProcessor = settings.payments?.processor
31+
32+
if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) {
33+
debug('unauthorized request from %s to /callbacks/zebedee', remoteAddress)
34+
response
35+
.status(403)
36+
.send('Forbidden')
37+
return
38+
}
39+
40+
if (paymentProcessor !== 'zebedee') {
41+
debug('denied request from %s to /callbacks/zebedee which is not the current payment processor', remoteAddress)
42+
response
43+
.status(403)
44+
.send('Forbidden')
45+
return
46+
}
47+
2448
const invoice = fromZebedeeInvoice(request.body)
2549

2650
debug('invoice', invoice)
2751

52+
let updatedInvoice: Invoice
2853
try {
29-
if (invoice.bolt11) {
30-
await this.paymentsService.updateInvoice(invoice)
31-
}
54+
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
3255
} catch (error) {
3356
console.error(`Unable to persist invoice ${invoice.id}`, error)
3457

3558
throw error
3659
}
3760

3861
if (
39-
invoice.status !== InvoiceStatus.COMPLETED
40-
&& !invoice.confirmedAt
62+
updatedInvoice.status !== InvoiceStatus.COMPLETED
63+
&& !updatedInvoice.confirmedAt
4164
) {
4265
response
4366
.status(200)
@@ -47,10 +70,15 @@ export class ZebedeeCallbackController implements IController {
4770
}
4871

4972
invoice.amountPaid = invoice.amountRequested
73+
updatedInvoice.amountPaid = invoice.amountRequested
5074

5175
try {
52-
await this.paymentsService.confirmInvoice(invoice)
53-
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
76+
await this.paymentsService.confirmInvoice({
77+
id: invoice.id,
78+
confirmedAt: updatedInvoice.confirmedAt,
79+
amountPaid: invoice.amountRequested,
80+
})
81+
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
5482
} catch (error) {
5583
console.error(`Unable to confirm invoice ${invoice.id}`, error)
5684

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { path, pathEq } from 'ramda'
2+
import { Request, Response } from 'express'
3+
import { readFileSync } from 'fs'
4+
5+
import { createSettings } from '../../factories/settings-factory'
6+
import { FeeSchedule } from '../../@types/settings'
7+
import { IController } from '../../@types/controllers'
8+
9+
let pageCache: string
10+
11+
export class GetInvoiceController implements IController {
12+
public async handleRequest(
13+
_req: Request,
14+
res: Response,
15+
): Promise<void> {
16+
const settings = createSettings()
17+
18+
if (pathEq(['payments', 'enabled'], true, settings)
19+
&& pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)) {
20+
if (!pageCache) {
21+
const name = path<string>(['info', 'name'])(settings)
22+
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
23+
pageCache = readFileSync('./resources/index.html', 'utf8')
24+
.replaceAll('{{name}}', name)
25+
.replaceAll('{{processor}}', settings.payments.processor)
26+
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
27+
}
28+
29+
res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
30+
} else {
31+
res.status(404).send()
32+
}
33+
}
34+
}

0 commit comments

Comments
 (0)