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

457 lines
16 KiB
Markdown

# 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](https://github.com/shocknet/clink). 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:
```typescript
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):
```typescript
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)
```json
{
"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)
```json
{
"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
```json
{
"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)
```json
{
"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:
```json
{
"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:
```json
{
"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
```json
{
"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
```json
{
"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
```typescript
// 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
```typescript
// 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
```typescript
import {
encodeNoffer,
decodeNoffer,
encodeNdebit,
decodeNdebit,
formatNdebitUri,
CLINKClient,
createOfferSuccess,
createOfferError,
} from '@lamassu/clink'
```
### Reference Implementation
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
- [@lamassu/clink](../packages/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 |