feat: implement opennode payments processor (#315)

This commit is contained in:
Ricardo Arturo Cabral Mejía 2023-05-23 11:08:16 -04:00 committed by GitHub
parent 7331f9560f
commit df1a36437c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
39 changed files with 646 additions and 405 deletions

View File

@ -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"
@ -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
@ -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)

View File

@ -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:*"

View File

@ -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

View File

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

View File

@ -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)

View File

@ -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'
@ -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

View File

@ -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'
@ -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'),

View File

@ -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')
}
}

View File

@ -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')
@ -21,14 +23,35 @@ 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)
@ -36,8 +59,8 @@ export class ZebedeeCallbackController implements IController {
}
if (
invoice.status !== InvoiceStatus.COMPLETED
&& !invoice.confirmedAt
updatedInvoice.status !== InvoiceStatus.COMPLETED
&& !updatedInvoice.confirmedAt
) {
response
.status(200)
@ -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)

View File

@ -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()
}
}
}

View File

@ -0,0 +1,3 @@
import { GetInvoiceController } from '../../controllers/invoices/get-invoice-controller'
export const createGetInvoiceController = () => new GetInvoiceController()

View File

@ -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)
}

View File

@ -0,0 +1,12 @@
import { createPaymentsService } from '../payments-service-factory'
import { getMasterDbClient } from '../../database/client'
import { IController } from '../../@types/controllers'
import { InvoiceRepository } from '../../repositories/invoice-repository'
import { LNbitsCallbackController } from '../../controllers/callbacks/lnbits-callback-controller'
export const createLNbitsCallbackController = (): IController => {
return new LNbitsCallbackController(
createPaymentsService(),
new InvoiceRepository(getMasterDbClient())
)
}

View File

@ -0,0 +1,7 @@
import { createPaymentsService } from '../payments-service-factory'
import { IController } from '../../@types/controllers'
import { NodelessCallbackController } from '../../controllers/callbacks/nodeless-callback-controller'
export const createNodelessCallbackController = (): IController => new NodelessCallbackController(
createPaymentsService(),
)

View File

@ -0,0 +1,9 @@
import { createPaymentsService } from '../payments-service-factory'
import { IController } from '../../@types/controllers'
import { OpenNodeCallbackController } from '../../controllers/callbacks/opennode-callback-controller'
export const createOpenNodeCallbackController = (): IController => {
return new OpenNodeCallbackController(
createPaymentsService(),
)
}

View File

@ -0,0 +1,20 @@
import { createPaymentsService } from '../payments-service-factory'
import { createSettings } from '../settings-factory'
import { getMasterDbClient } from '../../database/client'
import { IController } from '../../@types/controllers'
import { PostInvoiceController } from '../../controllers/invoices/post-invoice-controller'
import { slidingWindowRateLimiterFactory } from '../rate-limiter-factory'
import { UserRepository } from '../../repositories/user-repository'
export const createPostInvoiceController = (): IController => {
const dbClient = getMasterDbClient()
const userRepository = new UserRepository(dbClient)
const paymentsService = createPaymentsService()
return new PostInvoiceController(
userRepository,
paymentsService,
createSettings,
slidingWindowRateLimiterFactory,
)
}

View File

@ -0,0 +1,9 @@
import { createPaymentsService } from '../payments-service-factory'
import { IController } from '../../@types/controllers'
import { ZebedeeCallbackController } from '../../controllers/callbacks/zebedee-callback-controller'
export const createZebedeeCallbackController = (): IController => {
return new ZebedeeCallbackController(
createPaymentsService(),
)
}

View File

@ -1,11 +0,0 @@
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)
}

View File

@ -1,12 +0,0 @@
import { createPaymentsService } from './payments-service-factory'
import { getMasterDbClient } from '../database/client'
import { IController } from '../@types/controllers'
import { InvoiceRepository } from '../repositories/invoice-repository'
import { LNbitsCallbackController } from '../controllers/callbacks/lnbits-callback-controller'
export const createLNbitsCallbackController = (): IController => {
return new LNbitsCallbackController(
createPaymentsService(),
new InvoiceRepository(getMasterDbClient())
)
}

