diff --git a/docs/features/membership-lightning-integration.md b/docs/features/membership-lightning-integration.md new file mode 100644 index 0000000..9c5c73a --- /dev/null +++ b/docs/features/membership-lightning-integration.md @@ -0,0 +1,1027 @@ +--- +title: Membership & Lightning Integration +created: 2026-01-22 +updated: 2026-01-22 +tags: + - feature + - lightning + - lnbits + - membership + - loyalty +status: planning +priority: high +--- + +# Membership & Lightning Integration + +> [!abstract] Summary +> A membership/loyalty system that provides **tiered discounts** AND **automatic Lightning wallet integration** through a single membership ID scan. Powered by [[LNbits Integration|LNbits]] as the Lightning backend. + +## Quick Links + +- [[#User Flows]] +- [[#Architecture]] +- [[#Database Schema]] +- [[#API Design]] +- [[#Machine Changes]] +- [[#Implementation Phases]] + +--- + +## Overview + +### The Problem + +Current ATM flow requires: +1. User selects coin +2. User scans their wallet QR code +3. User inserts cash +4. Server sends BTC to scanned address + +**Pain points:** +- No loyalty/rewards program +- User must have wallet app ready +- No recurring customer recognition +- Manual address entry every time + +### The Solution + +**One scan, two benefits:** + +``` +Membership QR Scan + ↓ +┌──────────────────────────────────┐ +│ • Tier discount applied (15%) │ +│ • Lightning address retrieved │ +│ • No wallet QR needed! │ +└──────────────────────────────────┘ +``` + +#membership #lightning #ux + +--- + +## User Flows + +### Cash-In Flow (Buy BTC with Cash) + +```mermaid +stateDiagram-v2 + [*] --> ChooseCoin + ChooseCoin --> MembershipPrompt + + MembershipPrompt --> MembershipScan: Yes + MembershipPrompt --> ScanWalletAddress: No + + MembershipScan --> MembershipValid: Valid ID + MembershipScan --> MembershipInvalid: Invalid + + MembershipInvalid --> MembershipPrompt: Retry + MembershipInvalid --> ScanWalletAddress: Skip + + MembershipValid --> InsertBills: Address from server + ScanWalletAddress --> InsertBills + + InsertBills --> SendCoins + SendCoins --> [*] +``` + +> [!tip] Key Benefit +> Members skip the wallet address scan entirely. Server already knows their Lightning address from membership data. + +**Member Flow:** +1. User starts cash-in transaction +2. "Do you have a membership card?" → **[Yes]** +3. Scan membership QR code +4. Server validates: `user_12345` → Gold tier (15% discount) + Lightning address +5. "Welcome Gold Member! 15% discount applied. Bitcoin will be sent to your wallet automatically." +6. User inserts cash +7. Server sends BTC to user's Lightning address via LNbits + +**Non-Member Flow:** +1. User starts cash-in transaction +2. "Do you have a membership card?" → **[No]** +3. User scans wallet QR code (standard flow) +4. User inserts cash +5. Server sends BTC to scanned address + +### Cash-Out Flow (Sell BTC for Cash) + +```mermaid +stateDiagram-v2 + [*] --> ChooseAmount + ChooseAmount --> MembershipPrompt + + MembershipPrompt --> MembershipScan: Yes + MembershipPrompt --> ScanInvoice: No + + MembershipScan --> MembershipValid: Valid + MembershipValid --> ScanInvoice: Discount applied + + ScanInvoice --> WaitForPayment + WaitForPayment --> DispenseCash: Payment received + DispenseCash --> [*] +``` + +> [!note] Cash-Out Still Requires Invoice +> For cash-out, user still provides a Lightning invoice (security requirement). Membership only provides the discount. + +**Member Flow:** +1. User starts cash-out transaction +2. "Do you have a membership card?" → **[Yes]** +3. Scan membership QR code +4. Server validates: Gold tier → 15% discount applied +5. User scans Lightning invoice to receive payment +6. ATM pays invoice via LNbits +7. User receives cash + +#userflow #cashin #cashout + +--- + +## Architecture + +### System Overview + +```mermaid +graph TB + subgraph "ATM Machine" + brain[brain.js State Machine] + trader[Trader API Client] + ui[Kiosk UI] + end + + subgraph "Lamassu Server" + membership[Membership Service] + lightning[Lightning Service] + graphql[GraphQL API] + rest[REST API] + end + + subgraph "LNbits" + lnbits_api[LNbits API] + wallet[ATM Wallet] + funding[Funding Source] + end + + subgraph "Lightning Network" + ln[Lightning Nodes] + end + + ui --> brain + brain --> trader + trader -->|REST| rest + trader -->|GraphQL| graphql + + rest --> membership + rest --> lightning + graphql --> membership + + lightning --> lnbits_api + lnbits_api --> wallet + wallet --> funding + funding --> ln +``` + +### LNbits as Lightning Backend + +> [!decision] Why LNbits? +> - Abstracts 30+ Lightning implementations (LND, CLN, etc.) +> - Clean REST API for integration +> - Multi-wallet support (per-ATM or shared) +> - Built-in LNURL/Lightning Address support +> - Open source, self-hostable +> - PostgreSQL backend (matches Lamassu) + +**Integration Pattern:** + +```typescript +// packages/server/lib/lightning/lnbits-client.ts +interface LNbitsConfig { + baseUrl: string // https://lnbits.example.com + adminKey: string // For paying out (cash-in) + invoiceKey: string // For creating invoices (cash-out) +} + +class LNbitsClient { + // Cash-in: Pay to user's Lightning address + async payToLightningAddress(address: string, amountSats: number): Promise + + // Cash-out: Create invoice for user to pay + async createInvoice(amountSats: number, memo: string): Promise + + // Check payment status + async getPaymentStatus(paymentHash: string): Promise + + // Get wallet balance + async getBalance(): Promise +} +``` + +See [[LNbits Integration]] for detailed API documentation. + +#architecture #lnbits + +--- + +## Database Schema + +### New Tables + +```sql +-- Discount tiers (Bronze, Silver, Gold, Platinum) +CREATE TABLE discount_tiers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(50) NOT NULL UNIQUE, -- 'gold' + display_name VARCHAR(100) NOT NULL, -- 'Gold Member' + discount_percentage INT NOT NULL, -- 15 + min_monthly_volume INT DEFAULT 0, -- Auto-upgrade threshold (sats) + priority INT DEFAULT 0, -- Display order + color VARCHAR(7), -- '#FFD700' for UI + created TIMESTAMPTZ DEFAULT NOW(), + enabled BOOLEAN DEFAULT TRUE +); + +-- Memberships linking external IDs to tiers + Lightning +CREATE TABLE memberships ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + external_user_id VARCHAR(255) NOT NULL UNIQUE, -- QR/NFC payload + tier_id UUID REFERENCES discount_tiers(id), + customer_id UUID REFERENCES customers(id), -- Link to existing customer + + -- Lightning wallet info + lightning_address VARCHAR(255), -- user@wallet.com + lightning_address_type VARCHAR(20), -- 'lnurl-pay', 'bolt12' + lnbits_user_id VARCHAR(255), -- If using LNbits accounts + lnbits_wallet_id VARCHAR(255), -- If using LNbits wallets + + -- Metadata + metadata JSONB, -- Flexible extra data + created TIMESTAMPTZ DEFAULT NOW(), + last_used TIMESTAMPTZ, + enabled BOOLEAN DEFAULT TRUE +); + +-- Usage audit log +CREATE TABLE membership_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + membership_id UUID REFERENCES memberships(id), + device_id VARCHAR(255), -- ATM machine ID + tx_id UUID, -- Transaction reference + action VARCHAR(20), -- 'cash_in', 'cash_out' + amount_fiat INT, -- Original amount + amount_crypto BIGINT, -- Sats + discount_applied INT, -- Percentage applied + discount_saved INT, -- Amount saved in fiat + lightning_payment_hash VARCHAR(64), -- For tracking + created TIMESTAMPTZ DEFAULT NOW() +); + +-- Indexes +CREATE INDEX idx_memberships_external_id ON memberships(external_user_id); +CREATE INDEX idx_memberships_tier ON memberships(tier_id); +CREATE INDEX idx_memberships_lightning ON memberships(lightning_address); +CREATE INDEX idx_membership_usage_membership ON membership_usage(membership_id); +CREATE INDEX idx_membership_usage_created ON membership_usage(created); +``` + +### Seed Data + +```sql +INSERT INTO discount_tiers (name, display_name, discount_percentage, priority, color) VALUES + ('bronze', 'Bronze Member', 5, 1, '#CD7F32'), + ('silver', 'Silver Member', 10, 2, '#C0C0C0'), + ('gold', 'Gold Member', 15, 3, '#FFD700'), + ('platinum', 'Platinum Member', 20, 4, '#E5E4E2'); +``` + +### Drizzle Schema + +```typescript +// packages/typesafe-db/src/schema/membership.ts +import { pgTable, uuid, varchar, integer, boolean, timestamp, jsonb } from 'drizzle-orm/pg-core' + +export const discountTiers = pgTable('discount_tiers', { + id: uuid('id').primaryKey().defaultRandom(), + name: varchar('name', { length: 50 }).notNull().unique(), + displayName: varchar('display_name', { length: 100 }).notNull(), + discountPercentage: integer('discount_percentage').notNull(), + minMonthlyVolume: integer('min_monthly_volume').default(0), + priority: integer('priority').default(0), + color: varchar('color', { length: 7 }), + created: timestamp('created', { withTimezone: true }).defaultNow(), + enabled: boolean('enabled').default(true), +}) + +export const memberships = pgTable('memberships', { + id: uuid('id').primaryKey().defaultRandom(), + externalUserId: varchar('external_user_id', { length: 255 }).notNull().unique(), + tierId: uuid('tier_id').references(() => discountTiers.id), + customerId: uuid('customer_id').references(() => customers.id), + lightningAddress: varchar('lightning_address', { length: 255 }), + lightningAddressType: varchar('lightning_address_type', { length: 20 }), + lnbitsUserId: varchar('lnbits_user_id', { length: 255 }), + lnbitsWalletId: varchar('lnbits_wallet_id', { length: 255 }), + metadata: jsonb('metadata'), + created: timestamp('created', { withTimezone: true }).defaultNow(), + lastUsed: timestamp('last_used', { withTimezone: true }), + enabled: boolean('enabled').default(true), +}) + +export const membershipUsage = pgTable('membership_usage', { + id: uuid('id').primaryKey().defaultRandom(), + membershipId: uuid('membership_id').references(() => memberships.id), + deviceId: varchar('device_id', { length: 255 }), + txId: uuid('tx_id'), + action: varchar('action', { length: 20 }), + amountFiat: integer('amount_fiat'), + amountCrypto: integer('amount_crypto'), + discountApplied: integer('discount_applied'), + discountSaved: integer('discount_saved'), + lightningPaymentHash: varchar('lightning_payment_hash', { length: 64 }), + created: timestamp('created', { withTimezone: true }).defaultNow(), +}) +``` + +#database #drizzle #schema + +--- + +## API Design + +### REST Endpoints (Machine → Server) + +#### Validate Membership + +```http +POST /api/v1/membership/validate +Content-Type: application/json +X-Machine-ID: machine_abc123 + +{ + "membershipId": "MEM-2026-GOLD-12345" +} +``` + +**Response (Success):** +```json +{ + "valid": true, + "membership": { + "id": "uuid-here", + "externalUserId": "MEM-2026-GOLD-12345", + "tier": { + "name": "gold", + "displayName": "Gold Member", + "discountPercentage": 15, + "color": "#FFD700" + }, + "lightningAddress": "user123@walletofsatoshi.com", + "lightningAddressType": "lnurl-pay", + "customerName": "John D." + } +} +``` + +**Response (Invalid):** +```json +{ + "valid": false, + "error": "MEMBERSHIP_NOT_FOUND", + "message": "Membership ID not recognized" +} +``` + +#### Pay to Member (Cash-In) + +```http +POST /api/v1/membership/pay +Content-Type: application/json +X-Machine-ID: machine_abc123 + +{ + "membershipId": "MEM-2026-GOLD-12345", + "amountSats": 50000, + "amountFiat": 25.00, + "currency": "USD", + "txId": "tx-uuid-here" +} +``` + +**Response:** +```json +{ + "success": true, + "paymentHash": "abc123...", + "preimage": "def456...", + "feeSats": 5, + "discountApplied": 15, + "discountSavedFiat": 3.75 +} +``` + +#### Create Invoice for Member (Cash-Out) + +```http +POST /api/v1/membership/invoice +Content-Type: application/json +X-Machine-ID: machine_abc123 + +{ + "membershipId": "MEM-2026-GOLD-12345", + "amountSats": 50000, + "memo": "ATM Cash-out - Gold Member" +} +``` + +**Response:** +```json +{ + "bolt11": "lnbc500u1p...", + "paymentHash": "abc123...", + "expiresAt": "2026-01-22T15:30:00Z", + "discountApplied": 15 +} +``` + +### GraphQL API (Admin) + +```graphql +# Types +type DiscountTier { + id: ID! + name: String! + displayName: String! + discountPercentage: Int! + minMonthlyVolume: Int + priority: Int! + color: String + enabled: Boolean! + memberCount: Int! +} + +type Membership { + id: ID! + externalUserId: String! + tier: DiscountTier! + customer: Customer + lightningAddress: String + lightningAddressType: String + lnbitsUserId: String + metadata: JSONObject + created: DateTime! + lastUsed: DateTime + enabled: Boolean! + usageStats: MembershipStats! +} + +type MembershipStats { + totalTransactions: Int! + totalVolumeSats: BigInt! + totalDiscountSaved: Float! + lastUsed: DateTime +} + +type MembershipUsage { + id: ID! + membership: Membership! + deviceId: String + action: String! + amountFiat: Int! + amountCrypto: BigInt! + discountApplied: Int! + discountSaved: Int! + created: DateTime! +} + +# Queries +type Query { + discountTiers: [DiscountTier!]! + memberships( + tierId: ID + search: String + limit: Int = 50 + offset: Int = 0 + ): [Membership!]! + membership(id: ID!): Membership + membershipByExternalId(externalUserId: String!): Membership + membershipUsage( + membershipId: ID + deviceId: String + startDate: DateTime + endDate: DateTime + limit: Int = 100 + ): [MembershipUsage!]! +} + +# Mutations +type Mutation { + # Tier management + createDiscountTier(input: CreateTierInput!): DiscountTier! + updateDiscountTier(id: ID!, input: UpdateTierInput!): DiscountTier! + deleteDiscountTier(id: ID!): Boolean! + + # Membership management + createMembership(input: CreateMembershipInput!): Membership! + updateMembership(id: ID!, input: UpdateMembershipInput!): Membership! + deleteMembership(id: ID!): Boolean! + + # Bulk operations + importMemberships(memberships: [CreateMembershipInput!]!): ImportResult! + + # LNbits integration + syncMembershipWithLNbits(id: ID!): Membership! +} + +input CreateMembershipInput { + externalUserId: String! + tierName: String! + lightningAddress: String + customerId: ID + metadata: JSONObject +} + +input UpdateMembershipInput { + tierName: String + lightningAddress: String + customerId: ID + metadata: JSONObject + enabled: Boolean +} +``` + +### tRPC Router (Future) + +```typescript +// packages/server/lib/trpc/routers/membership.ts +import { router, protectedProcedure } from '../trpc' +import { z } from 'zod' + +export const membershipRouter = router({ + validate: protectedProcedure + .input(z.object({ membershipId: z.string() })) + .query(async ({ input, ctx }) => { + return ctx.membershipService.validate(input.membershipId) + }), + + list: protectedProcedure + .input(z.object({ + tierId: z.string().optional(), + search: z.string().optional(), + limit: z.number().default(50), + offset: z.number().default(0), + })) + .query(async ({ input, ctx }) => { + return ctx.membershipService.list(input) + }), + + create: protectedProcedure + .input(createMembershipSchema) + .mutation(async ({ input, ctx }) => { + return ctx.membershipService.create(input) + }), +}) +``` + +#api #rest #graphql #trpc + +--- + +## Machine Changes + +### New States in brain.js + +```javascript +// lib/brain.js - Add to state machine + +states: { + // ... existing states ... + + membershipPrompt: { + _onEnter: function () { + this._transitionState('membershipPrompt') + }, + membershipYes: 'membershipScan', + membershipNo: function () { + // Continue to standard flow + return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' + } + }, + + membershipScan: { + _onEnter: function () { + this._transitionState('membershipScan', { timeout: 60000 }) + this.scanner.scanMembership() + }, + membershipScanned: function (membershipId) { + this.tx.membershipId = membershipId + return 'membershipValidating' + }, + timeout: 'membershipPrompt', + cancelMembership: 'membershipPrompt' + }, + + membershipValidating: { + _onEnter: function () { + this._transitionState('membershipValidating') + this.trader.validateMembership(this.tx.membershipId) + .then(result => this.emit('membershipResult', result)) + .catch(err => this.emit('membershipError', err)) + }, + membershipResult: function (result) { + if (result.valid) { + this.tx.membership = result.membership + this.tx.discountPercentage = result.membership.tier.discountPercentage + this.tx.lightningAddress = result.membership.lightningAddress + return 'membershipValid' + } + return 'membershipInvalid' + }, + membershipError: 'membershipInvalid' + }, + + membershipValid: { + _onEnter: function () { + const tier = this.tx.membership.tier + this._transitionState('membershipValid', { + tierName: tier.displayName, + discountPercentage: tier.discountPercentage, + hasLightningAddress: !!this.tx.lightningAddress + }) + }, + continue: function () { + if (this.tx.direction === 'cashIn' && this.tx.lightningAddress) { + // Skip address scan - we have Lightning address! + return 'insertBills' + } + return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' + } + }, + + membershipInvalid: { + _onEnter: function () { + this._transitionState('membershipInvalid') + }, + retry: 'membershipScan', + skip: function () { + return this.tx.direction === 'cashIn' ? 'scanAddress' : 'insertBills' + } + } +} +``` + +### New Trader Methods + +```javascript +// lib/trader.js + +Trader.prototype.validateMembership = async function (membershipId) { + const response = await this.request({ + method: 'POST', + path: '/api/v1/membership/validate', + body: { membershipId } + }) + return response.data +} + +Trader.prototype.payToMembership = async function (membershipId, amountSats, amountFiat, txId) { + const response = await this.request({ + method: 'POST', + path: '/api/v1/membership/pay', + body: { membershipId, amountSats, amountFiat, currency: this.currency, txId } + }) + return response.data +} + +Trader.prototype.createMembershipInvoice = async function (membershipId, amountSats, memo) { + const response = await this.request({ + method: 'POST', + path: '/api/v1/membership/invoice', + body: { membershipId, amountSats, memo } + }) + return response.data +} +``` + +### UI Screens + +``` +screens/ +├── membership-prompt.html # "Do you have a membership card?" +├── membership-scan.html # "Scan your membership QR code" +├── membership-validating.html # Loading spinner +├── membership-valid.html # "Welcome Gold Member! 15% discount" +└── membership-invalid.html # "Membership not recognized" +``` + +#machine #statemachine #ui + +--- + +## LNbits Integration + +### Server Configuration + +```typescript +// packages/server/lib/lightning/config.ts +export interface LightningConfig { + provider: 'lnbits' | 'lnd' | 'cln' + lnbits?: { + baseUrl: string + adminKey: string // For outgoing payments (cash-in) + invoiceKey: string // For creating invoices (cash-out) + walletId: string + } +} +``` + +### LNbits Client Implementation + +```typescript +// packages/server/lib/lightning/lnbits-client.ts +import { z } from 'zod' + +const PaymentResponseSchema = z.object({ + payment_hash: z.string(), + checking_id: z.string(), +}) + +const InvoiceResponseSchema = z.object({ + payment_hash: z.string(), + payment_request: z.string(), // bolt11 + checking_id: z.string(), +}) + +export class LNbitsClient { + constructor(private config: LNbitsConfig) {} + + async payToLightningAddress( + address: string, + amountSats: number, + comment?: string + ): Promise { + // Step 1: Resolve Lightning Address to LNURL + const [name, domain] = address.split('@') + const lnurlResponse = await fetch( + `https://${domain}/.well-known/lnurlp/${name}` + ) + const lnurlData = await lnurlResponse.json() + + // Step 2: Get invoice from LNURL callback + const amountMsat = amountSats * 1000 + const callbackUrl = new URL(lnurlData.callback) + callbackUrl.searchParams.set('amount', amountMsat.toString()) + if (comment) callbackUrl.searchParams.set('comment', comment) + + const invoiceResponse = await fetch(callbackUrl.toString()) + const { pr: bolt11 } = await invoiceResponse.json() + + // Step 3: Pay the invoice via LNbits + return this.payInvoice(bolt11) + } + + async payInvoice(bolt11: string): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'X-API-KEY': this.config.adminKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ out: true, bolt11 }), + }) + + const data = PaymentResponseSchema.parse(await response.json()) + return { + success: true, + paymentHash: data.payment_hash, + checkingId: data.checking_id, + } + } + + async createInvoice( + amountSats: number, + memo: string, + expiry: number = 600 + ): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/payments`, { + method: 'POST', + headers: { + 'X-API-KEY': this.config.invoiceKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + out: false, + amount: amountSats, + memo, + expiry, + }), + }) + + const data = InvoiceResponseSchema.parse(await response.json()) + return { + bolt11: data.payment_request, + paymentHash: data.payment_hash, + checkingId: data.checking_id, + expiresAt: new Date(Date.now() + expiry * 1000), + } + } + + async getPaymentStatus(checkingId: string): Promise { + const response = await fetch( + `${this.config.baseUrl}/api/v1/payments/${checkingId}`, + { + headers: { 'X-API-KEY': this.config.invoiceKey }, + } + ) + + const data = await response.json() + return { + paid: data.paid === true, + pending: data.pending === true, + preimage: data.preimage, + } + } + + async getBalance(): Promise { + const response = await fetch(`${this.config.baseUrl}/api/v1/wallet`, { + headers: { 'X-API-KEY': this.config.adminKey }, + }) + + const data = await response.json() + return Math.floor(data.balance / 1000) // msat to sats + } +} +``` + +### Supported Lightning Addresses + +| Provider | Format | Type | +|----------|--------|------| +| Wallet of Satoshi | `user@walletofsatoshi.com` | Custodial | +| Alby | `user@getalby.com` | Custodial | +| Blink | `user@blink.sv` | Custodial | +| Strike | `user@strike.me` | Custodial | +| Phoenix | LNURL-pay | Self-custodial | +| Breez | LNURL-pay | Self-custodial | +| Zeus | Own node | Self-custodial | +| LNbits | `user@lnbits.instance.com` | Self-hosted | + +#lnbits #lightning #integration + +--- + +## Implementation Phases + +### Phase 1: Database & Core Services + +> [!todo] Phase 1 Tasks + +- [ ] Create database migration for `discount_tiers`, `memberships`, `membership_usage` +- [ ] Seed default discount tiers +- [ ] Implement `MembershipService` in TypeScript +- [ ] Implement `LNbitsClient` for Lightning operations +- [ ] Add REST endpoints: `/membership/validate`, `/membership/pay`, `/membership/invoice` +- [ ] Add GraphQL types and resolvers for admin API +- [ ] Write unit tests for membership service +- [ ] Write integration tests for LNbits client + +**Files to create:** +``` +packages/server/ +├── lib/ +│ ├── membership/ +│ │ ├── index.ts +│ │ ├── membership-service.ts +│ │ ├── membership-types.ts +│ │ └── membership-repository.ts +│ └── lightning/ +│ ├── index.ts +│ ├── lnbits-client.ts +│ ├── lightning-address-resolver.ts +│ └── lightning-types.ts +├── routes/ +│ └── membership-routes.ts +└── migrations/ + └── 20260122_membership_tables.sql + +packages/typesafe-db/src/ +└── schema/ + └── membership.ts +``` + +### Phase 2: Machine Integration + +> [!todo] Phase 2 Tasks + +- [ ] Add new states to `brain.js` state machine +- [ ] Implement membership scan flow +- [ ] Modify cash-in flow to skip wallet scan for members +- [ ] Add Trader methods for membership API +- [ ] Create UI screens for membership flow +- [ ] Add i18n translations +- [ ] Test with mock membership data + +**Files to modify:** +``` +lamassu-machine/ +├── lib/ +│ ├── brain.js # Add membership states +│ └── trader.js # Add membership methods +├── ui/ +│ ├── src/app.js # Add membership handlers +│ └── html/ +│ ├── membership-prompt.html +│ ├── membership-scan.html +│ ├── membership-valid.html +│ └── membership-invalid.html +└── i18n/ + └── ui/ # Add translations +``` + +### Phase 3: Admin Dashboard + +> [!todo] Phase 3 Tasks + +- [ ] Create membership management pages +- [ ] Create tier configuration UI +- [ ] Add membership import/export +- [ ] Add usage analytics dashboard +- [ ] Generate membership QR codes +- [ ] Build member lookup interface + +**Files to create:** +``` +packages/admin-ui/src/ +├── pages/ +│ ├── Memberships/ +│ │ ├── index.tsx +│ │ ├── MembershipList.tsx +│ │ ├── MembershipDetail.tsx +│ │ └── CreateMembership.tsx +│ └── DiscountTiers/ +│ ├── index.tsx +│ └── TierConfig.tsx +└── components/ + └── membership/ + ├── MembershipQRCode.tsx + └── UsageChart.tsx +``` + +### Phase 4: LNbits Deep Integration + +> [!todo] Phase 4 Tasks + +- [ ] Test Lightning Address resolution for all major wallets +- [ ] Implement LNURL-pay flow with amount limits +- [ ] Handle edge cases (offline wallets, expired invoices) +- [ ] Add webhook support for payment notifications +- [ ] Implement automatic LNbits user creation for members +- [ ] Add balance monitoring and alerts + +### Phase 5: Web Portal (Optional) + +> [!todo] Phase 5 Tasks + +- [ ] Self-service membership registration +- [ ] Member dashboard (transaction history, tier status) +- [ ] QR code download/print +- [ ] Lightning Address configuration +- [ ] Upgrade tier based on volume + +#implementation #phases #roadmap + +--- + +## Security Considerations + +> [!warning] Security Requirements + +1. **Membership ID Format**: Use cryptographically random IDs, not sequential +2. **Rate Limiting**: Limit membership validation attempts per machine +3. **Audit Trail**: Log all membership usage for compliance +4. **Lightning Key Security**: Store LNbits admin key in encrypted config +5. **Amount Limits**: Enforce per-transaction and daily limits per tier +6. **Address Verification**: Validate Lightning Address format before storage + +### Membership ID Format + +``` +MEM-{YEAR}-{TIER}-{RANDOM} +MEM-2026-GOLD-A7X9K2M4 +``` + +- 4-digit year +- Tier indicator (for visual identification) +- 8-character random alphanumeric + +#security + +--- + +## Related Documents + +- [[modernization-plan]] - Overall modernization roadmap +- [[LNbits Integration]] - Detailed LNbits API documentation +- [[API Key Authentication]] - Prerequisite for programmatic access +- [[CLAUDE]] - Development guidance diff --git a/docs/integrations/lnbits-integration.md b/docs/integrations/lnbits-integration.md new file mode 100644 index 0000000..ac8b32a --- /dev/null +++ b/docs/integrations/lnbits-integration.md @@ -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( + method: string, + path: string, + key: 'admin' | 'invoice', + body?: unknown + ): Promise { + 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 { + const info = await this.getWalletInfo() + return info.balanceSats + } + + // Invoice Operations + + async createInvoice( + amountSats: number, + memo: string, + options?: { + expiry?: number + webhook?: string + } + ): Promise { + 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 { + 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 { + 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 { + 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 { + // 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 { + 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 { + 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