From d5a77c4311a48dee49f8e0c9e90698bc5d8861c3 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Fri, 23 Jan 2026 06:18:04 -0500 Subject: [PATCH] Align CLINK and Lightning.Pub packages with actual protocol specs CLINK Protocol (@lamassu/clink): - Add CLINKErrorCode enum (codes 1-6) matching Lightning.Pub - Update OfferRequest to use { offer, amount_sats, payer_data, ... } - Update OfferResponse to { bolt11 } or { code, error, range } - Update DebitRequest to { amount_sats, memo, rules } - Update DebitResponse to { res: 'ok'|'GFY', preimage?, error?, code? } - Add ManagementRequest/Response with proper action types - Add ServiceBeacon type for Kind 30078 discovery - Add DebitRules for recurring payment authorization - Change all amounts from millisatoshis to satoshis - Add helper functions: createOfferSuccess, createOfferError, etc. - Add type guards: isOfferError, isDebitFailure, isManagementFailure Lightning.Pub RPC (@lamassu/lightning): - Change from NIP-47 kinds (23194/23195) to Kind 21000 RPC - Add proper RPC request format: { rpcName, body, authIdentifier, requestId, appId } - Add RPC response handling: { status: 'OK'|'ERROR', reason?, ...result } - Add LightningPubEventKind enum - Add isRPCError type guard - Remove unused types (ChannelInfo, NodeInfo) - Update all amounts from millisatoshis to satoshis This aligns our implementation with the actual Lightning.Pub source code at /home/padreug/Work/tries/2026-01-22-lamassu-refactor-packages/Lightning.Pub Co-Authored-By: Claude Opus 4.5 --- lamassu-next/packages/clink/src/client.ts | 208 ++++++++++-- lamassu-next/packages/clink/src/index.ts | 80 +++-- lamassu-next/packages/clink/src/noffer.ts | 12 +- lamassu-next/packages/clink/src/types.ts | 307 +++++++++++++----- lamassu-next/packages/lightning/src/client.ts | 227 +++++++------ lamassu-next/packages/lightning/src/index.ts | 53 ++- lamassu-next/packages/lightning/src/types.ts | 177 +++++++--- 7 files changed, 753 insertions(+), 311 deletions(-) diff --git a/lamassu-next/packages/clink/src/client.ts b/lamassu-next/packages/clink/src/client.ts index 378e021..f357222 100644 --- a/lamassu-next/packages/clink/src/client.ts +++ b/lamassu-next/packages/clink/src/client.ts @@ -6,6 +6,8 @@ * - Handling offer requests and generating invoices * - Processing debit requests * - Receiving management commands + * + * Uses NIP-44v2 encryption for all messages. */ import type { Event, UnsignedEvent } from 'nostr-tools' @@ -14,16 +16,25 @@ import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client' import { encryptContent, decryptJSON } from '@lamassu/nostr-client' import { CLINKEventKind, + CLINKErrorCode, type CLINKOffer, type OfferRequest, type OfferResponse, + type OfferSuccessResponse, + type OfferErrorResponse, type DebitRequest, type DebitResponse, - type ManagementDelegation, + type DebitSuccessResponse, + type DebitFailureResponse, + type ManagementRequest, + type ManagementResponse, + type ServiceBeacon, type GenerateInvoice, type PayInvoice, + isOfferError, + isDebitFailure, } from './types.js' -import { encodeNoffer } from './noffer.js' +import { encodeNoffer, decodeNoffer } from './noffer.js' /** CLINK client options */ export interface CLINKClientOptions { @@ -41,23 +52,23 @@ export interface CLINKClientOptions { payInvoice?: PayInvoice } -/** Offer request handler */ +/** Offer request handler - returns invoice or error */ export type OfferRequestHandler = ( request: OfferRequest, senderPubkey: string ) => Promise -/** Debit request handler */ +/** Debit request handler - returns success/failure */ export type DebitRequestHandler = ( request: DebitRequest, senderPubkey: string ) => Promise -/** Management command handler */ +/** Management request handler */ export type ManagementHandler = ( - delegation: ManagementDelegation, + request: ManagementRequest, senderPubkey: string -) => Promise +) => Promise /** * CLINK protocol client for ATM payments @@ -90,40 +101,47 @@ export class CLINKClient { */ createOffer(options: { priceType: CLINKOffer['priceType'] - amountMsat?: number + amountSats?: number description?: string - minAmountMsat?: number - maxAmountMsat?: number + minAmountSats?: number + maxAmountSats?: number }): string { const offer: CLINKOffer = { pubkey: this.identity.publicKey, relays: this.relays, priceType: options.priceType, - amountMsat: options.amountMsat, + amountSats: options.amountSats, description: options.description, - minAmountMsat: options.minAmountMsat, - maxAmountMsat: options.maxAmountMsat, + minAmountSats: options.minAmountSats, + maxAmountSats: options.maxAmountSats, } return encodeNoffer(offer) } /** - * Set handler for incoming offer requests + * Decode a noffer string + */ + decodeOffer(noffer: string): CLINKOffer { + return decodeNoffer(noffer) + } + + /** + * Set handler for incoming offer requests (Kind 21001) */ onOfferRequest(handler: OfferRequestHandler): void { this.offerHandler = handler } /** - * Set handler for incoming debit requests + * Set handler for incoming debit requests (Kind 21002) */ onDebitRequest(handler: DebitRequestHandler): void { this.debitHandler = handler } /** - * Set handler for management commands + * Set handler for management commands (Kind 21003) */ onManagement(handler: ManagementHandler): void { this.managementHandler = handler @@ -159,28 +177,59 @@ export class CLINKClient { } /** - * Send an offer request to another pubkey + * Request an invoice from a noffer (Kind 21001) */ - async requestOffer(targetPubkey: string, request: OfferRequest): Promise { - const content = encryptContent(this.identity, targetPubkey, request) + async requestOffer( + noffer: string, + amountSats: number, + options?: { + payerData?: Record + description?: string + expiresInSeconds?: number + } + ): Promise { + const offer = decodeNoffer(noffer) + + const request: OfferRequest = { + offer: noffer, + amount_sats: amountSats, + payer_data: options?.payerData, + description: options?.description, + expires_in_seconds: options?.expiresInSeconds, + } + + const content = encryptContent(this.identity, offer.pubkey, request) const event = this.createSignedEvent({ kind: CLINKEventKind.Offer, content, - tags: [['p', targetPubkey]], + tags: [['p', offer.pubkey]], created_at: Math.floor(Date.now() / 1000), }) await this.nostrClient.publish(event) // Wait for response - return this.waitForResponse(targetPubkey, CLINKEventKind.Offer) + return this.waitForResponse(offer.pubkey, CLINKEventKind.Offer, event.id) } /** - * Send a debit request + * Send a debit request (Kind 21002) */ - async requestDebit(targetPubkey: string, request: DebitRequest): Promise { + async requestDebit( + targetPubkey: string, + amountSats: number, + options?: { + memo?: string + rules?: DebitRequest['rules'] + } + ): Promise { + const request: DebitRequest = { + amount_sats: amountSats, + memo: options?.memo, + rules: options?.rules, + } + const content = encryptContent(this.identity, targetPubkey, request) const event = this.createSignedEvent({ @@ -192,7 +241,50 @@ export class CLINKClient { await this.nostrClient.publish(event) - return this.waitForResponse(targetPubkey, CLINKEventKind.Debit) + return this.waitForResponse(targetPubkey, CLINKEventKind.Debit, event.id) + } + + /** + * Send a management request (Kind 21003) + */ + async sendManagementRequest( + targetPubkey: string, + request: ManagementRequest + ): Promise { + const content = encryptContent(this.identity, targetPubkey, request) + + const event = this.createSignedEvent({ + kind: CLINKEventKind.Manage, + content, + tags: [['p', targetPubkey]], + created_at: Math.floor(Date.now() / 1000), + }) + + await this.nostrClient.publish(event) + + return this.waitForResponse(targetPubkey, CLINKEventKind.Manage, event.id) + } + + /** + * Discover service beacon (Kind 30078) + */ + async discoverService(servicePubkey: string): Promise { + const events = await this.nostrClient.queryEvents([ + { + kinds: [CLINKEventKind.Beacon], + authors: [servicePubkey], + '#d': ['Lightning.Pub'], + limit: 1, + }, + ]) + + if (events.length === 0) return null + + try { + return JSON.parse(events[0]?.content ?? '{}') as ServiceBeacon + } catch { + return null + } } /** @@ -217,7 +309,7 @@ export class CLINKClient { } /** - * Handle offer request/response + * Handle offer request (Kind 21001) */ private async handleOfferEvent(event: Event): Promise { if (!this.offerHandler) return @@ -244,7 +336,7 @@ export class CLINKClient { } /** - * Handle debit request + * Handle debit request (Kind 21002) */ private async handleDebitEvent(event: Event): Promise { if (!this.debitHandler) return @@ -270,7 +362,7 @@ export class CLINKClient { } /** - * Handle management command + * Handle management command (Kind 21003) */ private async handleManageEvent(event: Event): Promise { // Only accept from operator @@ -281,15 +373,31 @@ export class CLINKClient { if (!this.managementHandler) return - const delegation = decryptJSON(this.identity, event.pubkey, event.content) + const request = decryptJSON(this.identity, event.pubkey, event.content) - await this.managementHandler(delegation, event.pubkey) + const response = await this.managementHandler(request, event.pubkey) + if (!response) return + + // Send encrypted response + const content = encryptContent(this.identity, event.pubkey, response) + + const responseEvent = this.createSignedEvent({ + kind: CLINKEventKind.Manage, + content, + tags: [ + ['p', event.pubkey], + ['e', event.id], + ], + created_at: Math.floor(Date.now() / 1000), + }) + + await this.nostrClient.publish(responseEvent) } /** * Wait for a response event */ - private waitForResponse(fromPubkey: string, kind: number): Promise { + private waitForResponse(fromPubkey: string, kind: number, requestEventId: string): Promise { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { this.nostrClient.unsubscribe(subId) @@ -302,6 +410,7 @@ export class CLINKClient { kinds: [kind], authors: [fromPubkey], '#p': [this.identity.publicKey], + '#e': [requestEventId], since: Math.floor(Date.now() / 1000) - 5, }, ], @@ -329,3 +438,42 @@ export class CLINKClient { return finalizeEvent(event, this.identity.privateKey) } } + +// ============================================================================ +// Helper functions for creating responses +// ============================================================================ + +/** + * Create an offer success response + */ +export function createOfferSuccess(bolt11: string): OfferSuccessResponse { + return { bolt11 } +} + +/** + * Create an offer error response + */ +export function createOfferError( + code: CLINKErrorCode, + error: string, + range?: { min: number; max: number } +): OfferErrorResponse { + return { code, error, range } +} + +/** + * Create a debit success response + */ +export function createDebitSuccess(preimage: string): DebitSuccessResponse { + return { res: 'ok', preimage } +} + +/** + * Create a debit failure response + */ +export function createDebitFailure(code: CLINKErrorCode, error: string): DebitFailureResponse { + return { res: 'GFY', code, error } +} + +// Re-export type guards +export { isOfferError, isDebitFailure } diff --git a/lamassu-next/packages/clink/src/index.ts b/lamassu-next/packages/clink/src/index.ts index de87f97..8c7b8eb 100644 --- a/lamassu-next/packages/clink/src/index.ts +++ b/lamassu-next/packages/clink/src/index.ts @@ -3,12 +3,18 @@ * * 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. + * CLINK enables: + * - Static payment codes (noffers) for receiving payments + * - Offer requests/responses for invoice generation (Kind 21001) + * - Debit authorization for outgoing payments (Kind 21002) + * - Management delegation for remote control (Kind 21003) + * - Service discovery via beacons (Kind 30078) + * + * All CLINK messages use NIP-44v2 encryption. * * @example * ```typescript - * import { CLINKClient, encodeNoffer, decodeNoffer } from '@lamassu/clink' + * import { CLINKClient, encodeNoffer, createOfferSuccess } from '@lamassu/clink' * * // Create a client * const clink = new CLINKClient({ @@ -26,42 +32,66 @@ * * // 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, - * } + * const bolt11 = await generateInvoice(request.amount_sats) + * return createOfferSuccess(bolt11) * }) * * 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 { + // Event kinds CLINKEventKind, - NofferTLV, - type PriceType, - type CLINKOffer, + CLINKErrorCode, + // Offer types (Kind 21001) type OfferRequest, type OfferResponse, + type OfferSuccessResponse, + type OfferErrorResponse, + // Debit types (Kind 21002) type DebitRequest, type DebitResponse, + type DebitSuccessResponse, + type DebitFailureResponse, + type DebitRules, + type DebitInterval, + // Management types (Kind 21003) + type ManagementRequest, + type ManagementResponse, + type ManagementSuccessResponse, + type ManagementFailureResponse, type ManagementAction, - type ManagementDelegation, + type OfferConfig, + // Beacon types (Kind 30078) + type ServiceBeacon, + // Noffer types + type CLINKOffer, + type PriceType, + NofferTLV, + // Function types type GenerateInvoice, type PayInvoice, + // Type guards + isOfferError, + isDebitFailure, + isManagementFailure, } from './types.js' + +// Noffer encoding/decoding +export { encodeNoffer, decodeNoffer, isValidNoffer } from './noffer.js' + +// Client +export { + CLINKClient, + type CLINKClientOptions, + type OfferRequestHandler, + type DebitRequestHandler, + type ManagementHandler, + // Response helpers + createOfferSuccess, + createOfferError, + createDebitSuccess, + createDebitFailure, +} from './client.js' diff --git a/lamassu-next/packages/clink/src/noffer.ts b/lamassu-next/packages/clink/src/noffer.ts index a25567b..7ce42e1 100644 --- a/lamassu-next/packages/clink/src/noffer.ts +++ b/lamassu-next/packages/clink/src/noffer.ts @@ -34,9 +34,9 @@ export function encodeNoffer(offer: CLINKOffer): string { const priceTypeByte = encodePriceType(offer.priceType) writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte]) - // Amount (TLV 3) - if present - if (offer.amountMsat !== undefined) { - const amountBytes = encodeUint64BE(offer.amountMsat) + // Amount (TLV 3) - if present (in satoshis) + if (offer.amountSats !== undefined) { + const amountBytes = encodeUint64BE(offer.amountSats) writeTLV(tlvData, NofferTLV.Amount, amountBytes) } @@ -71,7 +71,7 @@ export function decodeNoffer(noffer: string): CLINKOffer { let pubkey: string | undefined const relays: string[] = [] let priceType: PriceType = 'spontaneous' - let amountMsat: number | undefined + let amountSats: number | undefined let description: string | undefined let offset = 0 @@ -102,7 +102,7 @@ export function decodeNoffer(noffer: string): CLINKOffer { priceType = decodePriceType(value[0] ?? 2) break case NofferTLV.Amount: - amountMsat = decodeUint64BE(value) + amountSats = decodeUint64BE(value) break case NofferTLV.Description: description = new TextDecoder().decode(value) @@ -123,7 +123,7 @@ export function decodeNoffer(noffer: string): CLINKOffer { pubkey, relays, priceType, - amountMsat, + amountSats, description, } } diff --git a/lamassu-next/packages/clink/src/types.ts b/lamassu-next/packages/clink/src/types.ts index 688f11d..e1b22f0 100644 --- a/lamassu-next/packages/clink/src/types.ts +++ b/lamassu-next/packages/clink/src/types.ts @@ -5,22 +5,236 @@ * static payment codes (noffers) over Nostr relays. * * Event kinds: + * - 21000: Generic RPC request/response * - 21001: Offer request/response * - 21002: Debit request/response - * - 21003: Management delegation + * - 21003: Management request/response + * - 30078: Service beacon (replaceable) + * + * All CLINK messages use NIP-44v2 encryption. */ /** CLINK event kinds */ export enum CLINKEventKind { + /** Generic RPC request/response */ + RPC = 21000, /** Offer request/response */ Offer = 21001, /** Debit request/response (authorized payments) */ Debit = 21002, - /** Management delegation */ + /** Management request/response */ Manage = 21003, + /** Service beacon (replaceable event) */ + Beacon = 30078, } -/** Offer price type */ +/** CLINK error codes */ +export enum CLINKErrorCode { + /** Request was denied (warning) */ + RequestDenied = 1, + /** Temporary failure, may retry */ + TemporaryFailure = 2, + /** Request has expired */ + ExpiredRequest = 3, + /** Rate limited */ + RateLimited = 4, + /** Invalid amount (out of range) */ + InvalidAmount = 5, + /** Invalid request format */ + InvalidRequest = 6, +} + +// ============================================================================ +// Kind 21001: Offer (Noffer) +// ============================================================================ + +/** Offer request (Kind 21001) - sent by payer to service */ +export interface OfferRequest { + /** Offer identifier from service beacon or noffer string */ + offer: string + /** Amount in satoshis */ + amount_sats: number + /** Custom payer metadata */ + payer_data?: Record + /** Payment description */ + description?: string + /** Custom expiry in seconds */ + expires_in_seconds?: number + /** Whether this is a zap payment */ + zap?: boolean +} + +/** Offer success response (Kind 21001) */ +export interface OfferSuccessResponse { + /** BOLT-11 Lightning invoice */ + bolt11: string +} + +/** Offer error response (Kind 21001) */ +export interface OfferErrorResponse { + /** Error code */ + code: CLINKErrorCode + /** Human-readable error message */ + error: string + /** Acceptable amount range (for InvalidAmount errors) */ + range?: { min: number; max: number } +} + +/** Offer response - either success or error */ +export type OfferResponse = OfferSuccessResponse | OfferErrorResponse + +/** Type guard for offer error response */ +export function isOfferError(response: OfferResponse): response is OfferErrorResponse { + return 'code' in response && 'error' in response +} + +// ============================================================================ +// Kind 21002: Debit (Ndebit) +// ============================================================================ + +/** Debit frequency interval */ +export type DebitInterval = 'day' | 'week' | 'month' + +/** Debit rules for recurring payments */ +export interface DebitRules { + /** Expiration rule */ + expiration?: { + /** Unix timestamp when authorization expires */ + expires_at_unix: number + } + /** Frequency rule for recurring debits */ + frequency?: { + /** Number of intervals allowed */ + number_of_intervals: number + /** Interval type */ + interval: DebitInterval + /** Maximum amount per interval in sats */ + amount: number + } +} + +/** Debit request (Kind 21002) - sent by service to request payment authorization */ +export interface DebitRequest { + /** Amount in satoshis */ + amount_sats: number + /** Optional memo/description */ + memo?: string + /** Rules for recurring debits */ + rules?: DebitRules +} + +/** Debit success response (Kind 21002) */ +export interface DebitSuccessResponse { + /** Success indicator */ + res: 'ok' + /** Payment preimage (proof of payment) */ + preimage: string +} + +/** Debit failure response (Kind 21002) */ +export interface DebitFailureResponse { + /** Failure indicator (Generic Failure) */ + res: 'GFY' + /** Error message */ + error: string + /** Error code */ + code: CLINKErrorCode +} + +/** Debit response - either success or failure */ +export type DebitResponse = DebitSuccessResponse | DebitFailureResponse + +/** Type guard for debit failure */ +export function isDebitFailure(response: DebitResponse): response is DebitFailureResponse { + return response.res === 'GFY' +} + +// ============================================================================ +// Kind 21003: Management (Nmanage) +// ============================================================================ + +/** Management action types */ +export type ManagementAction = 'create' | 'update' | 'delete' | 'list' | 'get' + +/** Offer configuration for create/update */ +export interface OfferConfig { + /** Human-readable label */ + label?: string + /** Fixed price in satoshis */ + price_sats?: number + /** Callback URL for payment notifications */ + callback_url?: string + /** Expected payer data fields */ + payer_data?: string[] + /** Use blinded paths for privacy */ + blind?: boolean +} + +/** Management request (Kind 21003) */ +export interface ManagementRequest { + /** Action to perform */ + action: ManagementAction + /** App user identifier/pointer */ + pointer?: string + /** Offer configuration (for create/update) */ + offer?: OfferConfig + /** Offer ID (for update/delete/get) */ + offer_id?: string +} + +/** Management success response (Kind 21003) */ +export interface ManagementSuccessResponse { + /** Action that was performed */ + action: ManagementAction + /** Result data (varies by action) */ + result: unknown +} + +/** Management failure response (Kind 21003) */ +export interface ManagementFailureResponse { + /** Failure indicator */ + res: 'GFY' + /** Error message */ + error: string + /** Error code */ + code: CLINKErrorCode + /** Retry after (seconds) for rate limiting */ + retry_after?: number +} + +/** Management response */ +export type ManagementResponse = ManagementSuccessResponse | ManagementFailureResponse + +/** Type guard for management failure */ +export function isManagementFailure( + response: ManagementResponse +): response is ManagementFailureResponse { + return 'res' in response && response.res === 'GFY' +} + +// ============================================================================ +// Kind 30078: Service Beacon +// ============================================================================ + +/** Service beacon content (Kind 30078) */ +export interface ServiceBeacon { + /** Beacon type */ + type: 'service' | 'provider' + /** Service name */ + name: string + /** Service avatar URL */ + avatarUrl?: string + /** Fee structure */ + fees?: Record + /** Optional relay URL for additional communication */ + nextRelay?: string +} + +// ============================================================================ +// Noffer (Static Payment Code) encoding +// ============================================================================ + +/** Offer price type for noffer encoding */ export type PriceType = 'fixed' | 'variable' | 'spontaneous' /** CLINK Offer (noffer) configuration */ @@ -31,81 +245,14 @@ export interface CLINKOffer { relays: string[] /** Pricing model */ priceType: PriceType - /** Amount in millisatoshis (for fixed price) */ - amountMsat?: number + /** Amount in satoshis (for fixed price) */ + amountSats?: 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 + /** Minimum amount in sats (for variable/spontaneous) */ + minAmountSats?: number + /** Maximum amount in sats (for variable/spontaneous) */ + maxAmountSats?: number } /** noffer TLV types */ @@ -116,14 +263,18 @@ export enum NofferTLV { Relay = 1, /** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */ PriceType = 2, - /** Amount in msats (8 bytes, big-endian) */ + /** Amount in sats (8 bytes, big-endian) */ Amount = 3, /** Description (variable length string) */ Description = 4, } +// ============================================================================ +// Function types for service injection +// ============================================================================ + /** Invoice generation function type */ -export type GenerateInvoice = (amountMsat: number) => Promise +export type GenerateInvoice = (amountSats: number, description?: string) => Promise /** Payment function type */ export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }> diff --git a/lamassu-next/packages/lightning/src/client.ts b/lamassu-next/packages/lightning/src/client.ts index 9814cd2..4bd96f3 100644 --- a/lamassu-next/packages/lightning/src/client.ts +++ b/lamassu-next/packages/lightning/src/client.ts @@ -5,52 +5,41 @@ * account system that wraps LND. * * Lightning.Pub provides: - * - Account management (sublayers) - * - Invoice generation - * - Payment processing - * - CLINK protocol support + * - Account management + * - Invoice generation via RPC (kind 21000) + * - Payment processing via RPC (kind 21000) + * - CLINK protocol support (kinds 21001-21003) + * + * This client handles the RPC layer. For CLINK payments, + * use @lamassu/clink. */ -import type { Event } from 'nostr-tools' +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 type { - LightningPubConfig, - AccountInfo, - Invoice, - PaymentResult, - ExchangeRate, - InvoiceCallback, +import { + LightningPubEventKind, + type LightningPubConfig, + type RPCRequest, + type RPCResponse, + type AccountInfo, + type BalanceResponse, + type Invoice, + type CreateInvoiceParams, + type CreateInvoiceResponse, + type LookupInvoiceResponse, + type PaymentResult, + type PayInvoiceResponse, + type ExchangeRate, + type InvoiceCallback, + isRPCError, } from './types.js' -/** Event kinds for Lightning.Pub communication */ -const LIGHTNING_PUB_KINDS = { - REQUEST: 23194, // NIP-47 compatible - RESPONSE: 23195, -} - -/** Request types */ -type RequestMethod = - | 'get_info' - | 'get_balance' - | 'make_invoice' - | 'pay_invoice' - | 'lookup_invoice' - | 'list_transactions' - -interface Request { - method: RequestMethod - params: Record -} - -interface Response { - result?: T - error?: { code: number; message: string } -} - /** * Lightning.Pub client for ATM operations + * + * Uses Kind 21000 for RPC communication with Lightning.Pub service. */ export class LightningPubClient { private config: Required @@ -58,10 +47,12 @@ export class LightningPubClient { private identity: MachineIdentity | null = null private invoiceCallbacks: Map = new Map() private subscriptionId?: string + private requestCounter = 0 constructor(config: LightningPubConfig) { this.config = { timeout: 30000, + appId: 'lamassu-atm', ...config, } } @@ -79,51 +70,39 @@ export class LightningPubClient { * Get account information */ async getAccountInfo(): Promise { - const response = await this.sendRequest({ - method: 'get_info', - params: {}, - }) + const response = await this.sendRPC('GetInfo', {}) - return response + return { + pubkey: response.pubkey ?? this.config.accountPubkey, + balanceSats: response.balanceSats ?? 0, + maxSendSats: response.maxSendSats ?? 0, + maxReceiveSats: response.maxReceiveSats ?? 0, + } } /** * Get current balance */ async getBalance(): Promise<{ balanceSats: number }> { - const response = await this.sendRequest<{ balance: number }>({ - method: 'get_balance', - params: {}, - }) - - return { balanceSats: response.balance } + const response = await this.sendRPC('GetBalance', {}) + return { balanceSats: response.balance ?? 0 } } /** * Create a Lightning invoice */ - async createInvoice(params: { - amountMsat: number - description?: string - expirySecs?: number - }): Promise { - const response = await this.sendRequest<{ - payment_request: string - payment_hash: string - expires_at: number - }>({ - method: 'make_invoice', - params: { - amount: params.amountMsat, - description: params.description || 'ATM Payment', - expiry: params.expirySecs || 600, - }, + async createInvoice(params: CreateInvoiceParams): Promise { + const response = await this.sendRPC('NewInvoice', { + amount: params.amountSats, + memo: params.description || 'ATM Payment', + expiry: params.expirySecs || 3600, + private: params.privateHints ?? false, }) return { paymentRequest: response.payment_request, paymentHash: response.payment_hash, - amountMsat: params.amountMsat, + amountSats: params.amountSats, description: params.description, createdAt: Math.floor(Date.now() / 1000), expiresAt: response.expires_at, @@ -136,20 +115,14 @@ export class LightningPubClient { */ async payInvoice(paymentRequest: string): Promise { try { - const response = await this.sendRequest<{ - preimage: string - fee_paid?: number - }>({ - method: 'pay_invoice', - params: { - invoice: paymentRequest, - }, + const response = await this.sendRPC('PayInvoice', { + invoice: paymentRequest, }) return { success: true, preimage: response.preimage, - feeMsat: response.fee_paid, + feeSats: response.fee_paid, } } catch (error) { return { @@ -164,25 +137,14 @@ export class LightningPubClient { */ async lookupInvoice(paymentHash: string): Promise { try { - const response = await this.sendRequest<{ - payment_request: string - amount: number - description: string - created_at: number - expires_at: number - settled: boolean - preimage?: string - }>({ - method: 'lookup_invoice', - params: { - payment_hash: paymentHash, - }, + const response = await this.sendRPC('LookupInvoice', { + payment_hash: paymentHash, }) return { paymentRequest: response.payment_request, paymentHash, - amountMsat: response.amount, + amountSats: response.amount, description: response.description, createdAt: response.created_at, expiresAt: response.expires_at, @@ -228,42 +190,43 @@ export class LightningPubClient { } /** - * Decode a BOLT11 invoice + * Decode a BOLT11 invoice (basic parsing) + * + * For production use, consider using a proper bolt11 library. */ decodeInvoice(paymentRequest: string): { - amountMsat: number | null + amountSats: number | null paymentHash: string description: string expiresAt: number } { - // Basic invoice parsing (for production, use bolt11 library) - // This is a simplified version + // Basic invoice parsing const amountMatch = paymentRequest.match(/lnbc(\d+)([munp]?)/) - let amountMsat: number | null = null + let amountSats: number | null = null if (amountMatch) { const [, amount, multiplier] = amountMatch const baseAmount = parseInt(amount ?? '0', 10) switch (multiplier) { case 'm': - amountMsat = baseAmount * 100_000_000 + amountSats = baseAmount * 100_000 // milli-BTC to sats break case 'u': - amountMsat = baseAmount * 100_000 + amountSats = baseAmount * 100 // micro-BTC to sats break case 'n': - amountMsat = baseAmount * 100 + amountSats = Math.floor(baseAmount / 10) // nano-BTC to sats break case 'p': - amountMsat = baseAmount / 10 + amountSats = Math.floor(baseAmount / 10_000) // pico-BTC to sats break default: - amountMsat = baseAmount * 100_000_000_000 + amountSats = baseAmount * 100_000_000 // BTC to sats } } return { - amountMsat, + amountSats, paymentHash: '', // Would need proper parsing description: '', expiresAt: Math.floor(Date.now() / 1000) + 3600, @@ -271,11 +234,12 @@ export class LightningPubClient { } /** - * Get exchange rate (via external service or Lightning.Pub) + * Get exchange rate (mock implementation) + * + * For production, integrate with price feeds or Lightning.Pub's rate service. */ async getExchangeRate(currency: string): Promise { - // For now, return a mock rate - // In production, integrate with price feeds + // Mock rates - in production, fetch from service const mockRates: Record = { USD: 2500, // sats per dollar (example) EUR: 2700, @@ -291,7 +255,7 @@ export class LightningPubClient { } /** - * Start listening for responses + * Start listening for RPC responses */ private startListening(): void { if (!this.nostrClient || !this.identity) return @@ -300,8 +264,9 @@ export class LightningPubClient { this.subscriptionId = this.nostrClient.subscribe( [ { - kinds: [LIGHTNING_PUB_KINDS.RESPONSE], + kinds: [LightningPubEventKind.RPC], '#p': [this.identity.publicKey], + authors: [this.config.accountPubkey], }, ], { @@ -313,25 +278,35 @@ export class LightningPubClient { /** * Handle incoming response events */ - private handleResponse(event: Event): void { - // Handle async responses if needed - // For invoice watching, we use polling instead + private handleResponse(_event: Event): void { + // Responses are handled by waitForResponse + // This is for async notifications if needed } /** - * Send a request to Lightning.Pub + * Send an RPC request to Lightning.Pub */ - private async sendRequest(request: Request): Promise { + private async sendRPC(rpcName: string, body: unknown): Promise { if (!this.nostrClient || !this.identity) { throw new Error('Client not initialized') } + const requestId = this.generateRequestId() + + const request: RPCRequest = { + rpcName, + body, + authIdentifier: this.identity.publicKey, + requestId, + appId: this.config.appId, + } + const content = encryptContent(this.identity, this.config.accountPubkey, request) // finalizeEvent derives pubkey from the secret key const event = finalizeEvent( { - kind: LIGHTNING_PUB_KINDS.REQUEST, + kind: LightningPubEventKind.RPC, content, tags: [['p', this.config.accountPubkey]], created_at: Math.floor(Date.now() / 1000), @@ -342,13 +317,13 @@ export class LightningPubClient { await this.nostrClient.publish(event) // Wait for response - return this.waitForResponse(event.id) + return this.waitForResponse(event.id, requestId) } /** * Wait for a response to a specific request */ - private waitForResponse(requestId: string): Promise { + private waitForResponse(eventId: string, requestId: string): Promise { return new Promise((resolve, reject) => { if (!this.nostrClient || !this.identity) { reject(new Error('Client not initialized')) @@ -363,9 +338,11 @@ export class LightningPubClient { const subId = this.nostrClient.subscribe( [ { - kinds: [LIGHTNING_PUB_KINDS.RESPONSE], - '#e': [requestId], + kinds: [LightningPubEventKind.RPC], + authors: [this.config.accountPubkey], '#p': [this.identity!.publicKey], + '#e': [eventId], + since: Math.floor(Date.now() / 1000) - 5, }, ], { @@ -374,12 +351,18 @@ export class LightningPubClient { this.nostrClient!.unsubscribe(subId) try { - const response = decryptJSON>(this.identity!, event.pubkey, event.content) + const response = decryptJSON>( + this.identity!, + this.config.accountPubkey, + event.content + ) - if (response.error) { - reject(new Error(response.error.message)) + if (isRPCError(response)) { + reject(new Error(response.reason)) } else { - resolve(response.result as T) + // Extract result from response (excluding status) + const { status, ...result } = response + resolve(result as T) } } catch (e) { reject(e) @@ -390,6 +373,14 @@ export class LightningPubClient { }) } + /** + * Generate a unique request ID + */ + private generateRequestId(): string { + this.requestCounter++ + return `${Date.now()}-${this.requestCounter}` + } + /** * Disconnect and clean up */ diff --git a/lamassu-next/packages/lightning/src/index.ts b/lamassu-next/packages/lightning/src/index.ts index 1535ff8..91fd283 100644 --- a/lamassu-next/packages/lightning/src/index.ts +++ b/lamassu-next/packages/lightning/src/index.ts @@ -4,19 +4,21 @@ * Lightning.Pub client for Nostr-native Lightning operations. * * Lightning.Pub is an account system that wraps LND and provides - * CLINK protocol support. This package handles: - * - Invoice generation - * - Payment processing + * CLINK protocol support. This package handles the RPC layer: + * - Invoice generation via Kind 21000 + * - Payment processing via Kind 21000 * - Balance queries * - Exchange rate fetching * + * For CLINK payment operations (offers, debits, management), + * use @lamassu/clink instead. + * * @example * ```typescript * import { LightningPubClient } from '@lamassu/lightning' * * const client = new LightningPubClient({ - * serviceUrl: 'https://lightning.pub', - * accountPubkey: 'npub1...', + * accountPubkey: 'hex-pubkey...', * relays: ['wss://relay.example.com'], * }) * @@ -25,7 +27,7 @@ * * // Create an invoice * const invoice = await client.createInvoice({ - * amountMsat: 100000, // 100 sats + * amountSats: 100, * description: 'ATM withdrawal', * }) * @@ -46,15 +48,32 @@ export { LightningPubClient } from './client.js' // Types -export type { - LightningPubConfig, - AccountInfo, - Invoice, - InvoiceStatus, - PaymentResult, - ExchangeRate, - ChannelInfo, - NodeInfo, - InvoiceCallback, - PaymentCallback, +export { + // Event kinds + LightningPubEventKind, + // RPC types + type RPCRequest, + type RPCResponse, + type RPCSuccessResponse, + type RPCErrorResponse, + isRPCError, + // Account types + type AccountInfo, + type BalanceResponse, + // Invoice types + type Invoice, + type InvoiceStatus, + type CreateInvoiceParams, + type CreateInvoiceResponse, + type LookupInvoiceResponse, + // Payment types + type PaymentResult, + type PayInvoiceResponse, + // Exchange types + type ExchangeRate, + // Config + type LightningPubConfig, + // Callbacks + type InvoiceCallback, + type PaymentCallback, } from './types.js' diff --git a/lamassu-next/packages/lightning/src/types.ts b/lamassu-next/packages/lightning/src/types.ts index b7cbed6..f4d774c 100644 --- a/lamassu-next/packages/lightning/src/types.ts +++ b/lamassu-next/packages/lightning/src/types.ts @@ -3,8 +3,67 @@ * * Lightning.Pub is a Nostr-native account system that wraps LND * and provides CLINK payment support. + * + * Communication uses Kind 21000 for generic RPC requests. + * Payment operations use CLINK protocol (kinds 21001-21003). */ +/** Lightning.Pub event kinds */ +export enum LightningPubEventKind { + /** Generic RPC request/response */ + RPC = 21000, +} + +// ============================================================================ +// RPC Request/Response +// ============================================================================ + +/** Generic RPC request format (Kind 21000) */ +export interface RPCRequest { + /** Method name (e.g., "NewInvoice", "GetBalance") */ + rpcName: string + /** URL params */ + params?: Record + /** Query params */ + query?: Record + /** Request body */ + body?: unknown + /** Pubkey of requester (for validation) */ + authIdentifier: string + /** Unique request identifier */ + requestId: string + /** Application identifier */ + appId?: string +} + +/** RPC success response */ +export interface RPCSuccessResponse { + /** Success status */ + status: 'OK' + /** Result data - spread into the response object */ + [key: string]: unknown +} + +/** RPC error response */ +export interface RPCErrorResponse { + /** Error status */ + status: 'ERROR' + /** Error reason */ + reason: string +} + +/** RPC response - either success or error */ +export type RPCResponse = (RPCSuccessResponse & T) | RPCErrorResponse + +/** Type guard for RPC error */ +export function isRPCError(response: RPCResponse): response is RPCErrorResponse { + return response.status === 'ERROR' +} + +// ============================================================================ +// Account & Balance +// ============================================================================ + /** Lightning.Pub account information */ export interface AccountInfo { /** Account public key */ @@ -17,6 +76,16 @@ export interface AccountInfo { maxReceiveSats: number } +/** Balance response */ +export interface BalanceResponse { + /** Balance in satoshis */ + balance: number +} + +// ============================================================================ +// Invoices +// ============================================================================ + /** Invoice status */ export type InvoiceStatus = 'pending' | 'paid' | 'expired' | 'cancelled' @@ -26,8 +95,8 @@ export interface Invoice { paymentRequest: string /** Payment hash */ paymentHash: string - /** Amount in millisatoshis */ - amountMsat: number + /** Amount in satoshis */ + amountSats: number /** Invoice description */ description?: string /** Creation timestamp */ @@ -40,18 +109,74 @@ export interface Invoice { preimage?: string } +/** Create invoice request params */ +export interface CreateInvoiceParams { + /** Amount in satoshis */ + amountSats: number + /** Invoice description/memo */ + description?: string + /** Expiry in seconds (default: 3600) */ + expirySecs?: number + /** Include private route hints */ + privateHints?: boolean +} + +/** Create invoice response (from RPC) */ +export interface CreateInvoiceResponse { + /** BOLT-11 payment request */ + payment_request: string + /** Payment hash (hex) */ + payment_hash: string + /** Expiry timestamp */ + expires_at: number +} + +/** Lookup invoice response (from RPC) */ +export interface LookupInvoiceResponse { + /** BOLT-11 payment request */ + payment_request: string + /** Amount in satoshis */ + amount: number + /** Invoice description */ + description: string + /** Creation timestamp */ + created_at: number + /** Expiry timestamp */ + expires_at: number + /** Whether invoice has been paid */ + settled: boolean + /** Payment preimage (if paid) */ + preimage?: string +} + +// ============================================================================ +// Payments +// ============================================================================ + /** Payment result */ export interface PaymentResult { /** Whether payment succeeded */ success: boolean /** Payment preimage (proof of payment) */ preimage?: string - /** Fee paid in millisatoshis */ - feeMsat?: number + /** Fee paid in satoshis */ + feeSats?: number /** Error message if failed */ error?: string } +/** Pay invoice response (from RPC) */ +export interface PayInvoiceResponse { + /** Payment preimage */ + preimage: string + /** Fee paid in satoshis */ + fee_paid?: number +} + +// ============================================================================ +// Exchange Rates +// ============================================================================ + /** Exchange rate information */ export interface ExchangeRate { /** Fiat currency code */ @@ -64,47 +189,25 @@ export interface ExchangeRate { source: string } +// ============================================================================ +// Client Configuration +// ============================================================================ + /** Lightning.Pub client configuration */ export interface LightningPubConfig { - /** Lightning.Pub service URL */ - serviceUrl: string - /** Account pubkey on Lightning.Pub */ + /** Lightning.Pub account pubkey */ accountPubkey: string /** Nostr relays for communication */ relays: string[] - /** Request timeout in ms */ + /** Request timeout in ms (default: 30000) */ timeout?: number + /** Application identifier */ + appId?: string } -/** Channel information */ -export interface ChannelInfo { - /** Channel ID */ - channelId: string - /** Remote pubkey */ - remotePubkey: string - /** Local balance in sats */ - localBalanceSats: number - /** Remote balance in sats */ - remoteBalanceSats: number - /** Channel capacity in sats */ - capacitySats: number - /** Whether channel is active */ - active: boolean -} - -/** Node information */ -export interface NodeInfo { - /** Node public key */ - pubkey: string - /** Node alias */ - alias: string - /** Number of channels */ - numChannels: number - /** Total capacity in sats */ - totalCapacitySats: number - /** Synced to chain */ - syncedToChain: boolean -} +// ============================================================================ +// Callbacks +// ============================================================================ /** Invoice callback for watching payments */ export type InvoiceCallback = (invoice: Invoice) => void