feat: add LNURL processor (#202)

* feat: add new lnurl processor

* fix: lnbits issues

* fix: add default settings for lnurl processor

* fix: small changes

* fix: more changes

* fix: add verify url in upsert omit

* fix: change comment

* chore: add updateInvoiceStatus

* chore: revert lnbits change

* fix: changes
This commit is contained in:
Adithya Vardhan
2023-03-06 19:04:38 +05:30
committed by GitHub
parent d80342837a
commit f23740073f
18 changed files with 159 additions and 36 deletions

View File

@@ -0,0 +1,9 @@
exports.up = function (knex) {
return knex.raw('ALTER TABLE invoices ADD verify_url TEXT;')
}
exports.down = function (knex) {
return knex.schema.alterTable('invoices', function (table) {
table.dropColumn('verify_url')
})
}

View File

@@ -28,6 +28,8 @@ paymentsProcessors:
lnbits: lnbits:
baseURL: https://lnbits.your-domain.com/ baseURL: https://lnbits.your-domain.com/
callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits
lnurl:
invoiceURL: https://getalby.com/lnurlp/your-username
network: network:
maxPayloadSize: 524288 maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy # Comment the next line if using CloudFlare proxy

View File

@@ -168,7 +168,7 @@
if (event.pubkey === relayPubkey) { if (event.pubkey === relayPubkey) {
paid = true paid = true
clearTimeout(timeout) if (expiresAt) clearTimeout(timeout)
hide('pending') hide('pending')
show('paid') show('paid')
@@ -213,12 +213,14 @@
} }
} }
const expiry = (new Date(expiresAt).getTime() - new Date().getTime()) if (expiresAt) {
console.log('expiry at', expiresAt, Math.floor(expiry / 1000)) const expiry = (new Date(expiresAt).getTime() - new Date().getTime())
timeout = setTimeout(() => { console.log('expiry at', expiresAt, Math.floor(expiry / 1000))
hide('pending') timeout = setTimeout(() => {
show('expired') hide('pending')
}, expiry) show('expired')
}, expiry)
}
new QRCode(document.getElementById("invoice"), { new QRCode(document.getElementById("invoice"), {
text: `lightning:${invoice}`, text: `lightning:${invoice}`,

View File

@@ -12,6 +12,7 @@ export interface CreateInvoiceResponse {
confirmedAt?: Date | null confirmedAt?: Date | null
createdAt: Date createdAt: Date
rawResponse?: string rawResponse?: string
verifyURL?: string
} }
export interface CreateInvoiceRequest { export interface CreateInvoiceRequest {
@@ -20,9 +21,9 @@ export interface CreateInvoiceRequest {
requestId?: string requestId?: string
} }
export type GetInvoiceResponse = Invoice export type GetInvoiceResponse = Partial<Invoice>
export interface IPaymentsProcessor { export interface IPaymentsProcessor {
createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse>
getInvoice(invoiceId: string): Promise<GetInvoiceResponse> getInvoice(invoice: string | Invoice): Promise<GetInvoiceResponse>
} }

View File

@@ -24,6 +24,7 @@ export interface Invoice {
expiresAt: Date | null expiresAt: Date | null
updatedAt: Date updatedAt: Date
createdAt: Date createdAt: Date
verifyURL?: string
} }
export interface DBInvoice { export interface DBInvoice {
@@ -39,4 +40,5 @@ export interface DBInvoice {
expires_at: Date expires_at: Date
updated_at: Date updated_at: Date
created_at: Date created_at: Date
verify_url: string
} }

View File

@@ -2,13 +2,14 @@ import { Invoice } from './invoice'
import { Pubkey } from './base' import { Pubkey } from './base'
export interface IPaymentsService { export interface IPaymentsService {
getInvoiceFromPaymentsProcessor(invoiceId: string): Promise<Invoice> getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise<Partial<Invoice>>
createInvoice( createInvoice(
pubkey: Pubkey, pubkey: Pubkey,
amount: bigint, amount: bigint,
description: string, description: string,
): Promise<Invoice> ): Promise<Invoice>
updateInvoice(invoice: Invoice): Promise<void> updateInvoice(invoice: Partial<Invoice>): Promise<void>
updateInvoiceStatus(invoice: Partial<Invoice>): Promise<void>
confirmInvoice( confirmInvoice(
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>, invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>,
): Promise<void> ): Promise<void>

View File

@@ -142,6 +142,10 @@ export interface Payments {
feeSchedules: FeeSchedules feeSchedules: FeeSchedules
} }
export interface LnurlPaymentsProcessor {
invoiceURL: string
}
export interface ZebedeePaymentsProcessor { export interface ZebedeePaymentsProcessor {
baseURL: string baseURL: string
callbackBaseURL: string callbackBaseURL: string
@@ -154,6 +158,7 @@ export interface LNbitsPaymentProcessor {
} }
export interface PaymentsProcessors { export interface PaymentsProcessors {
lnurl?: LnurlPaymentsProcessor,
zebedee?: ZebedeePaymentsProcessor zebedee?: ZebedeePaymentsProcessor
lnbits?: LNbitsPaymentProcessor lnbits?: LNbitsPaymentProcessor
} }

View File

@@ -48,10 +48,10 @@ export class MaintenanceWorker implements IRunnable {
debug('invoice %s: %o', invoice.id, invoice) debug('invoice %s: %o', invoice.id, invoice)
try { try {
debug('getting invoice %s from payment processor', invoice.id) debug('getting invoice %s from payment processor', invoice.id)
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice.id) const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)
await delay() await delay()
debug('updating invoice %s: %o', invoice.id, invoice) debug('updating invoice status %s: %o', invoice.id, invoice)
await this.paymentsService.updateInvoice(updatedInvoice) await this.paymentsService.updateInvoiceStatus(updatedInvoice)
if ( if (
invoice.status !== updatedInvoice.status invoice.status !== updatedInvoice.status

View File

@@ -40,6 +40,7 @@ export enum EventTags {
} }
export enum PaymentsProcessors { export enum PaymentsProcessors {
LNURL = 'lnurl',
ZEBEDEE = 'zebedee', ZEBEDEE = 'zebedee',
LNBITS = 'lnbits', LNBITS = 'lnbits',
} }

View File

@@ -1,9 +1,9 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { Invoice, InvoiceStatus } from '../../@types/invoice'
import { createLogger } from '../../factories/logger-factory' import { createLogger } from '../../factories/logger-factory'
import { IController } from '../../@types/controllers' import { IController } from '../../@types/controllers'
import { IInvoiceRepository } from '../../@types/repositories' import { IInvoiceRepository } from '../../@types/repositories'
import { InvoiceStatus } from '../../@types/invoice'
import { IPaymentsService } from '../../@types/services' import { IPaymentsService } from '../../@types/services'
const debug = createLogger('lnbits-callback-controller') const debug = createLogger('lnbits-callback-controller')
@@ -72,8 +72,8 @@ export class LNbitsCallbackController implements IController {
invoice.amountPaid = invoice.amountRequested invoice.amountPaid = invoice.amountRequested
try { try {
await this.paymentsService.confirmInvoice(invoice) await this.paymentsService.confirmInvoice(invoice as Invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice) await this.paymentsService.sendInvoiceUpdateNotification(invoice as Invoice)
} catch (error) { } catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error) console.error(`Unable to confirm invoice ${invoice.id}`, error)

View File

@@ -165,7 +165,7 @@ export class PostInvoiceController implements IController {
relay_url: relayUrl, relay_url: relayUrl,
pubkey, pubkey,
relay_pubkey: relayPubkey, relay_pubkey: relayPubkey,
expires_at: invoice.expiresAt?.toISOString(), expires_at: invoice.expiresAt?.toISOString() ?? '',
invoice: invoice.bolt11, invoice: invoice.bolt11,
amount: amount / 1000n, amount: amount / 1000n,
} }

View File

@@ -5,6 +5,7 @@ import { createLogger } from './logger-factory'
import { createSettings } from './settings-factory' import { createSettings } from './settings-factory'
import { IPaymentsProcessor } from '../@types/clients' import { IPaymentsProcessor } from '../@types/clients'
import { LNbitsPaymentsProcesor } from '../payments-processors/lnbits-payment-processor' import { LNbitsPaymentsProcesor } from '../payments-processors/lnbits-payment-processor'
import { LnurlPaymentsProcesor } from '../payments-processors/lnurl-payments-processor'
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor' import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
import { PaymentsProcessor } from '../payments-processors/payments-procesor' import { PaymentsProcessor } from '../payments-processors/payments-procesor'
import { Settings } from '../@types/settings' import { Settings } from '../@types/settings'
@@ -44,6 +45,19 @@ const getLNbitsAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
} }
} }
const createLnurlPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const invoiceURL = path(['paymentsProcessors', 'lnurl', 'invoiceURL'], settings) as string | undefined
if (typeof invoiceURL === 'undefined') {
throw new Error('Unable to create payments processor: Setting paymentsProcessor.lnurl.invoiceURL is not configured.')
}
const client = axios.create()
const app = new LnurlPaymentsProcesor(client, createSettings)
return new PaymentsProcessor(app)
}
const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => { const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined const callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) { if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
@@ -98,6 +112,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
} }
switch (settings.payments?.processor) { switch (settings.payments?.processor) {
case 'lnurl':
return createLnurlPaymentsProcessor(settings)
case 'zebedee': case 'zebedee':
return createZebedeePaymentsProcessor(settings) return createZebedeePaymentsProcessor(settings)
case 'lnbits': case 'lnbits':

View File

@@ -0,0 +1,69 @@
import { AxiosInstance } from 'axios'
import { Factory } from '../@types/base'
import { CreateInvoiceRequest, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { Invoice, InvoiceStatus, InvoiceUnit } from '../@types/invoice'
import { createLogger } from '../factories/logger-factory'
import { randomUUID } from 'crypto'
import { Settings } from '../@types/settings'
const debug = createLogger('lnurl-payments-processor')
export class LnurlPaymentsProcesor implements IPaymentsProcessor {
public constructor(
private httpClient: AxiosInstance,
private settings: Factory<Settings>
) {}
public async getInvoice(invoice: Invoice): Promise<GetInvoiceResponse> {
debug('get invoice: %s', invoice.id)
try {
const response = await this.httpClient.get(invoice.verifyURL)
return {
id: invoice.id,
status: response.data.settled ? InvoiceStatus['COMPLETED'] : InvoiceStatus['PENDING'],
}
} catch (error) {
console.error(`Unable to get invoice ${invoice.id}. Reason:`, error)
throw error
}
}
public async createInvoice(request: CreateInvoiceRequest): Promise<any> {
debug('create invoice: %o', request)
const {
amount: amountMsats,
description,
requestId,
} = request
try {
const response = await this.httpClient.get(`${this.settings().paymentsProcessors?.lnurl?.invoiceURL}/callback?amount=${amountMsats}&comment=${description}`)
const result = {
id: randomUUID(),
pubkey: requestId,
bolt11: response.data.pr,
amountRequested: amountMsats,
description,
unit: InvoiceUnit.MSATS,
status: InvoiceStatus.PENDING,
expiresAt: null,
confirmedAt: null,
createdAt: new Date(),
verifyURL: response.data.verify,
}
debug('result: %o', result)
return result
} catch (error) {
console.error('Unable to request invoice. Reason:', error.message)
throw error
}
}
}

View File

@@ -16,6 +16,7 @@ export class NullPaymentsProcessor implements IPaymentsProcessor {
confirmedAt: null, confirmedAt: null,
createdAt: date, createdAt: date,
updatedAt: date, updatedAt: date,
verifyURL: '',
} }
} }
@@ -32,6 +33,7 @@ export class NullPaymentsProcessor implements IPaymentsProcessor {
rawResponse: '', rawResponse: '',
confirmedAt: null, confirmedAt: null,
createdAt: new Date(), createdAt: new Date(),
verifyURL: '',
} }
} }
} }

