feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
845
docs/ndebit-cash-in-flow.md
Normal file
845
docs/ndebit-cash-in-flow.md
Normal file
|
|
@ -0,0 +1,845 @@
|
|||
# NDebit Cash-In Flow Implementation Guide
|
||||
|
||||
This document describes how to implement the ndebit scanning flow for ATM cash-in, where a user scans an ndebit QR code from an ATM to withdraw sats to their wallet.
|
||||
|
||||
## Overview
|
||||
|
||||
**Flow Summary:**
|
||||
|
||||
1. ATM displays QR code containing `clink:ndebit1...?amount=X`
|
||||
2. User scans QR with wallet → navigates to Claim screen
|
||||
3. Wallet creates a Lightning invoice for the specified amount
|
||||
4. Wallet sends Kind 21002 debit request to the relay encoded in the ndebit
|
||||
5. Lightning.Pub (ATM's backend) receives the request and pays the invoice
|
||||
6. User receives sats
|
||||
|
||||
**Key Insight:** The user is _receiving_ sats, not sending. The ndebit flow is a RECEIVE action from the wallet's perspective.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ ATM Machine │ │ Nostr Relay │ │ User's Wallet │
|
||||
│ │ │ │ │ │
|
||||
│ Displays QR: │ │ ws://relay:7777│ │ ShockWallet │
|
||||
│ clink: │ │ │ │ │
|
||||
│ ndebit1... │ │ │ │ │
|
||||
│ ?amount=1000 │ │ │ │ │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │
|
||||
│ │ 1. Scan QR │
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ │ 2. Create invoice │
|
||||
│ │ (via Lightning.Pub)│
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ │ 3. Kind 21002 debit │
|
||||
│ │ request with │
|
||||
│ │ invoice │
|
||||
│ │◄──────────────────────┤
|
||||
│ │ │
|
||||
│ 4. Lightning.Pub │ │
|
||||
│ receives request │ │
|
||||
│◄──────────────────────┤ │
|
||||
│ │ │
|
||||
│ 5. ATM validates & │ │
|
||||
│ pays invoice │ │
|
||||
│──────────────────────►│ │
|
||||
│ │ │
|
||||
│ │ 6. Payment received │
|
||||
│ │──────────────────────►│
|
||||
│ │ │
|
||||
```
|
||||
|
||||
## NDebit Format
|
||||
|
||||
### Bech32 Encoding
|
||||
|
||||
The ndebit string is bech32-encoded with the following TLV data:
|
||||
|
||||
```typescript
|
||||
interface DebitPointer {
|
||||
pubkey: string // Lightning.Pub's pubkey (hex)
|
||||
relay: string // Relay URL for communication
|
||||
pointer?: string // Optional session/voucher identifier
|
||||
}
|
||||
```
|
||||
|
||||
### URI Format with Amount
|
||||
|
||||
Following the BIP-21 pattern for unified QR codes, but using `clink:` as the scheme since CLINK is protocol-agnostic (could work with Cashu/Fedimint, not just Lightning):
|
||||
|
||||
```
|
||||
clink:ndebit1<bech32data>?amount=<sats>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
clink:ndebit1qgpkzardqyfhwue69uhkcmmrv9kxsmmnwsarwdehxuqzq34ndmupyc4vrc3tx0rp0km6xmyldrwmum0afjc58rzjegz9hh0m7up5w0?amount=1000
|
||||
```
|
||||
|
||||
**Important:** The amount is NOT encoded in the bech32 ndebit itself - it's passed as a query parameter. This allows a static ndebit to be used with dynamic amounts.
|
||||
|
||||
### Why `clink:` Instead of `lightning:`?
|
||||
|
||||
We use `clink:` as the URI scheme instead of `lightning:` for several reasons:
|
||||
|
||||
1. **Protocol-agnostic** - CLINK is not Lightning-specific. The same ndebit flow could work with Cashu mints or Fedimint in the future.
|
||||
|
||||
2. **Accuracy** - `lightning:` was designed for BOLT11 invoices (`lnbc...`) and LNURL. Using it for `ndebit1...` strings is semantically incorrect.
|
||||
|
||||
3. **Future-proofing** - As CLINK expands to support more payment backends, `clink:` remains accurate while `lightning:` would be misleading.
|
||||
|
||||
**Browser Support Note:** Neither `clink:` nor `lightning:` are IANA-registered schemes, so browsers treat them identically. For QR code scanning (the primary use case), both work fine since the camera app hands the URI directly to the OS for app routing.
|
||||
|
||||
Wallets SHOULD support both schemes for compatibility:
|
||||
|
||||
```typescript
|
||||
const CLINK_SCHEME = '(?:clink:|lightning:)?'
|
||||
```
|
||||
|
||||
## Wallet Implementation
|
||||
|
||||
### 1. Regex for Parsing
|
||||
|
||||
```typescript
|
||||
// In lib/regex.ts
|
||||
// Support both clink: (preferred) and lightning: (legacy) schemes
|
||||
const CLINK_SCHEME = '(?:clink:|lightning:)?'
|
||||
const BECH32_DATA = '[02-9ac-hj-np-z]'
|
||||
|
||||
export const NDEBIT_REGEX = new RegExp(
|
||||
`^${CLINK_SCHEME}(ndebit1${BECH32_DATA}+)(?:\\?amount=(\\d+))?`,
|
||||
'i'
|
||||
)
|
||||
```
|
||||
|
||||
### 2. Type Definitions
|
||||
|
||||
```typescript
|
||||
// In lib/types/parse.ts
|
||||
import type { DebitPointer } from '@lamassu/clink'
|
||||
import type { Satoshi } from './units'
|
||||
|
||||
export enum InputClassification {
|
||||
// ... other types
|
||||
NDEBIT = 'Ndebit',
|
||||
}
|
||||
|
||||
export interface ParsedNdebitInput {
|
||||
type: InputClassification.NDEBIT
|
||||
data: string // The raw ndebit string
|
||||
ndebit: DebitPointer
|
||||
amount?: Satoshi // From ?amount= query parameter
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Input Parser
|
||||
|
||||
```typescript
|
||||
// In lib/parse.ts
|
||||
import { decodeNdebit } from '@lamassu/clink'
|
||||
|
||||
// Add to VALIDATORS array:
|
||||
{
|
||||
type: InputClassification.NDEBIT,
|
||||
test: (s) => {
|
||||
const m = s.match(NDEBIT_REGEX);
|
||||
if (m) {
|
||||
const ndebit = m[1].toLowerCase();
|
||||
const amount = m[2]; // may be undefined
|
||||
return {
|
||||
classification: InputClassification.NDEBIT,
|
||||
value: amount ? `${ndebit}?amount=${amount}` : ndebit
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// In parseBitcoinInput switch:
|
||||
case InputClassification.NDEBIT: {
|
||||
const [ndebitPart, queryPart] = input.split("?");
|
||||
|
||||
let amount: Satoshi | undefined;
|
||||
if (queryPart) {
|
||||
const amountMatch = queryPart.match(/amount=(\d+)/);
|
||||
if (amountMatch) {
|
||||
amount = parseInt(amountMatch[1], 10) as Satoshi;
|
||||
}
|
||||
}
|
||||
|
||||
const decoded = decodeNdebit(ndebitPart)
|
||||
if (!decoded) {
|
||||
throw new Error("Invalid ndebit string");
|
||||
}
|
||||
|
||||
return {
|
||||
type: InputClassification.NDEBIT,
|
||||
data: ndebitPart,
|
||||
ndebit: decoded,
|
||||
amount
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Claim Thunk
|
||||
|
||||
```typescript
|
||||
// In State/scoped/backups/sources/history/claimNdebitThunk.ts
|
||||
import { getNostrClient } from '@/Api/nostr'
|
||||
// Note: SendNdebitRequest is wallet-side code, not part of @lamassu/clink
|
||||
// This example shows the wallet's implementation pattern
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { SimplePool } from 'nostr-tools'
|
||||
import { hexToBytes } from '@noble/hashes/utils'
|
||||
|
||||
export const claimNdebitThunk = ({
|
||||
sourceId,
|
||||
parsedInput,
|
||||
amount,
|
||||
note,
|
||||
showToast,
|
||||
}: {
|
||||
sourceId: string
|
||||
parsedInput: ParsedNdebitInput
|
||||
amount: Satoshi
|
||||
note?: string
|
||||
showToast: ShowToast
|
||||
}): AppThunk<Promise<boolean>> => {
|
||||
return async (dispatch, getState) => {
|
||||
const selectedSource = selectSourceViewById(getState(), sourceId)
|
||||
if (!selectedSource || selectedSource.type !== SourceType.NPROFILE_SOURCE) {
|
||||
showToast({ message: 'Source not found', color: 'danger' })
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Step 1: Create invoice to RECEIVE payment
|
||||
let client
|
||||
try {
|
||||
client = await getNostrClient(
|
||||
{ pubkey: selectedSource.lpk, relays: selectedSource.relays },
|
||||
selectedSource.keys
|
||||
)
|
||||
} catch (err) {
|
||||
throw new Error('Cannot connect to Lightning.Pub')
|
||||
}
|
||||
|
||||
let invoiceRes
|
||||
try {
|
||||
invoiceRes = await client.NewInvoice({
|
||||
amountSats: amount,
|
||||
memo: note || `Debit request for ${amount} sats`,
|
||||
})
|
||||
} catch (err) {
|
||||
throw new Error('Failed to create invoice')
|
||||
}
|
||||
|
||||
if (invoiceRes.status === 'ERROR') {
|
||||
throw new Error(invoiceRes.reason || 'Failed to create invoice')
|
||||
}
|
||||
|
||||
const invoice = invoiceRes.invoice
|
||||
|
||||
// Step 2: Send debit request
|
||||
showToast({ message: 'Waiting for approval...', color: 'primary' })
|
||||
|
||||
const pool = new SimplePool()
|
||||
const ndebitRes = await SendNdebitRequest(
|
||||
pool,
|
||||
hexToBytes(selectedSource.keys.privateKey),
|
||||
[parsedInput.ndebit.relay],
|
||||
parsedInput.ndebit.pubkey,
|
||||
{
|
||||
bolt11: invoice,
|
||||
amount_sats: amount,
|
||||
pointer: parsedInput.ndebit.pointer,
|
||||
},
|
||||
30 // 30 second timeout
|
||||
)
|
||||
|
||||
if (ndebitRes.res === 'GFY') {
|
||||
throw new Error(ndebitRes.error || 'Debit request denied')
|
||||
}
|
||||
|
||||
// Success!
|
||||
showToast({
|
||||
message: `Received ${amount} sats`,
|
||||
color: 'success',
|
||||
})
|
||||
|
||||
// Refresh history
|
||||
dispatch(historyFetchSourceRequested({ sourceId }))
|
||||
return true
|
||||
} catch (err: any) {
|
||||
showToast({
|
||||
message: err?.message || 'Debit request failed',
|
||||
color: 'danger',
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. UI Component (Claim Tab)
|
||||
|
||||
Place the ndebit claim UI in the **Receive** page (not Send!) since the user is receiving sats.
|
||||
|
||||
Key UI elements:
|
||||
|
||||
- Text input for pasting ndebit strings
|
||||
- QR scanner button
|
||||
- Amount display (pre-filled if from URI, editable if not)
|
||||
- "Amount set by sender" indicator when amount comes from URI
|
||||
- Claim button
|
||||
|
||||
## ATM/Service Implementation
|
||||
|
||||
### Overview
|
||||
|
||||
The ATM implements a **debit approval service** that:
|
||||
|
||||
1. Generates ndebit QR codes with the ATM's Lightning.Pub account
|
||||
2. Subscribes to `GetLiveDebitRequests` to receive incoming debit requests
|
||||
3. Validates requests against active sessions (single-use protection)
|
||||
4. Approves valid requests via `RespondToDebit` RPC
|
||||
|
||||
### Architecture: Debit Approval Flow
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ ATM Machine │ │ Nostr Relay │ │ Lightning.Pub │ │ User's Wallet │
|
||||
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘ └────────┬────────┘
|
||||
│ │ │ │
|
||||
│ 1. GetLiveDebitRequests │ │
|
||||
│ (Kind 21000 subscription) │ │
|
||||
│──────────────────────►│──────────────────────►│ │
|
||||
│ │ │ │
|
||||
│ 2. Display QR: │ │ │
|
||||
│ clink:ndebit1... │ │ 3. Scan QR │
|
||||
│ ?amount=2450 │ │◄──────────────────────┤
|
||||
│ │ │ │
|
||||
│ │ │ 4. Kind 21002 │
|
||||
│ │ │ debit request │
|
||||
│ │◄──────────────────────┼───────────────────────┤
|
||||
│ │ │ │
|
||||
│ 5. Live debit request │ │ │
|
||||
│ (via subscription) │ │ │
|
||||
│◄──────────────────────┤◄──────────────────────┤ │
|
||||
│ │ │ │
|
||||
│ 6. Validate session │ │ │
|
||||
│ & approve │ │ │
|
||||
│──────────────────────►│──────────────────────►│ │
|
||||
│ (RespondToDebit) │ │ │
|
||||
│ │ │ │
|
||||
│ │ │ 7. Pay invoice │
|
||||
│ │ │──────────────────────►│
|
||||
│ │ │ │
|
||||
```
|
||||
|
||||
### Generating the NDebit QR
|
||||
|
||||
```typescript
|
||||
// apps/machine/src/services/lightning.ts
|
||||
|
||||
generateNdebit: async (context: ATMContext): Promise<string> => {
|
||||
// The pointer MUST be a valid Lightning.Pub account identifier
|
||||
// Lightning.Pub uses this to find which account should pay
|
||||
const pointer = 'atm' // ATM's account identifier in Lightning.Pub
|
||||
|
||||
const ndebit = encodeNdebit({
|
||||
pubkey: CONFIG.lightningPubPubkey, // Lightning.Pub's pubkey (NOT ATM's!)
|
||||
relay: CONFIG.relayUrl,
|
||||
pointer,
|
||||
})
|
||||
|
||||
// Register session for single-use tracking (see below)
|
||||
registerActiveSession(context.cashInSessionId, context.satsAmount)
|
||||
|
||||
// Format as full URI with amount
|
||||
return formatNdebitUri(ndebit, context.satsAmount)
|
||||
// Returns: clink:ndebit1qqsyh68zq...?amount=2450
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** The ndebit encodes **Lightning.Pub's pubkey**, not the ATM's pubkey. This is because:
|
||||
|
||||
- The wallet sends Kind 21002 to the pubkey in the ndebit
|
||||
- Lightning.Pub must receive it to process the payment
|
||||
- The `pointer` field tells Lightning.Pub which account to debit
|
||||
|
||||
### Single-Use Protection (Session Management)
|
||||
|
||||
Each ndebit QR is valid for **one use only**. This prevents:
|
||||
|
||||
- Double-spending from the same QR
|
||||
- Replay attacks with saved QR codes
|
||||
|
||||
#### How It Works
|
||||
|
||||
1. **Session Registration**: When generating an ndebit, we create a session:
|
||||
|
||||
```typescript
|
||||
interface ActiveSession {
|
||||
sessionId: string // Unique ID for this transaction
|
||||
satsAmount: number // Expected amount
|
||||
createdAt: number // Timestamp
|
||||
status: 'active' | 'paid' | 'expired'
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, ActiveSession>()
|
||||
|
||||
function registerActiveSession(sessionId: string, satsAmount: number): void {
|
||||
activeSessions.set(sessionId, {
|
||||
sessionId,
|
||||
satsAmount,
|
||||
createdAt: Date.now(),
|
||||
status: 'active',
|
||||
})
|
||||
|
||||
// Auto-expire after 5 minutes
|
||||
setTimeout(
|
||||
() => {
|
||||
const session = activeSessions.get(sessionId)
|
||||
if (session?.status === 'active') {
|
||||
session.status = 'expired'
|
||||
}
|
||||
},
|
||||
5 * 60 * 1000
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
2. **Session Validation**: When a debit request arrives, we find a matching session:
|
||||
|
||||
```typescript
|
||||
function findActiveSessionByAmount(amountSats: number): ActiveSession | null {
|
||||
for (const session of activeSessions.values()) {
|
||||
if (session.status !== 'active') continue
|
||||
|
||||
// Match with small tolerance for rounding
|
||||
const tolerance = Math.max(1, Math.floor(session.satsAmount * 0.001))
|
||||
if (Math.abs(amountSats - session.satsAmount) <= tolerance) {
|
||||
return session
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
```
|
||||
|
||||
3. **Atomic Status Update**: Before approving, we mark the session as paid:
|
||||
|
||||
```typescript
|
||||
// In debit request handler:
|
||||
const matchingSession = findActiveSessionByAmount(amountSats)
|
||||
if (!matchingSession) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark as paid BEFORE sending approval (prevents race conditions)
|
||||
matchingSession.status = 'paid'
|
||||
|
||||
// Now approve...
|
||||
```
|
||||
|
||||
#### Additional Protections
|
||||
|
||||
```typescript
|
||||
// Track processed events (prevents replay of same Nostr event)
|
||||
const processedEventIds = new Set<string>()
|
||||
|
||||
// Track approved invoices (prevents same invoice being approved twice)
|
||||
const approvedInvoices = new Set<string>()
|
||||
|
||||
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||
// Check 1: Event already processed?
|
||||
if (processedEventIds.has(eventId)) {
|
||||
console.log('[Debit] REJECTED: Event already processed')
|
||||
return
|
||||
}
|
||||
|
||||
// Check 2: Invoice already approved?
|
||||
if (approvedInvoices.has(invoice)) {
|
||||
console.log('[Debit] REJECTED: Invoice already approved')
|
||||
return
|
||||
}
|
||||
|
||||
// Check 3: Matching active session?
|
||||
const session = findActiveSessionByAmount(amountSats)
|
||||
if (!session) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark everything BEFORE sending approval
|
||||
session.status = 'paid'
|
||||
processedEventIds.add(eventId)
|
||||
approvedInvoices.add(invoice)
|
||||
|
||||
// Now approve...
|
||||
}
|
||||
```
|
||||
|
||||
### Debit Approval Service
|
||||
|
||||
The ATM runs a background service that listens for debit requests:
|
||||
|
||||
```typescript
|
||||
function startDebitApprovalService(
|
||||
nostrClient: NostrClient,
|
||||
identity: MachineIdentity,
|
||||
onPaymentApproved?: DebitPaymentCallback
|
||||
): () => void {
|
||||
// 1. Subscribe to Kind 21000 events from Lightning.Pub
|
||||
const subscriptionId = nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [CONFIG.lightningPubPubkey],
|
||||
'#p': [identity.publicKey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{ onEvent: eventHandler }
|
||||
)
|
||||
|
||||
// 2. Send GetLiveDebitRequests subscription request
|
||||
const subscribeRequest = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: identity.publicKey,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const event = createSignedEvent(identity, {
|
||||
kind: 21000,
|
||||
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||
content: encryptContent(identity, CONFIG.lightningPubPubkey, subscribeRequest),
|
||||
})
|
||||
|
||||
await nostrClient.publish(event)
|
||||
}
|
||||
```
|
||||
|
||||
### Handling Debit Requests
|
||||
|
||||
When a debit request arrives via the subscription:
|
||||
|
||||
```typescript
|
||||
const eventHandler = (event: NostrEvent) => {
|
||||
// Decrypt the message
|
||||
const message = JSON.parse(decryptContent(identity, CONFIG.lightningPubPubkey, event.content))
|
||||
|
||||
// Check if it's a live debit request
|
||||
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||
handleDebitRequest(message, event.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDebitRequest = async (message: DebitRequest, eventId: string) => {
|
||||
// Message structure:
|
||||
// {
|
||||
// request_id: "9f22a67c...",
|
||||
// npub: "43bfda6c...",
|
||||
// debit: {
|
||||
// type: "invoice",
|
||||
// invoice: "lnbcrt24500n1p5hm..."
|
||||
// }
|
||||
// }
|
||||
|
||||
// Extract amount from BOLT11 invoice (amount_sats field is often missing)
|
||||
let amountSats = message.debit.amount_sats
|
||||
if (!amountSats) {
|
||||
amountSats = decodeAmountFromBolt11(message.debit.invoice)
|
||||
}
|
||||
|
||||
// Validate against active sessions (single-use check)
|
||||
const session = findActiveSessionByAmount(amountSats)
|
||||
if (!session) {
|
||||
console.log('[Debit] REJECTED: No matching active session')
|
||||
return
|
||||
}
|
||||
|
||||
// Mark session as paid (prevents double-use)
|
||||
session.status = 'paid'
|
||||
|
||||
// Approve the debit request
|
||||
const approveRequest = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: identity.publicKey,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: message.debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const approveEvent = createSignedEvent(identity, {
|
||||
kind: 21000,
|
||||
tags: [['p', CONFIG.lightningPubPubkey]],
|
||||
content: encryptContent(identity, CONFIG.lightningPubPubkey, approveRequest),
|
||||
})
|
||||
|
||||
await nostrClient.publish(approveEvent)
|
||||
console.log('[Debit] SUCCESS: Approved debit request')
|
||||
}
|
||||
```
|
||||
|
||||
### BOLT11 Amount Decoding
|
||||
|
||||
Lightning.Pub doesn't always include `amount_sats` in the debit request, so we decode it from the invoice:
|
||||
|
||||
```typescript
|
||||
function decodeAmountFromBolt11(invoice: string): number | null {
|
||||
// BOLT11 format: ln<network><amount><multiplier>...
|
||||
// Networks: bc (mainnet), tb (testnet), bcrt (regtest)
|
||||
// Multipliers: m=milli (0.001), u=micro (0.000001), n=nano, p=pico
|
||||
|
||||
const match = invoice.toLowerCase().match(/^ln(bc|tb|bcrt)(\d+)([munp])?/)
|
||||
if (!match) return null
|
||||
|
||||
const [, , amountStr, multiplier] = match
|
||||
let amount = parseInt(amountStr, 10)
|
||||
|
||||
// Convert to satoshis (1 BTC = 100,000,000 sats)
|
||||
switch (multiplier) {
|
||||
case 'm':
|
||||
amount = amount * 100000
|
||||
break // milli-BTC
|
||||
case 'u':
|
||||
amount = amount * 100
|
||||
break // micro-BTC
|
||||
case 'n':
|
||||
amount = Math.floor(amount / 10)
|
||||
break // nano-BTC
|
||||
case 'p':
|
||||
amount = Math.floor(amount / 10000)
|
||||
break // pico-BTC
|
||||
default:
|
||||
amount = amount * 100000000 // BTC
|
||||
}
|
||||
|
||||
return amount
|
||||
}
|
||||
```
|
||||
|
||||
### Lightning.Pub's Role
|
||||
|
||||
Lightning.Pub handles the actual payment:
|
||||
|
||||
1. Receives Kind 21002 debit request from user's wallet
|
||||
2. Looks up the account by `pointer` field (e.g., "atm")
|
||||
3. Forwards request to GetLiveDebitRequests subscribers
|
||||
4. Waits for approval via RespondToDebit
|
||||
5. Pays the invoice from the account's balance
|
||||
6. Sends Kind 21002 response to user's wallet
|
||||
|
||||
**Important:** The ATM account must have sufficient balance to pay invoices.
|
||||
|
||||
## Infrastructure Requirements
|
||||
|
||||
### Relay Configuration
|
||||
|
||||
The relay must be accessible from:
|
||||
|
||||
1. **Lightning.Pub** (for publishing responses)
|
||||
2. **User's browser** (for subscribing to responses)
|
||||
|
||||
For Docker setups:
|
||||
|
||||
- Lightning.Pub uses internal Docker DNS: `ws://strfry:7777`
|
||||
- Browser uses host mapping: `ws://localhost:7777`
|
||||
- Both resolve to the same relay
|
||||
|
||||
**Lightning.Pub docker-compose config:**
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- NOSTR_RELAYS=ws://strfry:7777 # Docker internal
|
||||
```
|
||||
|
||||
**NDebit must encode browser-accessible relay:**
|
||||
|
||||
```
|
||||
ws://localhost:7777 # NOT ws://strfry:7777
|
||||
```
|
||||
|
||||
### Funding the ATM
|
||||
|
||||
The ATM user needs a balance to pay invoices:
|
||||
|
||||
```bash
|
||||
# Create invoice for ATM user
|
||||
curl -X POST "http://localhost:1776/api/app/user/add/invoice" \
|
||||
-H "Authorization: Bearer $APP_TOKEN" \
|
||||
-d '{"receiver_identifier": "atm", "payer_identifier": "external",
|
||||
"invoice_req": {"amountSats": 50000, "memo": "Fund ATM"}}'
|
||||
|
||||
# Pay from external node
|
||||
lncli payinvoice <invoice>
|
||||
```
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### 1. Wrong Page for NDebit UI
|
||||
|
||||
**Problem:** Putting ndebit in the Send page
|
||||
**Solution:** NDebit is a RECEIVE action - put it in the Receive page
|
||||
|
||||
### 2. Relay Mismatch
|
||||
|
||||
**Problem:** NDebit encodes Docker-internal relay (`ws://strfry:7777`)
|
||||
**Solution:** Rewrite relay to browser-accessible URL (`ws://localhost:7777`)
|
||||
|
||||
### 3. Pubkey Mismatch
|
||||
|
||||
**Problem:** Hardcoded Lightning.Pub pubkey doesn't match after container recreation
|
||||
**Solution:** Dynamically fetch pubkey from ndebit or API
|
||||
|
||||
### 4. Zero Balance
|
||||
|
||||
**Problem:** Debit request fails with "Error in single invoice payment"
|
||||
**Solution:** Fund the ATM user before testing
|
||||
|
||||
### 5. LND Not Synced
|
||||
|
||||
**Problem:** All RPC calls timeout
|
||||
**Solution:** Mine blocks to sync LND: `bitcoin-cli -generate 10`
|
||||
|
||||
### 6. Amount Not in Bech32
|
||||
|
||||
**Problem:** Trying to encode amount in the ndebit bech32 string
|
||||
**Solution:** Use query parameter: `?amount=1000`
|
||||
|
||||
### 7. Invalid Pointer in NDebit
|
||||
|
||||
**Problem:** Using custom session IDs or arbitrary strings as the ndebit pointer
|
||||
|
||||
```
|
||||
wallet >> user atm:ml2b5abxdppn58sty5 not found wallet
|
||||
DebitManager >> ERROR application user not found
|
||||
```
|
||||
|
||||
**Cause:** Lightning.Pub uses the `pointer` field to look up which account should pay. It must be a valid Lightning.Pub user identifier (e.g., `atm`), not a custom session string.
|
||||
|
||||
**Solution:** Use the ATM's Lightning.Pub account identifier as the pointer:
|
||||
|
||||
```typescript
|
||||
const pointer = 'atm' // NOT 'atm:sessionId123'
|
||||
```
|
||||
|
||||
Track sessions locally using amount-based matching instead.
|
||||
|
||||
### 8. Debit Requests Not Received
|
||||
|
||||
**Problem:** GetLiveDebitRequests subscription sent but no debit requests arrive
|
||||
|
||||
**Possible Causes:**
|
||||
|
||||
1. Wrong pubkey in ndebit (must be Lightning.Pub's pubkey)
|
||||
2. Subscription filter doesn't match (check `authors` and `#p` tags)
|
||||
3. Using SimplePool instead of direct Relay connection (see `packages/lightning/TROUBLESHOOTING.md`)
|
||||
4. Race condition - published before subscription was ready
|
||||
|
||||
**Solution:** Add verbose logging to trace the flow:
|
||||
|
||||
```typescript
|
||||
nostrClient.subscribe([...], {
|
||||
onEvent: (event) => {
|
||||
console.log('[Debit] Event received:', event.kind, event.id)
|
||||
// ...
|
||||
},
|
||||
onEose: () => {
|
||||
console.log('[Debit] EOSE received - subscription active')
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 9. Missing Amount in Debit Request
|
||||
|
||||
**Problem:** `message.debit.amount_sats` is undefined
|
||||
|
||||
**Cause:** Lightning.Pub doesn't always include the amount in the forwarded debit request
|
||||
|
||||
**Solution:** Decode the amount from the BOLT11 invoice:
|
||||
|
||||
```typescript
|
||||
const amountSats = message.debit.amount_sats || decodeAmountFromBolt11(invoice)
|
||||
```
|
||||
|
||||
### 10. Double Debit (Same QR Used Twice)
|
||||
|
||||
**Problem:** User can claim the same ndebit QR multiple times
|
||||
|
||||
**Solution:** Implement session-based single-use protection:
|
||||
|
||||
1. Register each ndebit generation as a session with expected amount
|
||||
2. Match incoming requests against active sessions
|
||||
3. Mark session as `paid` **before** sending approval (atomic)
|
||||
4. Reject requests with no matching active session
|
||||
|
||||
See "Single-Use Protection" section above for implementation details.
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
### Infrastructure
|
||||
|
||||
- [ ] Lightning.Pub health check returns OK
|
||||
- [ ] LND is synced (`synced_to_chain: true`)
|
||||
- [ ] ATM user exists and has balance (`fund-atm` command)
|
||||
- [ ] Relay is accessible from browser (`ws://localhost:7777`)
|
||||
|
||||
### NDebit QR Generation
|
||||
|
||||
- [ ] QR contains `clink:` prefix
|
||||
- [ ] QR contains `?amount=` parameter
|
||||
- [ ] NDebit encodes **Lightning.Pub's pubkey** (not ATM's)
|
||||
- [ ] NDebit pointer is valid account identifier (`atm`)
|
||||
- [ ] Relay URL is browser-accessible (not Docker-internal)
|
||||
|
||||
### Debit Approval Service
|
||||
|
||||
- [ ] Service starts: `[Debit] Starting debit approval service`
|
||||
- [ ] Subscription active: `[Debit] EOSE received`
|
||||
- [ ] Events received: `[Debit] Event received: {kind: 21000, ...}`
|
||||
- [ ] Decryption works: `[Debit] Decrypted message: {...}`
|
||||
|
||||
### Single-Use Protection
|
||||
|
||||
- [ ] First claim succeeds: `[Debit] SUCCESS: Approved debit request`
|
||||
- [ ] Second claim fails: `[Debit] REJECTED: No matching active session`
|
||||
- [ ] Session marked as paid after approval
|
||||
- [ ] Same invoice rejected: `[Debit] REJECTED: Invoice already approved`
|
||||
|
||||
### End-to-End
|
||||
|
||||
- [ ] ATM displays ndebit QR
|
||||
- [ ] Wallet scans and claims
|
||||
- [ ] Debit approved and payment received
|
||||
- [ ] State machine transitions to success
|
||||
- [ ] Repeated scan is rejected
|
||||
|
||||
## Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"@lamassu/clink": "workspace:*",
|
||||
"nostr-tools": "^2.x.x",
|
||||
"@noble/hashes": "^1.x.x",
|
||||
"qrcode": "^1.x.x"
|
||||
}
|
||||
```
|
||||
|
||||
## References
|
||||
|
||||
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
||||
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
||||
- [NIP-19 (bech32 encoding)](https://github.com/nostr-protocol/nips/blob/master/19.md)
|
||||
- [BIP-21 (URI scheme)](https://github.com/bitcoin/bips/blob/master/bip-0021.mediawiki)
|
||||
Loading…
Add table
Add a link
Reference in a new issue