Implement @lamassu/nostr-client package

Core Nostr client library for ATM communication:

- Identity management (generateIdentity, load from nsec/hex)
- NIP-44 encryption/decryption for sensitive data
- Event creation utilities (machine status, transactions, auth)
- NostrClient class with relay management
- NIP-42 authentication support for private relays
- Automatic reconnection with exponential backoff
- Subscription management

Types for ATM-specific events:
- Kind 30078: Machine status (replaceable)
- Kind 30079: Transaction records (replaceable)
- Kind 21001-21003: CLINK protocol events
- Kind 22242: NIP-42 auth

Includes unit tests for identity, encryption, and events.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Mulligan 2026-01-23 05:46:18 -05:00
commit f4cd45c81c
15 changed files with 1144 additions and 1 deletions

3
.gitignore vendored
View file

@ -48,3 +48,6 @@ coverage/
# Pre-commit (auto-generated by devenv)
.pre-commit-config.yaml
# External dependencies (cloned for docker)
Lightning.Pub/

View file

@ -21,9 +21,11 @@
"validate-schemas": "tsx scripts/validate-schemas.ts"
},
"dependencies": {
"nostr-tools": "^2.10.0"
"nostr-tools": "^2.10.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/ws": "^8.5.13",
"@types/node": "^22.0.0",
"typescript": "^5.7.0",
"vitest": "^2.1.0",

View file

@ -0,0 +1,14 @@
/**
* Schema validation script for Nostr events
*
* This script validates that event schemas conform to Nostr NIPs.
* Used by pre-commit hooks to catch schema errors early.
*/
// TODO: Implement schema validation
// - Validate event kind numbers match NIP specifications
// - Validate tag formats
// - Validate content structure for typed events
console.log('Schema validation not yet implemented')
process.exit(0)

View file

@ -0,0 +1,46 @@
import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js'
import { encryptContent, decryptContent, decryptJSON } from '../encryption.js'
describe('encryption', () => {
describe('encryptContent / decryptContent', () => {
it('should encrypt and decrypt string content', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const message = 'Hello, Nostr!'
const encrypted = encryptContent(sender, recipient.publicKey, message)
expect(encrypted).not.toBe(message)
expect(typeof encrypted).toBe('string')
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
expect(decrypted).toBe(message)
})
it('should encrypt and decrypt object content', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const data = { amount: 1000, currency: 'USD', timestamp: Date.now() }
const encrypted = encryptContent(sender, recipient.publicKey, data)
const decrypted = decryptContent(recipient, sender.publicKey, encrypted)
expect(JSON.parse(decrypted)).toEqual(data)
})
})
describe('decryptJSON', () => {
it('should decrypt and parse JSON directly', () => {
const sender = generateIdentity()
const recipient = generateIdentity()
const data = { test: true, nested: { value: 42 } }
const encrypted = encryptContent(sender, recipient.publicKey, data)
const decrypted = decryptJSON<typeof data>(recipient, sender.publicKey, encrypted)
expect(decrypted).toEqual(data)
})
})
})

View file

