Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add LNURL processor #202

Merged
merged 10 commits into from
Mar 6, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions migrations/20230213103904_add_verify_url_to_invoices_table.js
Original file line number Diff line number Diff line change
@@ -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')
})
}
2 changes: 2 additions & 0 deletions resources/default-settings.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ paymentsProcessors:
lnbits:
baseURL: https://lnbits.your-domain.com/
callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits
lnurl:
invoiceURL: https://getalby.com/lnurlp/your-username
network:
maxPayloadSize: 524288
# Comment the next line if using CloudFlare proxy
Expand Down
16 changes: 9 additions & 7 deletions resources/invoices.html
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,7 @@ <h2 class="text-danger">Invoice expired!</h2>
if (event.pubkey === relayPubkey) {
paid = true

clearTimeout(timeout)
if (expiresAt) clearTimeout(timeout)

hide('pending')
show('paid')
Expand Down Expand Up @@ -213,12 +213,14 @@ <h2 class="text-danger">Invoice expired!</h2>
}
}

const expiry = (new Date(expiresAt).getTime() - new Date().getTime())
console.log('expiry at', expiresAt, Math.floor(expiry / 1000))
timeout = setTimeout(() => {
hide('pending')
show('expired')
}, expiry)
if (expiresAt) {
const expiry = (new Date(expiresAt).getTime() - new Date().getTime())
console.log('expiry at', expiresAt, Math.floor(expiry / 1000))
timeout = setTimeout(() => {
hide('pending')
show('expired')
}, expiry)
}