View File

@@ -1,4 +1,4 @@
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients' import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { Invoice } from '../@types/invoice' import { Invoice } from '../@types/invoice'
export class PaymentsProcessor implements IPaymentsProcessor { export class PaymentsProcessor implements IPaymentsProcessor {
@@ -6,8 +6,8 @@ export class PaymentsProcessor implements IPaymentsProcessor {
private readonly processor: IPaymentsProcessor private readonly processor: IPaymentsProcessor
) {} ) {}
public async getInvoice(invoiceId: string): Promise<Invoice> { public async getInvoice(invoice: string | Invoice): Promise<GetInvoiceResponse> {
return this.processor.getInvoice(invoiceId) return this.processor.getInvoice(invoice)
} }
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> { public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {

View File

@@ -3,7 +3,6 @@ import {
applySpec, applySpec,
ifElse, ifElse,
is, is,
isNil,
omit, omit,
pipe, pipe,
prop, prop,
@@ -92,17 +91,10 @@ export class InvoiceRepository implements IInvoiceRepository {
status: prop('status'), status: prop('status'),
description: prop('description'), description: prop('description'),
// confirmed_at: prop('confirmedAt'), // confirmed_at: prop('confirmedAt'),
expires_at: ifElse( expires_at: prop('expiresAt'),
propSatisfies(isNil, 'expiresAt'),
always(undefined),
prop('expiresAt'),
),
updated_at: always(new Date()), updated_at: always(new Date()),
created_at: ifElse( created_at: prop('createdAt'),
propSatisfies(isNil, 'createdAt'), verify_url: prop('verifyURL'),
always(undefined),
prop('createdAt'),
),
})(invoice) })(invoice)
debug('row: %o', row) debug('row: %o', row)
@@ -120,6 +112,7 @@ export class InvoiceRepository implements IInvoiceRepository {
'description', 'description',
'expires_at', 'expires_at',
'created_at', 'created_at',
'verify_url',
])(row) ])(row)
) )