@ -0,0 +1,82 @@
import { describe, it, expect } from 'vitest'
import { generateIdentity } from '../identity.js'
import {
createSignedEvent,
createMachineStatusEvent,
createAuthEvent,
validateEvent,
generateTxId,
} from '../events.js'
import { LamassuEventKind, type MachineStatus } from '../types.js'
describe('events', () => {
describe('createSignedEvent', () => {
it('should create a properly signed event', () => {
const identity = generateIdentity()
const event = createSignedEvent(identity, {
kind: 1,
content: 'test',
tags: [],
created_at: Math.floor(Date.now() / 1000),
})
expect(event.pubkey).toBe(identity.publicKey)
expect(event.kind).toBe(1)
expect(event.content).toBe('test')
expect(event.id).toMatch(/^[0-9a-f]{64}$/)
expect(event.sig).toMatch(/^[0-9a-f]{128}$/)
})
})
describe('createMachineStatusEvent', () => {
it('should create encrypted status event', () => {
const machine = generateIdentity()
const operator = generateIdentity()
const status: MachineStatus = {
online: true,
lastTransaction: Date.now(),
cashLevels: {
validator: 1000,
dispenser: [{ denomination: 20, count: 100, capacity: 500 }],
},
errors: [],
version: '1.0.0',
}
const event = createMachineStatusEvent(machine, operator.publicKey, status)
expect(event.kind).toBe(LamassuEventKind.MachineStatus)
expect(event.pubkey).toBe(machine.publicKey)
expect(event.tags).toContainEqual(['d', 'status'])
expect(event.tags).toContainEqual(['p', operator.publicKey])
// Content should be encrypted (not readable JSON)
expect(() => JSON.parse(event.content)).toThrow()
})
})
describe('createAuthEvent', () => {
it('should create NIP-42 auth event', () => {
const identity = generateIdentity()
const relayUrl = 'wss://relay.test.com'
const challenge = 'random-challenge-string'
const event = createAuthEvent(identity, relayUrl, challenge)
expect(event.kind).toBe(LamassuEventKind.Auth)
expect(event.content).toBe('')
expect(event.tags).toContainEqual(['relay', relayUrl])
expect(event.tags).toContainEqual(['challenge', challenge])
})
})
describe('generateTxId', () => {
it('should generate unique IDs', () => {
const ids = new Set<string>()
for (let i = 0; i < 100; i++) {
ids.add(generateTxId())
}
expect(ids.size).toBe(100)
})
})
})

View file

@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest'
import {
generateIdentity,
loadIdentityFromNsec,
exportToNsec,
parsePublicKey,
} from '../identity.js'
describe('identity', () => {
describe('generateIdentity', () => {
it('should generate a valid identity', () => {
const identity = generateIdentity()
expect(identity.privateKey).toBeInstanceOf(Uint8Array)
expect(identity.privateKey.length).toBe(32)
expect(identity.publicKey).toMatch(/^[0-9a-f]{64}$/)
expect(identity.npub).toMatch(/^npub1[a-z0-9]{58}$/)
})
it('should generate unique identities', () => {
const id1 = generateIdentity()
const id2 = generateIdentity()
expect(id1.publicKey).not.toBe(id2.publicKey)
})
})
describe('exportToNsec / loadIdentityFromNsec', () => {
it('should round-trip identity through nsec', () => {
const original = generateIdentity()
const nsec = exportToNsec(original)
expect(nsec).toMatch(/^nsec1[a-z0-9]{58}$/)
const restored = loadIdentityFromNsec(nsec)
expect(restored.publicKey).toBe(original.publicKey)
expect(restored.npub).toBe(original.npub)
})
})
describe('parsePublicKey', () => {
it('should parse hex public key', () => {
const identity = generateIdentity()
const parsed = parsePublicKey(identity.publicKey)
expect(parsed).toBe(identity.publicKey)
})
it('should parse npub', () => {
const identity = generateIdentity()
const parsed = parsePublicKey(identity.npub)
expect(parsed).toBe(identity.publicKey)
})
it('should throw on invalid format', () => {
expect(() => parsePublicKey('invalid')).toThrow()
})
})
})

View file

