- @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.
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 '@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)
- 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 '@bitSpire/clink'
Reference Implementation
- CLINK Protocol Spec
- Lightning.Pub
- @bitSpire/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 |