bitspire/docs/clink-protocol.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

16 KiB

CLINK Protocol

Status on the dev branch: dormant. The @bitSpire/clink package is still in the workspace, but apps/machine/src/services/lightning.ts no longer wires its offer/debit/management handlers — those were Lightning.Pub-paired and were removed when the backend switched to LNbits over the nostr-native-transport. The cash-out flow on dev uses BOLT11 + subscribe_payments({payment_hash}); the cash-in flow uses LNURL-withdraw + subscribe_payments({tag, link_id}). Both subsume CLINK's role for the ATM use case.

This doc remains as the protocol reference — the wire format, event kinds, and encoding conventions are unchanged from the upstream CLINK spec. Read it if you want to understand what kinds 21001-21003 mean, or if you're considering re-introducing CLINK flows on dev (e.g., a nostr-native cash-in path that doesn't go through LNURL).

The kind-21003 management surface (operator commands like "manual dispense") is the one piece of CLINK still actively wired on dev — clink.onManagement listens for those events and they have no LP dependency, so they survived the migration.


CLINK (Custodial Lightning Keys) is a Nostr-based protocol for Lightning payments. It enables wallets and services to communicate payment requests and authorizations through Nostr relays.

Overview

CLINK defines three Nostr event kinds:

Kind Name Purpose
21001 Offer Request and receive invoices
21002 Debit Authorize outgoing payments
21003 Manage Create and revoke payment offers

And two encoding formats:

Format Purpose Example Use
noffer Encode a payment offer (receivable) ATM displays "pay me" QR
ndebit Encode a debit authorization ATM displays "pay you" QR

noffer (Payment Offer)

A noffer encodes information needed to request a payment from someone. When scanned, the payer's wallet sends a Kind 21001 to request an invoice.

Format

noffer1<bech32-encoded-data>

Encoded Data (TLV)

Field Type Description
pubkey 32 bytes Recipient's Nostr pubkey (who receives payment)
relay string Relay URL for communication
price_type uint8 0=fixed, 1=variable, 2=spontaneous
amount uint64 Amount in sats (if fixed)
description string Human-readable description
pointer string Optional identifier (e.g., user ID)

Example: ATM Cash-Out noffer

The ATM wants to receive payment from a customer:

import { encodeNoffer } from '@lamassu/clink'

// ATM creates a noffer for receiving payment
const noffer = encodeNoffer({
  pubkey: 'abc123...', // ATM's pubkey (or Lightning.Pub's pubkey)
  relay: 'wss://relay.example.com',
  priceType: 'spontaneous', // Customer chooses amount
  description: 'bitSpire - Cash Out',
})

// Result: noffer1qqs8h5mrx8v9...
// Display as QR code

Flow: Customer Pays noffer

┌──────────────┐                  ┌─────────────┐                  ┌─────────────┐
│   Customer   │                  │    Relay    │                  │     ATM     │
│   (Wallet)   │                  │             │                  │             │
└──────┬───────┘                  └──────┬──────┘                  └──────┬──────┘
       │                                 │                                │
       │ 1. Scan noffer QR               │                                │
       │                                 │                                │
       │ 2. Send Kind 21001 request      │                                │
       │ ───────────────────────────────►│                                │
       │    {amount: 50000, ...}         │───────────────────────────────►│
       │                                 │                                │
       │                                 │  3. ATM creates invoice        │
       │                                 │                                │
       │                                 │  4. Send Kind 21001 response   │
       │                                 │◄───────────────────────────────│
       │◄────────────────────────────────│    {invoice: "lnbc..."}        │
       │                                 │                                │
       │ 5. Pay invoice                  │                                │
       │ ════════════════════════════════════════════════════════════════►│
       │    (Lightning Network)          │                                │
       │                                 │                                │
       │                                 │  6. Payment received           │
       │                                 │     ATM dispenses cash         │
       │                                 │                                │

ndebit (Debit Authorization)

An ndebit encodes information needed to authorize a payment from someone's account. When scanned, the payer's wallet sends a Kind 21002 to authorize the payment.

Format

ndebit1<bech32-encoded-data>

Encoded Data (TLV)

Field Type Description
pubkey 32 bytes Custodian's pubkey (who holds the funds)
relay string Relay URL for communication
pointer string Account identifier at the custodian