@ -0,0 +1,347 @@
/**
* Nostr client for Lamassu ATM
*
* Manages connections to Nostr relays with support for:
* - NIP-42 authentication
* - Event publishing and subscription
* - Automatic reconnection
*/
import { type Event, type Filter, Relay, SimplePool, useWebSocketImplementation } from 'nostr-tools'
import { createAuthEvent } from './events.js'
import type {
NostrClientConfig,
RelayConfig,
SubscriptionFilter,
SubscriptionOptions,
ConnectionState,
EventHandler,
} from './types.js'
// Use ws for Node.js environments
if (typeof WebSocket === 'undefined') {
// Dynamic import for Node.js
import('ws').then((ws) => {
useWebSocketImplementation(ws.default as never)
})
}
interface RelayConnection {
config: RelayConfig
relay: Relay | null
state: ConnectionState
reconnectAttempts: number
}
interface Subscription {
id: string
filters: Filter[]
options: SubscriptionOptions
close: () => void
}
/**
* Nostr client for ATM communication
*/
export class NostrClient {
private config: Required<NostrClientConfig>
private connections: Map<string, RelayConnection> = new Map()
private subscriptions: Map<string, Subscription> = new Map()
private pool: SimplePool
private eventHandlers: Map<string, Set<EventHandler>> = new Map()
private subscriptionCounter = 0
constructor(config: NostrClientConfig) {
this.config = {
connectionTimeout: 10000,
autoReconnect: true,
maxReconnectAttempts: 5,
...config,
}
this.pool = new SimplePool()
// Initialize connections
for (const relayConfig of this.config.relays) {
this.connections.set(relayConfig.url, {
config: relayConfig,
relay: null,
state: 'disconnected',
reconnectAttempts: 0,
})
}
}
/**
* Connect to all configured relays
*/
async connect(): Promise<void> {
const connectPromises = Array.from(this.connections.keys()).map((url) =>
this.connectToRelay(url)
)
await Promise.allSettled(connectPromises)
}
/**
* Connect to a specific relay
*/
private async connectToRelay(url: string): Promise<void> {
const connection = this.connections.get(url)
if (!connection) return
connection.state = 'connecting'
try {
const relay = await Relay.connect(url)
connection.relay = relay
connection.state = 'connected'
connection.reconnectAttempts = 0
// Handle NIP-42 auth if required
if (connection.config.requiresAuth) {
await this.handleAuth(connection)
}
// Set up event handlers
relay.onclose = () => {
connection.state = 'disconnected'
this.emitEvent('disconnect', { relay: url })
if (this.config.autoReconnect) {
this.scheduleReconnect(url)
}
}
this.emitEvent('connect', { relay: url })
} catch (error) {
connection.state = 'error'
this.emitEvent('error', {
relay: url,
error: error instanceof Error ? error : new Error(String(error)),
})
if (this.config.autoReconnect) {
this.scheduleReconnect(url)
}
}
}
/**
* Handle NIP-42 authentication
*/
private async handleAuth(connection: RelayConnection): Promise<void> {
if (!connection.relay) return
connection.state = 'authenticating'
// Listen for AUTH challenge
// Note: nostr-tools handles this internally, but we need to provide the signed event
const relay = connection.relay
// Subscribe to auth challenges
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Auth timeout'))
}, this.config.connectionTimeout)
// The relay will send an AUTH challenge when auth is required
// We respond by publishing an auth event
relay
.auth(async (challenge: string) => {
const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge)
return authEvent
})
.then(() => {
clearTimeout(timeout)
connection.state = 'authenticated'
this.emitEvent('auth', { relay: connection.config.url, success: true })
resolve()
})
.catch((error) => {
clearTimeout(timeout)
connection.state = 'error'
this.emitEvent('auth', { relay: connection.config.url, success: false })
reject(error)
})
})
}
/**
* Schedule a reconnection attempt
*/
private scheduleReconnect(url: string): void {
const connection = this.connections.get(url)
if (!connection) return
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
return
}
connection.reconnectAttempts++
const delay = Math.min(1000 * Math.pow(2, connection.reconnectAttempts), 30000)
setTimeout(() => {
this.connectToRelay(url)
}, delay)
}
/**
* Publish an event to all writable relays
*/
async publish(event: Event): Promise<void> {
const writableUrls = Array.from(this.connections.values())
.filter((c) => !c.config.readOnly && c.state === 'authenticated')
.map((c) => c.config.url)
if (writableUrls.length === 0) {
throw new Error('No writable relays available')
}
await Promise.all(this.pool.publish(writableUrls, event))
}
/**
* Subscribe to events matching filters
*/
subscribe(filters: SubscriptionFilter[], options: SubscriptionOptions): string {
const id = `sub_${++this.subscriptionCounter}`
const connectedUrls = Array.from(this.connections.values())
.filter((c) => c.state === 'authenticated' || c.state === 'connected')
.map((c) => c.config.url)
if (connectedUrls.length === 0) {
throw new Error('No connected relays')
}
const sub = this.pool.subscribeMany(connectedUrls, filters as Filter[], {
onevent: (event) => {
options.onEvent(event)
this.emitEvent('event', { relay: 'pool', event })
},
oneose: () => {
options.onEose?.()
if (options.closeOnEose) {
this.unsubscribe(id)
}
},
})
this.subscriptions.set(id, {
id,
filters: filters as Filter[],
options,
close: () => sub.close(),
})
return id
}
/**
* Unsubscribe from a subscription
*/
unsubscribe(subscriptionId: string): void {
const sub = this.subscriptions.get(subscriptionId)
if (sub) {
sub.close()
this.subscriptions.delete(subscriptionId)
}
}
/**
* Query events (one-time fetch)
*/
async queryEvents(filters: SubscriptionFilter[]): Promise<Event[]> {
const connectedUrls = Array.from(this.connections.values())
.filter((c) => c.state === 'authenticated' || c.state === 'connected')
.map((c) => c.config.url)
if (connectedUrls.length === 0) {
throw new Error('No connected relays')
}
return this.pool.querySync(connectedUrls, filters as Filter[])
}
/**
* Disconnect from all relays
*/
disconnect(): void {
// Close all subscriptions
for (const sub of this.subscriptions.values()) {
sub.close()
}
this.subscriptions.clear()
// Disconnect all relays
for (const connection of this.connections.values()) {
connection.relay?.close()
connection.state = 'disconnected'
}
this.pool.close(Array.from(this.connections.keys()))
}
/**
* Get connection state for a relay
*/
getConnectionState(url: string): ConnectionState | undefined {
return this.connections.get(url)?.state
}
/**
* Get all connection states
*/
getConnectionStates(): Map<string, ConnectionState> {
return new Map(Array.from(this.connections.entries()).map(([url, conn]) => [url, conn.state]))
}
/**
* Add event listener for client events
*/
on(event: string, handler: EventHandler): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set())
}
this.eventHandlers.get(event)!.add(handler)
}
/**
* Remove event listener
*/
off(event: string, handler: EventHandler): void {
this.eventHandlers.get(event)?.delete(handler)
}
/**
* Emit an event to handlers
*/
private emitEvent(event: string, data: unknown): void {
const handlers = this.eventHandlers.get(event)
if (handlers) {
for (const handler of handlers) {
try {
handler(data as Event)
} catch {
// Ignore handler errors
}
}
}
}
/**
* Get the machine's public key
*/
get publicKey(): string {
return this.config.identity.publicKey
}
/**
* Get the machine's npub
*/
get npub(): string {
return this.config.identity.npub
}
}

