Implement @lamassu/clink package

CLINK protocol implementation for Nostr-native Lightning payments:

- noffer encoding/decoding (bech32 TLV format)
- CLINKClient for protocol communication
- Event kinds: 21001 (Offer), 21002 (Debit), 21003 (Manage)

Features:
- Create static payment codes (noffers) for ATM
- Handle incoming offer requests and generate invoices
- Send/receive debit requests
- Process management commands from operator
- Encrypted communication with NIP-44

Includes tests for noffer encoding/decoding round-trip.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-23 05:48:29 -05:00
commit 6ddb7c7a06
8 changed files with 844 additions and 0 deletions

View file

@ -21,6 +21,7 @@
},
"dependencies": {
"@lamassu/nostr-client": "workspace:*",
"@scure/base": "^1.2.0",
"nostr-tools": "^2.10.0"
},
"devDependencies": {

View file

@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import { encodeNoffer, decodeNoffer, isValidNoffer } from '../noffer.js'
import type { CLINKOffer } from '../types.js'
describe('noffer encoding/decoding', () => {
const sampleOffer: CLINKOffer = {
pubkey: 'a'.repeat(64), // 64 hex chars
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
priceType: 'variable',
description: 'Test offer',
}
describe('encodeNoffer', () => {
it('should encode a basic offer', () => {
const noffer = encodeNoffer(sampleOffer)
expect(noffer).toMatch(/^noffer1/)
expect(typeof noffer).toBe('string')
})
it('should encode an offer with amount', () => {
const offer: CLINKOffer = {
...sampleOffer,
priceType: 'fixed',
amountMsat: 1000000,
}
const noffer = encodeNoffer(offer)
expect(noffer).toMatch(/^noffer1/)
})
})
describe('decodeNoffer', () => {
it('should round-trip encode/decode', () => {
const noffer = encodeNoffer(sampleOffer)
const decoded = decodeNoffer(noffer)
expect(decoded.pubkey).toBe(sampleOffer.pubkey)
expect(decoded.relays).toEqual(sampleOffer.relays)
expect(decoded.priceType).toBe(sampleOffer.priceType)
expect(decoded.description).toBe(sampleOffer.description)
})
it('should decode fixed price offer correctly', () => {
const offer: CLINKOffer = {
...sampleOffer,
priceType: 'fixed',
amountMsat: 50000,
}
const noffer = encodeNoffer(offer)
const decoded = decodeNoffer(noffer)
expect(decoded.priceType).toBe('fixed')
expect(decoded.amountMsat).toBe(50000)
})
it('should throw on invalid prefix', () => {
expect(() => decodeNoffer('invalid1abc')).toThrow()
})
})
describe('isValidNoffer', () => {
it('should return true for valid noffer', () => {
const noffer = encodeNoffer(sampleOffer)
expect(isValidNoffer(noffer)).toBe(true)
})
it('should return false for invalid string', () => {
expect(isValidNoffer('invalid')).toBe(false)
expect(isValidNoffer('noffer1invalid')).toBe(false)
})
})
})

View file

@ -0,0 +1,336 @@
/**
* CLINK Client
*
* Handles CLINK protocol communication for ATM payments:
* - Creating and serving offers (noffer)
* - Handling offer requests and generating invoices
* - Processing debit requests
* - Receiving management commands
*/
import type { Event, UnsignedEvent } from 'nostr-tools'
import { finalizeEvent } from 'nostr-tools'
import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client'
import { encryptContent, decryptJSON } from '@lamassu/nostr-client'
import {
CLINKEventKind,
type CLINKOffer,
type OfferRequest,
type OfferResponse,
type DebitRequest,
type DebitResponse,
type ManagementDelegation,
type GenerateInvoice,
type PayInvoice,
} from './types.js'
import { encodeNoffer } from './noffer.js'
/** CLINK client options */
export interface CLINKClientOptions {
/** Nostr client for communication */
nostrClient: NostrClient
/** Machine identity */
identity: MachineIdentity
/** Operator pubkey for management commands */
operatorPubkey: string
/** Relays to use for offers */
relays: string[]
/** Invoice generator function */
generateInvoice?: GenerateInvoice
/** Payment function */
payInvoice?: PayInvoice
}
/** Offer request handler */
export type OfferRequestHandler = (
request: OfferRequest,
senderPubkey: string
) => Promise<OfferResponse | null>
/** Debit request handler */
export type DebitRequestHandler = (
request: DebitRequest,
senderPubkey: string
) => Promise<DebitResponse>
/** Management command handler */
export type ManagementHandler = (
delegation: ManagementDelegation,
senderPubkey: string
) => Promise<void>
/**
* CLINK protocol client for ATM payments
*/
export class CLINKClient {
private nostrClient: NostrClient
private identity: MachineIdentity
private operatorPubkey: string
private relays: string[]
private generateInvoice?: GenerateInvoice
private payInvoice?: PayInvoice
private offerHandler?: OfferRequestHandler
private debitHandler?: DebitRequestHandler
private managementHandler?: ManagementHandler
private subscriptionId?: string
constructor(options: CLINKClientOptions) {
this.nostrClient = options.nostrClient
this.identity = options.identity
this.operatorPubkey = options.operatorPubkey
this.relays = options.relays
this.generateInvoice = options.generateInvoice
this.payInvoice = options.payInvoice
}
/**
* Create a noffer (static payment code) for this machine
*/
createOffer(options: {
priceType: CLINKOffer['priceType']
amountMsat?: number
description?: string
minAmountMsat?: number
maxAmountMsat?: number
}): string {
const offer: CLINKOffer = {
pubkey: this.identity.publicKey,
relays: this.relays,
priceType: options.priceType,
amountMsat: options.amountMsat,
description: options.description,
minAmountMsat: options.minAmountMsat,
maxAmountMsat: options.maxAmountMsat,
}
return encodeNoffer(offer)
}
/**
* Set handler for incoming offer requests
*/
onOfferRequest(handler: OfferRequestHandler): void {
this.offerHandler = handler
}
/**
* Set handler for incoming debit requests
*/
onDebitRequest(handler: DebitRequestHandler): void {
this.debitHandler = handler
}
/**
* Set handler for management commands
*/
onManagement(handler: ManagementHandler): void {
this.managementHandler = handler
}
/**
* Start listening for CLINK events
*/
startListening(): void {
if (this.subscriptionId) return
this.subscriptionId = this.nostrClient.subscribe(
[
{
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
'#p': [this.identity.publicKey],
},
],
{
onEvent: (event) => this.handleEvent(event),
}
)
}
/**
* Stop listening for CLINK events
*/
stopListening(): void {
if (this.subscriptionId) {
this.nostrClient.unsubscribe(this.subscriptionId)
this.subscriptionId = undefined
}
}
/**
* Send an offer request to another pubkey
*/
async requestOffer(targetPubkey: string, request: OfferRequest): Promise<OfferResponse> {
const content = encryptContent(this.identity, targetPubkey, request)
const event = this.createSignedEvent({
kind: CLINKEventKind.Offer,
content,
tags: [['p', targetPubkey]],
created_at: Math.floor(Date.now() / 1000),
})
await this.nostrClient.publish(event)
// Wait for response
return this.waitForResponse<OfferResponse>(targetPubkey, CLINKEventKind.Offer)
}
/**
* Send a debit request
*/
async requestDebit(targetPubkey: string, request: DebitRequest): Promise<DebitResponse> {
const content = encryptContent(this.identity, targetPubkey, request)
const event = this.createSignedEvent({
kind: CLINKEventKind.Debit,
content,
tags: [['p', targetPubkey]],
created_at: Math.floor(Date.now() / 1000),
})
await this.nostrClient.publish(event)
return this.waitForResponse<DebitResponse>(targetPubkey, CLINKEventKind.Debit)
}
/**
* Handle incoming CLINK event
*/
private async handleEvent(event: Event): Promise<void> {
try {
switch (event.kind) {
case CLINKEventKind.Offer:
await this.handleOfferEvent(event)
break
case CLINKEventKind.Debit:
await this.handleDebitEvent(event)
break
case CLINKEventKind.Manage:
await this.handleManageEvent(event)
break
}
} catch (error) {
console.error('Error handling CLINK event:', error)
}
}
/**
* Handle offer request/response
*/
private async handleOfferEvent(event: Event): Promise<void> {
if (!this.offerHandler) return
const request = decryptJSON<OfferRequest>(this.identity, event.pubkey, event.content)
const response = await this.offerHandler(request, event.pubkey)
if (!response) return
// Send encrypted response
const content = encryptContent(this.identity, event.pubkey, response)
const responseEvent = this.createSignedEvent({
kind: CLINKEventKind.Offer,
content,
tags: [
['p', event.pubkey],
['e', event.id],
],
created_at: Math.floor(Date.now() / 1000),
})
await this.nostrClient.publish(responseEvent)
}
/**
* Handle debit request
*/
private async handleDebitEvent(event: Event): Promise<void> {
if (!this.debitHandler) return
const request = decryptJSON<DebitRequest>(this.identity, event.pubkey, event.content)
const response = await this.debitHandler(request, event.pubkey)
// Send encrypted response
const content = encryptContent(this.identity, event.pubkey, response)
const responseEvent = this.createSignedEvent({
kind: CLINKEventKind.Debit,
content,
tags: [
['p', event.pubkey],
['e', event.id],
],
created_at: Math.floor(Date.now() / 1000),
})
await this.nostrClient.publish(responseEvent)
}
/**
* Handle management command
*/
private async handleManageEvent(event: Event): Promise<void> {
// Only accept from operator
if (event.pubkey !== this.operatorPubkey) {
console.warn('Ignoring management command from non-operator:', event.pubkey)
return
}
if (!this.managementHandler) return
const delegation = decryptJSON<ManagementDelegation>(this.identity, event.pubkey, event.content)
await this.managementHandler(delegation, event.pubkey)
}
/**
* Wait for a response event
*/
private waitForResponse<T>(fromPubkey: string, kind: number): Promise<T> {
return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.nostrClient.unsubscribe(subId)
reject(new Error('Response timeout'))
}, 30000)
const subId = this.nostrClient.subscribe(
[
{
kinds: [kind],
authors: [fromPubkey],
'#p': [this.identity.publicKey],
since: Math.floor(Date.now() / 1000) - 5,
},
],
{
onEvent: (event) => {
clearTimeout(timeout)
this.nostrClient.unsubscribe(subId)
try {
const response = decryptJSON<T>(this.identity, fromPubkey, event.content)
resolve(response)
} catch (e) {
reject(e)
}
},
}
)
})
}
/**
* Create a signed event
*/
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
return finalizeEvent(
{
...event,
pubkey: this.identity.publicKey,
},
this.identity.privateKey
)
}
}

