mirror of
https://github.com/Cameri/nostream.git
synced 2025-03-26 17:52:30 +01:00
feat: implement nodeless payments processor (#305)
* chore: hide powered by zebedee if payment processor is not * chore: add nodeless as payments processor to settings * fix: bad content type on zebedee callback req handler * chore(release): 1.23.0 [skip ci] # [1.23.0](https://github.com/Cameri/nostream/compare/v1.22.6...v1.23.0) (2023-05-12) ### Bug Fixes * add SECRET as env variable ([#298](https://github.com/Cameri/nostream/issues/298)) ([58a1254](58a12546f0
)) * invoice auto marked as paid ([be6d6f1](be6d6f1454
)) * issues with invoices ([#271](https://github.com/Cameri/nostream/issues/271)) ([e1561e7](e1561e78fd
)) ### Features * add LNURL processor ([#202](https://github.com/Cameri/nostream/issues/202)) ([f237400](f23740073f
)) * allow lightning zap receipts on paid relays ([#303](https://github.com/Cameri/nostream/issues/303)) ([14bc96f](14bc96f516
)) * feat: implement nodeless payments processor * docs: add accepting payments section * chore: validate nodeless webhook secret * chore: hide powered-by-zebedee for non-zebedee processors --------- Co-authored-by: semantic-release-bot <semantic-release-bot@martynus.net>
This commit is contained in:
parent
62c1dbe22c
commit
52aac39875
17
README.md
17
README.md
@ -86,6 +86,18 @@ Install Docker from their [official guide](https://docs.docker.com/engine/instal
|
||||
- [Set up a Paid Nostr relay with Nostream and ZBD](https://andreneves.xyz/p/how-to-setup-a-paid-nostr-relay) by [André Neves](https://snort.social/p/npub1rvg76s0gz535txd9ypg2dfqv0x7a80ar6e096j3v343xdxyrt4ksmkxrck) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/))
|
||||
- [Set up a Nostr relay in under 5 minutes](https://andreneves.xyz/p/set-up-a-nostr-relay-server-in-under) by [André Neves](https://twitter.com/andreneves) (CTO & Co-Founder at [ZEBEDEE](https://zebedee.io/))
|
||||
|
||||
### Accepting payments
|
||||
|
||||
1. Zebedee
|
||||
- You must set ZEBEDEE_API_KEY with an API Key from one of your projects in your Zebedee Developer Dashboard. Contact @foxp2zeb on Telegram or npub1rvg76s0gz535txd9ypg2dfqv0x7a80ar6e096j3v343xdxyrt4ksmkxrck on Nostr requesting access to the Zebedee Developer Dashboard. See the Zebedee full guide on how to set up a paid relay.
|
||||
|
||||
2. Nodeless.io
|
||||
- Sign up for a new account at https://nodeless.io and create a new store. Take note of the store ID.
|
||||
- Create a store webhook and make sure to check all of the events. Grab the store webhook secret.
|
||||
- Go to Profile > API Tokens and generate a new key and keep note of it.
|
||||
- Set NODELESS_API_KEY and NODELESS_WEBHOOK_SECRET environment variables with generated key and webhook secret, respectively.
|
||||
- On your .nostr/settings.yaml file, update the field `paymentsProcessors.nodeless.storeId1 with your store ID.
|
||||
|
||||
## Quick Start (Docker Compose)
|
||||
|
||||
Install Docker following the [official guide](https://docs.docker.com/engine/install/).
|
||||
@ -201,10 +213,7 @@ You may want to use `openssl rand -hex 128` to generate a secret.
|
||||
# Secret shortened for brevity
|
||||
```
|
||||
|
||||
In addition, if using Zebedee for payments, you must also set ZEBEDEE_API_KEY with
|
||||
an API Key from one of your projects in your Zebedee Developer Dashboard. Contact
|
||||
@foxp2zeb on Telegram or npub1rvg76s0gz535txd9ypg2dfqv0x7a80ar6e096j3v343xdxyrt4ksmkxrck on Nostr requesting
|
||||
access to the Zebedee Developer Dashboard.
|
||||
### Initializing the database
|
||||
|
||||
Create `nostr_ts_relay` database:
|
||||
|
||||
|
@ -46,6 +46,9 @@ services:
|
||||
TOR_CONTROL_PORT: 9051
|
||||
TOR_PASSWORD: nostr_ts_relay
|
||||
HIDDEN_SERVICE_PORT: 80
|
||||
# Nodeless.io
|
||||
NODELESS_API_KEY: ${NODELESS_API_KEY}
|
||||
NODELESS_WEBHOOK_SECRET: ${NODELESS_WEBHOOK_SECRET}
|
||||
# Enable DEBUG for troubleshooting. Examples:
|
||||
# DEBUG: "primary:*"
|
||||
# DEBUG: "worker:*"
|
||||
|
@ -29,6 +29,9 @@ paymentsProcessors:
|
||||
callbackBaseURL: https://nostream.your-domain.com/callbacks/lnbits
|
||||
lnurl:
|
||||
invoiceURL: https://getalby.com/lnurlp/your-username
|
||||
nodeless:
|
||||
baseURL: https://nodeless.io
|
||||
storeId: your-nodeless-io-store-id
|
||||
network:
|
||||
maxPayloadSize: 524288
|
||||
# Comment the next line if using CloudFlare proxy
|
||||
|
@ -58,7 +58,7 @@
|
||||
<button id="submitBtn" class="btn btn-lg btn-warning" type="submit">Pay {{amount}} sats</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row d-none" id="powered-by-zebedee">
|
||||
<div class="d-flex justify-content-center mb-3 mt-4">
|
||||
<a href="https://zeb.gg/nostr-zbd-quickstart" target="_blank">
|
||||
<img class="poweredbyzbd-img" src="https://cdn.zebedee.io/an/nostr/poweredbyzbd.png" />
|
||||
@ -129,6 +129,7 @@
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.2.3/dist/js/bootstrap.min.js" integrity="sha384-cuYeSxntonz0PPNlHhBs68uyIAVpIIOZZ5JqeqvYYIcEL727kskC66kF92t6Xl2V" crossorigin="anonymous"></script>
|
||||
<script>
|
||||
var processor = "{{processor}}"
|
||||
function attemptGetPubkey() {
|
||||
const maxRetries = 10
|
||||
function getPubKey(retries) {
|
||||
@ -147,6 +148,9 @@
|
||||
|
||||
function onLoad() {
|
||||
setTimeout(attemptGetPubkey, 300)
|
||||
if (processor === 'zebedee') {
|
||||
document.getElementById('powered-by-zebedee').classList.remove('d-none')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
|
@ -86,7 +86,7 @@
|
||||
<button class="btn btn-lg btn-primary" type="submit">Get another invoice</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="row d-none" id="powered-by-zebedee">
|
||||
<div class="d-flex justify-content-center mb-3 mt-4">
|
||||
<a href="https://zeb.gg/nostr-zbd-quickstart" target="_blank">
|
||||
<img class="poweredbyzbd-img" src="https://cdn.zebedee.io/an/nostr/poweredbyzbd.png" />
|
||||
@ -104,6 +104,7 @@
|
||||
var invoice = "{{invoice}}";
|
||||
var pubkey = "{{pubkey}}"
|
||||
var expiresAt = "{{expires_at}}"
|
||||
var processor = "{{processor}}"
|
||||
var timeout
|
||||
var paid = false
|
||||
var fallbackTimeout
|
||||
@ -254,6 +255,9 @@
|
||||
sendPayment().catch(() => {
|
||||
document.getElementById('sendPaymentBtn').classList.remove('d-none')
|
||||
})
|
||||
if (processor === 'zebedee') {
|
||||
document.getElementById('powered-by-zebedee').classList.remove('d-none')
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -8,7 +8,8 @@ export enum InvoiceUnit {
|
||||
|
||||
export enum InvoiceStatus {
|
||||
PENDING = 'pending',
|
||||
COMPLETED = 'completed'
|
||||
COMPLETED = 'completed',
|
||||
EXPIRED = 'expired',
|
||||
}
|
||||
|
||||
export interface Invoice {
|
||||
|
@ -23,6 +23,10 @@ export interface IEventRepository {
|
||||
export interface IInvoiceRepository {
|
||||
findById(id: string, client?: DatabaseClient): Promise<Invoice | undefined>
|
||||
upsert(invoice: Partial<Invoice>, client?: DatabaseClient): Promise<number>
|
||||
updateStatus(
|
||||
invoice: Pick<Invoice, 'id' | 'status'>,
|
||||
client?: DatabaseClient,
|
||||
): Promise<Invoice | undefined>
|
||||
confirmInvoice(
|
||||
invoiceId: string,
|
||||
amountReceived: bigint,
|
||||
|
@ -9,7 +9,7 @@ export interface IPaymentsService {
|
||||
description: string,
|
||||
): Promise<Invoice>
|
||||
updateInvoice(invoice: Partial<Invoice>): Promise<void>
|
||||
updateInvoiceStatus(invoice: Partial<Invoice>): Promise<void>
|
||||
updateInvoiceStatus(invoice: Pick<Invoice, 'id' | 'status'>): Promise<Invoice>
|
||||
confirmInvoice(
|
||||
invoice: Pick<Invoice, 'id' | 'amountPaid' | 'confirmedAt'>,
|
||||
): Promise<void>
|
||||
|
@ -157,15 +157,26 @@ export interface ZebedeePaymentsProcessor {
|
||||
ipWhitelist: string[]
|
||||
}
|
||||
|
||||
export interface LNbitsPaymentProcessor {
|
||||
export interface NodelessPaymentsProcessor {
|
||||
baseURL: string
|
||||
storeId: string
|
||||
}
|
||||
|
||||
export interface LNbitsPaymentsProcessor {
|
||||
baseURL: string
|
||||
callbackBaseURL: string
|
||||
}
|
||||
|
||||
export interface NodelessPaymentsProcessor {
|
||||
baseURL: string
|
||||
storeId: string
|
||||
}
|
||||
|
||||
export interface PaymentsProcessors {
|
||||
lnurl?: LnurlPaymentsProcessor,
|
||||
zebedee?: ZebedeePaymentsProcessor
|
||||
lnbits?: LNbitsPaymentProcessor
|
||||
lnbits?: LNbitsPaymentsProcessor
|
||||
nodeless?: NodelessPaymentsProcessor
|
||||
}
|
||||
|
||||
export interface Local {
|
||||
|
@ -33,10 +33,10 @@ export class WebServerAdapter extends EventEmitter implements IWebServerAdapter
|
||||
}
|
||||
|
||||
private onClientError(error: Error, socket: Duplex) {
|
||||
console.error('web-server-adapter: client socket error:', error)
|
||||
if (error['code'] === 'ECONNRESET' || !socket.writable) {
|
||||
return
|
||||
}
|
||||
console.error('web-server-adapter: client socket error:', error)
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\nContent-Type: text/html\r\n')
|
||||
}
|
||||
|
||||
|
@ -51,7 +51,13 @@ export class MaintenanceWorker implements IRunnable {
|
||||
const updatedInvoice = await this.paymentsService.getInvoiceFromPaymentsProcessor(invoice)
|
||||
await delay()
|
||||
debug('updating invoice status %s: %o', updatedInvoice.id, updatedInvoice)
|
||||
await this.paymentsService.updateInvoiceStatus(updatedInvoice)
|
||||
|
||||
if (typeof updatedInvoice.id !== 'string' || typeof updatedInvoice.status !== 'string') {
|
||||
continue
|
||||
}
|
||||
const { id, status } = updatedInvoice
|
||||
|
||||
await this.paymentsService.updateInvoiceStatus({ id, status })
|
||||
|
||||
if (
|
||||
invoice.status !== updatedInvoice.status
|
||||
|
86
src/controllers/callbacks/nodeless-callback-controller.ts
Normal file
86
src/controllers/callbacks/nodeless-callback-controller.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { always, applySpec, ifElse, is, path, prop, propEq, propSatisfies } from 'ramda'
|
||||
import { Request, Response } from 'express'
|
||||
|
||||
import { Invoice, InvoiceStatus } from '../../@types/invoice'
|
||||
import { createLogger } from '../../factories/logger-factory'
|
||||
import { fromNodelessInvoice } from '../../utils/transform'
|
||||
import { IController } from '../../@types/controllers'
|
||||
import { IPaymentsService } from '../../@types/services'
|
||||
|
||||
const debug = createLogger('nodeless-callback-controller')
|
||||
|
||||
export class NodelessCallbackController implements IController {
|
||||
public constructor(
|
||||
private readonly paymentsService: IPaymentsService,
|
||||
) {}
|
||||
|
||||
// TODO: Validate
|
||||
public async handleRequest(
|
||||
request: Request,
|
||||
response: Response,
|
||||
) {
|
||||
debug('callback request headers: %o', request.headers)
|
||||
debug('callback request body: %O', request.body)
|
||||
|
||||
const nodelessInvoice = applySpec({
|
||||
id: prop('uuid'),
|
||||
status: prop('status'),
|
||||
satsAmount: prop('amount'),
|
||||
metadata: prop('metadata'),
|
||||
paidAt: ifElse(
|
||||
propEq('status', 'paid'),
|
||||
always(new Date().toISOString()),
|
||||
always(null),
|
||||
),
|
||||
createdAt: ifElse(
|
||||
propSatisfies(is(String), 'createdAt'),
|
||||
prop('createdAt'),
|
||||
path(['metadata', 'createdAt']),
|
||||
),
|
||||
})(request.body)
|
||||
|
||||
debug('nodeless invoice: %O', nodelessInvoice)
|
||||
|
||||
const invoice = fromNodelessInvoice(nodelessInvoice)
|
||||
|
||||
debug('invoice: %O', invoice)
|
||||
|
||||
let updatedInvoice: Invoice
|
||||
try {
|
||||
updatedInvoice = await this.paymentsService.updateInvoiceStatus(invoice)
|
||||
debug('updated invoice: %O', updatedInvoice)
|
||||
} catch (error) {
|
||||
console.error(`Unable to persist invoice ${invoice.id}`, error)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
if (
|
||||
updatedInvoice.status !== InvoiceStatus.COMPLETED
|
||||
&& !updatedInvoice.confirmedAt
|
||||
) {
|
||||
response
|
||||
.status(200)
|
||||
.send()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
invoice.amountPaid = invoice.amountRequested
|
||||
updatedInvoice.amountPaid = invoice.amountRequested
|
||||
|
||||
try {
|
||||
await this.paymentsService.confirmInvoice(invoice)
|
||||
await this.paymentsService.sendInvoiceUpdateNotification(updatedInvoice)
|
||||
} catch (error) {
|
||||
console.error(`Unable to confirm invoice ${invoice.id}`, error)
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
response
|
||||
.status(200)
|
||||
.setHeader('content-type', 'application/json; charset=utf8')
|
||||
.send('{"status":"ok"}')
|
||||
}
|
||||
}
|
@ -171,6 +171,7 @@ export class PostInvoiceController implements IController {
|
||||
expires_at: invoice.expiresAt?.toISOString() ?? '',
|
||||
invoice: invoice.bolt11,
|
||||
amount: amount / 1000n,
|
||||
processor: currentSettings.payments.processor,
|
||||
}
|
||||
|
||||
const body = Object
|
||||
|
7
src/factories/nodeless-callback-controller-factory.ts
Normal file
7
src/factories/nodeless-callback-controller-factory.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { createPaymentsService } from './payments-service-factory'
|
||||
import { IController } from '../@types/controllers'
|
||||
import { NodelessCallbackController } from '../controllers/callbacks/nodeless-callback-controller'
|
||||
|
||||
export const createNodelessCallbackController = (): IController => new NodelessCallbackController(
|
||||
createPaymentsService(),
|
||||
)
|
@ -6,8 +6,8 @@ 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 { NodelessPaymentsProcesor } from '../payments-processors/nodeless-payments-processor'
|
||||
import { NullPaymentsProcessor } from '../payments-processors/null-payments-processor'
|
||||
import { PaymentsProcessor } from '../payments-processors/payments-procesor'
|
||||
import { Settings } from '../@types/settings'
|
||||
import { ZebedeePaymentsProcesor } from '../payments-processors/zebedee-payments-processor'
|
||||
|
||||
@ -30,6 +30,24 @@ const getZebedeeAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> =>
|
||||
}
|
||||
}
|
||||
|
||||
const getNodelessAxiosConfig = (settings: Settings): CreateAxiosDefaults<any> => {
|
||||
if (!process.env.NODELESS_API_KEY) {
|
||||
const error = new Error('NODELESS_API_KEY must be set.')
|
||||
console.error('Unable to get Nodeless config.', error)
|
||||
throw error
|
||||
}
|
||||
|
||||
return {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'authorization': `Bearer ${process.env.NODELESS_API_KEY}`,
|
||||
'accept': 'application/json',
|
||||
},
|
||||
baseURL: path(['paymentsProcessors', 'nodeless', 'baseURL'], settings),
|
||||
maxRedirects: 1,
|
||||
}
|
||||
}
|
||||
|
||||
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.')
|
||||
@ -53,9 +71,7 @@ const createLnurlPaymentsProcessor = (settings: Settings): IPaymentsProcessor =>
|
||||
|
||||
const client = axios.create()
|
||||
|
||||
const app = new LnurlPaymentsProcesor(client, createSettings)
|
||||
|
||||
return new PaymentsProcessor(app)
|
||||
return new LnurlPaymentsProcesor(client, createSettings)
|
||||
}
|
||||
|
||||
const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
|
||||
@ -81,9 +97,7 @@ const createZebedeePaymentsProcessor = (settings: Settings): IPaymentsProcessor
|
||||
debug('config: %o', config)
|
||||
const client = axios.create(config)
|
||||
|
||||
const zpp = new ZebedeePaymentsProcesor(client, createSettings)
|
||||
|
||||
return new PaymentsProcessor(zpp)
|
||||
return new ZebedeePaymentsProcesor(client, createSettings)
|
||||
}
|
||||
|
||||
const createLNbitsPaymentProcessor = (settings: Settings): IPaymentsProcessor => {
|
||||
@ -99,9 +113,13 @@ const createLNbitsPaymentProcessor = (settings: Settings): IPaymentsProcessor =>
|
||||
debug('config: %o', config)
|
||||
const client = axios.create(config)
|
||||
|
||||
const pp = new LNbitsPaymentsProcesor(client, createSettings)
|
||||
return new LNbitsPaymentsProcesor(client, createSettings)
|
||||
}
|
||||
|
||||
return new PaymentsProcessor(pp)
|
||||
const createNodelessPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
|
||||
const client = axios.create(getNodelessAxiosConfig(settings))
|
||||
|
||||
return new NodelessPaymentsProcesor(client, createSettings)
|
||||
}
|
||||
|
||||
export const createPaymentsProcessor = (): IPaymentsProcessor => {
|
||||
@ -118,6 +136,8 @@ export const createPaymentsProcessor = (): IPaymentsProcessor => {
|
||||
return createZebedeePaymentsProcessor(settings)
|
||||
case 'lnbits':
|
||||
return createLNbitsPaymentProcessor(settings)
|
||||
case 'nodeless':
|
||||
return createNodelessPaymentsProcessor(settings)
|
||||
default:
|
||||
return new NullPaymentsProcessor()
|
||||
}
|
||||
|
@ -1,12 +1,9 @@
|
||||
import express from 'express'
|
||||
import helmet from 'helmet'
|
||||
|
||||
import { createLogger } from './logger-factory'
|
||||
import { createSettings } from './settings-factory'
|
||||
import router from '../routes'
|
||||
|
||||
const debug = createLogger('web-app-factory')
|
||||
|
||||
export const createWebApp = () => {
|
||||
const app = express()
|
||||
app
|
||||
@ -31,8 +28,6 @@ export const createWebApp = () => {
|
||||
'font-src': ["'self'", 'https://cdn.jsdelivr.net/npm/'],
|
||||
}
|
||||
|
||||
debug('CSP directives: %o', directives)
|
||||
|
||||
return helmet.contentSecurityPolicy({ directives })(req, res, next)
|
||||
})
|
||||
.use('/favicon.ico', express.static('./resources/favicon.ico'))
|
||||
|
@ -17,6 +17,7 @@ export const getInvoiceRequestHandler = (_req: Request, res: Response, next: Nex
|
||||
const feeSchedule = path<FeeSchedule>(['payments', 'feeSchedules', 'admission', '0'], settings)
|
||||
pageCache = readFileSync('./resources/index.html', 'utf8')
|
||||
.replaceAll('{{name}}', name)
|
||||
.replaceAll('{{processor}}', settings.payments.processor)
|
||||
.replaceAll('{{amount}}', (BigInt(feeSchedule.amount) / 1000n).toString())
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,18 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { createNodelessCallbackController } from '../../factories/nodeless-callback-controller-factory'
|
||||
|
||||
export const postNodelessCallbackRequestHandler = async (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => {
|
||||
const controller = createNodelessCallbackController()
|
||||
|
||||
try {
|
||||
await controller.handleRequest(req, res)
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.setHeader('content-type', 'text/plain')
|
||||
.send('Error handling request')
|
||||
}
|
||||
}
|
@ -12,7 +12,7 @@ export const postZebedeeCallbackRequestHandler = async (
|
||||
} catch (error) {
|
||||
res
|
||||
.status(500)
|
||||
.setHeader('content-type', 'text-plain')
|
||||
.setHeader('content-type', 'text/plain')
|
||||
.send('Error handling request')
|
||||
}
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ export const rateLimiterMiddleware = async (request: Request, response: Response
|
||||
|
||||
const clientAddress = getRemoteAddress(request, currentSettings).split(',')[0]
|
||||
|
||||
debug('request received from %s: %O', clientAddress, request.headers)
|
||||
debug('request received from %s: %o', clientAddress, request.headers)
|
||||
|
||||
if (await isRateLimited(clientAddress, currentSettings)) {
|
||||
response.destroy()
|
||||
|
78
src/payments-processors/nodeless-payments-processor.ts
Normal file
78
src/payments-processors/nodeless-payments-processor.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { AxiosInstance } from 'axios'
|
||||
import { Factory } from '../@types/base'
|
||||
|
||||
import { CreateInvoiceRequest, CreateInvoiceResponse, GetInvoiceResponse, IPaymentsProcessor } from '../@types/clients'
|
||||
import { createLogger } from '../factories/logger-factory'
|
||||
import { fromNodelessInvoice } from '../utils/transform'
|
||||
import { Settings } from '../@types/settings'
|
||||
|
||||
const debug = createLogger('nodeless-payments-processor')
|
||||
|
||||
export class NodelessPaymentsProcesor implements IPaymentsProcessor {
|
||||
public constructor(
|
||||
private httpClient: AxiosInstance,
|
||||
private settings: Factory<Settings>
|
||||
) {}
|
||||
|
||||
public async getInvoice(invoiceId: string): Promise<GetInvoiceResponse> {
|
||||
debug('get invoice: %s', invoiceId)
|
||||
|
||||
const { storeId } = this.settings().paymentsProcessors.nodeless
|
||||
|
||||
try {
|
||||
const response = await this.httpClient.get(`/api/v1/store/${storeId}/invoice/${invoiceId}`, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
|
||||
return fromNodelessInvoice(response.data.data)
|
||||
} 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,
|
||||
} = request
|
||||
|
||||
const amountSats = Number(amountMsats / 1000n)
|
||||
|
||||
const body = {
|
||||
amount: amountSats,
|
||||
currency: 'SATS',
|
||||
metadata: {
|
||||
description,
|
||||
requestId,
|
||||
unit: 'sats',
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
}
|
||||
|
||||
const { storeId } = this.settings().paymentsProcessors.nodeless
|
||||
|
||||
try {
|
||||
debug('request body: %O', body)
|
||||
const response = await this.httpClient.post(`/api/v1/store/${storeId}/invoice`, body, {
|
||||
maxRedirects: 1,
|
||||
})
|
||||
|
||||
debug('response headers: %O', response.headers)
|
||||
debug('response data: %O', response.data)
|
||||
|
||||
const result = fromNodelessInvoice(response.data.data)
|
||||
|
||||
debug('invoice: %O', result)
|
||||
|
||||
return result
|
||||
} catch (error) {
|
||||
console.error('Unable to request invoice. Reason:', error.message)
|
||||
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
@ -1,16 +0,0 @@
|
||||
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(invoice: string | Invoice): Promise<GetInvoiceResponse> {
|
||||
return this.processor.getInvoice(invoice)
|
||||
}
|
||||
|
||||
public async createInvoice(request: CreateInvoiceRequest): Promise<CreateInvoiceResponse> {
|
||||
return this.processor.createInvoice(request)
|
||||
}
|
||||
}
|
@ -1,8 +1,10 @@
|
||||
import {
|
||||
always,
|
||||
applySpec,
|
||||
head,
|
||||
ifElse,
|
||||
is,
|
||||
map,
|
||||
omit,
|
||||
pipe,
|
||||
prop,
|
||||
@ -75,6 +77,31 @@ export class InvoiceRepository implements IInvoiceRepository {
|
||||
return dbInvoices.map(fromDBInvoice)
|
||||
}
|
||||
|
||||
public updateStatus(
|
||||
invoice: Invoice,
|
||||
client: DatabaseClient = this.dbClient,
|
||||
): Promise<Invoice | undefined> {
|
||||
debug('updating invoice status: %o', invoice)
|
||||
|
||||
const query = client<DBInvoice>('invoices')
|
||||
.update({
|
||||
status: invoice.status,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.where('id', invoice.id)
|
||||
.limit(1)
|
||||
.returning(['*'])
|
||||
|
||||
return {
|
||||
then: <T1, T2>(
|
||||
onfulfilled: (value: Invoice | undefined) => T1 | PromiseLike<T1>,
|
||||
onrejected: (reason: any) => T2 | PromiseLike<T2>
|
||||
) => query.then(pipe(map(fromDBInvoice), head)).then(onfulfilled, onrejected),
|
||||
catch: <T>(onrejected: (reason: any) => T | PromiseLike<T>) => query.catch(onrejected),
|
||||
toString: (): string => query.toString(),
|
||||
} as Promise<Invoice | undefined>
|
||||
}
|
||||
|
||||
public upsert(
|
||||
invoice: Invoice,
|
||||
client: DatabaseClient = this.dbClient
|
||||
|
@ -5,13 +5,14 @@ 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 { postNodelessCallbackRequestHandler } from '../../handlers/request-handlers/post-nodeless-callback-request-handler'
|
||||
import { postZebedeeCallbackRequestHandler } from '../../handlers/request-handlers/post-zebedee-callback-request-handler'
|
||||
|
||||
const debug = createLogger('routes-callbacks')
|
||||
|
||||
const router = Router()
|
||||
router
|
||||
.post('/zebedee', json(), (req, res) => {
|
||||
.post('/zebedee', json(), async (req, res) => {
|
||||
const settings = createSettings()
|
||||
const { ipWhitelist = [] } = settings.paymentsProcessors?.zebedee ?? {}
|
||||
const remoteAddress = getRemoteAddress(req, settings)
|
||||
@ -33,9 +34,9 @@ router
|
||||
return
|
||||
}
|
||||
|
||||
postZebedeeCallbackRequestHandler(req, res)
|
||||
return postZebedeeCallbackRequestHandler(req, res)
|
||||
})
|
||||
.post('/lnbits', json(), (req, res) => {
|
||||
.post('/lnbits', json(), async (req, res) => {
|
||||
const settings = createSettings()
|
||||
const remoteAddress = getRemoteAddress(req, settings)
|
||||
const paymentProcessor = settings.payments?.processor ?? 'null'
|
||||
@ -49,7 +50,7 @@ router
|
||||
}
|
||||
|
||||
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]) {
|
||||
@ -66,7 +67,36 @@ router
|
||||
.send('Forbidden')
|
||||
return
|
||||
}
|
||||
postLNbitsCallbackRequestHandler(req, res)
|
||||
return postLNbitsCallbackRequestHandler(req, res)
|
||||
})
|
||||
.post('/nodeless', json({
|
||||
verify(req, _res, buf) {
|
||||
(req as any).rawBody = buf
|
||||
},
|
||||
}), async (req, res) => {
|
||||
const settings = createSettings()
|
||||
const paymentProcessor = settings.payments?.processor
|
||||
|
||||
const expected = hmacSha256(process.env.NODELESS_WEBHOOK_SECRET, (req as any).rawBody).toString('hex')
|
||||
const actual = req.headers['nodeless-signature']
|
||||
|
||||
if (expected !== actual) {
|
||||
console.error('nodeless callback request rejected: signature mismatch:', { expected, actual })
|
||||
res
|
||||
.status(403)
|
||||
.send('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
if (paymentProcessor !== 'nodeless') {
|
||||
debug('denied request from %s to /callbacks/nodeless which is not the current payment processor')
|
||||
res
|
||||
.status(403)
|
||||
.send('Forbidden')
|
||||
return
|
||||
}
|
||||
|
||||
return postNodelessCallbackRequestHandler(req, res)
|
||||
})
|
||||
|
||||
export default router
|
||||
|
@ -114,17 +114,9 @@ export class PaymentsService implements IPaymentsService {
|
||||
public async updateInvoice(invoice: Partial<Invoice>): Promise<void> {
|
||||
debug('update invoice %s: %o', invoice.id, invoice)
|
||||
try {
|
||||
await this.invoiceRepository.upsert({
|
||||
await this.invoiceRepository.updateStatus({
|
||||
id: invoice.id,
|
||||
pubkey: invoice.pubkey,
|
||||
bolt11: invoice.bolt11,
|
||||
amountRequested: invoice.amountRequested,
|
||||
description: invoice.description,
|
||||
unit: invoice.unit,
|
||||
status: invoice.status,
|
||||
expiresAt: invoice.expiresAt,
|
||||
updatedAt: new Date(),
|
||||
createdAt: invoice.createdAt,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Unable to update invoice. Reason:', error)
|
||||
@ -132,15 +124,10 @@ export class PaymentsService implements IPaymentsService {
|
||||
}
|
||||
}
|
||||
|
||||
public async updateInvoiceStatus(invoice: Partial<Invoice>): Promise<void> {
|
||||
public async updateInvoiceStatus(invoice: Pick<Invoice, 'id' | 'status'>): Promise<Invoice> {
|
||||
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(),
|
||||
})
|
||||
return await this.invoiceRepository.updateStatus(invoice)
|
||||
} catch (error) {
|
||||
console.error('Unable to update invoice. Reason:', error)
|
||||
throw error
|
||||
@ -150,13 +137,13 @@ export class PaymentsService implements IPaymentsService {
|
||||
public async confirmInvoice(
|
||||
invoice: Invoice,
|
||||
): Promise<void> {
|
||||
debug('confirm invoice %s: %o', invoice.id, invoice)
|
||||
debug('confirm invoice %s: %O', invoice.id, invoice)
|
||||
|
||||
const transaction = new Transaction(this.dbClient)
|
||||
|
||||
try {
|
||||
if (!invoice.confirmedAt) {
|
||||
throw new Error('Invoince confirmation date is not set')
|
||||
throw new Error('Invoice confirmation date is not set')
|
||||
}
|
||||
if (invoice.status !== InvoiceStatus.COMPLETED) {
|
||||
throw new Error(`Invoice is not complete: ${invoice.status}`)
|
||||
|
@ -1,7 +1,7 @@
|
||||
import { always, applySpec, ifElse, is, isNil, path, pipe, prop, propSatisfies } from 'ramda'
|
||||
import { always, applySpec, cond, equals, ifElse, is, isNil, path, pipe, prop, propSatisfies, T } from 'ramda'
|
||||
import { bech32 } from 'bech32'
|
||||
|
||||
import { Invoice } from '../@types/invoice'
|
||||
import { Invoice, InvoiceStatus } from '../@types/invoice'
|
||||
import { User } from '../@types/user'
|
||||
|
||||
export const toJSON = (input: any) => JSON.stringify(input)
|
||||
@ -10,10 +10,12 @@ export const toBuffer = (input: any) => Buffer.from(input, 'hex')
|
||||
|
||||
export const fromBuffer = (input: Buffer) => input.toString('hex')
|
||||
|
||||
export const toBigInt = (input: string): bigint => BigInt(input)
|
||||
export const toBigInt = (input: string | number): bigint => BigInt(input)
|
||||
|
||||
export const fromBigInt = (input: bigint) => input.toString()
|
||||
|
||||
const addTime = (ms: number) => (input: Date) => new Date(input.getTime() + ms)
|
||||
|
||||
export const fromDBInvoice = applySpec<Invoice>({
|
||||
id: prop('id') as () => string,
|
||||
pubkey: pipe(prop('pubkey') as () => Buffer, fromBuffer),
|
||||
@ -84,3 +86,43 @@ export const fromZebedeeInvoice = applySpec<Invoice>({
|
||||
),
|
||||
rawRespose: toJSON,
|
||||
})
|
||||
|
||||
export const fromNodelessInvoice = applySpec<Invoice>({
|
||||
id: prop('id'),
|
||||
pubkey: path(['metadata', 'requestId']),
|
||||
bolt11: prop('lightningInvoice'),
|
||||
amountRequested: pipe(prop('satsAmount') as () => number, toBigInt),
|
||||
description: path(['metadata', 'description']),
|
||||
unit: path(['metadata', 'unit']),
|
||||
status: pipe(
|
||||
prop('status'),
|
||||
cond([
|
||||
[equals('new'), always(InvoiceStatus.PENDING)],
|
||||
[equals('pending_confirmation'), always(InvoiceStatus.PENDING)],
|
||||
[equals('underpaid'), always(InvoiceStatus.PENDING)],
|
||||
[equals('in_flight'), always(InvoiceStatus.PENDING)],
|
||||
[equals('paid'), always(InvoiceStatus.COMPLETED)],
|
||||
[equals('overpaid'), always(InvoiceStatus.COMPLETED)],
|
||||
[equals('expired'), always(InvoiceStatus.EXPIRED)],
|
||||
]),
|
||||
),
|
||||
expiresAt: ifElse(
|
||||
propSatisfies(is(String), 'expiresAt'),
|
||||
pipe(prop('expiresAt'), toDate),
|
||||
ifElse(
|
||||
propSatisfies(is(String), 'createdAt'),
|
||||
pipe(prop('createdAt'), toDate, addTime(15 * 60000)),
|
||||
always(null),
|
||||
),
|
||||
),
|
||||
confirmedAt: cond([
|
||||
[propSatisfies(is(String), 'paidAt'), pipe(prop('paidAt'), toDate)],
|
||||
[T, always(null)],
|
||||
]),
|
||||
createdAt: ifElse(
|
||||
propSatisfies(is(String), 'createdAt'),
|
||||
pipe(prop('createdAt'), toDate),
|
||||
always(null),
|
||||
),
|
||||
// rawResponse: toJSON,
|
||||
})
|
||||
|
@ -1,5 +1,5 @@
|
||||
import * as secp256k1 from '@noble/secp256k1'
|
||||
import { createHash, createHmac, Hash } from 'crypto'
|
||||
import { createHash, createHmac, getRandomValues, Hash } from 'crypto'
|
||||
import { Observable } from 'rxjs'
|
||||
import WebSocket from 'ws'
|
||||
|
||||
@ -54,7 +54,9 @@ export async function createEvent(input: Partial<Event>, privkey: any): Promise<
|
||||
}
|
||||
|
||||
export function createIdentity(name: string) {
|
||||
const hmac = createHmac('sha256', Math.random().toString())
|
||||
const buffer = new Uint32Array(10)
|
||||
getRandomValues(buffer)
|
||||
const hmac = createHmac('sha256', buffer)
|
||||
hmac.update(name)
|
||||
const privkey = hmac.digest().toString('hex')
|
||||
const pubkey = Buffer.from(secp256k1.getPublicKey(privkey, true)).toString('hex').substring(2)
|
||||
|
Loading…
x
Reference in New Issue
Block a user