View file

@ -0,0 +1,64 @@
/**
* NIP-44 Encryption utilities
*
* Used for encrypting sensitive data in events (machine status,
* transaction records, operator commands).
*/
import { nip44 } from 'nostr-tools'
import type { MachineIdentity } from './types.js'
/**
* Encrypt content for a recipient using NIP-44
*
* @param identity - Sender's identity (machine)
* @param recipientPubkey - Recipient's public key (hex)
* @param content - Content to encrypt (will be JSON stringified if object)
* @returns Encrypted string
*/
export function encryptContent(
identity: MachineIdentity,
recipientPubkey: string,
content: unknown
): string {
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
const conversationKey = nip44.v2.utils.getConversationKey(identity.privateKey, recipientPubkey)
return nip44.v2.encrypt(plaintext, conversationKey)
}
/**
* Decrypt content from a sender using NIP-44
*
* @param identity - Recipient's identity (machine)
* @param senderPubkey - Sender's public key (hex)
* @param ciphertext - Encrypted content
* @returns Decrypted string
*/
export function decryptContent(
identity: MachineIdentity,
senderPubkey: string,
ciphertext: string
): string {
const conversationKey = nip44.v2.utils.getConversationKey(identity.privateKey, senderPubkey)
return nip44.v2.decrypt(ciphertext, conversationKey)
}
/**
* Decrypt and parse JSON content
*
* @param identity - Recipient's identity
* @param senderPubkey - Sender's public key
* @param ciphertext - Encrypted content
* @returns Parsed JSON object
*/
export function decryptJSON<T = unknown>(
identity: MachineIdentity,
senderPubkey: string,
ciphertext: string
): T {
const plaintext = decryptContent(identity, senderPubkey, ciphertext)
return JSON.parse(plaintext) as T
}

