bitspire/docs/ndebit-cash-in-flow.md
Padreug 0b94bef4be docs: refresh deploy/nixos/README + flag obsolete flow docs
deploy/nixos/README.md was the most-stale doc in the tree: it still
talked about a `lamassu-atm` systemd unit, `/opt/lamassu-atm` paths,
nixos-install with a non-existent `lamassu-atm` flake output, and an
scp-the-built-electron-bundle workflow that hasn't been the deploy
path for many months. Replaced with a rewrite that documents the
actual current pipeline:

  - File layout: bitspire-atm.nix (not lamassu-atm.nix), live.nix,
    hardware/{douro,batm3,upboard}.nix, and the udev / helper scripts
  - Build pipeline: `nix build .#disk-image-<model>` and the four
    flake-output flavours per model (live config, installed config,
    iso, disk-image)
  - Full Sintra walkthrough end-to-end: prep a flashing USB on the
    dev box, boot Alpine live on the Sintra, identify the eMMC,
    dd with count= to skip the trailing USB padding, repair the
    GPT secondary header + grow root with parted, poweroff, boot,
    provision via provision-atm.sh. Every quirk we hit during the
    first real flash is now baked in (mdev for /dev nodes, parted
    Fix prompt, lbu-style notes).
  - Runtime layout cheat-sheet: /var/lib/bitspire/.env (0600
    lamassu:lamassu), state.db, /etc/bitspire/config.env, etc.
  - Common-operations playbook: re-provision, nixos-rebuild switch
    over SSH with --use-remote-sudo (much faster than reflashing),
    journalctl filtering, hardware-side health checks.
  - NixOS module reference for services.bitspire, including the
    LNbits-flavoured options (relayUrl, lnbitsServerPubkey,
    lnbitsHttpUrl) instead of the retired lightningPubUrl.
  - Sintra-specific gotchas section: eMMC-via-sdhci-acpi, the
    ttyS4 dispenser placement, the ttyS1..3 phantom-node issue.
  - Security-notes section updated to reflect passwordless sudo
    enabled for nixos-rebuild deploys, and the implications.

Auto-upgrade behaviour explained explicitly (the ?ref=dev pin) so
contributors understand why production ATMs on main don't pick up
dev branch changes.

docs/ndebit-cash-in-flow.md: added a header banner flagging the
document as historical — cash-in on dev is LNURL-withdraw +
subscribe_payments push, not ndebit. Original content kept as a
reference for any future revival of nostr-native cash-in.

docs/clink-protocol.md: same treatment — flagged as dormant on dev,
explaining which pieces still apply (kind-21003 management) and
which are unused (kinds 21001/21002). Protocol reference content
left intact since the wire format is unchanged upstream.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-01 19:08:03 +02:00

28 KiB

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).

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:

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.

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:

const CLINK_SCHEME = '(?:clink:|lightning:)?'

Wallet Implementation

1. Regex for Parsing

// 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

// 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

// 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

// 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

// 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:
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
  )
}
  1. Session Validation: When a debit request arrives, we find a matching session:
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
}
  1. Atomic Status Update: Before approving, we mark the session as paid:
// 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

// 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:

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:

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:

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:

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:

# 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:

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:

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:

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

{
  "@lamassu/clink": "workspace:*",
  "nostr-tools": "^2.x.x",
  "@noble/hashes": "^1.x.x",
  "qrcode": "^1.x.x"
}

References