Add membership & LNbits integration documentation
Feature specification: - Membership/loyalty system with tiered discounts (Bronze/Silver/Gold/Platinum) - Lightning wallet integration via Lightning Addresses - Simplified cash-in flow (no wallet QR needed for members) - Database schema, API design, machine state changes LNbits integration: - Full TypeScript client implementation - REST API reference - Deployment architecture options - NixOS configuration examples - Monitoring & error handling patterns Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
3eb0e7e85d
commit
a5e6b32c24
2 changed files with 1852 additions and 0 deletions
1027
docs/features/membership-lightning-integration.md
Normal file
1027
docs/features/membership-lightning-integration.md
Normal file
File diff suppressed because it is too large
Load diff
825
docs/integrations/lnbits-integration.md
Normal file
825
docs/integrations/lnbits-integration.md
Normal file
|
|
@ -0,0 +1,825 @@
|
||||||
|
---
|
||||||
|
title: LNbits Integration
|
||||||
|
created: 2026-01-22
|
||||||
|
updated: 2026-01-22
|
||||||
|
tags:
|
||||||
|
- integration
|
||||||
|
- lightning
|
||||||
|
- lnbits
|
||||||
|
- api
|
||||||
|
status: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
# LNbits Integration
|
||||||
|
|
||||||
|
> [!abstract] Summary
|
||||||
|
> LNbits serves as the Lightning Network backend for Lamassu ATMs, abstracting the underlying Lightning node implementation and providing a clean REST API for payments.
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
- [[#Why LNbits]]
|
||||||
|
- [[#API Reference]]
|
||||||
|
- [[#Deployment Architecture]]
|
||||||
|
- [[#Configuration]]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Why LNbits
|
||||||
|
|
||||||
|
> [!decision] Choice of Lightning Backend
|
||||||
|
> LNbits was chosen over direct LND/CLN integration for these reasons:
|
||||||
|
|
||||||
|
| Feature | LNbits | Direct LND/CLN |
|
||||||
|
|---------|--------|----------------|
|
||||||
|
| Backend Abstraction | 30+ implementations | Single implementation |
|
||||||
|
| API Complexity | Simple REST | gRPC/REST varies |
|
||||||
|
| Multi-wallet | Built-in | Custom implementation |
|
||||||
|
| User Management | Built-in | None |
|
||||||
|
| Extensions | Rich ecosystem | None |
|
||||||
|
| Self-hostable | Yes | Yes |
|
||||||
|
| Open Source | MIT License | Varies |
|
||||||
|
|
||||||
|
**Supported Backends:**
|
||||||
|
- LND (lndrest, lndgrpc)
|
||||||
|
- Core Lightning (CLN, CLNRest)
|
||||||
|
- Eclair
|
||||||
|
- LNPay, OpenNode, Alby
|
||||||
|
- Breez, Phoenix
|
||||||
|
- NWC (Nostr Wallet Connect)
|
||||||
|
- And 20+ more...
|
||||||
|
|
||||||
|
#lnbits #lightning
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Reference
|
||||||
|
|
||||||
|
### Authentication
|
||||||
|
|
||||||
|
LNbits uses API keys for authentication:
|
||||||
|
|
||||||
|
| Key Type | Header | Permissions |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| Admin Key | `X-API-KEY: {adminkey}` | Full wallet control |
|
||||||
|
| Invoice Key | `X-API-KEY: {invoicekey}` | Create invoices, view payments |
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const headers = {
|
||||||
|
'X-API-KEY': config.adminKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Endpoints
|
||||||
|
|
||||||
|
#### Get Wallet Info
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/wallet
|
||||||
|
X-API-KEY: {adminkey}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "wallet-uuid",
|
||||||
|
"name": "ATM Wallet",
|
||||||
|
"balance": 1500000
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> [!note] Balance Units
|
||||||
|
> All amounts in LNbits API are in **millisatoshis (msat)**. Divide by 1000 for satoshis.
|
||||||
|
|
||||||
|
#### Create Invoice (Receive)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/payments
|
||||||
|
X-API-KEY: {invoicekey}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"out": false,
|
||||||
|
"amount": 50000,
|
||||||
|
"memo": "ATM Cash-out",
|
||||||
|
"expiry": 600,
|
||||||
|
"webhook": "https://lamassu.example.com/api/webhook/payment"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_hash": "abc123...",
|
||||||
|
"payment_request": "lnbc500u1p...",
|
||||||
|
"checking_id": "xyz789..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| Field | Description |
|
||||||
|
|-------|-------------|
|
||||||
|
| `out` | `false` for receiving, `true` for sending |
|
||||||
|
| `amount` | Amount in **satoshis** |
|
||||||
|
| `memo` | Invoice description |
|
||||||
|
| `expiry` | Seconds until expiration (default: 3600) |
|
||||||
|
| `webhook` | Optional callback URL |
|
||||||
|
|
||||||
|
#### Pay Invoice (Send)
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/payments
|
||||||
|
X-API-KEY: {adminkey}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"out": true,
|
||||||
|
"bolt11": "lnbc500u1p..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"payment_hash": "abc123...",
|
||||||
|
"checking_id": "xyz789...",
|
||||||
|
"fee": 5
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Check Payment Status
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/payments/{checking_id}
|
||||||
|
X-API-KEY: {invoicekey}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"paid": true,
|
||||||
|
"pending": false,
|
||||||
|
"preimage": "def456...",
|
||||||
|
"payment_hash": "abc123...",
|
||||||
|
"amount": 50000,
|
||||||
|
"fee": 5,
|
||||||
|
"memo": "ATM Cash-out",
|
||||||
|
"time": 1706018400,
|
||||||
|
"bolt11": "lnbc500u1p..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### LNURL Scan
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/v1/lnurlscan/{code}
|
||||||
|
X-API-KEY: {invoicekey}
|
||||||
|
```
|
||||||
|
|
||||||
|
Decodes LNURL or Lightning Address and returns metadata.
|
||||||
|
|
||||||
|
**Response (Lightning Address):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kind": "pay",
|
||||||
|
"domain": "walletofsatoshi.com",
|
||||||
|
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
|
||||||
|
"minSendable": 1000,
|
||||||
|
"maxSendable": 100000000000,
|
||||||
|
"metadata": "[['text/plain', 'Sats for user']]",
|
||||||
|
"allowsNostr": true,
|
||||||
|
"commentAllowed": 255
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Pay to LNURL
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/v1/payments/lnurl
|
||||||
|
X-API-KEY: {adminkey}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"callback": "https://walletofsatoshi.com/lnurlp/user/callback",
|
||||||
|
"amount": 50000,
|
||||||
|
"comment": "ATM withdrawal"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#api #endpoints
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Client
|
||||||
|
|
||||||
|
### Full Implementation
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// packages/server/lib/lightning/lnbits-client.ts
|
||||||
|
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
// Schemas
|
||||||
|
const WalletInfoSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
balance: z.number(), // msat
|
||||||
|
})
|
||||||
|
|
||||||
|
const CreateInvoiceResponseSchema = z.object({
|
||||||
|
payment_hash: z.string(),
|
||||||
|
payment_request: z.string(),
|
||||||
|
checking_id: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PaymentResponseSchema = z.object({
|
||||||
|
payment_hash: z.string(),
|
||||||
|
checking_id: z.string(),
|
||||||
|
fee: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const PaymentStatusSchema = z.object({
|
||||||
|
paid: z.boolean(),
|
||||||
|
pending: z.boolean(),
|
||||||
|
preimage: z.string().nullable(),
|
||||||
|
payment_hash: z.string(),
|
||||||
|
amount: z.number(),
|
||||||
|
fee: z.number(),
|
||||||
|
memo: z.string().nullable(),
|
||||||
|
time: z.number(),
|
||||||
|
bolt11: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const LnurlPayResponseSchema = z.object({
|
||||||
|
kind: z.literal('pay'),
|
||||||
|
callback: z.string(),
|
||||||
|
minSendable: z.number(),
|
||||||
|
maxSendable: z.number(),
|
||||||
|
metadata: z.string(),
|
||||||
|
commentAllowed: z.number().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export interface LNbitsConfig {
|
||||||
|
baseUrl: string
|
||||||
|
adminKey: string
|
||||||
|
invoiceKey: string
|
||||||
|
walletId: string
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
bolt11: string
|
||||||
|
paymentHash: string
|
||||||
|
checkingId: string
|
||||||
|
expiresAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentResult {
|
||||||
|
success: boolean
|
||||||
|
paymentHash: string
|
||||||
|
checkingId: string
|
||||||
|
feeSats?: number
|
||||||
|
error?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentStatus {
|
||||||
|
paid: boolean
|
||||||
|
pending: boolean
|
||||||
|
preimage: string | null
|
||||||
|
amountSats: number
|
||||||
|
feeSats: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Implementation
|
||||||
|
export class LNbitsClient {
|
||||||
|
private baseUrl: string
|
||||||
|
private adminKey: string
|
||||||
|
private invoiceKey: string
|
||||||
|
private timeout: number
|
||||||
|
|
||||||
|
constructor(config: LNbitsConfig) {
|
||||||
|
this.baseUrl = config.baseUrl.replace(/\/$/, '')
|
||||||
|
this.adminKey = config.adminKey
|
||||||
|
this.invoiceKey = config.invoiceKey
|
||||||
|
this.timeout = config.timeout ?? 30000
|
||||||
|
}
|
||||||
|
|
||||||
|
private async request<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
key: 'admin' | 'invoice',
|
||||||
|
body?: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
const apiKey = key === 'admin' ? this.adminKey : this.invoiceKey
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'X-API-KEY': apiKey,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text()
|
||||||
|
throw new Error(`LNbits API error: ${response.status} - ${error}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json()
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wallet Operations
|
||||||
|
|
||||||
|
async getWalletInfo(): Promise<{ id: string; name: string; balanceSats: number }> {
|
||||||
|
const data = await this.request('GET', '/api/v1/wallet', 'admin')
|
||||||
|
const parsed = WalletInfoSchema.parse(data)
|
||||||
|
return {
|
||||||
|
id: parsed.id,
|
||||||
|
name: parsed.name,
|
||||||
|
balanceSats: Math.floor(parsed.balance / 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBalance(): Promise<number> {
|
||||||
|
const info = await this.getWalletInfo()
|
||||||
|
return info.balanceSats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoice Operations
|
||||||
|
|
||||||
|
async createInvoice(
|
||||||
|
amountSats: number,
|
||||||
|
memo: string,
|
||||||
|
options?: {
|
||||||
|
expiry?: number
|
||||||
|
webhook?: string
|
||||||
|
}
|
||||||
|
): Promise<Invoice> {
|
||||||
|
const data = await this.request('POST', '/api/v1/payments', 'invoice', {
|
||||||
|
out: false,
|
||||||
|
amount: amountSats,
|
||||||
|
memo,
|
||||||
|
expiry: options?.expiry ?? 600,
|
||||||
|
webhook: options?.webhook,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsed = CreateInvoiceResponseSchema.parse(data)
|
||||||
|
return {
|
||||||
|
bolt11: parsed.payment_request,
|
||||||
|
paymentHash: parsed.payment_hash,
|
||||||
|
checkingId: parsed.checking_id,
|
||||||
|
expiresAt: new Date(Date.now() + (options?.expiry ?? 600) * 1000),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPaymentStatus(checkingId: string): Promise<PaymentStatus> {
|
||||||
|
const data = await this.request('GET', `/api/v1/payments/${checkingId}`, 'invoice')
|
||||||
|
const parsed = PaymentStatusSchema.parse(data)
|
||||||
|
return {
|
||||||
|
paid: parsed.paid,
|
||||||
|
pending: parsed.pending,
|
||||||
|
preimage: parsed.preimage,
|
||||||
|
amountSats: parsed.amount,
|
||||||
|
feeSats: parsed.fee,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForPayment(
|
||||||
|
checkingId: string,
|
||||||
|
timeoutMs: number = 600000
|
||||||
|
): Promise<PaymentStatus> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const pollInterval = 2000
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
const status = await this.getPaymentStatus(checkingId)
|
||||||
|
if (status.paid) return status
|
||||||
|
if (!status.pending) throw new Error('Payment failed or expired')
|
||||||
|
await new Promise(resolve => setTimeout(resolve, pollInterval))
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Payment timeout')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment Operations
|
||||||
|
|
||||||
|
async payInvoice(bolt11: string): Promise<PaymentResult> {
|
||||||
|
try {
|
||||||
|
const data = await this.request('POST', '/api/v1/payments', 'admin', {
|
||||||
|
out: true,
|
||||||
|
bolt11,
|
||||||
|
})
|
||||||
|
|
||||||
|
const parsed = PaymentResponseSchema.parse(data)
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
paymentHash: parsed.payment_hash,
|
||||||
|
checkingId: parsed.checking_id,
|
||||||
|
feeSats: parsed.fee,
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
paymentHash: '',
|
||||||
|
checkingId: '',
|
||||||
|
error: error instanceof Error ? error.message : 'Unknown error',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightning Address Operations
|
||||||
|
|
||||||
|
async resolveLightningAddress(address: string): Promise<{
|
||||||
|
callback: string
|
||||||
|
minSats: number
|
||||||
|
maxSats: number
|
||||||
|
commentAllowed: number
|
||||||
|
}> {
|
||||||
|
const [name, domain] = address.split('@')
|
||||||
|
if (!name || !domain) {
|
||||||
|
throw new Error('Invalid Lightning Address format')
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(
|
||||||
|
`https://${domain}/.well-known/lnurlp/${name}`,
|
||||||
|
{ signal: AbortSignal.timeout(10000) }
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to resolve Lightning Address: ${response.status}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = LnurlPayResponseSchema.parse(await response.json())
|
||||||
|
return {
|
||||||
|
callback: data.callback,
|
||||||
|
minSats: Math.ceil(data.minSendable / 1000),
|
||||||
|
maxSats: Math.floor(data.maxSendable / 1000),
|
||||||
|
commentAllowed: data.commentAllowed ?? 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async payToLightningAddress(
|
||||||
|
address: string,
|
||||||
|
amountSats: number,
|
||||||
|
comment?: string
|
||||||
|
): Promise<PaymentResult> {
|
||||||
|
// Step 1: Resolve address to LNURL-pay endpoint
|
||||||
|
const resolved = await this.resolveLightningAddress(address)
|
||||||
|
|
||||||
|
// Validate amount
|
||||||
|
if (amountSats < resolved.minSats || amountSats > resolved.maxSats) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
paymentHash: '',
|
||||||
|
checkingId: '',
|
||||||
|
error: `Amount must be between ${resolved.minSats} and ${resolved.maxSats} sats`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Get invoice from callback
|
||||||
|
const callbackUrl = new URL(resolved.callback)
|
||||||
|
callbackUrl.searchParams.set('amount', (amountSats * 1000).toString())
|
||||||
|
if (comment && resolved.commentAllowed > 0) {
|
||||||
|
callbackUrl.searchParams.set('comment', comment.slice(0, resolved.commentAllowed))
|
||||||
|
}
|
||||||
|
|
||||||
|
const invoiceResponse = await fetch(callbackUrl.toString(), {
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!invoiceResponse.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
paymentHash: '',
|
||||||
|
checkingId: '',
|
||||||
|
error: 'Failed to get invoice from Lightning Address',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { pr: bolt11 } = await invoiceResponse.json()
|
||||||
|
|
||||||
|
// Step 3: Pay the invoice
|
||||||
|
return this.payInvoice(bolt11)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Examples
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Initialize client
|
||||||
|
const lnbits = new LNbitsClient({
|
||||||
|
baseUrl: 'https://lnbits.example.com',
|
||||||
|
adminKey: process.env.LNBITS_ADMIN_KEY!,
|
||||||
|
invoiceKey: process.env.LNBITS_INVOICE_KEY!,
|
||||||
|
walletId: process.env.LNBITS_WALLET_ID!,
|
||||||
|
})
|
||||||
|
|
||||||
|
// Check balance
|
||||||
|
const balance = await lnbits.getBalance()
|
||||||
|
console.log(`Wallet balance: ${balance} sats`)
|
||||||
|
|
||||||
|
// Cash-out: Create invoice for customer to pay
|
||||||
|
const invoice = await lnbits.createInvoice(50000, 'ATM Cash-out')
|
||||||
|
console.log(`Invoice: ${invoice.bolt11}`)
|
||||||
|
|
||||||
|
// Wait for payment
|
||||||
|
const status = await lnbits.waitForPayment(invoice.checkingId, 600000)
|
||||||
|
if (status.paid) {
|
||||||
|
console.log('Payment received!')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cash-in: Pay to customer's Lightning Address
|
||||||
|
const result = await lnbits.payToLightningAddress(
|
||||||
|
'user@walletofsatoshi.com',
|
||||||
|
50000,
|
||||||
|
'ATM withdrawal'
|
||||||
|
)
|
||||||
|
if (result.success) {
|
||||||
|
console.log(`Payment sent! Hash: ${result.paymentHash}`)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#typescript #implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Architecture
|
||||||
|
|
||||||
|
### Single LNbits Instance (Recommended)
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "ATM Fleet"
|
||||||
|
atm1[ATM Berlin]
|
||||||
|
atm2[ATM Paris]
|
||||||
|
atm3[ATM Amsterdam]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Lamassu Server"
|
||||||
|
api[REST API]
|
||||||
|
lightning[Lightning Service]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "LNbits"
|
||||||
|
lnbits_api[LNbits API]
|
||||||
|
wallet[Shared Wallet]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph "Lightning Node"
|
||||||
|
lnd[LND / CLN]
|
||||||
|
end
|
||||||
|
|
||||||
|
atm1 --> api
|
||||||
|
atm2 --> api
|
||||||
|
atm3 --> api
|
||||||
|
|
||||||
|
api --> lightning
|
||||||
|
lightning --> lnbits_api
|
||||||
|
lnbits_api --> wallet
|
||||||
|
wallet --> lnd
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Single point of management
|
||||||
|
- Shared liquidity
|
||||||
|
- Simpler monitoring
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Single point of failure
|
||||||
|
- Requires robust HA setup
|
||||||
|
|
||||||
|
### Per-ATM Wallets
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TB
|
||||||
|
subgraph "LNbits"
|
||||||
|
lnbits_api[LNbits API]
|
||||||
|
|
||||||
|
subgraph "Wallets"
|
||||||
|
wallet1[ATM-Berlin Wallet]
|
||||||
|
wallet2[ATM-Paris Wallet]
|
||||||
|
wallet3[ATM-Amsterdam Wallet]
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
atm1[ATM Berlin] -->|adminkey_1| wallet1
|
||||||
|
atm2[ATM Paris] -->|adminkey_2| wallet2
|
||||||
|
atm3[ATM Amsterdam] -->|adminkey_3| wallet3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Isolated balances
|
||||||
|
- Per-ATM accounting
|
||||||
|
- Granular key management
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- More complex setup
|
||||||
|
- Fragmented liquidity
|
||||||
|
|
||||||
|
#architecture #deployment
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (packages/server)
|
||||||
|
|
||||||
|
# LNbits Connection
|
||||||
|
LNBITS_URL=https://lnbits.example.com
|
||||||
|
LNBITS_ADMIN_KEY=abc123...
|
||||||
|
LNBITS_INVOICE_KEY=def456...
|
||||||
|
LNBITS_WALLET_ID=wallet-uuid
|
||||||
|
|
||||||
|
# Lightning Settings
|
||||||
|
LIGHTNING_PROVIDER=lnbits
|
||||||
|
LIGHTNING_TIMEOUT_MS=30000
|
||||||
|
LIGHTNING_MAX_FEE_PERCENT=1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
### NixOS Configuration
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# /etc/nixos/lnbits.nix
|
||||||
|
{ config, pkgs, ... }:
|
||||||
|
|
||||||
|
{
|
||||||
|
services.lnbits = {
|
||||||
|
enable = true;
|
||||||
|
host = "127.0.0.1";
|
||||||
|
port = 5000;
|
||||||
|
|
||||||
|
settings = {
|
||||||
|
LNBITS_BACKEND_WALLET_CLASS = "LndRestWallet";
|
||||||
|
LND_REST_ENDPOINT = "https://localhost:8080";
|
||||||
|
LND_REST_CERT = "/var/lib/lnd/tls.cert";
|
||||||
|
LND_REST_MACAROON = "/var/lib/lnd/admin.macaroon";
|
||||||
|
|
||||||
|
LNBITS_DATABASE_URL = "postgres://lnbits:password@localhost/lnbits";
|
||||||
|
LNBITS_SITE_TITLE = "Lamassu Lightning";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
# Reverse proxy with Caddy
|
||||||
|
services.caddy.virtualHosts."lnbits.example.com" = {
|
||||||
|
extraConfig = ''
|
||||||
|
reverse_proxy localhost:5000
|
||||||
|
'';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### sops-nix Secrets
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# secrets/lnbits.yaml
|
||||||
|
lnbits_admin_key: ENC[AES256_GCM,data:...,type:str]
|
||||||
|
lnbits_invoice_key: ENC[AES256_GCM,data:...,type:str]
|
||||||
|
```
|
||||||
|
|
||||||
|
```nix
|
||||||
|
# NixOS module
|
||||||
|
sops.secrets.lnbits_admin_key = {
|
||||||
|
sopsFile = ./secrets/lnbits.yaml;
|
||||||
|
owner = "lamassu";
|
||||||
|
};
|
||||||
|
|
||||||
|
sops.secrets.lnbits_invoice_key = {
|
||||||
|
sopsFile = ./secrets/lnbits.yaml;
|
||||||
|
owner = "lamassu";
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
#configuration #nixos #secrets
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Monitoring & Alerts
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Health check endpoint
|
||||||
|
async function checkLNbitsHealth(): Promise<HealthStatus> {
|
||||||
|
try {
|
||||||
|
const balance = await lnbits.getBalance()
|
||||||
|
return {
|
||||||
|
healthy: true,
|
||||||
|
balance,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
healthy: false,
|
||||||
|
error: error.message,
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Balance Alerts
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const LOW_BALANCE_THRESHOLD = 100000 // 100k sats
|
||||||
|
|
||||||
|
async function checkBalanceAlerts() {
|
||||||
|
const balance = await lnbits.getBalance()
|
||||||
|
|
||||||
|
if (balance < LOW_BALANCE_THRESHOLD) {
|
||||||
|
await sendAlert({
|
||||||
|
level: 'warning',
|
||||||
|
message: `Low LNbits balance: ${balance} sats`,
|
||||||
|
action: 'Top up Lightning wallet',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Prometheus Metrics
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Expose metrics for Prometheus
|
||||||
|
import { Counter, Gauge } from 'prom-client'
|
||||||
|
|
||||||
|
const lnbitsBalance = new Gauge({
|
||||||
|
name: 'lnbits_wallet_balance_sats',
|
||||||
|
help: 'Current LNbits wallet balance in satoshis',
|
||||||
|
})
|
||||||
|
|
||||||
|
const lnbitsPayments = new Counter({
|
||||||
|
name: 'lnbits_payments_total',
|
||||||
|
help: 'Total LNbits payments',
|
||||||
|
labelNames: ['direction', 'status'],
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update metrics
|
||||||
|
setInterval(async () => {
|
||||||
|
const balance = await lnbits.getBalance()
|
||||||
|
lnbitsBalance.set(balance)
|
||||||
|
}, 60000)
|
||||||
|
```
|
||||||
|
|
||||||
|
#monitoring #alerts #prometheus
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
### Common Errors
|
||||||
|
|
||||||
|
| Error | Cause | Resolution |
|
||||||
|
|-------|-------|------------|
|
||||||
|
| `INSUFFICIENT_BALANCE` | Wallet balance too low | Top up wallet |
|
||||||
|
| `INVOICE_EXPIRED` | Invoice not paid in time | Create new invoice |
|
||||||
|
| `PAYMENT_FAILED` | Route not found | Check node connectivity |
|
||||||
|
| `RATE_LIMITED` | Too many requests | Implement backoff |
|
||||||
|
| `UNAUTHORIZED` | Invalid API key | Check key configuration |
|
||||||
|
|
||||||
|
### Retry Strategy
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import pRetry from 'p-retry'
|
||||||
|
|
||||||
|
async function payWithRetry(bolt11: string): Promise<PaymentResult> {
|
||||||
|
return pRetry(
|
||||||
|
async () => {
|
||||||
|
const result = await lnbits.payInvoice(bolt11)
|
||||||
|
if (!result.success && result.error?.includes('ROUTE')) {
|
||||||
|
throw new Error('Retryable: No route found')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
},
|
||||||
|
{
|
||||||
|
retries: 3,
|
||||||
|
minTimeout: 1000,
|
||||||
|
maxTimeout: 10000,
|
||||||
|
onFailedAttempt: (error) => {
|
||||||
|
console.log(`Payment attempt ${error.attemptNumber} failed`)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#errors #retry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Related Documents
|
||||||
|
|
||||||
|
- [[membership-lightning-integration]] - Membership feature using LNbits
|
||||||
|
- [[modernization-plan]] - Overall modernization roadmap
|
||||||
|
- [[API Key Authentication]] - Server authentication
|
||||||
Loading…
Add table
Add a link
Reference in a new issue