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 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-23 06:18:04 -05:00
commit d5a77c4311
7 changed files with 754 additions and 312 deletions

View file

@ -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<OfferResponse | null>
/** Debit request handler */
/** Debit request handler - returns success/failure */
export type DebitRequestHandler = (
request: DebitRequest,
senderPubkey: string
) => Promise<DebitResponse>
/** Management command handler */
/** Management request handler */
export type ManagementHandler = (
delegation: ManagementDelegation,
request: ManagementRequest,
senderPubkey: string
) => Promise<void>
) => Promise<ManagementResponse | null>
/**
* 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<OfferResponse> {
const content = encryptContent(this.identity, targetPubkey, request)
async requestOffer(
noffer: string,
amountSats: number,
options?: {
payerData?: Record<string, string>
description?: string
expiresInSeconds?: number
}
): Promise<OfferResponse> {
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<OfferResponse>(targetPubkey, CLINKEventKind.Offer)
return this.waitForResponse<OfferResponse>(offer.pubkey, CLINKEventKind.Offer, event.id)
}
/**
* Send a debit request
* Send a debit request (Kind 21002)
*/
async requestDebit(targetPubkey: string, request: DebitRequest): Promise<DebitResponse> {
async requestDebit(
targetPubkey: string,
amountSats: number,
options?: {
memo?: string
rules?: DebitRequest['rules']
}
): Promise<DebitResponse> {
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<DebitResponse>(targetPubkey, CLINKEventKind.Debit)
return this.waitForResponse<DebitResponse>(targetPubkey, CLINKEventKind.Debit, event.id)
}
/**
* Send a management request (Kind 21003)
*/
async sendManagementRequest(
targetPubkey: string,
request: ManagementRequest
): Promise<ManagementResponse> {
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<ManagementResponse>(targetPubkey, CLINKEventKind.Manage, event.id)
}
/**
* Discover service beacon (Kind 30078)
*/
async discoverService(servicePubkey: string): Promise<ServiceBeacon | null> {
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<void> {
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<void> {
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<void> {
// Only accept from operator
@ -281,15 +373,31 @@ export class CLINKClient {
if (!this.managementHandler) return
const delegation = decryptJSON<ManagementDelegation>(this.identity, event.pubkey, event.content)
const request = decryptJSON<ManagementRequest>(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<T>(fromPubkey: string, kind: number): Promise<T> {
private waitForResponse<T>(fromPubkey: string, kind: number, requestEventId: string): Promise<T> {
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 }

View file

@ -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'

View file

@ -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,
}
}

View file

@ -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<string, string>
/** 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<string, number>
/** 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<string>
export type GenerateInvoice = (amountSats: number, description?: string) => Promise<string>
/** Payment function type */
export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }>

View file

@ -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<string, unknown>
}
interface Response<T = unknown> {
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<LightningPubConfig>
@ -58,10 +47,12 @@ export class LightningPubClient {
private identity: MachineIdentity | null = null
private invoiceCallbacks: Map<string, InvoiceCallback> = 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<AccountInfo> {
const response = await this.sendRequest<AccountInfo>({
method: 'get_info',
params: {},
})
const response = await this.sendRPC<AccountInfo>('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<BalanceResponse>('GetBalance', {})
return { balanceSats: response.balance ?? 0 }
}
/**
* Create a Lightning invoice
*/
async createInvoice(params: {
amountMsat: number
description?: string
expirySecs?: number
}): Promise<Invoice> {
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<Invoice> {
const response = await this.sendRPC<CreateInvoiceResponse>('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<PaymentResult> {
try {
const response = await this.sendRequest<{
preimage: string
fee_paid?: number
}>({
method: 'pay_invoice',
params: {
const response = await this.sendRPC<PayInvoiceResponse>('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<Invoice | null> {
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: {
const response = await this.sendRPC<LookupInvoiceResponse>('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<ExchangeRate> {
// For now, return a mock rate
// In production, integrate with price feeds
// Mock rates - in production, fetch from service
const mockRates: Record<string, number> = {
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<T>(request: Request): Promise<T> {
private async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
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<T>(event.id)
return this.waitForResponse<T>(event.id, requestId)
}
/**
* Wait for a response to a specific request
*/
private waitForResponse<T>(requestId: string): Promise<T> {
private waitForResponse<T>(eventId: string, requestId: string): Promise<T> {
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<Response<T>>(this.identity!, event.pubkey, event.content)
const response = decryptJSON<RPCResponse<T>>(
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
*/

View file

@ -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'

View file

@ -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<string, string>
/** Query params */
query?: Record<string, string>
/** 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<T = unknown> {
/** 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<T = unknown> = (RPCSuccessResponse<T> & 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