Example: ATM Cash-In ndebit

The ATM wants to pay the customer (customer inserted cash, wants Bitcoin):

import { encodeNdebit, formatNdebitUri } from '@lamassu/clink'

// ATM creates an ndebit for the customer to authorize withdrawal
const ndebit = encodeNdebit({
  pubkey: '4be8e203...', // Lightning.Pub's pubkey (custodian)
  relay: 'wss://relay.example.com',
  pointer: 'atm', // ATM's account identifier in Lightning.Pub
})

// Add amount as query parameter
const uri = formatNdebitUri(ndebit, 50000) // 50,000 sats

// Result: clink:ndebit1qqs8h5mrx8v9...?amount=50000
// Display as QR code

Flow: Customer Authorizes ndebit

┌──────────────┐                  ┌─────────────┐                  ┌─────────────┐
│   Customer   │                  │    Relay    │                  │Lightning.Pub│
│   (Wallet)   │                  │             │                  │             │
└──────┬───────┘                  └──────┬──────┘                  └──────┬──────┘
       │                                 │                                │
       │ 1. Scan ndebit QR               │                                │
       │    (shows: "ATM wants to        │                                │
       │     send you 50,000 sats")      │                                │
       │                                 │                                │
       │ 2. User confirms in wallet      │                                │
       │                                 │                                │
       │ 3. Wallet sends Kind 21002      │                                │
       │ ───────────────────────────────►│                                │
       │    {amount: 50000,              │───────────────────────────────►│
       │     invoice: "lnbc...",         │                                │
       │     pointer: "atm"}             │                                │
       │                                 │                                │
       │                                 │  4. Lightning.Pub verifies     │
       │                                 │     and pays the invoice       │
       │                                 │                                │
       │                                 │  5. Send Kind 21002 response   │
       │                                 │◄───────────────────────────────│
       │◄────────────────────────────────│    {preimage: "abc..."}        │
       │                                 │                                │
       │ 6. Customer receives sats       │                                │
       │                                 │                                │

Kind 21001: Offer (Invoice Request/Response)

Used to request and receive Lightning invoices.

Request (Payer → Recipient)

{
  "kind": 21001,
  "pubkey": "<payer_pubkey>",
  "created_at": 1706540000,
  "tags": [
    ["p", "<recipient_pubkey>"],
    ["e", "<noffer_event_id>", "", "root"]
  ],
  "content": "<nip44_encrypted>{
    \"amount_sats\": 50000,
    \"description\": \"ATM withdrawal\"
  }"
}

Response (Recipient → Payer)

{
  "kind": 21001,
  "pubkey": "<recipient_pubkey>",
  "created_at": 1706540001,
  "tags": [
    ["p", "<payer_pubkey>"],
    ["e", "<request_event_id>", "", "reply"]
  ],
  "content": "<nip44_encrypted>{
    \"invoice\": \"lnbc500u1p...\",
    \"amount_sats\": 50000
  }"
}

Error Response

{
  "kind": 21001,
  "pubkey": "<recipient_pubkey>",
  "content": "<nip44_encrypted>{
    \"error\": {
      \"code\": 1,
      \"message\": \"Insufficient balance\"
    }
  }"
}

Error Codes

Code Name Description
0 Unknown Unknown error
1 InsufficientFunds Not enough balance
2 InvalidAmount Amount out of range
3 Expired Offer has expired
4 Unauthorized Not authorized for this offer
5 TemporaryFailure Try again later

Kind 21002: Debit (Payment Authorization)

Used to authorize a custodian to pay an invoice on your behalf.

Request (Payer → Custodian)

{
  "kind": 21002,
  "pubkey": "<payer_pubkey>",
  "created_at": 1706540000,
  "tags": [
    ["p", "<custodian_pubkey>"]
  ],
  "content": "<nip44_encrypted>{
    \"amount_sats\": 50000,
    \"invoice\": \"lnbc500u1p...\",
    \"pointer\": \"atm\"
  }"
}

The pointer identifies which account at the custodian should pay.

Response (Custodian → Payer)

Success:

