From e816f503632858e9a489f26260a30caa9b4eed9a Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 30 Jan 2026 11:22:31 -0500 Subject: [PATCH] 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 --- lamassu-next/docs/clink-protocol.md | 434 ++++++++++++++++++++++++++++ 1 file changed, 434 insertions(+) create mode 100644 lamassu-next/docs/clink-protocol.md diff --git a/lamassu-next/docs/clink-protocol.md b/lamassu-next/docs/clink-protocol.md new file mode 100644 index 0000000..01f50e9 --- /dev/null +++ b/lamassu-next/docs/clink-protocol.md @@ -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 +``` + +### 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 +``` + +### 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": "", + "created_at": 1706540000, + "tags": [ + ["p", ""], + ["e", "", "", "root"] + ], + "content": "{ + \"amount_sats\": 50000, + \"description\": \"ATM withdrawal\" + }" +} +``` + +### Response (Recipient → Payer) + +```json +{ + "kind": 21001, + "pubkey": "", + "created_at": 1706540001, + "tags": [ + ["p", ""], + ["e", "", "", "reply"] + ], + "content": "{ + \"invoice\": \"lnbc500u1p...\", + \"amount_sats\": 50000 + }" +} +``` + +### Error Response + +```json +{ + "kind": 21001, + "pubkey": "", + "content": "{ + \"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": "", + "created_at": 1706540000, + "tags": [ + ["p", ""] + ], + "content": "{ + \"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": "", + "created_at": 1706540001, + "tags": [ + ["p", ""], + ["e", "", "", "reply"] + ], + "content": "{ + \"preimage\": \"0123456789abcdef...\", + \"amount_sats\": 50000 + }" +} +``` + +Error: + +```json +{ + "kind": 21002, + "content": "{ + \"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": "", + "tags": [ + ["p", ""], + ["action", "create"] + ], + "content": "{ + \"price_type\": \"fixed\", + \"amount_sats\": 10000, + \"description\": \"Coffee\", + \"max_uses\": 100, + \"expires_at\": 1707000000 + }" +} +``` + +### Revoke Offer + +```json +{ + "kind": 21003, + "pubkey": "", + "tags": [ + ["p", ""], + ["action", "revoke"], + ["e", ""] + ], + "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 |