diff --git a/migrations/20230205_004400_change_invoice_id_to_string.js b/migrations/20230205_004400_change_invoice_id_to_string.js new file mode 100644 index 0000000..eac958b --- /dev/null +++ b/migrations/20230205_004400_change_invoice_id_to_string.js @@ -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; +$$;`) +} diff --git a/migrations/20230217_235600_scale_balance_addition_with_unit.js b/migrations/20230217_235600_scale_balance_addition_with_unit.js new file mode 100644 index 0000000..2fd198c --- /dev/null +++ b/migrations/20230217_235600_scale_balance_addition_with_unit.js @@ -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; + $$;`) +} diff --git a/resources/default-settings.yaml b/resources/default-settings.yaml index 4351fd8..0e8110f 100755 --- a/resources/default-settings.yaml +++ b/resources/default-settings.yaml @@ -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 diff --git a/src/@types/invoice.ts b/src/@types/invoice.ts index 112ab71..dbf9ec6 100644 --- a/src/@types/invoice.ts +++ b/src/@types/invoice.ts @@ -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 diff --git a/src/@types/settings.ts b/src/@types/settings.ts index 818e596..da59af3 100644 --- a/src/@types/settings.ts +++ b/src/@types/settings.ts @@ -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 { diff --git a/src/app/app.ts b/src/app/app.ts index 7e02cbb..cab5cef 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -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 diff --git a/src/constants/base.ts b/src/constants/base.ts index b5a29a0..569da88 100644 --- a/src/constants/base.ts +++ b/src/constants/base.ts @@ -41,6 +41,7 @@ export enum EventTags { export enum PaymentsProcessors { ZEBEDEE = 'zebedee', + LNBITS = 'lnbits', } export const EventDelegatorMetadataKey = Symbol('Delegator') diff --git a/src/controllers/callbacks/lnbits-callback-controller.ts b/src/controllers/callbacks/lnbits-callback-controller.ts new file mode 100644 index 0000000..a4e3a48 --- /dev/null +++ b/src/controllers/callbacks/lnbits-callback-controller.ts @@ -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') + } +} diff --git a/src/controllers/invoices/post-invoice-controller.ts b/src/controllers/invoices/post-invoice-controller.ts index 981f5a1..b4817e5 100644 --- a/src/controllers/invoices/post-invoice-controller.ts +++ b/src/controllers/invoices/post-invoice-controller.ts @@ -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 = { diff --git a/src/factories/lnbits-callback-controller-factory.ts b/src/factories/lnbits-callback-controller-factory.ts new file mode 100644 index 0000000..326def2 --- /dev/null +++ b/src/factories/lnbits-callback-controller-factory.ts @@ -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()) + ) +} diff --git a/src/factories/maintenance-worker-factory.ts b/src/factories/maintenance-worker-factory.ts index 2436c99..5f0f7ab 100644 --- a/src/factories/maintenance-worker-factory.ts +++ b/src/factories/maintenance-worker-factory.ts @@ -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) } diff --git a/src/factories/payments-processor-factory.ts b/src/factories/payments-processor-factory.ts index 367d879..50815a0 100644 --- a/src/factories/payments-processor-factory.ts +++ b/src/factories/payments-processor-factory.ts @@ -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 => { +const getZebedeeAxiosConfig = (settings: Settings): CreateAxiosDefaults => { if (!process.env.ZEBEDEE_API_KEY) { throw new Error('ZEBEDEE_API_KEY must be set.') } @@ -26,6 +27,21 @@ const getConfig = (settings: Settings): CreateAxiosDefaults => { } } +const getLNbitsAxiosConfig = (settings: Settings): CreateAxiosDefaults => { + 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() } diff --git a/src/factories/post-invoice-controller-factory.ts b/src/factories/post-invoice-controller-factory.ts index e3f57b2..2cc3f29 100644 --- a/src/factories/post-invoice-controller-factory.ts +++ b/src/factories/post-invoice-controller-factory.ts @@ -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, diff --git a/src/factories/zebedee-callback-controller-factory.ts b/src/factories/zebedee-callback-controller-factory.ts index 28fd957..72091b0 100644 --- a/src/factories/zebedee-callback-controller-factory.ts +++ b/src/factories/zebedee-callback-controller-factory.ts @@ -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(), ) } diff --git a/src/handlers/request-handlers/post-lnbits-callback-request-handler.ts b/src/handlers/request-handlers/post-lnbits-callback-request-handler.ts new file mode 100644 index 0000000..4cc152c --- /dev/null +++ b/src/handlers/request-handlers/post-lnbits-callback-request-handler.ts @@ -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') + } +} diff --git a/src/payments-processors/lnbits-payment-processor.ts b/src/payments-processors/lnbits-payment-processor.ts new file mode 100644 index 0000000..6996c08 --- /dev/null +++ b/src/payments-processors/lnbits-payment-processor.ts @@ -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 + ) {} + + public async getInvoice(invoiceId: string): Promise { + 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 { + 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 + } + } +} diff --git a/src/routes/callbacks/index.ts b/src/routes/callbacks/index.ts index e38a671..0beeca1 100644 --- a/src/routes/callbacks/index.ts +++ b/src/routes/callbacks/index.ts @@ -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 diff --git a/src/services/payments-service.ts b/src/services/payments-service.ts index 968a246..e7c5227 100644 --- a/src/services/payments-service.ts +++ b/src/services/payments-service.ts @@ -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 diff --git a/src/utils/event.ts b/src/utils/event.ts index 7edd026..577bb47 100644 --- a/src/utils/event.ts +++ b/src/utils/event.ts @@ -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' @@ -35,9 +37,9 @@ export const toNostrEvent: (event: DBEvent) => Event = applySpec({ export const isEventKindOrRangeMatch = ({ kind }: Event) => (item: EventKinds | EventKindsRange) => - typeof item === 'number' - ? item === kind - : kind >= item[0] && kind <= item[1] + typeof item === 'number' + ? item === kind + : kind >= item[0] && kind <= item[1] export const isEventMatchingFilter = (filter: SubscriptionFilter) => (event: Event): boolean => { const startsWith = (input: string) => (prefix: string) => input.startsWith(prefix) @@ -179,17 +181,12 @@ export const identifyEvent = async (event: UnidentifiedEvent): Promise (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)) @@ -289,7 +286,7 @@ export const getEventExpiration = (event: Event): number | undefined => { const expirationTime = Number(rawExpirationTime) if ((Number.isSafeInteger(expirationTime) && Math.log10(expirationTime))) { return expirationTime - } + } } export const getEventProofOfWork = (eventId: EventId): number => { diff --git a/src/utils/secret.ts b/src/utils/secret.ts new file mode 100644 index 0000000..4260ec9 --- /dev/null +++ b/src/utils/secret.ts @@ -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() +}