new QRCode(document.getElementById("invoice"), {
text: `lightning:${invoice}`,
Expand Down
5 changes: 3 additions & 2 deletions src/@types/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CreateInvoiceResponse {
confirmedAt?: Date | null
createdAt: Date
rawResponse?: string
verifyURL?: string
}

export interface CreateInvoiceRequest {
Expand All @@ -20,9 +21,9 @@ export interface CreateInvoiceRequest {
requestId?: string
}

export type GetInvoiceResponse = Invoice
export type GetInvoiceResponse = Partial<Invoice>

export interface IPaymentsProcessor {
createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse>
getInvoice(invoiceId: string): Promise<GetInvoiceResponse>
getInvoice(invoice: string | Invoice): Promise<GetInvoiceResponse>
}
2 changes: 2 additions & 0 deletions src/@types/invoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface Invoice {
expiresAt: Date | null
updatedAt: Date
createdAt: Date
verifyURL?: string
}

export interface DBInvoice {
Expand All @@ -39,4 +40,5 @@ export interface DBInvoice {
expires_at: Date
updated_at: Date
created_at: Date
verify_url: string
}
5 changes: 3 additions & 2 deletions src/@types/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Invoice } from './invoice'
import { Pubkey } from './base'

export interface IPaymentsService {
getInvoiceFromPaymentsProcessor(invoiceId: string): Promise<Invoice>
getInvoiceFromPaymentsProcessor(invoice: string | Invoice): Promise<Partial<Invoice>>
createInvoice(
pubkey: Pubkey,
amount: bigint,
description: string,
): Promise<Invoice>
updateInvoice(invoice: Invoice): Promise<void>
updateInvoice(invoice: Partial<Invoice>): Promise<void>
updateInvoiceStatus(invoice: Partial<Invoice>): Promise<void>
confirmInvoice(
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>,
): Promise<void>
Expand Down
5 changes: 5 additions & 0 deletions src/@types/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ export interface Payments {
feeSchedules: FeeSchedules
}

export interface LnurlPaymentsProcessor {
invoiceURL: string
}

export interface ZebedeePaymentsProcessor {
baseURL: string
callbackBaseURL: string
Expand All @@ -154,6 +158,7 @@ export interface LNbitsPaymentProcessor {
}

export interface PaymentsProcessors {
lnurl?: LnurlPaymentsProcessor,
zebedee?: ZebedeePaymentsProcessor
lnbits?: LNbitsPaymentProcessor
}
Expand Down
6 changes: 3 additions & 3 deletions src/app/maintenance-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,10 +48,10 @@ export class MaintenanceWorker implements IRunnable {
debug('invoice %s: %o', invoice.id, invoice)
try {
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()
debug('updating invoice %s: %o', invoice.id, invoice)
await this.paymentsService.updateInvoice(updatedInvoice)
debug('updating invoice status %s: %o', invoice.id, invoice)
await this.paymentsService.updateInvoiceStatus(updatedInvoice)

if (
invoice.status !== updatedInvoice.status
Expand Down
1 change: 1 addition & 0 deletions src/constants/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export enum EventTags {
}

export enum PaymentsProcessors {
LNURL = 'lnurl',
im-adithya marked this conversation as resolved.
Show resolved Hide resolved
ZEBEDEE = 'zebedee',
LNBITS = 'lnbits',
}
Expand Down
6 changes: 3 additions & 3 deletions src/controllers/callbacks/lnbits-callback-controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Request, Response } from 'express'

import { Invoice, InvoiceStatus } from '../../@types/invoice'
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')
Expand Down Expand Up @@ -72,8 +72,8 @@ export class LNbitsCallbackController implements IController {
invoice.amountPaid = invoice.amountRequested

try {
await this.paymentsService.confirmInvoice(invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
await this.paymentsService.confirmInvoice(invoice as Invoice)
await this.paymentsService.sendInvoiceUpdateNotification(invoice as Invoice)
} catch (error) {
console.error(`Unable to confirm invoice ${invoice.id}`, error)

Expand Down
2 changes: 1 addition & 1 deletion src/controllers/invoices/post-invoice-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ export class PostInvoiceController implements IController {
relay_url: relayUrl,
pubkey,
relay_pubkey: relayPubkey,
expires_at: invoice.expiresAt?.toISOString(),
expires_at: invoice.expiresAt?.toISOString() ?? '',
invoice: invoice.bolt11,
amount: amount / 1000n,
}
Expand Down
16 changes: 16 additions & 0 deletions src/factories/payments-processor-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createLogger } from './logger-factory'
import { createSettings } from './settings-factory'
import { IPaymentsProcessor } from '../@types/clients'
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 { PaymentsProcessor } from '../payments-processors/payments-procesor'
import { Settings } from '../@types/settings'
Expand Down Expand Up @@ -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 callbackBaseURL = path(['paymentsProcessors', 'zebedee', 'callbackBaseURL'], settings) as string | undefined
if (typeof callbackBaseURL === 'undefined' || callbackBaseURL.indexOf('nostream.your-domain.com') >= 0) {
Expand Down Expand Up @@ -98,6 +112,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
}

switch (settings.payments?.processor) {
case 'lnurl':
return createLnurlPaymentsProcessor(settings)
case 'zebedee':
return createZebedeePaymentsProcessor(settings)
case 'lnbits':
Expand Down
69 changes: 69 additions & 0 deletions src/payments-processors/lnurl-payments-processor.ts
Original file line number Diff line number Diff line change
@@ -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(),
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

although this works, I'm concerned we won't be able to look up this invoice on your LNURL provider. Should we instead use UUID v5 which uses namespaces and can be used to generate deterministic UUIDs? That way we can pass the invoice description and always get the same UUID back.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't have a uuid string coming from the response: https://getalby.com/lnurlp/adithya/callback?amount=10000
So we thought of using a random UUID.

instead use UUID v5 which uses namespaces and can be used to generate deterministic UUIDs?

The problem is that we don't return the description (and because of that I changed the getInvoice as well to take the whole invoice instead of just invoice.id)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

well, this could be a future improvement, not a blocker. But we definitely want to give relay operators the ability to go to their LNURL provider to lookup invoices. If they can at least see descriptions which should include the kind of fee and pubkey then it's all good for now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great! I'll then change the below endpoint to have request.description instead of requestId

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
}
}
}
2 changes: 2 additions & 0 deletions src/payments-processors/null-payments-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export class NullPaymentsProcessor implements IPaymentsProcessor {
confirmedAt: null,
createdAt: date,
updatedAt: date,
verifyURL: '',
}
}

Expand All @@ -32,6 +33,7 @@ export class NullPaymentsProcessor implements IPaymentsProcessor {
rawResponse: '',
confirmedAt: null,
createdAt: new Date(),
verifyURL: '',
}
}
}
6 changes: 3 additions & 3 deletions src/payments-processors/payments-procesor.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { CreateInvoiceRequest, CreateInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
import { Invoice } from '../@types/invoice'

export class PaymentsProcessor implements IPaymentsProcessor {
public constructor(
private readonly processor: IPaymentsProcessor
) {}

public async getInvoice(invoiceId: string): Promise<Invoice> {
return this.processor.getInvoice(invoiceId)
public async getInvoice(invoice: string | Invoice): Promise<GetInvoiceResponse> {
return this.processor.getInvoice(invoice)
}

public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
Expand Down
15 changes: 4 additions & 11 deletions src/repositories/invoice-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import {
applySpec,
ifElse,
is,
isNil,
omit,
pipe,
prop,
Expand Down Expand Up @@ -92,17 +91,10 @@ export class InvoiceRepository implements IInvoiceRepository {
status: prop('status'),
description: prop('description'),
// confirmed_at: prop('confirmedAt'),
expires_at: ifElse(
propSatisfies(isNil, 'expiresAt'),
always(undefined),
prop('expiresAt'),
),
expires_at: prop('expiresAt'),
updated_at: always(new Date()),
created_at: ifElse(
propSatisfies(isNil, 'createdAt'),
always(undefined),
prop('createdAt'),
),
created_at: prop('createdAt'),
verify_url: prop('verifyURL'),
})(invoice)

debug('row: %o', row)
Expand All @@ -120,6 +112,7 @@ export class InvoiceRepository implements IInvoiceRepository {
'description',
'expires_at',
'created_at',
'verify_url',
])(row)
)

Expand Down
Loading