{
  "kind": 21002,
  "pubkey": "<custodian_pubkey>",
  "created_at": 1706540001,
  "tags": [
    ["p", "<payer_pubkey>"],
    ["e", "<request_event_id>", "", "reply"]
  ],
  "content": "<nip44_encrypted>{
    \"preimage\": \"0123456789abcdef...\",
    \"amount_sats\": 50000
  }"
}

Error:

{
  "kind": 21002,
  "content": "<nip44_encrypted>{
    \"error\": {
      \"code\": 1,
      \"message\": \"Insufficient balance in ATM account\"
    }
  }"
}

Kind 21003: Manage (Offer Management)

Used to create, update, or revoke payment offers.

Create Offer

{
  "kind": 21003,
  "pubkey": "<merchant_pubkey>",
  "tags": [
    ["p", "<custodian_pubkey>"],
    ["action", "create"]
  ],
  "content": "<nip44_encrypted>{
    \"price_type\": \"fixed\",
    \"amount_sats\": 10000,
    \"description\": \"Coffee\",
    \"max_uses\": 100,
    \"expires_at\": 1707000000
  }"
}

Revoke Offer

{
  "kind": 21003,
  "pubkey": "<merchant_pubkey>",
  "tags": [
    ["p", "<custodian_pubkey>"],
    ["action", "revoke"],
    ["e", "<offer_event_id>"]
  ],
  "content": ""
}

ATM Use Cases

Cash-Out (Customer Buys Cash with Bitcoin)

  1. Customer approaches ATM, selects "Sell Bitcoin"
  2. ATM displays noffer QR (or invoice directly)
  3. Customer scans with wallet
  4. Wallet sends Kind 21001 request
  5. ATM responds with Kind 21001 containing invoice
  6. Customer pays invoice
  7. ATM detects payment, dispenses cash
// ATM generates noffer for cash-out
const noffer = clink.createOffer({
  priceType: 'spontaneous',
  description: 'bitSpire - Cash Out',
})

// Display QR code with noffer
displayQR(noffer)

// Listen for offer requests
clink.onOfferRequest(async (request, senderPubkey) => {
  // Create invoice for requested amount
  const invoice = await lightningPub.createInvoice({
    amountSats: request.amount_sats,
  })

  // Response is sent automatically by CLINK client
  return createOfferSuccess(invoice.paymentRequest)
})

Cash-In (Customer Buys Bitcoin with Cash)

  1. Customer approaches ATM, selects "Buy Bitcoin"
  2. Customer inserts cash bills
  3. ATM displays ndebit QR
  4. Customer scans with wallet
  5. Wallet prompts: "ATM wants to send you 50,000 sats. Approve?"
  6. Customer confirms, wallet sends Kind 21002 with their invoice
  7. Lightning.Pub pays the invoice
  8. Customer receives sats
// ATM generates ndebit for cash-in
const ndebit = encodeNdebit({
  pubkey: LIGHTNING_PUB_PUBKEY, // Custodian who will pay
  relay: RELAY_URL,
  pointer: 'atm', // ATM's account at Lightning.Pub
})

const uri = formatNdebitUri(ndebit, satsAmount)

// Display QR code
displayQR(uri)

// Lightning.Pub handles the Kind 21002 automatically
// and pays the customer's invoice

Encryption

All content fields are encrypted using NIP-44 (XChaCha20-Poly1305):

  1. Derive shared secret from sender's private key + recipient's public key
  2. Encrypt content with XChaCha20-Poly1305
  3. Encode as base64

This ensures only the intended recipient can read payment details.

Security Considerations

  1. Always verify pubkeys - Ensure the pubkey in noffer/ndebit matches expected recipient/custodian
  2. Check amounts - Validate amount is within acceptable range before authorizing
  3. Verify relay - Use trusted relays to prevent MITM attacks
  4. Timestamp validation - Reject old events to prevent replay attacks
  5. Rate limiting - Implement rate limits on offer requests

Libraries

JavaScript/TypeScript

import {
  encodeNoffer,
  decodeNoffer,
  encodeNdebit,
  decodeNdebit,
  formatNdebitUri,
  CLINKClient,
  createOfferSuccess,
  createOfferError,
} from '@lamassu/clink'

Reference Implementation

NIP Purpose
NIP-01 Basic event structure
NIP-04 Encrypted DMs (legacy)
NIP-44 Encrypted payloads (used)
NIP-19 Bech32 encoding