Implement @lamassu/lightning package

Lightning.Pub client for Nostr-native Lightning operations:

Features:
- Account information and balance queries
- Invoice generation (BOLT11)
- Invoice payment with preimage
- Invoice lookup and status checking
- Payment watching with callbacks
- Exchange rate fetching (mock for now)
- Basic invoice decoding

Communication:
- NIP-47 compatible request/response events
- NIP-44 encrypted content
- Timeout handling

Types:
- AccountInfo, Invoice, PaymentResult
- ExchangeRate, ChannelInfo, NodeInfo

Includes basic unit tests for client initialization.

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

View file

@ -0,0 +1,66 @@
import { describe, it, expect, vi } from 'vitest'
import { LightningPubClient } from '../client.js'
describe('LightningPubClient', () => {
const config = {
serviceUrl: 'https://lightning.pub',
accountPubkey: 'a'.repeat(64),
relays: ['wss://relay.example.com'],
}
describe('constructor', () => {
it('should create a client with config', () => {
const client = new LightningPubClient(config)
expect(client).toBeDefined()
})
})
describe('getExchangeRate', () => {
it('should return mock exchange rate for USD', async () => {
const client = new LightningPubClient(config)
const rate = await client.getExchangeRate('USD')
expect(rate.currency).toBe('USD')
expect(rate.satsPerUnit).toBeGreaterThan(0)
expect(rate.timestamp).toBeDefined()
})
it('should return rate for different currencies', async () => {
const client = new LightningPubClient(config)
const usdRate = await client.getExchangeRate('USD')
const eurRate = await client.getExchangeRate('EUR')
expect(usdRate.satsPerUnit).not.toBe(eurRate.satsPerUnit)
})
})
describe('decodeInvoice', () => {
it('should decode a basic invoice', () => {
const client = new LightningPubClient(config)
// This is a simplified test since we have basic parsing
const result = client.decodeInvoice('lnbc100u1...')
expect(result).toBeDefined()
expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000)
})
})
describe('initialize', () => {
it('should require nostrClient and identity', () => {
const client = new LightningPubClient(config)
// Without initialization, operations should fail
expect(client.getBalance()).rejects.toThrow('Client not initialized')
})
})
describe('disconnect', () => {
it('should clean up resources', () => {
const client = new LightningPubClient(config)
// Should not throw
expect(() => client.disconnect()).not.toThrow()
})
})
})

View file

