bitspire/docs/clink-protocol.md
Padreug 723553522c docs: purge stale lamassu naming; fix the hal-check skill's provenance boundary
- @lamassu/clink import examples → @bitSpire/clink, the package's real name.
- machine-installation.md: the service user is `bitspire`, not `lamassu`
  (renamed in configuration.nix long ago; the doc never followed).
- README: clone aiolabs/bitspire, not lamassu-next; the fleet sentence
  claiming batm3/douro run `main` against Lightning.Pub was stale.
- nostr-check skill: table headers say bitSpire.
- hal-check skill: the boundary is c0b69d1, not v8.1.5 (CLAUDE.md corrected
  this 2026-07-04; the skill kept asserting the wrong tag), and the
  "forbidden operations" now reflect the recorded permission —
  reference over port, name the source commit — plus a rule born of the
  GTQ window: no value table without a test over it.

Deliberately kept: every `aiolabs/lamassu-next#NN` issue citation, the
provenance sections, "Ported from lamassu-machine" driver headers, and
the hardware names "Lamassu Sintra/Tejo/Douro" — those are the machines.
2026-10-09 21:58:55 +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 '@bitSpire/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 '@bitSpire/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 '@bitSpire/clink'

Reference Implementation

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