View File

@ -1,7 +0,0 @@
import { createPaymentsService } from './payments-service-factory'
import { IController } from '../@types/controllers'
import { NodelessCallbackController } from '../controllers/callbacks/nodeless-callback-controller'
export const createNodelessCallbackController = (): IController => new NodelessCallbackController(
createPaymentsService(),
)

View File

@ -1,129 +1,18 @@
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createLNbitsPaymentProcessor } from './payments-processors/lnbits-payments-processor-factory'
import { createLnurlPaymentsProcessor } from './payments-processors/lnurl-payments-processor-factory'
import { createLogger } from './logger-factory'
import { createNodelessPaymentsProcessor } from './payments-processors/nodeless-payments-processor-factory'
import { createOpenNodePaymentsProcessor } from './payments-processors/opennode-payments-processor-factory'
import { createSettings } from './settings-factory'
import { createZebedeePaymentsProcessor } from './payments-processors/zebedee-payments-processor-factory'
import { IPaymentsProcessor } from '../@types/clients'
import { LNbitsPaymentsProcesor } from '../payments-processors/lnbits-payment-processor'
import { LnurlPaymentsProcesor } from '../payments-processors/lnurl-payments-processor'
import { NodelessPaymentsProcesor } from '../payments-processors/nodeless-payments-processor'
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
import { Settings } from '../@types/settings'
import { ZebedeePaymentsProcesor } from '../payments-processors/zebedee-payments-processor'
const debug = createLogger('create-payments-processor')
const getZebedeeAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.ZEBEDEE_API_KEY) {
const error = new Error('ZEBEDEE_API_KEY must be set.')
console.error('Unable to get Zebedee config.', error)
throw error
}
return {
headers: {
'content-type': 'application/json',
'apikey': process.env.ZEBEDEE_API_KEY,
},
baseURL: path(['paymentsProcessors', 'zebedee', 'baseURL'], settings),
maxRedirects: 1,
}
}
const getNodelessAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.NODELESS_API_KEY) {
const error = new Error('NODELESS_API_KEY must be set.')
console.error('Unable to get Nodeless config.', error)
throw error
}
return {
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${process.env.NODELESS_API_KEY}`,
'accept': 'application/json',
},
baseURL: path(['paymentsProcessors', 'nodeless', 'baseURL'], settings),
maxRedirects: 1,
}
}
const getLNbitsAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY must be set to an invoice or admin key.')
}
return {
headers: {
'content-type': 'application/json',
'x-api-key': process.env.LNBITS_API_KEY,
},
baseURL: path(['paymentsProcessors', 'lnbits', 'baseURL'], settings),
maxRedirects: 1,
}
}
const createLnurlPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const invoiceURL = path(['paymentsProcessors', 'lnurl', 'invoiceURL'], settings) as string | undefined
if (typeof invoiceURL === 'undefined') {
throw new Error('Unable to create payments processor: Setting paymentsProcessor.lnurl.invoiceURL is not configured.')
}
const client = axios.create()
return new LnurlPaymentsProcesor(client, createSettings)
}
const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
const error = new Error('Setting paymentsProcessor.zebedee.callbackBaseURL is not configured.')
console.error('Unable to create payments processor.', error)
throw error
}
if (
!Array.isArray(settings.paymentsProcessors?.zebedee?.ipWhitelist)
|| !settings.paymentsProcessors?.zebedee?.ipWhitelist?.length
) {
const error = new Error('Setting paymentsProcessor.zebedee.ipWhitelist is empty.')
console.error('Unable to create payments processor.', error)
throw error
}
const config = getZebedeeAxiosConfig(settings)
debug('config: %o', config)
const client = axios.create(config)
return new ZebedeePaymentsProcesor(client, createSettings)
}
const createLNbitsPaymentProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'lnbits', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
const error = new Error('Setting paymentsProcessor.lnbits.callbackBaseURL is not configured.')
console.error('Unable to create payments processor.', error)
throw error
}
const config = getLNbitsAxiosConfig(settings)
debug('config: %o', config)
const client = axios.create(config)
return new LNbitsPaymentsProcesor(client, createSettings)
}
const createNodelessPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const client = axios.create(getNodelessAxiosConfig(settings))
return new NodelessPaymentsProcesor(client, createSettings)
}
export const createPaymentsProcessor = (): IPaymentsProcessor => {
debug('create payments processor')
const settings = createSettings()
if (!settings.payments?.enabled) {
return new NullPaymentsProcessor()
@ -138,6 +27,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
return createLNbitsPaymentProcessor(settings)
case 'nodeless':
return createNodelessPaymentsProcessor(settings)
case 'opennode':
return createOpenNodePaymentsProcessor(settings)
default:
return new NullPaymentsProcessor()
}

View File

@ -0,0 +1,39 @@
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createSettings } from '../settings-factory'
import { IPaymentsProcessor } from '../../@types/clients'
import { LNbitsPaymentsProcesor } from '../../payments-processors/lnbits-payment-processor'
import { Settings } from '../../@types/settings'
const getLNbitsAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.LNBITS_API_KEY) {
throw new Error('LNBITS_API_KEY must be set to an invoice or admin key.')
}
return {
headers: {
'content-type': 'application/json',
'x-api-key': process.env.LNBITS_API_KEY,
},
baseURL: path(['paymentsProcessors', 'lnbits', 'baseURL'], settings),
maxRedirects: 1,
}
}
export const createLNbitsPaymentProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'lnbits', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
const error = new Error('Setting paymentsProcessor.lnbits.callbackBaseURL is not configured.')
console.error('Unable to create payments processor.', error)
throw error
}
const config = getLNbitsAxiosConfig(settings)
const client = axios.create(config)
return new LNbitsPaymentsProcesor(client, createSettings)
}

View File

@ -0,0 +1,18 @@
import axios from 'axios'
import { path } from 'ramda'
import { createSettings } from '../settings-factory'
import { IPaymentsProcessor } from '../../@types/clients'
import { LnurlPaymentsProcesor } from '../../payments-processors/lnurl-payments-processor'
import { Settings } from '../../@types/settings'
export const createLnurlPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const invoiceURL = path(['paymentsProcessors', 'lnurl', 'invoiceURL'], settings) as string | undefined
if (typeof invoiceURL === 'undefined') {
throw new Error('Unable to create payments processor: Setting paymentsProcessor.lnurl.invoiceURL is not configured.')
}
const client = axios.create()
return new LnurlPaymentsProcesor(client, createSettings)
}

View File

@ -0,0 +1,31 @@
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createSettings } from '../settings-factory'
import { IPaymentsProcessor } from '../../@types/clients'
import { NodelessPaymentsProcesor } from '../../payments-processors/nodeless-payments-processor'
import { Settings } from '../../@types/settings'
const getNodelessAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.NODELESS_API_KEY) {
const error = new Error('NODELESS_API_KEY must be set.')
console.error('Unable to get Nodeless config.', error)
throw error
}
return {
headers: {
'content-type': 'application/json',
'authorization': `Bearer ${process.env.NODELESS_API_KEY}`,
'accept': 'application/json',
},
baseURL: path(['paymentsProcessors', 'nodeless', 'baseURL'], settings),
maxRedirects: 1,
}
}
export const createNodelessPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const client = axios.create(getNodelessAxiosConfig(settings))
return new NodelessPaymentsProcesor(client, createSettings)
}

View File

@ -0,0 +1,39 @@
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createSettings } from '../settings-factory'
import { IPaymentsProcessor } from '../../@types/clients'
import { OpenNodePaymentsProcesor } from '../../payments-processors/opennode-payments-processor'
import { Settings } from '../../@types/settings'
const getOpenNodeAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.OPENNODE_API_KEY) {
const error = new Error('OPENNODE_API_KEY must be set.')
console.error('Unable to get OpenNode config.', error)
throw error
}
return {
headers: {
'content-type': 'application/json',
'authorization': process.env.OPENNODE_API_KEY,
},
baseURL: path(['paymentsProcessors', 'opennode', 'baseURL'], settings),
maxRedirects: 1,
}
}
export const createOpenNodePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'opennode', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
const error = new Error('Setting paymentsProcessor.opennode.callbackBaseURL is not configured.')
console.error('Unable to create payments processor.', error)
throw error
}
const config = getOpenNodeAxiosConfig(settings)
const client = axios.create(config)
return new OpenNodePaymentsProcesor(client, createSettings)
}

View File

@ -0,0 +1,50 @@
import axios, { CreateAxiosDefaults } from 'axios'
import { path } from 'ramda'
import { createSettings } from '../settings-factory'
import { IPaymentsProcessor } from '../../@types/clients'
import { Settings } from '../../@types/settings'
import { ZebedeePaymentsProcesor } from '../../payments-processors/zebedee-payments-processor'
const getZebedeeAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
if (!process.env.ZEBEDEE_API_KEY) {
const error = new Error('ZEBEDEE_API_KEY must be set.')
console.error('Unable to get Zebedee config.', error)
throw error
}
return {
headers: {
'content-type': 'application/json',
'apikey': process.env.ZEBEDEE_API_KEY,
},
baseURL: path(['paymentsProcessors', 'zebedee', 'baseURL'], settings),
maxRedirects: 1,
}
}
export const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
const error = new Error('Setting paymentsProcessor.zebedee.callbackBaseURL is not configured.')
console.error('Unable to create payments processor.', error)
throw error
}
if (
!Array.isArray(settings.paymentsProcessors?.zebedee?.ipWhitelist)
|| !settings.paymentsProcessors?.zebedee?.ipWhitelist?.length
) {
const error = new Error('Setting paymentsProcessor.zebedee.ipWhitelist is empty.')
console.error('Unable to create payments processor.', error)
throw error
}
const config = getZebedeeAxiosConfig(settings)
const client = axios.create(config)
return new ZebedeePaymentsProcesor(client, createSettings)
}

View File

@ -1,20 +0,0 @@
import { createPaymentsService } from './payments-service-factory'
import { createSettings } from './settings-factory'
import { getMasterDbClient } from '../database/client'
import { IController } from '../@types/controllers'
import { PostInvoiceController } from '../controllers/invoices/post-invoice-controller'
import { slidingWindowRateLimiterFactory } from './rate-limiter-factory'
import { UserRepository } from '../repositories/user-repository'
export const createPostInvoiceController = (): IController => {
const dbClient = getMasterDbClient()
const userRepository = new UserRepository(dbClient)
const paymentsService = createPaymentsService()
return new PostInvoiceController(
userRepository,
paymentsService,
createSettings,
slidingWindowRateLimiterFactory,
)
}

View File

@ -1,9 +0,0 @@
import { createPaymentsService } from './payments-service-factory'
import { IController } from '../@types/controllers'
import { ZebedeeCallbackController } from '../controllers/callbacks/zebedee-callback-controller'
export const createZebedeeCallbackController = (): IController => {
return new ZebedeeCallbackController(
createPaymentsService(),
)
}

View File

@ -1,30 +0,0 @@
import { NextFunction, Request, Response } from 'express'
import { path, pathEq } from 'ramda'
import { readFileSync } from 'fs'
import { createSettings } from '../../factories/settings-factory'
import { FeeSchedule } from '../../@types/settings'
let pageCache: string
export const getInvoiceRequestHandler = (_req: Request, res: Response, next: NextFunction) => {
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()
}
next()
}

View File

@ -1,9 +0,0 @@
import { Request, Response } from 'express'
import { createGetInvoiceStatusController } from '../../factories/get-invoice-status-controller-factory'
export const getInvoiceStatusRequestHandler = async (req: Request, res: Response) => {
const controller = createGetInvoiceStatusController()
await controller.handleRequest(req, res)
}

View File

@ -1,19 +0,0 @@
import { Request, Response } from 'express'
import { createPostInvoiceController } from '../../factories/post-invoice-controller-factory'
export const postInvoiceRequestHandler = async (
req: Request,
res: Response,
) => {
const controller = createPostInvoiceController()
try {
await controller.handleRequest(req, res)
} catch (error) {
console.error('Unable handle request.', error)
res
.status(500)
.setHeader('content-type', 'text-plain')
.send('Error handling request')
}
}

View File

@ -1,20 +0,0 @@
import { Request, Response } from 'express'
import { createLNbitsCallbackController } from '../../factories/lnbits-callback-controller-factory'
export const postLNbitsCallbackRequestHandler = async (
req: Request,
res: Response,
) => {
const controller = createLNbitsCallbackController()
try {
await controller.handleRequest(req, res)
} catch (error) {
console.error('error while handling LNbits request: %o', error)
res
.status(500)
.setHeader('content-type', 'text/plain')
.send('Error handling request')
}
}

View File

@ -1,18 +0,0 @@
import { Request, Response } from 'express'
import { createNodelessCallbackController } from '../../factories/nodeless-callback-controller-factory'
export const postNodelessCallbackRequestHandler = async (
req: Request,
res: Response,
) => {
const controller = createNodelessCallbackController()
try {
await controller.handleRequest(req, res)
} catch (error) {
res
.status(500)
.setHeader('content-type', 'text/plain')
.send('Error handling request')
}
}

View File

@ -1,18 +0,0 @@
import { Request, Response } from 'express'
import { createZebedeeCallbackController } from '../../factories/zebedee-callback-controller-factory'
export const postZebedeeCallbackRequestHandler = async (
req: Request,
res: Response,
) => {
const controller = createZebedeeCallbackController()
try {
await controller.handleRequest(req, res)
} catch (error) {
res
.status(500)
.setHeader('content-type', 'text/plain')
.send('Error handling request')
}
}

View File

@ -0,0 +1,18 @@
import { Request, Response } from 'express'
import { Factory } from '../../@types/base'
import { IController } from '../../@types/controllers'
export const withController = (controllerFactory: Factory<IController>) => async (
request: Request,
response: Response,
) => {
try {
return await controllerFactory().handleRequest(request, response)
} catch (error) {
response
.status(500)
.setHeader('content-type', 'text/plain')
.send('Error handling request')
}
}

View File

@ -0,0 +1,68 @@
import { AxiosInstance } from 'axios'
import { Factory } from '../@types/base'
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { createLogger } from '../factories/logger-factory'
import { fromOpenNodeInvoice } from '../utils/transform'
import { Settings } from '../@types/settings'
const debug = createLogger('opennode-payments-processor')
export class OpenNodePaymentsProcesor implements IPaymentsProcessor {
public constructor(
private httpClient: AxiosInstance,
private settings: Factory<Settings>
) {}
public async getInvoice(invoiceId: string): Promise<GetInvoiceResponse> {
debug('get invoice: %s', invoiceId)
try {
const response = await this.httpClient.get(`/v2/charge/${invoiceId}`, {
maxRedirects: 1,
})
return fromOpenNodeInvoice(response.data.data)
} catch (error) {
console.error(`Unable to get invoice ${invoiceId}. Reason:`, error)
throw error
}
}
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
debug('create invoice: %o', request)
const {
amount: amountMsats,
description,
requestId,
} = request
const amountSats = Number(amountMsats / 1000n)
const body = {
amount: amountSats,
description,
order_id: requestId,
callback_url: this.settings().paymentsProcessors?.opennode?.callbackBaseURL,
ttl: 10,
}
try {
debug('request body: %o', body)
const response = await this.httpClient.post('/v1/charges', body, {
maxRedirects: 1,
})
const result = fromOpenNodeInvoice(response.data.data)
debug('result: %o', result)
return result
} catch (error) {
console.error('Unable to request invoice. Reason:', error.message)
throw error
}
}
}

View File

@ -1,103 +1,21 @@
import { deriveFromSecret, hmacSha256 } from '../../utils/secret'
import { json, Router } from 'express'
import { createLogger } from '../../factories/logger-factory'
import { createSettings } from '../../factories/settings-factory'
import { getRemoteAddress } from '../../utils/http'
import { postLNbitsCallbackRequestHandler } from '../../handlers/request-handlers/post-lnbits-callback-request-handler'
import { postNodelessCallbackRequestHandler } from '../../handlers/request-handlers/post-nodeless-callback-request-handler'
import { postZebedeeCallbackRequestHandler } from '../../handlers/request-handlers/post-zebedee-callback-request-handler'
const debug = createLogger('routes-callbacks')
import { createLNbitsCallbackController } from '../../factories/controllers/lnbits-callback-controller-factory'
import { createNodelessCallbackController } from '../../factories/controllers/nodeless-callback-controller-factory'
import { createOpenNodeCallbackController } from '../../factories/controllers/opennode-callback-controller-factory'
import { createZebedeeCallbackController } from '../../factories/controllers/zebedee-callback-controller-factory'
import { withController } from '../../handlers/request-handlers/with-controller-request-handler'
const router = Router()
router
.post('/zebedee', json(), async (req, res) => {
const settings = createSettings()
const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
const remoteAddress = getRemoteAddress(req, settings)
const paymentProcessor = settings.payments?.processor ?? 'null'
if (ipWhitelist.length && !ipWhitelist.includes(remoteAddress)) {
debug('unauthorized request from %s to /callbacks/zebedee', remoteAddress)
res
.status(403)
.send('Forbidden')
return
}
if (paymentProcessor !== 'zebedee') {
debug('denied request from %s to /callbacks/zebedee which is not the current payment processor', remoteAddress)
res
.status(403)
.send('Forbidden')
return
}
return postZebedeeCallbackRequestHandler(req, res)
})
.post('/lnbits', json(), async (req, res) => {
const settings = createSettings()
const remoteAddress = getRemoteAddress(req, 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)
res
.status(403)
.send('Forbidden')
return
}
let validationPassed = false
if (typeof req.query.hmac === 'string' && req.query.hmac.match(/^[0-9]{1,20}:[0-9a-f]{64}$/)) {
const split = req.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)
res
.status(403)
.send('Forbidden')
return
}
return postLNbitsCallbackRequestHandler(req, res)
})
.post('/zebedee', json(), withController(createZebedeeCallbackController))
.post('/lnbits', json(), withController(createLNbitsCallbackController))
.post('/nodeless', json({
verify(req, _res, buf) {
(req as any).rawBody = buf
},
}), async (req, res) => {
const settings = createSettings()
const paymentProcessor = settings.payments?.processor
const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (req as any).rawBody).toString('hex')
const actual = req.headers['nodeless-signature']
if (expected !== actual) {
console.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
res
.status(403)
.send('Forbidden')
return
}
if (paymentProcessor !== 'nodeless') {
debug('denied request from %s to /callbacks/nodeless which is not the current payment processor')
res
.status(403)
.send('Forbidden')
return
}
return postNodelessCallbackRequestHandler(req, res)
})
}), withController(createNodelessCallbackController))
.post('/opennode', json(), withController(createOpenNodeCallbackController))
export default router

View File

@ -1,14 +1,15 @@
import { Router, urlencoded } from 'express'
import { getInvoiceRequestHandler } from '../../handlers/request-handlers/get-invoice-request-handler'
import { getInvoiceStatusRequestHandler } from '../../handlers/request-handlers/get-invoice-status-request-handler'
import { postInvoiceRequestHandler } from '../../handlers/request-handlers/post-invoice-request-handler'
import { createGetInvoiceController } from '../../factories/controllers/get-invoice-controller-factory'
import { createGetInvoiceStatusController } from '../../factories/controllers/get-invoice-status-controller-factory'
import { createPostInvoiceController } from '../../factories/controllers/post-invoice-controller-factory'
import { withController } from '../../handlers/request-handlers/with-controller-request-handler'
const invoiceRouter = Router()
invoiceRouter
.get('/', getInvoiceRequestHandler)
.get('/:invoiceId/status', getInvoiceStatusRequestHandler)
.post('/', urlencoded({ extended: true }), postInvoiceRequestHandler)
.get('/', withController(createGetInvoiceController))
.get('/:invoiceId/status', withController(createGetInvoiceStatusController))
.post('/', urlencoded({ extended: true }), withController(createPostInvoiceController))
export default invoiceRouter

View File

@ -1,7 +1,7 @@
import { always, applySpec, cond, equals, ifElse, is, isNil, path, pipe, prop, propSatisfies, T } from 'ramda'
import { always, applySpec, cond, equals, ifElse, is, isNil, multiply, path, pathSatisfies, pipe, prop, propSatisfies, T } from 'ramda'
import { bech32 } from 'bech32'
import { Invoice, InvoiceStatus } from '../@types/invoice'
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
import { User } from '../@types/user'
export const toJSON = (input: any) => JSON.stringify(input)
@ -59,7 +59,7 @@ export const toBech32 = (prefix: string) => (input: string): string => {
return bech32.encode(prefix, bech32.toWords(Buffer.from(input, 'hex')))
}
export const toDate = (input: string) => new Date(input)
export const toDate = (input: string | number) => new Date(input)
export const fromZebedeeInvoice = applySpec<Invoice>({
id: prop('id'),
@ -126,3 +126,54 @@ export const fromNodelessInvoice = applySpec<Invoice>({
),
// rawResponse: toJSON,
})
export const fromOpenNodeInvoice = applySpec<Invoice>({
id: prop('id'),
pubkey: prop('order_id'),
bolt11: ifElse(
pathSatisfies(is(String), ['lightning_invoice', 'payreq']),
path(['lightning_invoice', 'payreq']),
path(['lightning', 'payreq'])
),
amountRequested: pipe(
ifElse(
propSatisfies(is(Number), 'amount'),
prop('amount'),
prop('price'),
) as () => number,
toBigInt,
),
description: prop('description'),
unit: always(InvoiceUnit.SATS),
status: pipe(
prop('status'),
cond([
[equals('expired'), always(InvoiceStatus.EXPIRED)],
[equals('refunded'), always(InvoiceStatus.EXPIRED)],
[equals('unpaid'), always(InvoiceStatus.PENDING)],
[equals('processing'), always(InvoiceStatus.PENDING)],
[equals('underpaid'), always(InvoiceStatus.PENDING)],
[equals('paid'), always(InvoiceStatus.COMPLETED)],
]),
),
expiresAt: pipe(
cond([
[pathSatisfies(is(String), ['lightning', 'expires_at']), path(['lightning', 'expires_at'])],
[pathSatisfies(is(Number), ['lightning_invoice', 'expires_at']), pipe(path(['lightning_invoice', 'expires_at']), multiply(1000))],
]),
toDate,
),
confirmedAt: cond([
[propSatisfies(equals('paid'), 'status'), () => new Date()],
[T, always(null)],
]),
createdAt: pipe(
ifElse(
propSatisfies(is(Number), 'created_at'),
pipe(prop('created_at'), multiply(1000)),
prop('created_at'),
),
toDate,
),
rawResponse: toJSON,
})