View file

@ -0,0 +1,115 @@
/**
* Event creation utilities for Lamassu ATM
*/
import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools'
import { encryptContent } from './encryption.js'
import {
type MachineIdentity,
type MachineStatus,
type TransactionRecord,
LamassuEventKind,
} from './types.js'
/**
* Create a signed event
*/
export function createSignedEvent(
identity: MachineIdentity,
event: Omit<UnsignedEvent, 'pubkey'>
): Event {
const unsigned: UnsignedEvent = {
...event,
pubkey: identity.publicKey,
}
return finalizeEvent(unsigned, identity.privateKey)
}
/**
* Create a machine status event (Kind 30078)
*
* This is a replaceable event that represents the current machine state.
* Content is encrypted with NIP-44 for the operator.
*/
export function createMachineStatusEvent(
identity: MachineIdentity,
operatorPubkey: string,
status: MachineStatus
): Event {
const encryptedContent = encryptContent(identity, operatorPubkey, status)
return createSignedEvent(identity, {
kind: LamassuEventKind.MachineStatus,
content: encryptedContent,
tags: [
['d', 'status'],
['p', operatorPubkey],
],
created_at: Math.floor(Date.now() / 1000),
})
}
/**
* Create a transaction record event (Kind 30079)
*
* Replaceable event for each transaction, identified by txid.
* Content is encrypted with NIP-44 for the operator.
*/
export function createTransactionEvent(
identity: MachineIdentity,
operatorPubkey: string,
transaction: TransactionRecord
): Event {
const encryptedContent = encryptContent(identity, operatorPubkey, transaction)
return createSignedEvent(identity, {
kind: LamassuEventKind.TransactionRecord,
content: encryptedContent,
tags: [
['d', `tx:${transaction.txid}`],
['p', operatorPubkey],
],
created_at: Math.floor(Date.now() / 1000),
})
}
/**
* Create a NIP-42 auth event for relay authentication
*/
export function createAuthEvent(
identity: MachineIdentity,
relayUrl: string,
challenge: string
): Event {
return createSignedEvent(identity, {
kind: LamassuEventKind.Auth,
content: '',
tags: [
['relay', relayUrl],
['challenge', challenge],
],
created_at: Math.floor(Date.now() / 1000),
})
}
/**
* Validate an event signature
*/
export function validateEvent(event: Event): boolean {
try {
const hash = getEventHash(event)
return hash === event.id
} catch {
return false
}
}
/**
* Generate a unique transaction ID
*/
export function generateTxId(): string {
const timestamp = Date.now().toString(36)
const random = Math.random().toString(36).substring(2, 10)
return `${timestamp}-${random}`
}

View file

@ -0,0 +1,115 @@
/**
* Machine identity management
*
* Each ATM has a Nostr keypair that serves as its cryptographic identity.
* This replaces traditional certificate-based authentication.
*/
import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools'
import type { MachineIdentity } from './types.js'
/**
* Generate a new machine identity (keypair)
*/
export function generateIdentity(): MachineIdentity {
const privateKey = generateSecretKey()
const publicKey = getPublicKey(privateKey)
const npub = nip19.npubEncode(publicKey)
return {
privateKey,
publicKey,
npub,
}
}
/**
* Load identity from hex-encoded private key
*/
export function loadIdentityFromHex(privateKeyHex: string): MachineIdentity {
const privateKey = hexToBytes(privateKeyHex)
const publicKey = getPublicKey(privateKey)
const npub = nip19.npubEncode(publicKey)
return {
privateKey,
publicKey,
npub,
}
}
/**
* Load identity from nsec (bech32-encoded private key)
*/
export function loadIdentityFromNsec(nsec: string): MachineIdentity {
const decoded = nip19.decode(nsec)
if (decoded.type !== 'nsec') {
throw new Error('Invalid nsec format')
}
const privateKey = decoded.data
const publicKey = getPublicKey(privateKey)
const npub = nip19.npubEncode(publicKey)
return {
privateKey,
publicKey,
npub,
}
}
/**
* Export identity to nsec (for secure storage)
*/
export function exportToNsec(identity: MachineIdentity): string {
return nip19.nsecEncode(identity.privateKey)
}
/**
* Parse a public key from various formats
* Accepts: hex, npub, nprofile
*/
export function parsePublicKey(input: string): string {
// Already hex format (64 chars)
if (/^[0-9a-f]{64}$/i.test(input)) {
return input.toLowerCase()
}
// Try to decode as bech32
try {
const decoded = nip19.decode(input)
switch (decoded.type) {
case 'npub':
return decoded.data
case 'nprofile':
return decoded.data.pubkey
default:
throw new Error(`Unsupported format: ${decoded.type}`)
}
} catch {
throw new Error('Invalid public key format')
}
}
/**
* Convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
if (hex.length % 2 !== 0) {
throw new Error('Invalid hex string')
}
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}
/**
* Convert Uint8Array to hex string
*/
export function bytesToHex(bytes: Uint8Array): string {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('')
}