@ -0,0 +1,405 @@
/**
* Lightning.Pub Client
*
* Client for interacting with Lightning.Pub - a Nostr-native
* account system that wraps LND.
*
* Lightning.Pub provides:
* - Account management (sublayers)
* - Invoice generation
* - Payment processing
* - CLINK protocol support
*/
import type { Event } 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,
} 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
*/
export class LightningPubClient {
private config: Required<LightningPubConfig>
private nostrClient: NostrClient | null = null
private identity: MachineIdentity | null = null
private invoiceCallbacks: Map<string, InvoiceCallback> = new Map()
private subscriptionId?: string
constructor(config: LightningPubConfig) {
this.config = {
timeout: 30000,
...config,
}
}
/**
* Initialize the client with Nostr connection
*/
initialize(nostrClient: NostrClient, identity: MachineIdentity): void {
this.nostrClient = nostrClient
this.identity = identity
this.startListening()
}
/**
* Get account information
*/
async getAccountInfo(): Promise<AccountInfo> {
const response = await this.sendRequest<AccountInfo>({
method: 'get_info',
params: {},
})
return response
}
/**
* Get current balance
*/
async getBalance(): Promise<{ balanceSats: number }> {
const response = await this.sendRequest<{ balance: number }>({
method: 'get_balance',
params: {},
})
return { balanceSats: response.balance }
}
/**
* 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,
},
})
return {
paymentRequest: response.payment_request,
paymentHash: response.payment_hash,
amountMsat: params.amountMsat,
description: params.description,
createdAt: Math.floor(Date.now() / 1000),
expiresAt: response.expires_at,
status: 'pending',
}
}
/**
* Pay a Lightning invoice
*/
async payInvoice(paymentRequest: string): Promise<PaymentResult> {
try {
const response = await this.sendRequest<{
preimage: string
fee_paid?: number
}>({
method: 'pay_invoice',
params: {
invoice: paymentRequest,
},
})
return {
success: true,
preimage: response.preimage,
feeMsat: response.fee_paid,
}
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Payment failed',
}
}
}
/**
* Look up an invoice by payment hash
*/
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: {
payment_hash: paymentHash,
},
})
return {
paymentRequest: response.payment_request,
paymentHash,
amountMsat: response.amount,
description: response.description,
createdAt: response.created_at,
expiresAt: response.expires_at,
status: response.settled ? 'paid' : 'pending',
preimage: response.preimage,
}
} catch {
return null
}
}
/**
* Watch for invoice payment
*/
watchInvoice(paymentHash: string, callback: InvoiceCallback): void {
this.invoiceCallbacks.set(paymentHash, callback)
// Poll for payment status
const pollInterval = setInterval(async () => {
const invoice = await this.lookupInvoice(paymentHash)
if (invoice && invoice.status === 'paid') {
clearInterval(pollInterval)
this.invoiceCallbacks.delete(paymentHash)
callback(invoice)
}
}, 2000)
// Clean up after expiry
setTimeout(
() => {
clearInterval(pollInterval)
this.invoiceCallbacks.delete(paymentHash)
},
10 * 60 * 1000
) // 10 minutes max
}
/**
* Stop watching an invoice
*/
unwatchInvoice(paymentHash: string): void {
this.invoiceCallbacks.delete(paymentHash)
}
/**
* Decode a BOLT11 invoice
*/
decodeInvoice(paymentRequest: string): {
amountMsat: number | null
paymentHash: string
description: string
expiresAt: number
} {
// Basic invoice parsing (for production, use bolt11 library)
// This is a simplified version
const amountMatch = paymentRequest.match(/lnbc(\d+)([munp]?)/)
let amountMsat: number | null = null
if (amountMatch) {
const [, amount, multiplier] = amountMatch
const baseAmount = parseInt(amount ?? '0', 10)
switch (multiplier) {
case 'm':
amountMsat = baseAmount * 100_000_000
break
case 'u':
amountMsat = baseAmount * 100_000
break
case 'n':
amountMsat = baseAmount * 100
break
case 'p':
amountMsat = baseAmount / 10
break
default:
amountMsat = baseAmount * 100_000_000_000
}
}
return {
amountMsat,
paymentHash: '', // Would need proper parsing
description: '',
expiresAt: Math.floor(Date.now() / 1000) + 3600,
}
}
/**
* Get exchange rate (via external service or Lightning.Pub)
*/
async getExchangeRate(currency: string): Promise<ExchangeRate> {
// For now, return a mock rate
// In production, integrate with price feeds
const mockRates: Record<string, number> = {
USD: 2500, // sats per dollar (example)
EUR: 2700,
GBP: 3100,
}
return {
currency,
satsPerUnit: mockRates[currency] ?? 2500,
timestamp: Date.now(),
source: 'mock',
}
}
/**
* Start listening for responses
*/
private startListening(): void {
if (!this.nostrClient || !this.identity) return
if (this.subscriptionId) return
this.subscriptionId = this.nostrClient.subscribe(
[
{
kinds: [LIGHTNING_PUB_KINDS.RESPONSE],
'#p': [this.identity.publicKey],
},
],
{
onEvent: (event) => this.handleResponse(event),
}
)
}
/**
* Handle incoming response events
*/
private handleResponse(event: Event): void {
// Handle async responses if needed
// For invoice watching, we use polling instead
}
/**
* Send a request to Lightning.Pub
*/
private async sendRequest<T>(request: Request): Promise<T> {
if (!this.nostrClient || !this.identity) {
throw new Error('Client not initialized')
}
const content = encryptContent(this.identity, this.config.accountPubkey, request)
const event = finalizeEvent(
{
kind: LIGHTNING_PUB_KINDS.REQUEST,
content,
tags: [['p', this.config.accountPubkey]],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.identity.publicKey,
},
this.identity.privateKey
)
await this.nostrClient.publish(event)
// Wait for response
return this.waitForResponse<T>(event.id)
}
/**
* Wait for a response to a specific request
*/
private waitForResponse<T>(requestId: string): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.nostrClient || !this.identity) {
reject(new Error('Client not initialized'))
return
}
const timeout = setTimeout(() => {
this.nostrClient!.unsubscribe(subId)
reject(new Error('Request timeout'))
}, this.config.timeout)
const subId = this.nostrClient.subscribe(
[
{
kinds: [LIGHTNING_PUB_KINDS.RESPONSE],
'#e': [requestId],
'#p': [this.identity!.publicKey],
},
],
{
onEvent: (event) => {
clearTimeout(timeout)
this.nostrClient!.unsubscribe(subId)
try {
const response = decryptJSON<Response<T>>(this.identity!, event.pubkey, event.content)
if (response.error) {
reject(new Error(response.error.message))
} else {
resolve(response.result as T)
}
} catch (e) {
reject(e)
}
},
}
)
})
}
/**
* Disconnect and clean up
*/
disconnect(): void {
if (this.subscriptionId && this.nostrClient) {
this.nostrClient.unsubscribe(this.subscriptionId)
this.subscriptionId = undefined
}
this.invoiceCallbacks.clear()
this.nostrClient = null
this.identity = null
}
}

