bitspire/docs/ndebit-cash-in-flow.md
Padreug 763817b9f3 chore(dev): remove the Lightning.Pub-era regtest tooling
docker/ (two compose stacks, dev.sh, regtest.sh, start-with-regtest.sh,
regtest-bootstrap.sh, strfry.conf), packages/nostr-client/dev/ (nine
agent and test scripts), and the seventeen devenv commands that drove
them — infra-*, lncli, btccli, mine-blocks, auto-mine, setup-channel,
alice-*, fund-atm, test-setup, test-payment, node-info — along with the
devenv postgres service, DATABASE_URL, LIGHTNING_PUB_URL, pgcli,
docker-compose and the `just` runner (no justfile exists).

None of it could talk to the app on `dev`. Every piece was built around
Lightning.Pub (a `lightning-pub` service in both compose files, 37
references in dev.sh, LIGHTNING_PUB_PUBKEY and the :1776 API in every
dev script, a NIP-44 v1 implementation the project forbids), and the
last substantive change predates the LNbits cutover that deleted
packages/lightning. No container under either name exists on any
machine. Development runs against LNbits: FakeWallet needs nothing,
bohm's native instance answers on :5001, and the shared regtest stack
lives at ~/dev/local/docker/regtest, outside this repo.

devenv.nix keeps the toolchain, the hardware/serial utilities, the git
hooks and `relay-test`, now pointed at LNbits's bundled nostrrelay. The
Rust toolchain stays for the orphaned crate until that is removed on its
own. nostr-client drops the four dependencies and two devDependencies
only the dead scripts imported (@noble/curves, @scure/base,
@shocknet/clink-sdk, @stablelib/xchacha20, qrcode, ws); lockfile
regenerated, −272 lines.

Verified: devenv.nix parses; nostr-client 43/43 + tsc; clink 11/11 + tsc;
machine app vue-tsc clean. The ndebit-cash-in-flow doc, kept as CLINK
design history, now says the commands it quotes no longer exist here.
2026-10-09 22:25:07 +02:00

869 lines
28 KiB
Markdown

# NDebit Cash-In Flow Implementation Guide
> **Historical reference — NOT the cash-in flow on the `dev` branch.**
>
> Cash-in on `dev` is **LNURL-withdraw + LNbits `subscribe_payments({tag:"withdraw", link_id})` push**, implemented in
> `apps/machine/src/services/lightning.ts → generateLnurlWithdraw()`.
> The ATM no longer renders ndebit URIs; `CashInView.vue` ignores
> `generateNdebit`'s output and shows the LNURL QR instead. The CLINK
> debit-approval listener and all kind-21002 handling were removed in
> commit `3c14eea` (the 3b.4 cleanup of LP-paired infrastructure).
>
> The Lightning.Pub regtest tooling this doc leans on — the `docker/`
> compose stack, the `fund-atm` / `lncli` devenv commands, and the
> `packages/nostr-client/dev/` agent scripts — was removed on 2026-10-09.
> Commands quoted below no longer exist in this repo.
>
> This doc is retained because it explains *why* the previous flow
> existed and what the ndebit/CLINK protocol surface looks like — useful
> if the project ever wants to reintroduce nostr-native cash-in that
> bypasses LNURL. The protocol itself (kinds 21001-21003) is unchanged;
> only our wiring of it has been removed. The `@bitSpire/clink` package
> still ships the encode/decode helpers if a future implementation needs
> them.
---
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 '@bitSpire/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 '@bitSpire/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 @bitSpire/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
{
"@bitSpire/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)