- @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.
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 '@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):
|
|
|
|
```typescript
|
|
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)
|
|
|
|
```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 '@bitSpire/clink'
|
|
```
|
|
|
|
### Reference Implementation
|
|
|
|
- [CLINK Protocol Spec](https://github.com/shocknet/clink)
|
|
- [Lightning.Pub](https://github.com/shocknet/Lightning.Pub)
|
|
- [@bitSpire/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 |
|