View file

@ -0,0 +1,90 @@
/**
* @lamassu/nostr-client
*
* Nostr client library for Lamassu ATM communication.
*
* Features:
* - NIP-42 authentication for private relays
* - NIP-44 encryption for sensitive data
* - Machine identity management
* - Event publishing and subscription
* - Automatic reconnection
*
* @example
* ```typescript
* import {
* NostrClient,
* generateIdentity,
* createMachineStatusEvent
* } from '@lamassu/nostr-client'
*
* // Create or load identity
* const identity = generateIdentity()
*
* // Create client
* const client = new NostrClient({
* relays: [
* { url: 'wss://relay.youratm.company', requiresAuth: true }
* ],
* identity
* })
*
* // Connect
* await client.connect()
*
* // Publish machine status
* const statusEvent = createMachineStatusEvent(
* identity,
* operatorPubkey,
* { online: true, ... }
* )
* await client.publish(statusEvent)
* ```
*/
// Main client
export { NostrClient } from './client.js'
// Identity management
export {
generateIdentity,
loadIdentityFromHex,
loadIdentityFromNsec,
exportToNsec,
parsePublicKey,
bytesToHex,
} from './identity.js'
// Event creation
export {
createSignedEvent,
createMachineStatusEvent,
createTransactionEvent,
createAuthEvent,
validateEvent,
generateTxId,
} from './events.js'
// Encryption
export { encryptContent, decryptContent, decryptJSON } from './encryption.js'
// Types
export type {
ConnectionState,
RelayConfig,
MachineIdentity,
NostrClientConfig,
SubscriptionFilter,
EventHandler,
EoseHandler,
SubscriptionOptions,
MachineStatus,
TransactionRecord,
OperatorCommand,
ClientEvents,
} from './types.js'
export { LamassuEventKind } from './types.js'
// Re-export useful nostr-tools types
export type { Event, UnsignedEvent, Filter } from 'nostr-tools'

View file

