Skip to content

Commit

Permalink
feat: implement opennode payments processor (#315)
Browse files Browse the repository at this point in the history
  • Loading branch information
cameri authored May 23, 2023
1 parent 7331f95 commit df1a364
Show file tree
Hide file tree
Showing 39 changed files with 646 additions and 405 deletions.
25 changes: 20 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,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`, `lnbits`, `lnurl`
- Choose one of the following payment processors: `zebedee`, `nodeless`, `opennode`, `lnbits`, `lnurl`

2. [ZEBEDEE](https://zebedee.io)
- Complete the step "Before you begin"
Expand All @@ -113,9 +113,9 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
- 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)
3. [Nodeless.io](https://nodeless.io)
3. [Nodeless](https://nodeless.io/?ref=587f477f-ba1c-4bd3-8986-8302c98f6731)
- Complete the step "Before you begin"
- Sign up for a new account at https://nodeless.io, create a new store and take note of the store ID
- [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
- Go to Profile > API Tokens and generate a new key and take note of it
- 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
- Set `NODELESS_API_KEY` and `NODELESS_WEBHOOK_SECRET` environment variables with generated API key and webhook secret, respectively
Expand All @@ -130,9 +130,24 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
- Set `paymentsProcessors.nodeless.storeId` to your store ID
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
4. [LNBITS](https://lnbits.com/)
4. [OpenNode](https://www.opennode.com/)
- Complete the step "Before you begin"
- Sign up for a new account and get verified
- Go to Developers > Integrations and setup two-factor authentication
- Create a new API Key with Invoices permission
- Set `OPENNODE_API_KEY` environment variable on your `.env` file
```
OPENNODE_API_KEY={YOUR_OPENNODE_API_KEY}
```
- On your `.nostr/settings.yaml` file make the following changes:
- Set `payments.processor` to `opennode`
- Restart Nostream (`./scripts/stop` followed by `./scripts/start`)
5. [LNBITS](https://lnbits.com/)
- Complete the step "Before you begin"
- Create a new wallet on you public LNbits instance
- Create a new wallet on you public LNbits instance
- [Demo](https://legend.lnbits.com/) server must not be used for production
- Your instance must be accessible from the internet and have a valid SSL/TLS certificate
- Get wallet Invoice/read key (in Api docs section of your wallet)
Expand Down
7 changes: 7 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,16 @@ services:
TOR_CONTROL_PORT: 9051
TOR_PASSWORD: nostr_ts_relay
HIDDEN_SERVICE_PORT: 80
# Payments Processors
# Zebedee
ZEBEDEE_API_KEY: ${ZEBEDEE_API_KEY}
# Nodeless.io
NODELESS_API_KEY: ${NODELESS_API_KEY}
NODELESS_WEBHOOK_SECRET: ${NODELESS_WEBHOOK_SECRET}
# OpenNode
OPENNODE_API_KEY: ${OPENNODE_API_KEY}
# Lnbits
LNBITS_API_KEY: ${LNBITS_API_KEY}
# Enable DEBUG for troubleshooting. Examples:
# DEBUG: "primary:*"
# DEBUG: "worker:*"
Expand Down
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ paymentsProcessors:
nodeless:
baseURL: https://nodeless.io
storeId: your-nodeless-io-store-id
opennode:
baseURL: api.opennode.com
callbackBaseURL: https://nostream.your-domain.com/callbacks/opennode
network:
maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy
Expand Down
6 changes: 6 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ export interface LNbitsPaymentsProcessor {
callbackBaseURL: string
}

export interface OpenNodePaymentsProcessor {
baseURL: string
callbackBaseURL: string
}

export interface NodelessPaymentsProcessor {
baseURL: string
storeId: string
Expand All @@ -177,6 +182,7 @@ export interface PaymentsProcessors {
zebedee?: ZebedeePaymentsProcessor
lnbits?: LNbitsPaymentsProcessor
nodeless?: NodelessPaymentsProcessor
opennode?: OpenNodePaymentsProcessor
}

export interface Local {
Expand Down
1 change: 0 additions & 1 deletion src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ export class MaintenanceWorker implements IRunnable {
let successful = 0

for (const invoice of invoices) {
debug('invoice %s: %o', invoice.id, invoice)
try {
debug('getting invoice %s from payment processor: %o', invoice.id, invoice)
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)
Expand Down
34 changes: 34 additions & 0 deletions src/controllers/callbacks/lnbits-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { Request, Response } from 'express'

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

const settings = createSettings()
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor ?? 'null'

if (paymentProcessor !== 'lnbits') {
debug('denied request from %s to /callbacks/lnbits which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

let validationPassed = false

if (typeof request.query.hmac === 'string' && request.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) {
const split = request.query.hmac.split(':')
if (hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), split[0]).toString('hex') === split[1]) {
if (parseInt(split[0]) > Date.now()) {
validationPassed = true
}
}
}

if (!validationPassed) {
debug('unauthorized request from %s to /callbacks/lnbits', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const body = request.body
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
response
Expand Down
24 changes: 24 additions & 0 deletions src/controllers/callbacks/nodeless-callback-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { fromNodelessInvoice } from '../../utils/transform'
import { hmacSha256 } from '../../utils/secret'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'

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

const settings = createSettings()
const paymentProcessor = settings.payments?.processor

const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (request as any).rawBody).toString('hex')
const actual = request.headers['nodeless-signature']

if (expected !== actual) {
console.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
response
.status(403)
.send('Forbidden')
return
}

if (paymentProcessor !== 'nodeless') {
debug('denied request from %s to /callbacks/nodeless which is not the current payment processor')
response
.status(403)
.send('Forbidden')
return
}

const nodelessInvoice = applySpec({
id: prop('uuid'),
status: prop('status'),
Expand Down
69 changes: 69 additions & 0 deletions src/controllers/callbacks/opennode-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { fromOpenNodeInvoice } from '../../utils/transform'
import { IController } from '../../@types/controllers'
import { IPaymentsService } from '../../@types/services'

const debug = createLogger('opennode-callback-controller')

export class OpenNodeCallbackController implements IController {
public constructor(
private readonly paymentsService: IPaymentsService,
) {}

// TODO: Validate
public async handleRequest(
request: Request,
response: Response,
) {
debug('request headers: %o', request.headers)
debug('request body: %O', request.body)

const invoice = fromOpenNodeInvoice(request.body)

debug('invoice', invoice)

let updatedInvoice: Invoice
try {
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
} catch (error) {
console.error(`Unable to persist invoice ${invoice.id}`, error)

throw error
}

if (
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
response
.status(200)
.send()

return
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested

try {
await this.paymentsService.confirmInvoice({
id: invoice.id,
amountPaid: updatedInvoice.amountRequested,
confirmedAt: updatedInvoice.confirmedAt,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
} catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error)

throw error
}

response
.status(200)
.setHeader('content-type', 'text/plain; charset=utf8')
.send('OK')
}
}
44 changes: 36 additions & 8 deletions src/controllers/callbacks/zebedee-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { fromZebedeeInvoice } from '../../utils/transform'
import { getRemoteAddress } from '../../utils/http'
import { IController } from '../../@types/controllers'
import { InvoiceStatus } from '../../@types/invoice'
import { IPaymentsService } from '../../@types/services'

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

const settings = createSettings()

const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
const remoteAddress = getRemoteAddress(request, settings)
const paymentProcessor = settings.payments?.processor

if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) {
debug('unauthorized request from %s to /callbacks/zebedee', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

if (paymentProcessor !== 'zebedee') {
debug('denied request from %s to /callbacks/zebedee which is not the current payment processor', remoteAddress)
response
.status(403)
.send('Forbidden')
return
}

const invoice = fromZebedeeInvoice(request.body)

debug('invoice', invoice)

let updatedInvoice: Invoice
try {
if (invoice.bolt11) {
await this.paymentsService.updateInvoice(invoice)
}
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
} catch (error) {
console.error(`Unable to persist invoice ${invoice.id}`, error)

throw error
}

if (
invoice.status !== InvoiceStatus.COMPLETED
&& !invoice.confirmedAt
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
response
.status(200)
Expand All @@ -47,10 +70,15 @@ export class ZebedeeCallbackController implements IController {
}

invoice.amountPaid = invoice.amountRequested
updatedInvoice.amountPaid = invoice.amountRequested

try {
await this.paymentsService.confirmInvoice(invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
await this.paymentsService.confirmInvoice({
id: invoice.id,
confirmedAt: updatedInvoice.confirmedAt,
amountPaid: invoice.amountRequested,
})
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
} catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error)

Expand Down
34 changes: 34 additions & 0 deletions src/controllers/invoices/get-invoice-controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { path, pathEq } from 'ramda'
import { Request, Response } from 'express'
import { readFileSync } from 'fs'

import { createSettings } from '../../factories/settings-factory'
import { FeeSchedule } from '../../@types/settings'
import { IController } from '../../@types/controllers'

let pageCache: string

export class GetInvoiceController implements IController {
public async handleRequest(
_req: Request,
res: Response,
): Promise<void> {
const settings = createSettings()

if (pathEq(['payments', 'enabled'], true, settings)
&& pathEq(['payments', 'feeSchedules', 'admission', '0', 'enabled'], true, settings)) {
if (!pageCache) {
const name = path<string>(['info', 'name'])(settings)
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
pageCache = readFileSync('./resources/index.html', 'utf8')
.replaceAll('{{name}}', name)
.replaceAll('{{processor}}', settings.payments.processor)
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
}

res.status(200).setHeader('content-type', 'text/html; charset=utf8').send(pageCache)
} else {
res.status(404).send()
}
}
}
3 changes: 3 additions & 0 deletions src/factories/controllers/get-invoice-controller-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { GetInvoiceController } from '../../controllers/invoices/get-invoice-controller'

export const createGetInvoiceController = () => new GetInvoiceController()
11 changes: 11 additions & 0 deletions src/factories/controllers/get-invoice-status-controller-factory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { GetInvoiceStatusController } from '../../controllers/invoices/get-invoice-status-controller'
import { getReadReplicaDbClient } from '../../database/client'
import { InvoiceRepository } from '../../repositories/invoice-repository'

export const createGetInvoiceStatusController = () => {
const rrDbClient = getReadReplicaDbClient()

const invoiceRepository = new InvoiceRepository(rrDbClient)

return new GetInvoiceStatusController(invoiceRepository)
}
Loading

0 comments on commit df1a364

Please sign in to comment.