diff --git a/lamassu-next/packages/clink/package.json b/lamassu-next/packages/clink/package.json index ae70e8e..3450146 100644 --- a/lamassu-next/packages/clink/package.json +++ b/lamassu-next/packages/clink/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@lamassu/nostr-client": "workspace:*", + "@scure/base": "^1.2.0", "nostr-tools": "^2.10.0" }, "devDependencies": { diff --git a/lamassu-next/packages/clink/src/__tests__/noffer.test.ts b/lamassu-next/packages/clink/src/__tests__/noffer.test.ts new file mode 100644 index 0000000..996320e --- /dev/null +++ b/lamassu-next/packages/clink/src/__tests__/noffer.test.ts @@ -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) + }) + }) +}) diff --git a/lamassu-next/packages/clink/src/client.ts b/lamassu-next/packages/clink/src/client.ts new file mode 100644 index 0000000..d26daf8 --- /dev/null +++ b/lamassu-next/packages/clink/src/client.ts @@ -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 + +/** Debit request handler */ +export type DebitRequestHandler = ( + request: DebitRequest, + senderPubkey: string +) => Promise + +/** Management command handler */ +export type ManagementHandler = ( + delegation: ManagementDelegation, + senderPubkey: string +) => Promise + +/** + * 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 { + 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(targetPubkey, CLINKEventKind.Offer) + } + + /** + * Send a debit request + */ + async requestDebit(targetPubkey: string, request: DebitRequest): Promise { + 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(targetPubkey, CLINKEventKind.Debit) + } + + /** + * Handle incoming CLINK event + */ + private async handleEvent(event: Event): Promise { + 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 { + if (!this.offerHandler) return + + const request = decryptJSON(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 { + if (!this.debitHandler) return + + const request = decryptJSON(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 { + // 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(this.identity, event.pubkey, event.content) + + await this.managementHandler(delegation, event.pubkey) + } + + /** + * Wait for a response event + */ + private waitForResponse(fromPubkey: string, kind: number): Promise { + 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(this.identity, fromPubkey, event.content) + resolve(response) + } catch (e) { + reject(e) + } + }, + } + ) + }) + } + + /** + * Create a signed event + */ + private createSignedEvent(event: Omit): Event { + return finalizeEvent( + { + ...event, + pubkey: this.identity.publicKey, + }, + this.identity.privateKey + ) + } +} diff --git a/lamassu-next/packages/clink/src/index.ts b/lamassu-next/packages/clink/src/index.ts new file mode 100644 index 0000000..de87f97 --- /dev/null +++ b/lamassu-next/packages/clink/src/index.ts @@ -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' diff --git a/lamassu-next/packages/clink/src/noffer.ts b/lamassu-next/packages/clink/src/noffer.ts new file mode 100644 index 0000000..e98300c --- /dev/null +++ b/lamassu-next/packages/clink/src/noffer.ts @@ -0,0 +1,207 @@ +/** + * noffer encoding/decoding + * + * noffers are bech32-encoded static payment codes that contain + * pubkey, relays, and pricing information. + * + * Format: noffer1 + */ + +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('') +} diff --git a/lamassu-next/packages/clink/src/types.ts b/lamassu-next/packages/clink/src/types.ts new file mode 100644 index 0000000..688f11d --- /dev/null +++ b/lamassu-next/packages/clink/src/types.ts @@ -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 + +/** Payment function type */ +export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }> diff --git a/lamassu-next/packages/clink/tsconfig.json b/lamassu-next/packages/clink/tsconfig.json new file mode 100644 index 0000000..d770c39 --- /dev/null +++ b/lamassu-next/packages/clink/tsconfig.json @@ -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"] +} diff --git a/lamassu-next/packages/clink/vitest.config.ts b/lamassu-next/packages/clink/vitest.config.ts new file mode 100644 index 0000000..9fb4f14 --- /dev/null +++ b/lamassu-next/packages/clink/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: false, + }, +})