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>
457 lines
16 KiB
Markdown
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 |
|