View file

@ -0,0 +1,67 @@
/**
* @lamassu/clink
*
* CLINK protocol implementation for Nostr-native Lightning payments.
*
* CLINK enables static payment codes (noffers) that work over Nostr,
* providing a decentralized alternative to BOLT12 offers.
*
* @example
* ```typescript
* import { CLINKClient, encodeNoffer, decodeNoffer } from '@lamassu/clink'
*
* // Create a client
* const clink = new CLINKClient({
* nostrClient,
* identity,
* operatorPubkey,
* relays: ['wss://relay.example.com'],
* })
*
* // Create a noffer for the ATM
* const noffer = clink.createOffer({
* priceType: 'variable',
* description: 'Buy Bitcoin at ATM',
* })
*
* // Handle incoming offer requests
* clink.onOfferRequest(async (request, senderPubkey) => {
* const invoice = await generateInvoice(request.amountMsat)
* return {
* invoice,
* amountMsat: request.amountMsat,
* expiresAt: Date.now() / 1000 + 600,
* }
* })
*
* clink.startListening()
* ```
*/
// Client
export { CLINKClient } from './client.js'
export type {
CLINKClientOptions,
OfferRequestHandler,
DebitRequestHandler,
ManagementHandler,
} from './client.js'
// noffer encoding/decoding
export { encodeNoffer, decodeNoffer, isValidNoffer } from './noffer.js'
// Types
export {
CLINKEventKind,
NofferTLV,
type PriceType,
type CLINKOffer,
type OfferRequest,
type OfferResponse,
type DebitRequest,
type DebitResponse,
type ManagementAction,
type ManagementDelegation,
type GenerateInvoice,
type PayInvoice,
} from './types.js'

