Add future features roadmap and CLINK test script

- docs/future-features.md: Document planned features
  - Public cash availability display via kind 30078 beacons
  - Remote initiation with local redemption using signed claims
  - Hold invoices for safe dispensing (Lightning.Pub contribution)

- scripts/test-clink.ts: Test script for CLINK protocol verification

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-24 11:14:51 -05:00
commit df27e59dcb
2 changed files with 469 additions and 0 deletions

View file

@ -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": [<machine-pubkeys>]}]
```
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<void>
// Cancel if dispense fails
cancelHoldInvoice(params: { hash: string }): Promise<void>
}
```
**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)

View file

@ -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<void>((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)