@ -0,0 +1,147 @@
/**
* Nostr client type definitions for Lamassu ATM
*/
import type { Event, UnsignedEvent } from 'nostr-tools'
/** Connection states for relay */
export type ConnectionState =
| 'disconnected'
| 'connecting'
| 'connected'
| 'authenticating'
| 'authenticated'
| 'error'
/** Relay configuration */
export interface RelayConfig {
/** WebSocket URL (wss:// or ws://) */
url: string
/** Whether this relay requires NIP-42 authentication */
requiresAuth?: boolean
/** Read-only relay (no publishing) */
readOnly?: boolean
}
/** Machine identity configuration */
export interface MachineIdentity {
/** Private key in hex format */
privateKey: Uint8Array
/** Public key in hex format */
publicKey: string
/** Public key in npub format */
npub: string
}
/** Client configuration */
export interface NostrClientConfig {
/** Relays to connect to */
relays: RelayConfig[]
/** Machine identity (keypair) */
identity: MachineIdentity
/** Connection timeout in ms (default: 10000) */
connectionTimeout?: number
/** Reconnect automatically on disconnect */
autoReconnect?: boolean
/** Max reconnection attempts (default: 5) */
maxReconnectAttempts?: number
}
/** Subscription filter */
export interface SubscriptionFilter {
/** Event IDs to match */
ids?: string[]
/** Authors (pubkeys) to match */
authors?: string[]
/** Event kinds to match */
kinds?: number[]
/** Tags to match (#e, #p, etc.) */
'#e'?: string[]
'#p'?: string[]
'#d'?: string[]
/** Only events after this timestamp */
since?: number
/** Only events before this timestamp */
until?: number
/** Maximum number of events */
limit?: number
}
/** Event handler callback */
export type EventHandler = (event: Event) => void | Promise<void>
/** End of stored events callback */
export type EoseHandler = () => void
/** Subscription options */
export interface SubscriptionOptions {
/** Handler for each event */
onEvent: EventHandler
/** Handler when end of stored events reached */
onEose?: EoseHandler
/** Close subscription after EOSE */
closeOnEose?: boolean
}
/** ATM-specific event kinds */
export enum LamassuEventKind {
/** CLINK Offer Request/Response */
ClinkOffer = 21001,
/** CLINK Debit Request/Response */
ClinkDebit = 21002,
/** CLINK Management */
ClinkManage = 21003,
/** Machine status (replaceable) */
MachineStatus = 30078,
/** Transaction record (replaceable) */
TransactionRecord = 30079,
/** NIP-17 Direct Message */
DirectMessage = 14,
/** NIP-17 Gift Wrap */
GiftWrap = 1059,
/** NIP-42 Auth */
Auth = 22242,
}
/** Machine status content (encrypted) */
export interface MachineStatus {
online: boolean
lastTransaction: number
cashLevels: {
validator: number
dispenser: Array<{
denomination: number
count: number
capacity: number
}>
}
errors: string[]
version: string
}
/** Transaction record content (encrypted) */
export interface TransactionRecord {
txid: string
type: 'cash_in' | 'cash_out'
amountFiat: number
amountSats: number
fee: number
timestamp: number
paymentMethod: 'lnurl_withdraw' | 'clink_offer' | 'invoice' | 'cashu'
}
/** Operator command content (encrypted) */
export interface OperatorCommand {
command: 'restart' | 'update' | 'disable' | 'enable' | 'set_limits'
params?: Record<string, unknown>
timestamp: number
}
/** Events emitted by the client */
export interface ClientEvents {
connect: { relay: string }
disconnect: { relay: string; reason?: string }
auth: { relay: string; success: boolean }
error: { relay: string; error: Error }
event: { relay: string; event: Event }
}

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

View file

@ -83,10 +83,16 @@ importers:
nostr-tools:
specifier: ^2.10.0
version: 2.19.4(typescript@5.9.3)
ws:
specifier: ^8.18.0
version: 8.19.0
devDependencies:
'@types/node':
specifier: ^22.0.0
version: 22.19.7
'@types/ws':
specifier: ^8.5.13
version: 8.18.1
tsx:
specifier: ^4.19.0
version: 4.21.0
@ -793,6 +799,9 @@ packages:
'@types/node@22.19.7':
resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==}
'@types/ws@8.18.1':
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
'@vitejs/plugin-vue@5.2.4':
resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==}
engines: {node: ^18.0.0 || >=20.0.0}
@ -1246,6 +1255,18 @@ packages:
engines: {node: '>=8'}
hasBin: true
ws@8.19.0:
resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: '>=5.0.2'
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
xstate@5.25.1:
resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==}
@ -1625,6 +1646,10 @@ snapshots:
dependencies:
undici-types: 6.21.0
'@types/ws@8.18.1':
dependencies:
'@types/node': 22.19.7
'@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.7)(tsx@4.21.0))(vue@3.5.27(typescript@5.9.3))':
dependencies:
vite: 6.4.1(@types/node@22.19.7)(tsx@4.21.0)
@ -2135,4 +2160,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
ws@8.19.0: {}
xstate@5.25.1: {}