docs: add CLINK protocol documentation with examples
Comprehensive documentation for the CLINK protocol including: - Kind 21001 (Offer), 21002 (Debit), 21003 (Manage) event types - noffer and ndebit bech32 encoding formats with TLV fields - Flow diagrams for ATM cash-out and cash-in scenarios - JSON examples for all event types including error responses - ATM-specific code examples using @lamassu/clink library - Security considerations and related NIPs Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
56b1d594d7
commit
e816f50363
1 changed files with 434 additions and 0 deletions
434
lamassu-next/docs/clink-protocol.md
Normal file
434
lamassu-next/docs/clink-protocol.md
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
# CLINK Protocol
|
||||
|
||||
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: 'Lamassu ATM - 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: 'Lamassu ATM - 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 |
|
||||
Loading…
Add table
Add a link
Reference in a new issue