View file

@ -0,0 +1,207 @@
/**
* noffer encoding/decoding
*
* noffers are bech32-encoded static payment codes that contain
* pubkey, relays, and pricing information.
*
* Format: noffer1<bech32-encoded-tlv-data>
*/
import { bech32 } from '@scure/base'
import type { CLINKOffer, PriceType } from './types.js'
import { NofferTLV } from './types.js'
const NOFFER_PREFIX = 'noffer'
const BECH32_LIMIT = 2000
/**
* Encode a CLINK offer as a noffer string
*/
export function encodeNoffer(offer: CLINKOffer): string {
const tlvData: number[] = []
// Pubkey (TLV 0)
const pubkeyBytes = hexToBytes(offer.pubkey)
writeTLV(tlvData, NofferTLV.Pubkey, pubkeyBytes)
// Relays (TLV 1) - one entry per relay
for (const relay of offer.relays) {
const relayBytes = new TextEncoder().encode(relay)
writeTLV(tlvData, NofferTLV.Relay, relayBytes)
}
// Price type (TLV 2)
const priceTypeByte = encodePriceType(offer.priceType)
writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte])
// Amount (TLV 3) - if present
if (offer.amountMsat !== undefined) {
const amountBytes = encodeUint64BE(offer.amountMsat)
writeTLV(tlvData, NofferTLV.Amount, amountBytes)
}
// Description (TLV 4) - if present
if (offer.description) {
const descBytes = new TextEncoder().encode(offer.description)
writeTLV(tlvData, NofferTLV.Description, descBytes)
}
// Convert to Uint8Array and bech32 encode
const bytes = new Uint8Array(tlvData)
const words = bech32.toWords(bytes)
return bech32.encode(NOFFER_PREFIX, words, BECH32_LIMIT)
}
/**
* Decode a noffer string to a CLINK offer
*/
export function decodeNoffer(noffer: string): CLINKOffer {
// Decode bech32
const { prefix, words } = bech32.decode(noffer, BECH32_LIMIT)
if (prefix !== NOFFER_PREFIX) {
throw new Error(`Invalid noffer prefix: ${prefix}`)
}
const bytes = bech32.fromWords(words)
const data = new Uint8Array(bytes)
// Parse TLV entries
let pubkey: string | undefined
const relays: string[] = []
let priceType: PriceType = 'spontaneous'
let amountMsat: number | undefined
let description: string | undefined
let offset = 0
while (offset < data.length) {
const type = data[offset]
offset++
if (offset >= data.length) break
const length = data[offset]
offset++
if (offset + (length ?? 0) > data.length) {
throw new Error('Invalid TLV: data truncated')
}
const value = data.slice(offset, offset + (length ?? 0))
offset += length ?? 0
switch (type) {
case NofferTLV.Pubkey:
pubkey = bytesToHex(value)
break
case NofferTLV.Relay:
relays.push(new TextDecoder().decode(value))
break
case NofferTLV.PriceType:
priceType = decodePriceType(value[0] ?? 2)
break
case NofferTLV.Amount:
amountMsat = decodeUint64BE(value)
break
case NofferTLV.Description:
description = new TextDecoder().decode(value)
break
// Ignore unknown TLV types for forward compatibility
}
}
if (!pubkey) {
throw new Error('Invalid noffer: missing pubkey')
}
if (relays.length === 0) {
throw new Error('Invalid noffer: no relays')
}
return {
pubkey,
relays,
priceType,
amountMsat,
description,
}
}
/**
* Validate a noffer string without fully decoding
*/
export function isValidNoffer(noffer: string): boolean {
try {
decodeNoffer(noffer)
return true
} catch {
return false
}
}
// TLV helpers
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
data.push(type)
data.push(value.length)
for (const byte of value) {
data.push(byte)
}
}
function encodePriceType(priceType: PriceType): number {
switch (priceType) {
case 'fixed':
return 0
case 'variable':
return 1
case 'spontaneous':
return 2
default:
return 2
}
}
function decodePriceType(byte: number): PriceType {
switch (byte) {
case 0:
return 'fixed'
case 1:
return 'variable'
case 2:
return 'spontaneous'
default:
return 'spontaneous'
}
}
function encodeUint64BE(value: number): number[] {
const bytes: number[] = []
for (let i = 7; i >= 0; i--) {
bytes.push((value >> (i * 8)) & 0xff)
}
return bytes
}
function decodeUint64BE(bytes: Uint8Array): number {
let value = 0
for (let i = 0; i < bytes.length && i < 8; i++) {
value = value * 256 + (bytes[i] ?? 0)
}
return value
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

View file

@ -0,0 +1,129 @@
/**
* CLINK Protocol type definitions
*
* CLINK is a Nostr-native Lightning payment protocol that enables
* static payment codes (noffers) over Nostr relays.
*
* Event kinds:
* - 21001: Offer request/response
* - 21002: Debit request/response
* - 21003: Management delegation
*/
/** CLINK event kinds */
export enum CLINKEventKind {
/** Offer request/response */
Offer = 21001,
/** Debit request/response (authorized payments) */
Debit = 21002,
/** Management delegation */
Manage = 21003,
}
/** Offer price type */
export type PriceType = 'fixed' | 'variable' | 'spontaneous'
/** CLINK Offer (noffer) configuration */
export interface CLINKOffer {
/** Public key of the offer creator */
pubkey: string
/** Relays where the offer is accessible */
relays: string[]
/** Pricing model */
priceType: PriceType
/** Amount in millisatoshis (for fixed price) */
amountMsat?: number
/** Human-readable description */
description?: string
/** Minimum amount in msats (for variable/spontaneous) */
minAmountMsat?: number
/** Maximum amount in msats (for variable/spontaneous) */
maxAmountMsat?: number
}
/** Offer request (Kind 21001) */
export interface OfferRequest {
/** Requested amount in millisatoshis */
amountMsat: number
/** Fiat amount (for ATM context) */
fiatAmount?: number
/** Fiat currency code */
fiatCurrency?: string
/** Sender's callback pubkey for response */
senderPubkey: string
/** Optional message */
message?: string
}
/** Offer response (Kind 21001) */
export interface OfferResponse {
/** BOLT11 invoice to pay */
invoice: string
/** Amount in millisatoshis */
amountMsat: number
/** Optional description */
description?: string
/** Expiry timestamp */
expiresAt: number
}
/** Debit request (Kind 21002) */
export interface DebitRequest {
/** Amount to debit in millisatoshis */
amountMsat: number
/** Recipient's pubkey */
recipientPubkey: string
/** Optional description */
description?: string
/** Unique request ID for idempotency */
requestId: string
}
/** Debit response (Kind 21002) */
export interface DebitResponse {
/** Whether the debit was successful */
success: boolean
/** Payment preimage (proof of payment) */
preimage?: string
/** Error message if failed */
error?: string
/** Matching request ID */
requestId: string
}
/** Management action (Kind 21003) */
export type ManagementAction =
| { type: 'disable' }
| { type: 'enable' }
| { type: 'set_limits'; minMsat: number; maxMsat: number }
| { type: 'revoke' }
/** Management delegation (Kind 21003) */
export interface ManagementDelegation {
/** Target machine/account pubkey */
targetPubkey: string
/** Delegated action */
action: ManagementAction
/** Timestamp */
timestamp: number
}
/** noffer TLV types */
export enum NofferTLV {
/** Public key (32 bytes) */
Pubkey = 0,
/** Relay URL (variable length string) */
Relay = 1,
/** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */
PriceType = 2,
/** Amount in msats (8 bytes, big-endian) */
Amount = 3,
/** Description (variable length string) */
Description = 4,
}
/** Invoice generation function type */
export type GenerateInvoice = (amountMsat: number) => Promise<string>
/** Payment function type */
export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }>

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"strictNullChecks": true,
"noUncheckedIndexedAccess": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "**/*.test.ts"]
}

View file

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['src/**/*.test.ts'],
globals: false,
},
})