View File

@@ -36,10 +36,12 @@ export class PaymentsService implements IPaymentsService {
} }
} }
public async getInvoiceFromPaymentsProcessor(invoiceId: string): Promise<Invoice> { public async getInvoiceFromPaymentsProcessor(invoice: Invoice): Promise<Partial<Invoice>> {
debug('get invoice %s from payment processor', invoiceId) debug('get invoice %s from payment processor', invoice.id)
try { try {
return await this.paymentsProcessor.getInvoice(invoiceId) return await this.paymentsProcessor.getInvoice(
this.settings().payments?.processor === 'lnurl' ? invoice : invoice.id
)
} catch (error) { } catch (error) {
console.log('Unable to get invoice from payments processor. Reason:', error) console.log('Unable to get invoice from payments processor. Reason:', error)
@@ -82,6 +84,7 @@ export class PaymentsService implements IPaymentsService {
expiresAt: invoiceResponse.expiresAt, expiresAt: invoiceResponse.expiresAt,
updatedAt: date, updatedAt: date,
createdAt: date, createdAt: date,
verifyURL: invoiceResponse.verifyURL,
}, },
transaction.transaction, transaction.transaction,
) )
@@ -99,6 +102,7 @@ export class PaymentsService implements IPaymentsService {
expiresAt: invoiceResponse.expiresAt, expiresAt: invoiceResponse.expiresAt,
updatedAt: date, updatedAt: date,
createdAt: invoiceResponse.createdAt, createdAt: invoiceResponse.createdAt,
verifyURL: invoiceResponse.verifyURL,
} }
} catch (error) { } catch (error) {
await transaction.rollback() await transaction.rollback()
@@ -108,7 +112,7 @@ export class PaymentsService implements IPaymentsService {
} }
} }
public async updateInvoice(invoice: Invoice): Promise<void> { public async updateInvoice(invoice: Partial<Invoice>): Promise<void> {
debug('update invoice %s: %o', invoice.id, invoice) debug('update invoice %s: %o', invoice.id, invoice)
try { try {
await this.invoiceRepository.upsert({ await this.invoiceRepository.upsert({
@@ -129,6 +133,21 @@ export class PaymentsService implements IPaymentsService {
} }
} }
public async updateInvoiceStatus(invoice: Partial<Invoice>): Promise<void> {
debug('update invoice %s: %o', invoice.id, invoice)
try {
const fullInvoice = await this.invoiceRepository.findById(invoice.id)
await this.invoiceRepository.upsert({
...fullInvoice,
status: invoice.status,
updatedAt: new Date(),
})
} catch (error) {
console.error('Unable to update invoice. Reason:', error)
throw error
}
}
public async confirmInvoice( public async confirmInvoice(
invoice: Invoice, invoice: Invoice,
): Promise<void> { ): Promise<void> {

View File

@@ -31,6 +31,7 @@ export const fromDBInvoice = applySpec<Invoice>({
expiresAt: prop('expires_at'), expiresAt: prop('expires_at'),
updatedAt: prop('updated_at'), updatedAt: prop('updated_at'),
createdAt: prop('created_at'), createdAt: prop('created_at'),
verifyURL: prop('verify_url'),
}) })
export const fromDBUser = applySpec<User>({ export const fromDBUser = applySpec<User>({