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>
16 KiB
CLINK Protocol
Status on the
devbranch: dormant. The@bitSpire/clinkpackage is still in the workspace, butapps/machine/src/services/lightning.tsno 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-21003management surface (operator commands like "manual dispense") is the one piece of CLINK still actively wired on dev —clink.onManagementlistens 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)
- Customer approaches ATM, selects "Sell Bitcoin"
- ATM displays noffer QR (or invoice directly)
- Customer scans with wallet
- Wallet sends Kind 21001 request
- ATM responds with Kind 21001 containing invoice
- Customer pays invoice
- 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)
- Customer approaches ATM, selects "Buy Bitcoin"
- Customer inserts cash bills
- ATM displays ndebit QR
- Customer scans with wallet
- Wallet prompts: "ATM wants to send you 50,000 sats. Approve?"
- Customer confirms, wallet sends Kind 21002 with their invoice
- Lightning.Pub pays the invoice
- 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):
- Derive shared secret from sender's private key + recipient's public key
- Encrypt content with XChaCha20-Poly1305
- Encode as base64
This ensures only the intended recipient can read payment details.
Security Considerations
- Always verify pubkeys - Ensure the pubkey in noffer/ndebit matches expected recipient/custodian
- Check amounts - Validate amount is within acceptable range before authorizing
- Verify relay - Use trusted relays to prevent MITM attacks
- Timestamp validation - Reject old events to prevent replay attacks
- 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
- CLINK Protocol Spec
- Lightning.Pub
- @lamassu/clink - TypeScript implementation
Related NIPs
| NIP | Purpose |
|---|---|
| NIP-01 | Basic event structure |
| NIP-04 | Encrypted DMs (legacy) |
| NIP-44 | Encrypted payloads (used) |
| NIP-19 | Bech32 encoding |