feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
46
packages/nostr-client/src/__tests__/encryption.test.ts
Normal file
46
packages/nostr-client/src/__tests__/encryption.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
82
packages/nostr-client/src/__tests__/events.test.ts
Normal file
82
packages/nostr-client/src/__tests__/events.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
61
packages/nostr-client/src/__tests__/identity.test.ts
Normal file
61
packages/nostr-client/src/__tests__/identity.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
365
packages/nostr-client/src/client.ts
Normal file
365
packages/nostr-client/src/client.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 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,
|
||||
type VerifiedEvent,
|
||||
Relay,
|
||||
SimplePool,
|
||||
verifyEvent,
|
||||
} from 'nostr-tools'
|
||||
import { createAuthEvent } from './events.js'
|
||||
import type {
|
||||
NostrClientConfig,
|
||||
RelayConfig,
|
||||
SubscriptionFilter,
|
||||
SubscriptionOptions,
|
||||
ConnectionState,
|
||||
EventHandler,
|
||||
} from './types.js'
|
||||
|
||||
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)
|
||||
} else {
|
||||
// Mark as authenticated if no auth required
|
||||
connection.state = 'authenticated'
|
||||
}
|
||||
|
||||
// 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'
|
||||
|
||||
const relay = connection.relay
|
||||
|
||||
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 (evt) => {
|
||||
// evt is the challenge event template from the relay
|
||||
// We need to extract the challenge and create our auth response
|
||||
const challenge =
|
||||
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
|
||||
const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge)
|
||||
// Verify the event to get a VerifiedEvent type
|
||||
if (verifyEvent(authEvent)) {
|
||||
return authEvent as VerifiedEvent
|
||||
}
|
||||
throw new Error('Failed to create valid auth event')
|
||||
})
|
||||
.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
|
||||
*
|
||||
* Uses direct Relay connections instead of SimplePool for real-time event delivery.
|
||||
*/
|
||||
subscribe(filters: SubscriptionFilter[], options: SubscriptionOptions): string {
|
||||
const id = `sub_${++this.subscriptionCounter}`
|
||||
|
||||
// Get connected relay instances
|
||||
const connectedRelays = Array.from(this.connections.values())
|
||||
.filter((c) => c.state === 'authenticated' || c.state === 'connected')
|
||||
.filter((c) => c.relay !== null)
|
||||
|
||||
if (connectedRelays.length === 0) {
|
||||
throw new Error('No connected relays')
|
||||
}
|
||||
|
||||
// Subscribe on each connected relay directly (not through pool)
|
||||
const subs: Array<{ close: () => void }> = []
|
||||
for (const conn of connectedRelays) {
|
||||
if (!conn.relay) continue
|
||||
|
||||
const sub = conn.relay.subscribe(filters as Filter[], {
|
||||
onevent: (event: Event) => {
|
||||
options.onEvent(event)
|
||||
this.emitEvent('event', { relay: conn.config.url, event })
|
||||
},
|
||||
oneose: () => {
|
||||
options.onEose?.()
|
||||
if (options.closeOnEose) {
|
||||
this.unsubscribe(id)
|
||||
}
|
||||
},
|
||||
})
|
||||
subs.push(sub)
|
||||
}
|
||||
|
||||
this.subscriptions.set(id, {
|
||||
id,
|
||||
filters: filters as unknown as Filter[],
|
||||
options,
|
||||
close: () => subs.forEach((s) => s.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')
|
||||
}
|
||||
|
||||
// @ts-expect-error nostr-tools types expect single Filter but querySync accepts array
|
||||
return this.pool.querySync(connectedUrls, filters)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
289
packages/nostr-client/src/encryption.ts
Normal file
289
packages/nostr-client/src/encryption.ts
Normal file
|
|
@ -0,0 +1,289 @@
|
|||
/**
|
||||
* NIP-44 Encryption utilities
|
||||
*
|
||||
* Supports both:
|
||||
* - v1: Lightning.Pub's custom format (xchacha20, used for kind 21000)
|
||||
* - v2: Standard NIP-44 v2 (used for other kinds)
|
||||
*
|
||||
* NOTE: Lightning.Pub currently only supports NIP-44 v1 for kind 21000 RPC.
|
||||
* A contribution to support v2 would be welcome:
|
||||
* https://github.com/shocknet/Lightning.Pub
|
||||
*/
|
||||
|
||||
import { nip44 } from 'nostr-tools'
|
||||
import { bytesToHex, hexToBytes } from 'nostr-tools/utils'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import type { MachineIdentity } from './types.js'
|
||||
|
||||
const V1_ENCRYPTION_VERSION = 1
|
||||
|
||||
// Base64 utilities that work in both browser and Node
|
||||
function base64Encode(bytes: Uint8Array): string {
|
||||
if (typeof btoa !== 'undefined') {
|
||||
let binary = ''
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]!)
|
||||
}
|
||||
return btoa(binary)
|
||||
}
|
||||
return Buffer.from(bytes).toString('base64')
|
||||
}
|
||||
|
||||
function base64Decode(str: string): Uint8Array {
|
||||
if (typeof atob !== 'undefined') {
|
||||
const binary = atob(str)
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
return new Uint8Array(Buffer.from(str, 'base64'))
|
||||
}
|
||||
|
||||
// Crypto random bytes
|
||||
function getRandomBytes(length: number): Uint8Array {
|
||||
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
||||
return crypto.getRandomValues(new Uint8Array(length))
|
||||
}
|
||||
// Node.js fallback
|
||||
const { randomBytes } = require('crypto') as typeof import('crypto')
|
||||
return new Uint8Array(randomBytes(length))
|
||||
}
|
||||
|
||||
// XChaCha20 implementation
|
||||
function rotl(a: number, b: number): number {
|
||||
return ((a << b) | (a >>> (32 - b))) >>> 0
|
||||
}
|
||||
|
||||
function quarterRound(state: Uint32Array, a: number, b: number, c: number, d: number): void {
|
||||
state[a] = (state[a]! + state[b]!) >>> 0
|
||||
state[d] = rotl(state[d]! ^ state[a]!, 16)
|
||||
state[c] = (state[c]! + state[d]!) >>> 0
|
||||
state[b] = rotl(state[b]! ^ state[c]!, 12)
|
||||
state[a] = (state[a]! + state[b]!) >>> 0
|
||||
state[d] = rotl(state[d]! ^ state[a]!, 8)
|
||||
state[c] = (state[c]! + state[d]!) >>> 0
|
||||
state[b] = rotl(state[b]! ^ state[c]!, 7)
|
||||
}
|
||||
|
||||
function chacha20Block(key: Uint8Array, nonce: Uint8Array, counter: number): Uint8Array {
|
||||
const state = new Uint32Array(16)
|
||||
const keyBuf = new ArrayBuffer(32)
|
||||
new Uint8Array(keyBuf).set(key)
|
||||
const nonceBuf = new ArrayBuffer(12)
|
||||
new Uint8Array(nonceBuf).set(nonce)
|
||||
const view = new DataView(keyBuf)
|
||||
const nonceView = new DataView(nonceBuf)
|
||||
|
||||
// "expand 32-byte k"
|
||||
state[0] = 0x61707865
|
||||
state[1] = 0x3320646e
|
||||
state[2] = 0x79622d32
|
||||
state[3] = 0x6b206574
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
state[4 + i] = view.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
state[12] = counter >>> 0
|
||||
for (let i = 0; i < 3; i++) {
|
||||
state[13 + i] = nonceView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
const working = new Uint32Array(state)
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarterRound(working, 0, 4, 8, 12)
|
||||
quarterRound(working, 1, 5, 9, 13)
|
||||
quarterRound(working, 2, 6, 10, 14)
|
||||
quarterRound(working, 3, 7, 11, 15)
|
||||
quarterRound(working, 0, 5, 10, 15)
|
||||
quarterRound(working, 1, 6, 11, 12)
|
||||
quarterRound(working, 2, 7, 8, 13)
|
||||
quarterRound(working, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
const output = new Uint8Array(64)
|
||||
const outView = new DataView(output.buffer)
|
||||
for (let i = 0; i < 16; i++) {
|
||||
outView.setUint32(i * 4, (working[i]! + state[i]!) >>> 0, true)
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
function hchacha20(key: Uint8Array, nonce: Uint8Array): Uint8Array {
|
||||
const state = new Uint32Array(16)
|
||||
const keyBuf = new ArrayBuffer(32)
|
||||
new Uint8Array(keyBuf).set(key)
|
||||
const nonceBuf = new ArrayBuffer(16)
|
||||
new Uint8Array(nonceBuf).set(nonce)
|
||||
const keyView = new DataView(keyBuf)
|
||||
const nonceView = new DataView(nonceBuf)
|
||||
|
||||
state[0] = 0x61707865
|
||||
state[1] = 0x3320646e
|
||||
state[2] = 0x79622d32
|
||||
state[3] = 0x6b206574
|
||||
|
||||
for (let i = 0; i < 8; i++) {
|
||||
state[4 + i] = keyView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
state[12 + i] = nonceView.getUint32(i * 4, true)
|
||||
}
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
quarterRound(state, 0, 4, 8, 12)
|
||||
quarterRound(state, 1, 5, 9, 13)
|
||||
quarterRound(state, 2, 6, 10, 14)
|
||||
quarterRound(state, 3, 7, 11, 15)
|
||||
quarterRound(state, 0, 5, 10, 15)
|
||||
quarterRound(state, 1, 6, 11, 12)
|
||||
quarterRound(state, 2, 7, 8, 13)
|
||||
quarterRound(state, 3, 4, 9, 14)
|
||||
}
|
||||
|
||||
const result = new Uint8Array(32)
|
||||
const resultView = new DataView(result.buffer)
|
||||
resultView.setUint32(0, state[0]!, true)
|
||||
resultView.setUint32(4, state[1]!, true)
|
||||
resultView.setUint32(8, state[2]!, true)
|
||||
resultView.setUint32(12, state[3]!, true)
|
||||
resultView.setUint32(16, state[12]!, true)
|
||||
resultView.setUint32(20, state[13]!, true)
|
||||
resultView.setUint32(24, state[14]!, true)
|
||||
resultView.setUint32(28, state[15]!, true)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function xchacha20Encrypt(key: Uint8Array, nonce: Uint8Array, data: Uint8Array): Uint8Array {
|
||||
const subkey = hchacha20(key, nonce.subarray(0, 16))
|
||||
const chacha20Nonce = new Uint8Array(12)
|
||||
chacha20Nonce.set(nonce.subarray(16, 24), 4)
|
||||
|
||||
const result = new Uint8Array(data.length)
|
||||
let counter = 0
|
||||
|
||||
for (let offset = 0; offset < data.length; offset += 64) {
|
||||
const block = chacha20Block(subkey, chacha20Nonce, counter++)
|
||||
const remaining = Math.min(64, data.length - offset)
|
||||
for (let i = 0; i < remaining; i++) {
|
||||
result[offset + i] = data[offset + i]! ^ block[i]!
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared secret for v1 encryption (Lightning.Pub format)
|
||||
*
|
||||
* NIP-44 v1 key derivation:
|
||||
* sha256(secp256k1.getSharedSecret(privKey, "02" + pubKey).slice(1, 33))
|
||||
*
|
||||
* This differs from v2 which uses HKDF instead of plain SHA-256.
|
||||
*/
|
||||
function getConversationKeyV1(privateKey: Uint8Array, publicKey: string): Uint8Array {
|
||||
// Compute ECDH shared point with compressed pubkey (02 prefix for even y)
|
||||
const compressedPubkey = hexToBytes('02' + publicKey)
|
||||
const sharedPoint = secp256k1.getSharedSecret(privateKey, compressedPubkey)
|
||||
// Take x-coordinate only (skip the 0x04 prefix byte) and hash with SHA-256
|
||||
return sha256(sharedPoint.slice(1, 33))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content using v1 format (Lightning.Pub's format for kind 21000)
|
||||
*/
|
||||
export function encryptV1(content: string, sharedSecret: Uint8Array): string {
|
||||
const nonce = getRandomBytes(24)
|
||||
const plaintext = new TextEncoder().encode(content)
|
||||
const ciphertext = xchacha20Encrypt(sharedSecret, nonce, plaintext)
|
||||
|
||||
const payload = new Uint8Array(1 + nonce.length + ciphertext.length)
|
||||
payload[0] = V1_ENCRYPTION_VERSION
|
||||
payload.set(nonce, 1)
|
||||
payload.set(ciphertext, 25)
|
||||
|
||||
return base64Encode(payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt content using v1 format (Lightning.Pub's format)
|
||||
*/
|
||||
export function decryptV1(content: string, sharedSecret: Uint8Array): string {
|
||||
const buf = base64Decode(content)
|
||||
|
||||
if (buf[0] !== V1_ENCRYPTION_VERSION) {
|
||||
throw new Error('Encryption version unsupported')
|
||||
}
|
||||
|
||||
const nonce = buf.subarray(1, 25)
|
||||
const ciphertext = buf.subarray(25)
|
||||
const plaintext = xchacha20Encrypt(sharedSecret, nonce, ciphertext) // XChaCha20 is symmetric
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content for Lightning.Pub RPC (kind 21000)
|
||||
* Uses v1 format that Lightning.Pub expects
|
||||
*/
|
||||
export function encryptContent(
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
content: unknown
|
||||
): string {
|
||||
const plaintext = typeof content === 'string' ? content : JSON.stringify(content)
|
||||
const sharedSecret = getConversationKeyV1(identity.privateKey, recipientPubkey)
|
||||
return encryptV1(plaintext, sharedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt content from Lightning.Pub RPC (kind 21000)
|
||||
* Uses v1 format
|
||||
*/
|
||||
export function decryptContent(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): string {
|
||||
const sharedSecret = getConversationKeyV1(identity.privateKey, senderPubkey)
|
||||
return decryptV1(ciphertext, sharedSecret)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse JSON content
|
||||
*/
|
||||
export function decryptJSON<T = unknown>(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): T {
|
||||
const plaintext = decryptContent(identity, senderPubkey, ciphertext)
|
||||
return JSON.parse(plaintext) as T
|
||||
}
|
||||
|
||||
// Also export v2 functions for other use cases (non-RPC encrypted messages)
|
||||
export const encryptContentV2 = (
|
||||
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)
|
||||
}
|
||||
|
||||
export const decryptContentV2 = (
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): string => {
|
||||
const conversationKey = nip44.v2.utils.getConversationKey(identity.privateKey, senderPubkey)
|
||||
return nip44.v2.decrypt(ciphertext, conversationKey)
|
||||
}
|
||||
115
packages/nostr-client/src/events.ts
Normal file
115
packages/nostr-client/src/events.ts
Normal 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}`
|
||||
}
|
||||
115
packages/nostr-client/src/identity.ts
Normal file
115
packages/nostr-client/src/identity.ts
Normal 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('')
|
||||
}
|
||||
97
packages/nostr-client/src/index.ts
Normal file
97
packages/nostr-client/src/index.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* @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,
|
||||
// NIP-44 v2 (standard, for CLINK protocol)
|
||||
encryptContentV2,
|
||||
decryptContentV2,
|
||||
} 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'
|
||||
147
packages/nostr-client/src/types.ts
Normal file
147
packages/nostr-client/src/types.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue