mirror of
https://github.com/Cameri/nostream.git
synced 2025-09-17 19:13:35 +02:00
Add LNbits payment processor (#194)
* feat: add lnbits payment processor * fix: add lnbits error logging and add lnbits config * feat: use HMAC instead of IP whitelist for LNbits also adds two utility functions and ensures the SECRET environment variable is set. * refactor: remove unnecessary comment * fix(pay-to-relay/lnbits): compare by msat scaled amount * fix: scale balance addition with invoice unit on confirm_invoice
This commit is contained in:
55
migrations/20230205_004400_change_invoice_id_to_string.js
Normal file
55
migrations/20230205_004400_change_invoice_id_to_string.js
Normal file
@@ -0,0 +1,55 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.raw('ALTER TABLE invoices ALTER COLUMN id TYPE text USING id::text; ALTER TABLE invoices ALTER COLUMN id DROP DEFAULT;')
|
||||
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
payee BYTEA;
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
|
||||
IF confirmed_date IS NULL THEN
|
||||
UPDATE invoices
|
||||
SET
|
||||
"confirmed_at" = confirmation_date,
|
||||
"amount_paid" = amount_received,
|
||||
"updated_at" = now_utc()
|
||||
WHERE id = invoice_id;
|
||||
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;`)
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.raw('ALTER TABLE invoices ALTER COLUMN id TYPE uuid USING id::uuid; ALTER TABLE invoices ALTER COLUMN id SET DEFAULT uuid_generate_v4();')
|
||||
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id UUID, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
payee BYTEA;
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
|
||||
IF confirmed_date IS NULL THEN
|
||||
UPDATE invoices
|
||||
SET
|
||||
"confirmed_at" = confirmation_date,
|
||||
"amount_paid" = amount_received,
|
||||
"updated_at" = now_utc()
|
||||
WHERE id = invoice_id;
|
||||
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;`)
|
||||
}
|
@@ -0,0 +1,60 @@
|
||||
exports.up = function (knex) {
|
||||
return knex.schema
|
||||
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
payee BYTEA;
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
|
||||
unit TEXT;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, unit FROM "invoices" WHERE id = invoice_id;
|
||||
IF confirmed_date IS NULL THEN
|
||||
UPDATE invoices
|
||||
SET
|
||||
"confirmed_at" = confirmation_date,
|
||||
"amount_paid" = amount_received,
|
||||
"updated_at" = now_utc()
|
||||
WHERE id = invoice_id;
|
||||
IF unit = 'sats' THEN
|
||||
UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee;
|
||||
ELSIF unit = 'msats' THEN
|
||||
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
|
||||
ELSIF unit = 'btc' THEN
|
||||
UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;`)
|
||||
}
|
||||
|
||||
exports.down = function (knex) {
|
||||
return knex.schema
|
||||
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
|
||||
RETURNS INTEGER
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
payee BYTEA;
|
||||
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
|
||||
BEGIN
|
||||
PERFORM ASSERT_SERIALIZED();
|
||||
|
||||
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
|
||||
IF confirmed_date IS NULL THEN
|
||||
UPDATE invoices
|
||||
SET
|
||||
"confirmed_at" = confirmation_date,
|
||||
"amount_paid" = amount_received,
|
||||
"updated_at" = now_utc()
|
||||
WHERE id = invoice_id;
|
||||
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
|
||||
END IF;
|
||||
RETURN 0;
|
||||
END;
|
||||
$$;`)
|
||||
}
|
@@ -22,6 +22,9 @@ paymentsProcessors:
|
||||
ipWhitelist:
|
||||
- "3.225.112.64"
|
||||
- "::ffff:3.225.112.64"
|
||||
lnbits:
|
||||
baseURL: https://lnbits.your-domain.com/
|
||||
callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits
|
||||
network:
|
||||
maxPayloadSize: 524288
|
||||
remoteIpHeader: x-forwarded-for
|
||||
|
@@ -30,8 +30,8 @@ export interface DBInvoice {
|
||||
id: string
|
||||
pubkey: Buffer
|
||||
bolt11: string
|
||||
amount_requested: BigInt
|
||||
amount_paid: BigInt
|
||||
amount_requested: bigint
|
||||
amount_paid: bigint
|
||||
unit: InvoiceUnit
|
||||
status: InvoiceStatus,
|
||||
description: string
|
||||
|
@@ -148,8 +148,14 @@ export interface ZebedeePaymentsProcessor {
|
||||
ipWhitelist: string[]
|
||||
}
|
||||
|
||||
export interface LNbitsPaymentProcessor {
|
||||
baseURL: string
|
||||
callbackBaseURL: string
|
||||
}
|
||||
|
||||
export interface PaymentsProcessors {
|
||||
zebedee?: ZebedeePaymentsProcessor
|
||||
lnbits?: LNbitsPaymentProcessor
|
||||
}
|
||||
|
||||
export interface Local {
|
||||
|
@@ -62,6 +62,11 @@ export class App implements IRunnable {
|
||||
logCentered(`Pay-to-relay ${pathEq(['payments', 'enabled'], true, settings) ? 'enabled' : 'disabled'}`, width)
|
||||
logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`, width)
|
||||
|
||||
if (typeof this.process.env.SECRET !== 'string' || this.process.env.SECRET === 'changeme') {
|
||||
console.error('Please configure the secret using the SECRET environment variable.')
|
||||
this.process.exit(1)
|
||||
}
|
||||
|
||||
const workerCount = process.env.WORKER_COUNT
|
||||
? Number(process.env.WORKER_COUNT)
|
||||
: this.settings().workers?.count || cpus().length
|
||||
|
@@ -41,6 +41,7 @@ export enum EventTags {
|
||||
|
||||
export enum PaymentsProcessors {
|
||||
ZEBEDEE = 'zebedee',
|
||||
LNBITS = 'lnbits',
|
||||
}
|
||||
|
||||
export const EventDelegatorMetadataKey = Symbol('Delegator')
|
||||
|
88
src/controllers/callbacks/lnbits-callback-controller.ts
Normal file
88
src/controllers/callbacks/lnbits-callback-controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { createLogger } from '../../factories/logger-factory'
|
||||
import { IController } from '../../@types/controllers'
|
||||
import { IInvoiceRepository } from '../../@types/repositories'
|
||||
import { InvoiceStatus } from '../../@types/invoice'
|
||||
import { IPaymentsService } from '../../@types/services'
|
||||
|
||||
const debug = createLogger('lnbits-callback-controller')
|
||||
|
||||
export class LNbitsCallbackController implements IController {
|
||||
public constructor(
|
||||
private readonly paymentsService: IPaymentsService,
|
||||
private readonly invoiceRepository: IInvoiceRepository
|
||||
) { }
|
||||
|
||||
// TODO: Validate
|
||||
public async handleRequest(
|
||||
request: Request,
|
||||
response: Response,
|
||||
) {
|
||||
debug('request headers: %o', request.headers)
|
||||
debug('request body: %o', request.body)
|
||||
|
||||
const body = request.body
|
||||
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
|
||||
response
|
||||
.status(400)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Malformed body')
|
||||
return
|
||||
}
|
||||
|
||||
const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash)
|
||||
const storedInvoice = await this.invoiceRepository.findById(body.payment_hash)
|
||||
|
||||
if (!storedInvoice) {
|
||||
response
|
||||
.status(404)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('No such invoice')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await this.paymentsService.updateInvoice(invoice)
|
||||
} catch (error) {
|
||||
console.error(`Unable to persist invoice ${invoice.id}`, error)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (
|
||||
invoice.status !== InvoiceStatus.COMPLETED
|
||||
&& !invoice.confirmedAt
|
||||
) {
|
||||
response
|
||||
.status(200)
|
||||
.send()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (storedInvoice.status === InvoiceStatus.COMPLETED) {
|
||||
response
|
||||
.status(409)
|
||||
.setHeader('content-type', 'text/plain; charset=utf8')
|
||||
.send('Invoice is already marked paid')
|
||||
return
|
||||
}
|
||||
|
||||
invoice.amountPaid = invoice.amountRequested
|
||||
|
||||
try {
|
||||
await this.paymentsService.confirmInvoice(invoice)
|
||||
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
|
||||
} 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')
|
||||
}
|
||||
}
|
@@ -1,10 +1,8 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { path } from 'ramda'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
import { FeeSchedule, Settings } from '../../@types/settings'
|
||||
import { fromBech32, toBech32 } from '../../utils/transform'
|
||||
import { getPrivateKeyFromSecret, getPublicKey } from '../../utils/event'
|
||||
import { getPublicKey, getRelayPrivateKey } from '../../utils/event'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { createLogger } from '../../factories/logger-factory'
|
||||
import { getRemoteAddress } from '../../utils/http'
|
||||
import { IController } from '../../@types/controllers'
|
||||
@@ -12,6 +10,8 @@ import { Invoice } from '../../@types/invoice'
|
||||
import { IPaymentsService } from '../../@types/services'
|
||||
import { IRateLimiter } from '../../@types/utils'
|
||||
import { IUserRepository } from '../../@types/repositories'
|
||||
import { path } from 'ramda'
|
||||
import { readFileSync } from 'fs'
|
||||
|
||||
let pageCache: string
|
||||
|
||||
@@ -156,7 +156,7 @@ export class PostInvoiceController implements IController {
|
||||
return
|
||||
}
|
||||
|
||||
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
|
||||
const relayPrivkey = getRelayPrivateKey(relayUrl)
|
||||
const relayPubkey = getPublicKey(relayPrivkey)
|
||||
|
||||
const replacements = {
|
||||
|
12
src/factories/lnbits-callback-controller-factory.ts
Normal file
12
src/factories/lnbits-callback-controller-factory.ts
Normal 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())
|
||||
)
|
||||
}
|
@@ -1,28 +1,7 @@
|
||||
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
|
||||
import { createPaymentsProcessor } from './payments-processor-factory'
|
||||
import { createPaymentsService } from './payments-service-factory'
|
||||
import { createSettings } from './settings-factory'
|
||||
import { EventRepository } from '../repositories/event-repository'
|
||||
import { InvoiceRepository } from '../repositories/invoice-repository'
|
||||
import { MaintenanceWorker } from '../app/maintenance-worker'
|
||||
import { PaymentsService } from '../services/payments-service'
|
||||
import { UserRepository } from '../repositories/user-repository'
|
||||
|
||||
export const maintenanceWorkerFactory = () => {
|
||||
const dbClient = getMasterDbClient()
|
||||
const rrDbClient = getReadReplicaDbClient()
|
||||
const paymentsProcessor = createPaymentsProcessor()
|
||||
const userRepository = new UserRepository(dbClient)
|
||||
const invoiceRepository = new InvoiceRepository(dbClient)
|
||||
const eventRepository = new EventRepository(dbClient, rrDbClient)
|
||||
|
||||
const paymentsService = new PaymentsService(
|
||||
dbClient,
|
||||
paymentsProcessor,
|
||||
userRepository,
|
||||
invoiceRepository,
|
||||
eventRepository,
|
||||
createSettings,
|
||||
)
|
||||
|
||||
return new MaintenanceWorker(process, paymentsService, createSettings)
|
||||
return new MaintenanceWorker(process, createPaymentsService(), createSettings)
|
||||
}
|
||||
|
@@ -4,6 +4,7 @@ import { path } from 'ramda'
|
||||
import { createLogger } from './logger-factory'
|
||||
import { createSettings } from './settings-factory'
|
||||
import { IPaymentsProcessor } from '../@types/clients'
|
||||
import { LNbitsPaymentsProcesor } from '../payments-processors/lnbits-payment-processor'
|
||||
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
|
||||
import { PaymentsProcessor } from '../payments-processors/payments-procesor'
|
||||
import { Settings } from '../@types/settings'
|
||||
@@ -11,7 +12,7 @@ import { ZebedeePaymentsProcesor } from '../payments-processors/zebedee-payments
|
||||
|
||||
const debug = createLogger('create-zebedee-payments-processor')
|
||||
|
||||
const getConfig = (settings: Settings): CreateAxiosDefaults<any> => {
|
||||
const getZebedeeAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
|
||||
if (!process.env.ZEBEDEE_API_KEY) {
|
||||
throw new Error('ZEBEDEE_API_KEY must be set.')
|
||||
}
|
||||
@@ -26,6 +27,21 @@ const getConfig = (settings: Settings): CreateAxiosDefaults<any> => {
|
||||
}
|
||||
}
|
||||
|
||||
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 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) {
|
||||
@@ -39,7 +55,7 @@ const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor
|
||||
throw new Error('Unable to create payments processor: Setting paymentsProcessor.zebedee.ipWhitelist is empty.')
|
||||
}
|
||||
|
||||
const config = getConfig(settings)
|
||||
const config = getZebedeeAxiosConfig(settings)
|
||||
debug('config: %o', config)
|
||||
const client = axios.create(config)
|
||||
|
||||
@@ -48,6 +64,21 @@ const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor
|
||||
return new PaymentsProcessor(zpp)
|
||||
}
|
||||
|
||||
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) {
|
||||
throw new Error('Unable to create payments processor: Setting paymentsProcessor.lnbits.callbackBaseURL is not configured.')
|
||||
}
|
||||
|
||||
const config = getLNbitsAxiosConfig(settings)
|
||||
debug('config: %o', config)
|
||||
const client = axios.create(config)
|
||||
|
||||
const pp = new LNbitsPaymentsProcesor(client, createSettings)
|
||||
|
||||
return new PaymentsProcessor(pp)
|
||||
}
|
||||
|
||||
export const createPaymentsProcessor = (): IPaymentsProcessor => {
|
||||
const settings = createSettings()
|
||||
if (!settings.payments?.enabled) {
|
||||
@@ -58,6 +89,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
|
||||
switch (settings.payments?.processor) {
|
||||
case 'zebedee':
|
||||
return createZebedeePaymentsProcessor(settings)
|
||||
case 'lnbits':
|
||||
return createLNbitsPaymentProcessor(settings)
|
||||
default:
|
||||
return new NullPaymentsProcessor()
|
||||
}
|
||||
|
@@ -1,29 +1,15 @@
|
||||
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
|
||||
import { createPaymentsProcessor } from './payments-processor-factory'
|
||||
import { createPaymentsService } from './payments-service-factory'
|
||||
import { createSettings } from './settings-factory'
|
||||
import { EventRepository } from '../repositories/event-repository'
|
||||
import { getMasterDbClient } from '../database/client'
|
||||
import { IController } from '../@types/controllers'
|
||||
import { InvoiceRepository } from '../repositories/invoice-repository'
|
||||
import { PaymentsService } from '../services/payments-service'
|
||||
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 rrDbClient = getReadReplicaDbClient()
|
||||
const eventRepository = new EventRepository(dbClient, rrDbClient)
|
||||
const invoiceRepository = new InvoiceRepository(dbClient)
|
||||
const userRepository = new UserRepository(dbClient)
|
||||
const paymentsProcessor = createPaymentsProcessor()
|
||||
const paymentsService = new PaymentsService(
|
||||
dbClient,
|
||||
paymentsProcessor,
|
||||
userRepository,
|
||||
invoiceRepository,
|
||||
eventRepository,
|
||||
createSettings,
|
||||
)
|
||||
const paymentsService = createPaymentsService()
|
||||
|
||||
return new PostInvoiceController(
|
||||
userRepository,
|
||||
|
@@ -1,30 +1,9 @@
|
||||
import { getMasterDbClient, getReadReplicaDbClient } from '../database/client'
|
||||
import { createPaymentsProcessor } from './payments-processor-factory'
|
||||
import { createSettings } from './settings-factory'
|
||||
import { EventRepository } from '../repositories/event-repository'
|
||||
import { createPaymentsService } from './payments-service-factory'
|
||||
import { IController } from '../@types/controllers'
|
||||
import { InvoiceRepository } from '../repositories/invoice-repository'
|
||||
import { PaymentsService } from '../services/payments-service'
|
||||
import { UserRepository } from '../repositories/user-repository'
|
||||
import { ZebedeeCallbackController } from '../controllers/callbacks/zebedee-callback-controller'
|
||||
|
||||
export const createZebedeeCallbackController = (): IController => {
|
||||
const dbClient = getMasterDbClient()
|
||||
const rrDbClient = getReadReplicaDbClient()
|
||||
const eventRepository = new EventRepository(dbClient, rrDbClient)
|
||||
const invoiceRepotistory = new InvoiceRepository(dbClient)
|
||||
const userRepository = new UserRepository(dbClient)
|
||||
const paymentsProcessor = createPaymentsProcessor()
|
||||
const paymentsService = new PaymentsService(
|
||||
dbClient,
|
||||
paymentsProcessor,
|
||||
userRepository,
|
||||
invoiceRepotistory,
|
||||
eventRepository,
|
||||
createSettings,
|
||||
)
|
||||
|
||||
return new ZebedeeCallbackController(
|
||||
paymentsService,
|
||||
createPaymentsService(),
|
||||
)
|
||||
}
|
||||
|
@@ -0,0 +1,20 @@
|
||||
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')
|
||||
}
|
||||
}
|
136
src/payments-processors/lnbits-payment-processor.ts
Normal file
136
src/payments-processors/lnbits-payment-processor.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
|
||||
import { deriveFromSecret, hmacSha256 } from '../utils/secret'
|
||||
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
|
||||
|
||||
import { AxiosInstance } from 'axios'
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { Factory } from '../@types/base'
|
||||
import { Pubkey } from '../@types/base'
|
||||
import { Settings } from '../@types/settings'
|
||||
|
||||
const debug = createLogger('lnbits-payments-processor')
|
||||
|
||||
export class LNbitsInvoice implements Invoice {
|
||||
id: string
|
||||
pubkey: Pubkey
|
||||
bolt11: string
|
||||
amountRequested: bigint
|
||||
amountPaid?: bigint
|
||||
unit: InvoiceUnit
|
||||
status: InvoiceStatus
|
||||
description: string
|
||||
confirmedAt?: Date | null
|
||||
expiresAt: Date | null
|
||||
updatedAt: Date
|
||||
createdAt: Date
|
||||
}
|
||||
|
||||
export class LNbitsCreateInvoiceResponse implements CreateInvoiceResponse {
|
||||
id: string
|
||||
pubkey: string
|
||||
bolt11: string
|
||||
amountRequested: bigint
|
||||
description: string
|
||||
unit: InvoiceUnit
|
||||
status: InvoiceStatus
|
||||
expiresAt: Date | null
|
||||
confirmedAt?: Date | null
|
||||
createdAt: Date
|
||||
rawResponse?: string
|
||||
}
|
||||
|
||||
export class LNbitsPaymentsProcesor 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(`/api/v1/payments/${invoiceId}`, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
const invoice = new LNbitsInvoice()
|
||||
const data = response.data
|
||||
invoice.id = data.details.payment_hash
|
||||
invoice.pubkey = data.details.extra.internalId
|
||||
invoice.bolt11 = data.details.bolt11
|
||||
invoice.amountRequested = BigInt(Math.floor(data.details.amount / 1000))
|
||||
if (data.paid) invoice.amountPaid = BigInt(Math.floor(data.details.amount / 1000))
|
||||
invoice.unit = InvoiceUnit.SATS
|
||||
invoice.status = data.paid?InvoiceStatus.COMPLETED:InvoiceStatus.PENDING
|
||||
invoice.description = data.details.memo
|
||||
invoice.confirmedAt = data.paid ? new Date(data.details.time * 1000) : null
|
||||
invoice.expiresAt = new Date(data.details.expiry * 1000)
|
||||
invoice.createdAt = new Date(data.details.time * 1000)
|
||||
invoice.updatedAt = new Date()
|
||||
return invoice
|
||||
} 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: internalId,
|
||||
} = request
|
||||
|
||||
const callbackURL = new URL(this.settings().paymentsProcessors?.lnbits?.callbackBaseURL)
|
||||
const hmacExpiry = (Date.now() + (1 * 24 * 60 * 60 * 1000)).toString()
|
||||
callbackURL.searchParams.set('hmac', hmacExpiry + ':' +
|
||||
hmacSha256(deriveFromSecret('lnbits-callback-hmac-key'), hmacExpiry).toString('hex'))
|
||||
|
||||
const body = {
|
||||
amount: Number(amountMsats / 1000n),
|
||||
memo: description,
|
||||
extra: {
|
||||
internalId,
|
||||
},
|
||||
out: false,
|
||||
webhook: callbackURL.toString(),
|
||||
}
|
||||
|
||||
try {
|
||||
debug('request body: %o', body)
|
||||
const response = await this.httpClient.post('/api/v1/payments', body, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
|
||||
debug('response: %o', response.data)
|
||||
|
||||
const invoiceResponse = await this.httpClient.get(`/api/v1/payments/${encodeURIComponent(response.data.payment_hash)}`, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
debug('invoice data response: %o', invoiceResponse.data)
|
||||
|
||||
const invoice = new LNbitsCreateInvoiceResponse()
|
||||
const data = invoiceResponse.data
|
||||
invoice.id = data.details.payment_hash
|
||||
invoice.pubkey = data.details.extra.internalId
|
||||
invoice.bolt11 = data.details.bolt11
|
||||
invoice.amountRequested = BigInt(Math.floor(data.details.amount / 1000))
|
||||
invoice.unit = InvoiceUnit.SATS
|
||||
invoice.status = data.paid?InvoiceStatus.COMPLETED:InvoiceStatus.PENDING
|
||||
invoice.description = data.details.memo
|
||||
invoice.confirmedAt = null
|
||||
invoice.expiresAt = new Date(data.details.expiry * 1000)
|
||||
invoice.createdAt = new Date(data.details.time * 1000)
|
||||
invoice.rawResponse = JSON.stringify({
|
||||
invoiceResponse: invoiceResponse.data,
|
||||
createData: response.data,
|
||||
})
|
||||
|
||||
return invoice
|
||||
} catch (error) {
|
||||
console.error('Unable to request invoice. Reason:', error.message)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,18 +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 { postZebedeeCallbackRequestHandler } from '../../handlers/request-handlers/post-zebedee-callback-request-handler'
|
||||
|
||||
const debug = createLogger('routes-callbacks')
|
||||
|
||||
const router = Router()
|
||||
router
|
||||
.use((req, res, next) => {
|
||||
.post('/zebedee', json(), (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)
|
||||
@@ -22,9 +25,49 @@ router
|
||||
return
|
||||
}
|
||||
|
||||
next()
|
||||
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
|
||||
}
|
||||
|
||||
postZebedeeCallbackRequestHandler(req, res)
|
||||
})
|
||||
.post('/lnbits', json(), (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
|
||||
}
|
||||
postLNbitsCallbackRequestHandler(req, res)
|
||||
})
|
||||
.post('/zebedee', json(), postZebedeeCallbackRequestHandler)
|
||||
|
||||
export default router
|
||||
|
||||
|
@@ -1,10 +1,10 @@
|
||||
import { andThen, pipe } from 'ramda'
|
||||
|
||||
import { broadcastEvent, encryptKind4Event, getPrivateKeyFromSecret, getPublicKey, identifyEvent, signEvent } from '../utils/event'
|
||||
import { broadcastEvent, encryptKind4Event, getPublicKey, getRelayPrivateKey, identifyEvent, signEvent } from '../utils/event'
|
||||
import { DatabaseClient, Pubkey } from '../@types/base'
|
||||
import { FeeSchedule, Settings } from '../@types/settings'
|
||||
import { IEventRepository, IInvoiceRepository, IUserRepository } from '../@types/repositories'
|
||||
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
|
||||
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { EventKinds } from '../constants/base'
|
||||
import { IPaymentsProcessor } from '../@types/clients'
|
||||
@@ -159,6 +159,14 @@ export class PaymentsService implements IPaymentsService {
|
||||
|
||||
const currentSettings = this.settings()
|
||||
|
||||
let amountPaidMsat = invoice.amountPaid
|
||||
|
||||
if (invoice.unit === InvoiceUnit.SATS) {
|
||||
amountPaidMsat *= 1000n
|
||||
} else if (invoice.unit === InvoiceUnit.BTC) {
|
||||
amountPaidMsat *= 1000n * 100000000n
|
||||
}
|
||||
|
||||
const isApplicableFee = (feeSchedule: FeeSchedule) => feeSchedule.enabled
|
||||
&& !feeSchedule.whitelists?.pubkeys?.some((prefix) => invoice.pubkey.startsWith(prefix))
|
||||
const admissionFeeSchedules = currentSettings.payments?.feeSchedules?.admission ?? []
|
||||
@@ -169,7 +177,7 @@ export class PaymentsService implements IPaymentsService {
|
||||
|
||||
if (
|
||||
admissionFeeAmount > 0n
|
||||
&& invoice.amountPaid >= admissionFeeAmount
|
||||
&& amountPaidMsat >= admissionFeeAmount
|
||||
) {
|
||||
const date = new Date()
|
||||
// TODO: Convert to stored func
|
||||
@@ -204,7 +212,7 @@ export class PaymentsService implements IPaymentsService {
|
||||
},
|
||||
} = currentSettings
|
||||
|
||||
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
|
||||
const relayPrivkey = getRelayPrivateKey(relayUrl)
|
||||
const relayPubkey = getPublicKey(relayPrivkey)
|
||||
|
||||
let unit: string = invoice.unit
|
||||
@@ -266,7 +274,7 @@ ${invoice.bolt11}`,
|
||||
},
|
||||
} = currentSettings
|
||||
|
||||
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
|
||||
const relayPrivkey = getRelayPrivateKey(relayUrl)
|
||||
const relayPubkey = getPublicKey(relayPrivkey)
|
||||
|
||||
let unit: string = invoice.unit
|
||||
|
@@ -1,11 +1,13 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
|
||||
import { createCipheriv, createHmac, getRandomValues } from 'crypto'
|
||||
import cluster from 'cluster'
|
||||
|
||||
import { applySpec, converge, curry, mergeLeft, nth, omit, pipe, prop, reduceBy } from 'ramda'
|
||||
import { CanonicalEvent, DBEvent, Event, UnidentifiedEvent, UnsignedEvent } from '../@types/event'
|
||||
import { createCipheriv, getRandomValues } from 'crypto'
|
||||
import { EventId, Pubkey, Tag } from '../@types/base'
|
||||
import { EventKinds, EventTags } from '../constants/base'
|
||||
|
||||
import cluster from 'cluster'
|
||||
import { deriveFromSecret } from './secret'
|
||||
import { EventKindsRange } from '../@types/settings'
|
||||
import { fromBuffer } from './transform'
|
||||
import { getLeadingZeroBits } from './proof-of-work'
|
||||
@@ -179,17 +181,12 @@ export const identifyEvent = async (event: UnidentifiedEvent): Promise<UnsignedE
|
||||
return { ...event, id }
|
||||
}
|
||||
|
||||
export const getPrivateKeyFromSecret =
|
||||
(secret: string) => (data: string | Buffer): string => {
|
||||
export function getRelayPrivateKey(relayUrl: string): string {
|
||||
if (process.env.RELAY_PRIVATE_KEY) {
|
||||
return process.env.RELAY_PRIVATE_KEY
|
||||
}
|
||||
|
||||
const hmac = createHmac('sha256', secret)
|
||||
|
||||
hmac.update(typeof data === 'string' ? Buffer.from(data) : data)
|
||||
|
||||
return hmac.digest().toString('hex')
|
||||
return deriveFromSecret(relayUrl).toString('hex')
|
||||
}
|
||||
|
||||
export const getPublicKey = (privkey: string | Buffer) => Buffer.from(secp256k1.getPublicKey(privkey, true)).subarray(1).toString('hex')
|
||||
@@ -204,7 +201,7 @@ export const encryptKind4Event = (
|
||||
receiverPubkey: Pubkey,
|
||||
) => (event: UnsignedEvent): UnsignedEvent => {
|
||||
const key = secp256k1
|
||||
.getSharedSecret(senderPrivkey,`02${receiverPubkey}`, true)
|
||||
.getSharedSecret(senderPrivkey, `02${receiverPubkey}`, true)
|
||||
.subarray(1)
|
||||
|
||||
const iv = getRandomValues(new Uint8Array(16))
|
||||
|
11
src/utils/secret.ts
Normal file
11
src/utils/secret.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createHmac } from 'crypto'
|
||||
|
||||
export function deriveFromSecret(purpose: string | Buffer): Buffer {
|
||||
return hmacSha256(process.env.SECRET as string, purpose)
|
||||
}
|
||||
|
||||
export function hmacSha256(secret: string | Buffer, data: string | Buffer): Buffer {
|
||||
return createHmac('sha256', secret)
|
||||
.update(data)
|
||||
.digest()
|
||||
}
|
Reference in New Issue
Block a user