Skip to content

Commit

Permalink
feat: implement nodeless payments processor (#305)
Browse files Browse the repository at this point in the history
* 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](v1.22.6...v1.23.0) (2023-05-12)

### Bug Fixes

* add SECRET as env variable ([#298](#298)) ([58a1254](58a1254))
* invoice auto marked as paid ([be6d6f1](be6d6f1))
* issues with invoices ([#271](#271)) ([e1561e7](e1561e7))

### Features

* add LNURL processor ([#202](#202)) ([f237400](f237400))
* allow lightning zap receipts on paid relays ([#303](#303)) ([14bc96f](14bc96f))

* 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>
  • Loading branch information
cameri and semantic-release-bot committed May 15, 2023
1 parent 62c1dbe commit 52aac39
Show file tree
Hide file tree
Showing 27 changed files with 395 additions and 72 deletions.
17 changes: 13 additions & 4 deletions README.md
Expand Up @@ -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/).
Expand Down Expand Up @@ -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:

Expand Down
3 changes: 3 additions & 0 deletions docker-compose.yml
Expand Up @@ -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:*"
Expand Down
3 changes: 3 additions & 0 deletions resources/default-settings.yaml
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion resources/index.html
Expand Up @@ -58,7 +58,7 @@ <h1 class="mt-4 mb-4 text-center text-nowrap">{{name}}</h1>
<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" />
Expand Down Expand Up @@ -129,6 +129,7 @@ <h5 class="modal-title">Terms of Service</h5>
</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) {
Expand All @@ -147,6 +148,9 @@ <h5 class="modal-title">Terms of Service</h5>

function onLoad() {
setTimeout(attemptGetPubkey, 300)
if (processor === 'zebedee') {
document.getElementById('powered-by-zebedee').classList.remove('d-none')
}
}
</script>
</body>
Expand Down
6 changes: 5 additions & 1 deletion resources/invoices.html
Expand Up @@ -86,7 +86,7 @@ <h2 class="text-danger">Invoice expired!</h2>
<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" />
Expand All @@ -104,6 +104,7 @@ <h2 class="text-danger">Invoice expired!</h2>
var invoice = "{{invoice}}";
var pubkey = "{{pubkey}}"
var expiresAt = "{{expires_at}}"
var processor = "{{processor}}"
var timeout
var paid = false
var fallbackTimeout
Expand Down Expand Up @@ -254,6 +255,9 @@ <h2 class="text-danger">Invoice expired!</h2>
sendPayment().catch(() => {
document.getElementById('sendPaymentBtn').classList.remove('d-none')
})
if (processor === 'zebedee') {
document.getElementById('powered-by-zebedee').classList.remove('d-none')
}
</script>
</body>
</html>
3 changes: 2 additions & 1 deletion src/@types/invoice.ts
Expand Up @@ -8,7 +8,8 @@ export enum InvoiceUnit {

export enum InvoiceStatus {
PENDING = 'pending',
COMPLETED = 'completed'
COMPLETED = 'completed',
EXPIRED = 'expired',
}

export interface Invoice {
Expand Down
4 changes: 4 additions & 0 deletions src/@types/repositories.ts
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion src/@types/services.ts
Expand Up @@ -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>
Expand Down
15 changes: 13 additions & 2 deletions src/@types/settings.ts
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/adapters/web-server-adapter.ts
Expand Up @@ -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')
}

Expand Down
8 changes: 7 additions & 1 deletion src/app/maintenance-worker.ts
Expand Up @@ -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
Expand Down
86 changes: 86 additions & 0 deletions src/controllers/callbacks/nodeless-callback-controller.ts
@@ -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"}')
}
}
1 change: 1 addition & 0 deletions src/controllers/invoices/post-invoice-controller.ts
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions src/factories/nodeless-callback-controller-factory.ts
@@ -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(),
)
38 changes: 29 additions & 9 deletions src/factories/payments-processor-factory.ts
Expand Up @@ -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'

Expand All @@ -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.')
Expand All @@ -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 => {
Expand All @@ -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 => {
Expand All @@ -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)
}

const createNodelessPaymentsProcessor = (settings: Settings): IPaymentsProcessor => {
const client = axios.create(getNodelessAxiosConfig(settings))

return new PaymentsProcessor(pp)
return new NodelessPaymentsProcesor(client, createSettings)
}

export const createPaymentsProcessor = (): IPaymentsProcessor => {
Expand All @@ -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()
}
Expand Down
5 changes: 0 additions & 5 deletions src/factories/web-app-factory.ts
@@ -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
Expand All @@ -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'))
Expand Down

0 comments on commit 52aac39

Please sign in to comment.