Skip to content

Commit 2342386

Browse files
authored
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
1 parent eac8c50 commit 2342386

20 files changed

+518
-96
lines changed
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
exports.up = function (knex) {
2+
return knex.schema
3+
.raw('ALTER TABLE invoices ALTER COLUMN id TYPE text USING id::text; ALTER TABLE invoices ALTER COLUMN id DROP DEFAULT;')
4+
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
5+
RETURNS INTEGER
6+
LANGUAGE plpgsql
7+
AS $$
8+
DECLARE
9+
payee BYTEA;
10+
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
11+
BEGIN
12+
PERFORM ASSERT_SERIALIZED();
13+
14+
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
15+
IF confirmed_date IS NULL THEN
16+
UPDATE invoices
17+
SET
18+
"confirmed_at" = confirmation_date,
19+
"amount_paid" = amount_received,
20+
"updated_at" = now_utc()
21+
WHERE id = invoice_id;
22+
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
23+
END IF;
24+
RETURN 0;
25+
END;
26+
$$;`)
27+
}
28+
29+
exports.down = function (knex) {
30+
return knex.schema
31+
.raw('ALTER TABLE invoices ALTER COLUMN id TYPE uuid USING id::uuid; ALTER TABLE invoices ALTER COLUMN id SET DEFAULT uuid_generate_v4();')
32+
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id UUID, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
33+
RETURNS INTEGER
34+
LANGUAGE plpgsql
35+
AS $$
36+
DECLARE
37+
payee BYTEA;
38+
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
39+
BEGIN
40+
PERFORM ASSERT_SERIALIZED();
41+
42+
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
43+
IF confirmed_date IS NULL THEN
44+
UPDATE invoices
45+
SET
46+
"confirmed_at" = confirmation_date,
47+
"amount_paid" = amount_received,
48+
"updated_at" = now_utc()
49+
WHERE id = invoice_id;
50+
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
51+
END IF;
52+
RETURN 0;
53+
END;
54+
$$;`)
55+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
exports.up = function (knex) {
2+
return knex.schema
3+
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
4+
RETURNS INTEGER
5+
LANGUAGE plpgsql
6+
AS $$
7+
DECLARE
8+
payee BYTEA;
9+
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
10+
unit TEXT;
11+
BEGIN
12+
PERFORM ASSERT_SERIALIZED();
13+
14+
SELECT "pubkey", "confirmed_at", "unit" INTO payee, confirmed_date, unit FROM "invoices" WHERE id = invoice_id;
15+
IF confirmed_date IS NULL THEN
16+
UPDATE invoices
17+
SET
18+
"confirmed_at" = confirmation_date,
19+
"amount_paid" = amount_received,
20+
"updated_at" = now_utc()
21+
WHERE id = invoice_id;
22+
IF unit = 'sats' THEN
23+
UPDATE users SET balance = balance + amount_received * 1000 WHERE "pubkey" = payee;
24+
ELSIF unit = 'msats' THEN
25+
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
26+
ELSIF unit = 'btc' THEN
27+
UPDATE users SET balance = balance + amount_received * 100000000 * 1000 WHERE "pubkey" = payee;
28+
END IF;
29+
END IF;
30+
RETURN 0;
31+
END;
32+
$$;`)
33+
}
34+
35+
exports.down = function (knex) {
36+
return knex.schema
37+
.raw(`CREATE OR REPLACE FUNCTION confirm_invoice(invoice_id TEXT, amount_received BIGINT, confirmation_date TIMESTAMP WITHOUT TIME ZONE)
38+
RETURNS INTEGER
39+
LANGUAGE plpgsql
40+
AS $$
41+
DECLARE
42+
payee BYTEA;
43+
confirmed_date TIMESTAMP WITHOUT TIME ZONE;
44+
BEGIN
45+
PERFORM ASSERT_SERIALIZED();
46+
47+
SELECT "pubkey", "confirmed_at" INTO payee, confirmed_date FROM "invoices" WHERE id = invoice_id;
48+
IF confirmed_date IS NULL THEN
49+
UPDATE invoices
50+
SET
51+
"confirmed_at" = confirmation_date,
52+
"amount_paid" = amount_received,
53+
"updated_at" = now_utc()
54+
WHERE id = invoice_id;
55+
UPDATE users SET balance = balance + amount_received WHERE "pubkey" = payee;
56+
END IF;
57+
RETURN 0;
58+
END;
59+
$$;`)
60+
}

resources/default-settings.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ paymentsProcessors:
2222
ipWhitelist:
2323
- "3.225.112.64"
2424
- "::ffff:3.225.112.64"
25+
lnbits:
26+
baseURL: https://lnbits.your-domain.com/
27+
callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits
2528
network:
2629
maxPayloadSize: 524288
2730
remoteIpHeader: x-forwarded-for

src/@types/invoice.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ export interface DBInvoice {
3030
id: string
3131
pubkey: Buffer
3232
bolt11: string
33-
amount_requested: BigInt
34-
amount_paid: BigInt
33+
amount_requested: bigint
34+
amount_paid: bigint
3535
unit: InvoiceUnit
3636
status: InvoiceStatus,
3737
description: string

src/@types/settings.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,8 +148,14 @@ export interface ZebedeePaymentsProcessor {
148148
ipWhitelist: string[]
149149
}
150150

151+
export interface LNbitsPaymentProcessor {
152+
baseURL: string
153+
callbackBaseURL: string
154+
}
155+
151156
export interface PaymentsProcessors {
152157
zebedee?: ZebedeePaymentsProcessor
158+
lnbits?: LNbitsPaymentProcessor
153159
}
154160

155161
export interface Local {

src/app/app.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ export class App implements IRunnable {
6262
logCentered(`Pay-to-relay ${pathEq(['payments', 'enabled'], true, settings) ? 'enabled' : 'disabled'}`, width)
6363
logCentered(`Payments provider: ${path(['payments', 'processor'], settings)}`, width)
6464

65+
if (typeof this.process.env.SECRET !== 'string' || this.process.env.SECRET === 'changeme') {
66+
console.error('Please configure the secret using the SECRET environment variable.')
67+
this.process.exit(1)
68+
}
69+
6570
const workerCount = process.env.WORKER_COUNT
6671
? Number(process.env.WORKER_COUNT)
6772
: this.settings().workers?.count || cpus().length

src/constants/base.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ export enum EventTags {
4141

4242
export enum PaymentsProcessors {
4343
ZEBEDEE = 'zebedee',
44+
LNBITS = 'lnbits',
4445
}
4546

4647
export const EventDelegatorMetadataKey = Symbol('Delegator')
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import { Request, Response } from 'express'
2+
3+
import { createLogger } from '../../factories/logger-factory'
4+
import { IController } from '../../@types/controllers'
5+
import { IInvoiceRepository } from '../../@types/repositories'
6+
import { InvoiceStatus } from '../../@types/invoice'
7+
import { IPaymentsService } from '../../@types/services'
8+
9+
const debug = createLogger('lnbits-callback-controller')
10+
11+
export class LNbitsCallbackController implements IController {
12+
public constructor(
13+
private readonly paymentsService: IPaymentsService,
14+
private readonly invoiceRepository: IInvoiceRepository
15+
) { }
16+
17+
// TODO: Validate
18+
public async handleRequest(
19+
request: Request,
20+
response: Response,
21+
) {
22+
debug('request headers: %o', request.headers)
23+
debug('request body: %o', request.body)
24+
25+
const body = request.body
26+
if (!body || typeof body !== 'object' || typeof body.payment_hash !== 'string' || body.payment_hash.length !== 64) {
27+
response
28+
.status(400)
29+
.setHeader('content-type', 'text/plain; charset=utf8')
30+
.send('Malformed body')
31+
return
32+
}
33+
34+
const invoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(body.payment_hash)
35+
const storedInvoice = await this.invoiceRepository.findById(body.payment_hash)
36+
37+
if (!storedInvoice) {
38+
response
39+
.status(404)
40+
.setHeader('content-type', 'text/plain; charset=utf8')
41+
.send('No such invoice')
42+
return
43+
}
44+
45+
try {
46+
await this.paymentsService.updateInvoice(invoice)
47+
} catch (error) {
48+
console.error(`Unable to persist invoice ${invoice.id}`, error)
49+
50+
throw error
51+
}
52+
53+
if (
54+
invoice.status !== InvoiceStatus.COMPLETED
55+
&& !invoice.confirmedAt
56+
) {
57+
response
58+
.status(200)
59+
.send()
60+
61+
return
62+
}
63+
64+
if (storedInvoice.status === InvoiceStatus.COMPLETED) {
65+
response
66+
.status(409)
67+
.setHeader('content-type', 'text/plain; charset=utf8')
68+
.send('Invoice is already marked paid')
69+
return
70+
}
71+
72+
invoice.amountPaid = invoice.amountRequested
73+
74+
try {
75+
await this.paymentsService.confirmInvoice(invoice)
76+
await this.paymentsService.sendInvoiceUpdateNotification(invoice)
77+
} catch (error) {
78+
console.error(`Unable to confirm invoice ${invoice.id}`, error)
79+
80+
throw error
81+
}
82+
83+
response
84+
.status(200)
85+
.setHeader('content-type', 'text/plain; charset=utf8')
86+
.send('OK')
87+
}
88+
}

src/controllers/invoices/post-invoice-controller.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { Request, Response } from 'express'
2-
import { path } from 'ramda'
3-
import { readFileSync } from 'fs'
4-
51
import { FeeSchedule, Settings } from '../../@types/settings'
62
import { fromBech32, toBech32 } from '../../utils/transform'
7-
import { getPrivateKeyFromSecret, getPublicKey } from '../../utils/event'
3+
import { getPublicKey, getRelayPrivateKey } from '../../utils/event'
4+
import { Request, Response } from 'express'
5+
86
import { createLogger } from '../../factories/logger-factory'
97
import { getRemoteAddress } from '../../utils/http'
108
import { IController } from '../../@types/controllers'
119
import { Invoice } from '../../@types/invoice'
1210
import { IPaymentsService } from '../../@types/services'
1311
import { IRateLimiter } from '../../@types/utils'
1412
import { IUserRepository } from '../../@types/repositories'
13+
import { path } from 'ramda'
14+
import { readFileSync } from 'fs'
1515

1616
let pageCache: string
1717

@@ -156,7 +156,7 @@ export class PostInvoiceController implements IController {
156156
return
157157
}
158158

159-
const relayPrivkey = getPrivateKeyFromSecret(process.env.SECRET as string)(relayUrl)
159+
const relayPrivkey = getRelayPrivateKey(relayUrl)
160160
const relayPubkey = getPublicKey(relayPrivkey)
161161

162162
const replacements = {
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { createPaymentsService } from './payments-service-factory'
2+
import { getMasterDbClient } from '../database/client'
3+
import { IController } from '../@types/controllers'
4+
import { InvoiceRepository } from '../repositories/invoice-repository'
5+
import { LNbitsCallbackController } from '../controllers/callbacks/lnbits-callback-controller'
6+
7+
export const createLNbitsCallbackController = (): IController => {
8+
return new LNbitsCallbackController(
9+
createPaymentsService(),
10+
new InvoiceRepository(getMasterDbClient())
11+
)
12+
}

0 commit comments

Comments
 (0)