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:
Semisol
2023-02-18 17:13:49 +03:00
committed by GitHub
parent eac8c508ce
commit 2342386bb4
20 changed files with 518 additions and 96 deletions

View 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;
$$;`)
}

View File

@@ -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;
$$;`)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ export enum EventTags {
export enum PaymentsProcessors {
ZEBEDEE = 'zebedee',
LNBITS = 'lnbits',
}
export const EventDelegatorMetadataKey = Symbol('Delegator')

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}
}

View File

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

View File

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

View File

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