diff --git a/lamassu-next/docs/future-features.md b/lamassu-next/docs/future-features.md new file mode 100644 index 0000000..0bc7a59 --- /dev/null +++ b/lamassu-next/docs/future-features.md @@ -0,0 +1,352 @@ +# Future Features Roadmap + +This document outlines advanced features planned for Lamassu Next that leverage the Nostr-native architecture. + +## 1. Public Cash Availability Display + +**Status**: Fully Achievable +**Complexity**: Low +**Dependencies**: Service beacon (kind 30078) + +### Overview + +A public website or app can display real-time cash availability for ATMs without requiring any special authentication. + +### Technical Approach + +1. **Machine publishes beacon** (replaceable event kind 30078): + + ```json + { + "kind": 30078, + "tags": [ + ["d", "machine-status"], + ["location", "40.7128,-74.0060"], + ["currency", "USD"] + ], + "content": { + "cassettes": [ + { "denomination": 20, "count": 45 }, + { "denomination": 50, "count": 20 } + ], + "totalCash": 1900, + "status": "online", + "lastDispense": "2026-01-24T10:30:00Z" + } + } + ``` + +2. **Website subscribes** to relay: + + ```javascript + ["REQ", "availability", {"kinds": [30078], "authors": []}] + ``` + +3. **Real-time updates**: Replaceable events update in place, website gets live data. + +### Privacy Considerations + +- Location precision can be reduced (city-level vs exact coordinates) +- Operators choose what to publish (can omit exact counts) +- Machine identity is pseudonymous (pubkey only) + +### Implementation Notes + +- No encryption needed (public data) +- Works with any public relay +- Multiple machines can publish to same relay for fleet view +- Dashboard aggregates for operator, public website shows customer view + +--- + +## 2. Remote Initiation, Local Redemption + +**Status**: Fully Achievable +**Complexity**: Medium +**Dependencies**: CLINK protocol (kinds 21001-21003), signed claims + +### Overview + +Users can initiate a cash-out transaction remotely (from their phone/wallet) and redeem it physically at the ATM later. + +### User Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ REMOTE INITIATION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 1. User opens wallet app │ +│ └─> Selects "Cash Out at ATM" │ +│ │ +│ 2. Wallet shows available ATMs (from beacons) │ +│ └─> User selects ATM and amount ($100) │ +│ │ +│ 3. Wallet sends CLINK request to ATM via relay │ +│ └─> kind 21002 (debit request) │ +│ │ +│ 4. ATM responds with signed claim │ +│ └─> "Bearer of this claim can redeem $100" │ +│ └─> Includes: amount, expiry, ATM signature, claim_id │ +│ │ +│ 5. User pays Lightning invoice (from wallet balance) │ +│ └─> Payment settles instantly │ +│ │ +│ 6. Wallet stores claim locally │ +│ └─> Claim is valid for 24 hours │ +│ │ +└─────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────┐ +│ LOCAL REDEMPTION │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 7. User arrives at ATM (minutes to hours later) │ +│ └─> Taps "Redeem" on ATM screen │ +│ │ +│ 8. ATM displays QR code for claim submission │ +│ └─> Wallet scans and sends signed claim │ +│ │ +│ 9. ATM verifies claim │ +│ └─> Checks signature (did I sign this?) │ +│ └─> Checks expiry (still valid?) │ +│ └─> Checks claim_id (not already redeemed?) │ +│ │ +│ 10. ATM dispenses cash │ +│ └─> Marks claim as redeemed (local DB + Nostr) │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### Claim Structure + +```typescript +interface RedemptionClaim { + version: 1 + claim_id: string // Unique identifier + machine_pubkey: string // ATM that will honor this + amount_cents: number // Fiat amount to dispense + currency: string // "USD" + created_at: number // Unix timestamp + expires_at: number // Unix timestamp (24h default) + payment_preimage: string // Proof of payment + user_pubkey?: string // Optional: restrict to specific user + signature: string // ATM's signature over above fields +} +``` + +### Security Model + +1. **Claim is bearer token**: Whoever presents valid claim gets cash +2. **Single-use**: claim_id tracked to prevent double-redemption +3. **Time-limited**: Expires after configurable window +4. **User-binding** (optional): Can restrict to specific npub +5. **Offline-capable**: ATM can verify signature without network + +### State Sync + +- Redeemed claims published to relay (for dashboard visibility) +- Claims stored locally with SQLite for offline redemption +- Periodic sync ensures consistency + +### Implementation Notes + +- Claim fits in QR code (< 2KB when base64 encoded) +- Works offline after initial payment (ATM doesn't need network to verify) +- Natural fit with Cashu tokens (future enhancement) + +--- + +## 3. Hold Invoices for Safe Dispensing + +**Status**: Achievable with Hybrid Approach +**Complexity**: High +**Dependencies**: LND hold invoices, Lightning.Pub extension or direct LND access + +### Overview + +For cash-out, the current flow has a risk window: user pays invoice, but if dispenser jams, user loses funds. Hold invoices solve this by delaying settlement until cash is physically dispensed. + +### Current Flow (Risky) + +``` +User pays invoice ──> Invoice settles ──> Dispense attempted ──> JAM! + │ + └── User lost funds, ATM owes them +``` + +### Hold Invoice Flow (Safe) + +``` +User pays invoice ──> Invoice HELD ──> Dispense attempted ──> Success ──> SETTLE + │ │ + │ └── JAM! ──> CANCEL (funds returned) + │ + └── Funds locked but not settled +``` + +### Technical Background + +LND supports hold invoices via: + +- `AddHoldInvoice`: Create invoice with known preimage hash +- `SettleInvoice`: Release funds (ATM provides preimage) +- `CancelInvoice`: Return funds to payer + +### Lightning.Pub Status + +Lightning.Pub's codebase includes LND protobuf definitions for hold invoices: + +```typescript +// From Lightning.Pub/proto/lnd/invoices.ts +interface AddHoldInvoiceRequest { + hash: Uint8Array // SHA256 of preimage we choose + value: string // Amount in sats + memo: string + expiry: string + // ... +} + +interface SettleInvoiceMsg { + preimage: Uint8Array // Reveal to settle +} + +interface CancelInvoiceMsg { + payment_hash: Uint8Array +} +``` + +**However**: These are not currently exposed in Lightning.Pub's HTTP/Nostr API. + +### Implementation Options + +#### Option A: Lightning.Pub Extension (Recommended) + +Contribute hold invoice support to Lightning.Pub: + +```typescript +// New RPC methods needed +interface LightningPubExtension { + // Create hold invoice (doesn't settle automatically) + createHoldInvoice(params: { + amount_sats: number + memo: string + hash: string // We provide the hash + }): Promise<{ invoice: string }> + + // Settle after successful dispense + settleHoldInvoice(params: { preimage: string }): Promise + + // Cancel if dispense fails + cancelHoldInvoice(params: { hash: string }): Promise +} +``` + +**Pros**: Clean integration, benefits entire ecosystem +**Cons**: Requires upstream contribution, timeline uncertain + +#### Option B: Hybrid Approach + +ATM uses Lightning.Pub for accounting but connects to LND directly for hold invoices: + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ ATM Machine │────>│ Lightning.Pub │────>│ LND │ +│ │ │ (accounting) │ │ (payments) │ +│ │─────────────────────────────>│ │ +│ │ direct gRPC for hold │ │ +└─────────────────┘ invoices └─────────────────┘ +``` + +**Pros**: Works today, no upstream changes needed +**Cons**: More complex, two connections to manage + +#### Option C: Escrow Account + +Use Lightning.Pub's internal accounts as escrow: + +1. Create "escrow" account in Lightning.Pub +2. User pays to escrow account (instant settlement) +3. On successful dispense: transfer escrow → operator +4. On failed dispense: transfer escrow → user refund + +**Pros**: Works within Lightning.Pub model +**Cons**: Requires user to have Lightning.Pub account for refund + +### Chosen Approach: Lightning.Pub Contribution + +We will contribute hold invoice support directly to Lightning.Pub (Option A). This: + +- Benefits the entire Nostr+Lightning ecosystem +- Keeps our architecture clean (single payment backend) +- Aligns with the open-source-first philosophy of this project + +**Action Item**: Open PR to Lightning.Pub exposing hold invoice methods via Nostr RPC (kind 21000). + +### Cash-Out Flow with Hold Invoices + +```typescript +async function cashOutWithHoldInvoice(amount: number) { + // 1. Generate preimage and hash + const preimage = crypto.randomBytes(32) + const hash = sha256(preimage) + + // 2. Create hold invoice via LND + const invoice = await lnd.addHoldInvoice({ + hash, + value: amount, + memo: `ATM Cash-Out $${amount / 100}`, + expiry: 600, // 10 minutes + }) + + // 3. Display invoice, wait for payment + displayQR(invoice) + await waitForHtlcAccepted(hash) // Payment received but not settled + + // 4. Attempt to dispense + try { + await dispenser.dispense(calculateBills(amount)) + + // 5a. Success - settle the invoice + await lnd.settleInvoice({ preimage }) + return { success: true } + } catch (error) { + // 5b. Failure - cancel the invoice, funds return to user + await lnd.cancelInvoice({ payment_hash: hash }) + return { success: false, error: 'Dispense failed, payment cancelled' } + } +} +``` + +### Timeout Handling + +- Hold invoices have expiry (default 10 minutes) +- If ATM crashes mid-transaction, invoice eventually expires +- User's funds return automatically after timeout +- No manual intervention needed + +--- + +## Implementation Priority + +| Feature | Priority | Effort | Value | +| ------------------------ | -------- | ------ | ------------------- | +| Public Cash Availability | P1 | Low | High | +| Remote Initiation | P2 | Medium | High | +| Hold Invoices | P2 | High | Critical for safety | + +### Suggested Order + +1. **Phase 1**: Basic ATM flows (cash-in, cash-out with standard invoices) +2. **Phase 2**: Public availability beacon + simple dashboard +3. **Phase 3**: Remote initiation with signed claims +4. **Phase 4**: Hold invoices for production safety + +--- + +## Related Documentation + +- [CLINK Protocol Spec](https://github.com/shocknet/clink) +- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub) +- [LND Hold Invoices](https://docs.lightning.engineering/lightning-network-tools/lnd/hold-invoices) +- [NIP-44 Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) diff --git a/lamassu-next/scripts/test-clink.ts b/lamassu-next/scripts/test-clink.ts new file mode 100644 index 0000000..105b450 --- /dev/null +++ b/lamassu-next/scripts/test-clink.ts @@ -0,0 +1,117 @@ +#!/usr/bin/env npx tsx +/** + * CLINK Integration Test + * + * Tests the CLINK protocol against a local Lightning.Pub instance. + * + * Usage: npx tsx scripts/test-clink.ts + */ + +import { generateSecretKey, getPublicKey, finalizeEvent, nip44 } from 'nostr-tools' +import { hexToBytes } from '@noble/hashes/utils' + +// Lightning.Pub service info (from docker logs) +const LIGHTNING_PUB_PUBKEY = '0943fe710a0f48c77a0bdd11bd186d320cd1b01615cf9ec36fa2dc45bd86e92f' +const RELAY_URL = 'ws://localhost:7777' + +// CLINK Event Kinds +const KIND_OFFER = 21001 +const KIND_DEBIT = 21002 + +async function main() { + console.log('CLINK Integration Test') + console.log('======================\n') + + // Generate a test keypair + const sk = generateSecretKey() + const pk = getPublicKey(sk) + console.log('Test client pubkey:', pk) + console.log('Lightning.Pub pubkey:', LIGHTNING_PUB_PUBKEY) + console.log('Relay:', RELAY_URL) + console.log('') + + // Connect to relay + const WebSocket = (await import('ws')).default + const ws = new WebSocket(RELAY_URL) + + await new Promise((resolve, reject) => { + ws.on('open', () => { + console.log('Connected to relay\n') + resolve() + }) + ws.on('error', reject) + }) + + // Subscribe to responses from Lightning.Pub + const subId = 'clink-test' + ws.send( + JSON.stringify([ + 'REQ', + subId, + { + kinds: [KIND_OFFER, KIND_DEBIT], + '#p': [pk], + since: Math.floor(Date.now() / 1000) - 10, + }, + ]) + ) + + // Handle incoming messages + ws.on('message', (data) => { + const msg = JSON.parse(data.toString()) + if (msg[0] === 'EVENT') { + const event = msg[2] + console.log('Received event kind:', event.kind) + + // Decrypt the content + try { + const decrypted = nip44.decrypt(sk, event.pubkey, event.content) + console.log('Decrypted response:', JSON.parse(decrypted)) + } catch (e) { + console.log('Raw content:', event.content) + } + } else if (msg[0] === 'EOSE') { + console.log('End of stored events\n') + } else if (msg[0] === 'OK') { + console.log('Event published:', msg[1], msg[2] ? 'success' : 'failed', msg[3] || '') + } + }) + + // Create a CLINK Offer request (kind 21001) + // This requests an invoice from Lightning.Pub + console.log('Sending CLINK Offer request (kind 21001)...') + + const offerRequest = { + offer: 'lno1...', // Would be a real BOLT12 offer in production + amount_sats: 100, + payer_data: { + name: 'Test ATM', + }, + } + + // Encrypt the request content + const encryptedContent = nip44.encrypt(sk, LIGHTNING_PUB_PUBKEY, JSON.stringify(offerRequest)) + + const event = finalizeEvent( + { + kind: KIND_OFFER, + created_at: Math.floor(Date.now() / 1000), + tags: [['p', LIGHTNING_PUB_PUBKEY]], + content: encryptedContent, + }, + sk + ) + + ws.send(JSON.stringify(['EVENT', event])) + console.log('Event ID:', event.id) + console.log('') + + // Wait for response + console.log('Waiting for response (5s)...') + await new Promise((resolve) => setTimeout(resolve, 5000)) + + ws.close() + console.log('\nTest complete') +} + +main().catch(console.error)