View file

@ -0,0 +1,60 @@
/**
* @lamassu/lightning
*
* 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
* - Balance queries
* - Exchange rate fetching
*
* @example
* ```typescript
* import { LightningPubClient } from '@lamassu/lightning'
*
* const client = new LightningPubClient({
* serviceUrl: 'https://lightning.pub',
* accountPubkey: 'npub1...',
* relays: ['wss://relay.example.com'],
* })
*
* // Initialize with Nostr client
* client.initialize(nostrClient, machineIdentity)
*
* // Create an invoice
* const invoice = await client.createInvoice({
* amountMsat: 100000, // 100 sats
* description: 'ATM withdrawal',
* })
*
* // Watch for payment
* client.watchInvoice(invoice.paymentHash, (paid) => {
* console.log('Invoice paid:', paid.preimage)
* })
*
* // Pay an invoice
* const result = await client.payInvoice('lnbc...')
* if (result.success) {
* console.log('Paid with preimage:', result.preimage)
* }
* ```
*/
// Client
export { LightningPubClient } from './client.js'
// Types
export type {
LightningPubConfig,
AccountInfo,
Invoice,
InvoiceStatus,
PaymentResult,
ExchangeRate,
ChannelInfo,
NodeInfo,
InvoiceCallback,
PaymentCallback,
} from './types.js'

View file

@ -0,0 +1,113 @@
/**
* Lightning.Pub client type definitions
*
* Lightning.Pub is a Nostr-native account system that wraps LND
* and provides CLINK payment support.
*/
/** Lightning.Pub account information */
export interface AccountInfo {
/** Account public key */
pubkey: string
/** Account balance in satoshis */
balanceSats: number
/** Maximum send amount */
maxSendSats: number
/** Maximum receive amount */
maxReceiveSats: number
}
/** Invoice status */
export type InvoiceStatus = 'pending' | 'paid' | 'expired' | 'cancelled'
/** Invoice details */
export interface Invoice {
/** BOLT11 payment request */
paymentRequest: string
/** Payment hash */
paymentHash: string
/** Amount in millisatoshis */
amountMsat: number
/** Invoice description */
description?: string
/** Creation timestamp */
createdAt: number
/** Expiry timestamp */
expiresAt: number
/** Current status */
status: InvoiceStatus
/** Payment preimage (if paid) */
preimage?: string
}
/** Payment result */
export interface PaymentResult {
/** Whether payment succeeded */
success: boolean
/** Payment preimage (proof of payment) */
preimage?: string
/** Fee paid in millisatoshis */
feeMsat?: number
/** Error message if failed */
error?: string
}
/** Exchange rate information */
export interface ExchangeRate {
/** Fiat currency code */
currency: string
/** Satoshis per fiat unit */
satsPerUnit: number
/** Timestamp of rate */
timestamp: number
/** Source of rate */
source: string
}
/** Lightning.Pub client configuration */
export interface LightningPubConfig {
/** Lightning.Pub service URL */
serviceUrl: string
/** Account pubkey on Lightning.Pub */
accountPubkey: string
/** Nostr relays for communication */
relays: string[]
/** Request timeout in ms */
timeout?: number
}
/** 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
}
/** Invoice callback for watching payments */
export type InvoiceCallback = (invoice: Invoice) => void
/** Payment status update callback */
export type PaymentCallback = (result: PaymentResult) => void

View file

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

View file

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