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
30
packages/cashu/package.json
Normal file
30
packages/cashu/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@lamassu/cashu",
|
||||
"version": "0.1.0",
|
||||
"description": "Cashu ecash integration for offline ATM operation",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cashu/cashu-ts": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
17
packages/cashu/src/__tests__/index.test.ts
Normal file
17
packages/cashu/src/__tests__/index.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { version } from '../index.js'
|
||||
|
||||
describe('@lamassu/cashu', () => {
|
||||
describe('exports', () => {
|
||||
it('should export version', () => {
|
||||
expect(version).toBe('0.1.0')
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Add tests when cashu functionality is implemented
|
||||
describe('placeholder', () => {
|
||||
it('should pass placeholder test', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
21
packages/cashu/src/index.ts
Normal file
21
packages/cashu/src/index.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* @lamassu/cashu
|
||||
*
|
||||
* Cashu ecash integration for offline ATM operation.
|
||||
*
|
||||
* Cashu provides:
|
||||
* - Offline payment acceptance (ecash tokens)
|
||||
* - Privacy-preserving transactions
|
||||
* - Fallback when Lightning is unavailable
|
||||
*
|
||||
* This package will implement:
|
||||
* - Token minting and verification
|
||||
* - Token redemption
|
||||
* - Mint management
|
||||
*/
|
||||
|
||||
// Placeholder export - implementation will be added as needed
|
||||
export const version = '0.1.0'
|
||||
|
||||
// Re-export cashu-ts types for convenience when implemented
|
||||
// export * from '@cashu/cashu-ts'
|
||||
22
packages/cashu/tsconfig.json
Normal file
22
packages/cashu/tsconfig.json
Normal 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"]
|
||||
}
|
||||
32
packages/clink/package.json
Normal file
32
packages/clink/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@lamassu/clink",
|
||||
"version": "0.1.0",
|
||||
"description": "CLINK protocol implementation for Nostr-native Lightning payments",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@scure/base": "^1.2.0",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
125
packages/clink/src/__tests__/noffer.test.ts
Normal file
125
packages/clink/src/__tests__/noffer.test.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { encodeNoffer, decodeNoffer, isValidNoffer } from '../noffer.js'
|
||||
import type { CLINKOffer } from '../types.js'
|
||||
|
||||
describe('noffer encoding/decoding', () => {
|
||||
// Basic spontaneous payment offer (per CLINK spec)
|
||||
const sampleOffer: CLINKOffer = {
|
||||
pubkey: 'a'.repeat(64), // 64 hex chars
|
||||
relays: ['wss://relay.example.com', 'wss://relay2.example.com'],
|
||||
priceType: 'spontaneous',
|
||||
offerId: 'test-offer-123',
|
||||
}
|
||||
|
||||
describe('encodeNoffer', () => {
|
||||
it('should encode a basic offer', () => {
|
||||
const noffer = encodeNoffer(sampleOffer)
|
||||
|
||||
expect(noffer).toMatch(/^noffer1/)
|
||||
expect(typeof noffer).toBe('string')
|
||||
})
|
||||
|
||||
it('should encode an offer with amount (fixed price)', () => {
|
||||
const offer: CLINKOffer = {
|
||||
...sampleOffer,
|
||||
priceType: 'fixed',
|
||||
amountSats: 1000,
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(offer)
|
||||
expect(noffer).toMatch(/^noffer1/)
|
||||
})
|
||||
|
||||
it('should encode an offer with currency (variable price)', () => {
|
||||
const offer: CLINKOffer = {
|
||||
...sampleOffer,
|
||||
priceType: 'variable',
|
||||
amountSats: 10, // $10 USD
|
||||
currency: 'USD',
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(offer)
|
||||
expect(noffer).toMatch(/^noffer1/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeNoffer', () => {
|
||||
it('should round-trip encode/decode spontaneous offer', () => {
|
||||
const noffer = encodeNoffer(sampleOffer)
|
||||
const decoded = decodeNoffer(noffer)
|
||||
|
||||
expect(decoded.pubkey).toBe(sampleOffer.pubkey)
|
||||
expect(decoded.relays).toEqual(sampleOffer.relays)
|
||||
expect(decoded.priceType).toBe(sampleOffer.priceType)
|
||||
expect(decoded.offerId).toBe(sampleOffer.offerId)
|
||||
})
|
||||
|
||||
it('should decode fixed price offer correctly', () => {
|
||||
const offer: CLINKOffer = {
|
||||
...sampleOffer,
|
||||
priceType: 'fixed',
|
||||
amountSats: 50000,
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(offer)
|
||||
const decoded = decodeNoffer(noffer)
|
||||
|
||||
expect(decoded.priceType).toBe('fixed')
|
||||
expect(decoded.amountSats).toBe(50000)
|
||||
})
|
||||
|
||||
it('should decode variable price offer with currency', () => {
|
||||
const offer: CLINKOffer = {
|
||||
pubkey: 'b'.repeat(64),
|
||||
relays: ['wss://relay.example.com'],
|
||||
priceType: 'variable',
|
||||
amountSats: 25, // $25 USD
|
||||
currency: 'USD',
|
||||
offerId: 'fiat-product',
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(offer)
|
||||
const decoded = decodeNoffer(noffer)
|
||||
|
||||
expect(decoded.priceType).toBe('variable')
|
||||
expect(decoded.amountSats).toBe(25)
|
||||
expect(decoded.currency).toBe('USD')
|
||||
expect(decoded.offerId).toBe('fiat-product')
|
||||
})
|
||||
|
||||
it('should default to spontaneous when price type not specified', () => {
|
||||
// Encode an offer without price type (should still work)
|
||||
const minimalOffer: CLINKOffer = {
|
||||
pubkey: 'c'.repeat(64),
|
||||
relays: ['wss://relay.example.com'],
|
||||
priceType: 'spontaneous', // Required in our interface but defaults in spec
|
||||
}
|
||||
|
||||
const noffer = encodeNoffer(minimalOffer)
|
||||
const decoded = decodeNoffer(noffer)
|
||||
|
||||
expect(decoded.priceType).toBe('spontaneous')
|
||||
})
|
||||
|
||||
it('should throw on invalid prefix', () => {
|
||||
expect(() => decodeNoffer('invalid1abc')).toThrow()
|
||||
})
|
||||
|
||||
it('should throw on missing pubkey', () => {
|
||||
// This would require a malformed noffer string
|
||||
expect(() => decodeNoffer('noffer1qqqqqq')).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidNoffer', () => {
|
||||
it('should return true for valid noffer', () => {
|
||||
const noffer = encodeNoffer(sampleOffer)
|
||||
expect(isValidNoffer(noffer)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false for invalid string', () => {
|
||||
expect(isValidNoffer('invalid')).toBe(false)
|
||||
expect(isValidNoffer('noffer1invalid')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
580
packages/clink/src/client.ts
Normal file
580
packages/clink/src/client.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
/**
|
||||
* CLINK Client
|
||||
*
|
||||
* Handles CLINK protocol communication for ATM payments:
|
||||
* - Creating and serving offers (noffer)
|
||||
* - 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'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import type { MachineIdentity, NostrClient } from '@lamassu/nostr-client'
|
||||
import { encryptContentV2, decryptContentV2 } from '@lamassu/nostr-client'
|
||||
|
||||
/** CLINK protocol version tag (mandatory per CLINK spec) */
|
||||
const CLINK_VERSION_TAG: [string, string] = ['clink_version', '1']
|
||||
|
||||
/**
|
||||
* Encrypt content using NIP-44 v2 (required for CLINK events)
|
||||
*/
|
||||
function encryptCLINK(
|
||||
identity: MachineIdentity,
|
||||
recipientPubkey: string,
|
||||
content: unknown
|
||||
): string {
|
||||
return encryptContentV2(identity, recipientPubkey, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt and parse JSON content using NIP-44 v2
|
||||
*/
|
||||
function decryptCLINKJSON<T = unknown>(
|
||||
identity: MachineIdentity,
|
||||
senderPubkey: string,
|
||||
ciphertext: string
|
||||
): T {
|
||||
const plaintext = decryptContentV2(identity, senderPubkey, ciphertext)
|
||||
return JSON.parse(plaintext) as T
|
||||
}
|
||||
import {
|
||||
CLINKEventKind,
|
||||
OfferErrorCode,
|
||||
GFYCode,
|
||||
type CLINKOffer,
|
||||
type OfferRequest,
|
||||
type OfferResponse,
|
||||
type OfferSuccessResponse,
|
||||
type OfferErrorResponse,
|
||||
type DebitRequest,
|
||||
type DebitResponse,
|
||||
type DebitSuccessResponse,
|
||||
type DebitFailureResponse,
|
||||
type ManagementRequest,
|
||||
type ManagementResponse,
|
||||
type ServiceBeacon,
|
||||
type GenerateInvoice,
|
||||
type PayInvoice,
|
||||
isOfferError,
|
||||
isDebitFailure,
|
||||
} from './types.js'
|
||||
import { encodeNoffer, decodeNoffer } from './noffer.js'
|
||||
|
||||
/** CLINK client options */
|
||||
export interface CLINKClientOptions {
|
||||
/** Nostr client for communication */
|
||||
nostrClient: NostrClient
|
||||
/** Machine identity */
|
||||
identity: MachineIdentity
|
||||
/** Operator pubkey for management commands */
|
||||
operatorPubkey: string
|
||||
/** Relays to use for offers */
|
||||
relays: string[]
|
||||
/** Invoice generator function */
|
||||
generateInvoice?: GenerateInvoice
|
||||
/** Payment function */
|
||||
payInvoice?: PayInvoice
|
||||
}
|
||||
|
||||
/** Offer request handler - returns invoice or error */
|
||||
export type OfferRequestHandler = (
|
||||
request: OfferRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<OfferResponse | null>
|
||||
|
||||
/** Debit request handler - returns success/failure */
|
||||
export type DebitRequestHandler = (
|
||||
request: DebitRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<DebitResponse>
|
||||
|
||||
/** Management request handler */
|
||||
export type ManagementHandler = (
|
||||
request: ManagementRequest,
|
||||
senderPubkey: string
|
||||
) => Promise<ManagementResponse | null>
|
||||
|
||||
/**
|
||||
* CLINK protocol client for ATM payments
|
||||
*/
|
||||
export class CLINKClient {
|
||||
private nostrClient: NostrClient
|
||||
private identity: MachineIdentity
|
||||
private operatorPubkey: string
|
||||
private relays: string[]
|
||||
private generateInvoice?: GenerateInvoice
|
||||
private payInvoice?: PayInvoice
|
||||
|
||||
private offerHandler?: OfferRequestHandler
|
||||
private debitHandler?: DebitRequestHandler
|
||||
private managementHandler?: ManagementHandler
|
||||
|
||||
private subscriptionId?: string
|
||||
|
||||
constructor(options: CLINKClientOptions) {
|
||||
this.nostrClient = options.nostrClient
|
||||
this.identity = options.identity
|
||||
this.operatorPubkey = options.operatorPubkey
|
||||
this.relays = options.relays
|
||||
this.generateInvoice = options.generateInvoice
|
||||
this.payInvoice = options.payInvoice
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a noffer (static payment code) for this machine
|
||||
*
|
||||
* @param options.priceType - Pricing model (fixed, variable, spontaneous)
|
||||
* @param options.offerId - Opaque offer identifier (for routing/tracking)
|
||||
* @param options.amountSats - Amount in sats (for fixed price or display)
|
||||
* @param options.currency - Currency code (e.g., "USD") for variable pricing
|
||||
*/
|
||||
createOffer(options: {
|
||||
priceType: CLINKOffer['priceType']
|
||||
offerId?: string
|
||||
amountSats?: number
|
||||
currency?: string
|
||||
}): string {
|
||||
const offer: CLINKOffer = {
|
||||
pubkey: this.identity.publicKey,
|
||||
relays: this.relays,
|
||||
priceType: options.priceType,
|
||||
offerId: options.offerId,
|
||||
amountSats: options.amountSats,
|
||||
currency: options.currency,
|
||||
}
|
||||
|
||||
return encodeNoffer(offer)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (Kind 21002)
|
||||
*/
|
||||
onDebitRequest(handler: DebitRequestHandler): void {
|
||||
this.debitHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Set handler for management commands (Kind 21003)
|
||||
*/
|
||||
onManagement(handler: ManagementHandler): void {
|
||||
this.managementHandler = handler
|
||||
}
|
||||
|
||||
/**
|
||||
* Start listening for CLINK events
|
||||
*/
|
||||
startListening(): void {
|
||||
if (this.subscriptionId) return
|
||||
|
||||
this.subscriptionId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [CLINKEventKind.Offer, CLINKEventKind.Debit, CLINKEventKind.Manage],
|
||||
'#p': [this.identity.publicKey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => this.handleEvent(event),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for CLINK events
|
||||
*/
|
||||
stopListening(): void {
|
||||
if (this.subscriptionId) {
|
||||
this.nostrClient.unsubscribe(this.subscriptionId)
|
||||
this.subscriptionId = undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request an invoice from a noffer (Kind 21001)
|
||||
*/
|
||||
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: offer.offerId ?? noffer, // Use offer ID if available, otherwise full noffer
|
||||
amount_sats: amountSats,
|
||||
payer_data: options?.payerData,
|
||||
description: options?.description,
|
||||
expires_in_seconds: options?.expiresInSeconds,
|
||||
}
|
||||
|
||||
const content = encryptCLINK(this.identity, offer.pubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Offer,
|
||||
content,
|
||||
tags: [['p', offer.pubkey], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
// Wait for response
|
||||
return this.waitForResponse<OfferResponse>(offer.pubkey, CLINKEventKind.Offer, event.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a debit payment request (Kind 21002)
|
||||
* User's wallet will pay the provided invoice
|
||||
*/
|
||||
async requestDebitPayment(
|
||||
targetPubkey: string,
|
||||
bolt11: string,
|
||||
options?: {
|
||||
pointer?: string
|
||||
amountSats?: number
|
||||
description?: string
|
||||
}
|
||||
): Promise<DebitResponse> {
|
||||
const request: DebitRequest = {
|
||||
bolt11,
|
||||
pointer: options?.pointer,
|
||||
amount_sats: options?.amountSats,
|
||||
description: options?.description,
|
||||
}
|
||||
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
return this.waitForResponse<DebitResponse>(targetPubkey, CLINKEventKind.Debit, event.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a debit budget request (Kind 21002)
|
||||
* Request authorization for a spending budget
|
||||
*/
|
||||
async requestDebitBudget(
|
||||
targetPubkey: string,
|
||||
amountSats: number,
|
||||
options?: {
|
||||
pointer?: string
|
||||
frequency?: { number: number; unit: 'day' | 'week' | 'month' }
|
||||
description?: string
|
||||
}
|
||||
): Promise<DebitResponse> {
|
||||
const request: DebitRequest = {
|
||||
amount_sats: amountSats,
|
||||
pointer: options?.pointer,
|
||||
frequency: options?.frequency,
|
||||
description: options?.description,
|
||||
}
|
||||
|
||||
const content = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
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 = encryptCLINK(this.identity, targetPubkey, request)
|
||||
|
||||
const event = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Manage,
|
||||
content,
|
||||
tags: [['p', targetPubkey], CLINK_VERSION_TAG],
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming CLINK event
|
||||
*/
|
||||
private async handleEvent(event: Event): Promise<void> {
|
||||
try {
|
||||
switch (event.kind) {
|
||||
case CLINKEventKind.Offer:
|
||||
await this.handleOfferEvent(event)
|
||||
break
|
||||
case CLINKEventKind.Debit:
|
||||
await this.handleDebitEvent(event)
|
||||
break
|
||||
case CLINKEventKind.Manage:
|
||||
await this.handleManageEvent(event)
|
||||
break
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error handling CLINK event:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle offer request (Kind 21001)
|
||||
*/
|
||||
private async handleOfferEvent(event: Event): Promise<void> {
|
||||
if (!this.offerHandler) return
|
||||
|
||||
// Validate clink_version tag (per CLINK spec)
|
||||
const versionTag = event.tags.find((t) => t[0] === 'clink_version')
|
||||
if (!versionTag || versionTag[1] !== '1') {
|
||||
console.warn('Ignoring CLINK event with missing or unsupported clink_version')
|
||||
return
|
||||
}
|
||||
|
||||
const request = decryptCLINKJSON<OfferRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.offerHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Offer,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle debit request (Kind 21002)
|
||||
*/
|
||||
private async handleDebitEvent(event: Event): Promise<void> {
|
||||
if (!this.debitHandler) return
|
||||
|
||||
// Validate clink_version tag (per CLINK spec)
|
||||
const versionTag = event.tags.find((t) => t[0] === 'clink_version')
|
||||
if (!versionTag || versionTag[1] !== '1') {
|
||||
console.warn('Ignoring CLINK event with missing or unsupported clink_version')
|
||||
return
|
||||
}
|
||||
|
||||
const request = decryptCLINKJSON<DebitRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.debitHandler(request, event.pubkey)
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Debit,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle management command (Kind 21003)
|
||||
*/
|
||||
private async handleManageEvent(event: Event): Promise<void> {
|
||||
// Only accept from operator
|
||||
if (event.pubkey !== this.operatorPubkey) {
|
||||
console.warn('Ignoring management command from non-operator:', event.pubkey)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate clink_version tag (per CLINK spec)
|
||||
const versionTag = event.tags.find((t) => t[0] === 'clink_version')
|
||||
if (!versionTag || versionTag[1] !== '1') {
|
||||
console.warn('Ignoring CLINK event with missing or unsupported clink_version')
|
||||
return
|
||||
}
|
||||
|
||||
if (!this.managementHandler) return
|
||||
|
||||
const request = decryptCLINKJSON<ManagementRequest>(this.identity, event.pubkey, event.content)
|
||||
|
||||
const response = await this.managementHandler(request, event.pubkey)
|
||||
if (!response) return
|
||||
|
||||
// Send encrypted response with clink_version tag
|
||||
const content = encryptCLINK(this.identity, event.pubkey, response)
|
||||
|
||||
const responseEvent = this.createSignedEvent({
|
||||
kind: CLINKEventKind.Manage,
|
||||
content,
|
||||
tags: [['p', event.pubkey], ['e', event.id], CLINK_VERSION_TAG],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
})
|
||||
|
||||
await this.nostrClient.publish(responseEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response event
|
||||
*/
|
||||
private waitForResponse<T>(fromPubkey: string, kind: number, requestEventId: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.nostrClient.unsubscribe(subId)
|
||||
reject(new Error('Response timeout'))
|
||||
}, 30000)
|
||||
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [kind],
|
||||
authors: [fromPubkey],
|
||||
'#p': [this.identity.publicKey],
|
||||
'#e': [requestEventId],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Validate clink_version tag
|
||||
const versionTag = event.tags.find((t) => t[0] === 'clink_version')
|
||||
if (!versionTag || versionTag[1] !== '1') {
|
||||
console.warn('Ignoring response with missing or unsupported clink_version')
|
||||
return
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
this.nostrClient.unsubscribe(subId)
|
||||
try {
|
||||
const response = decryptCLINKJSON<T>(this.identity, fromPubkey, event.content)
|
||||
resolve(response)
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a signed event
|
||||
*/
|
||||
private createSignedEvent(event: Omit<UnsignedEvent, 'pubkey'>): Event {
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
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: OfferErrorCode,
|
||||
error: string,
|
||||
options?: { range?: { min: number; max: number }; latest?: string }
|
||||
): OfferErrorResponse {
|
||||
return { code, error, range: options?.range, latest: options?.latest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debit success response
|
||||
*/
|
||||
export function createDebitSuccess(preimage: string): DebitSuccessResponse {
|
||||
return { res: 'ok', preimage }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a debit failure response (GFY)
|
||||
*/
|
||||
export function createDebitFailure(
|
||||
code: GFYCode,
|
||||
error: string,
|
||||
options?: {
|
||||
delta?: { max_delta_ms: number; actual_delta_ms: number }
|
||||
retry_after?: number
|
||||
range?: { min: number; max: number }
|
||||
}
|
||||
): DebitFailureResponse {
|
||||
return {
|
||||
res: 'GFY',
|
||||
code,
|
||||
error,
|
||||
delta: options?.delta,
|
||||
retry_after: options?.retry_after,
|
||||
range: options?.range,
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export type guards
|
||||
export { isOfferError, isDebitFailure }
|
||||
115
packages/clink/src/index.ts
Normal file
115
packages/clink/src/index.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
/**
|
||||
* @lamassu/clink
|
||||
*
|
||||
* CLINK protocol implementation for Nostr-native Lightning payments.
|
||||
*
|
||||
* 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, createOfferSuccess } from '@lamassu/clink'
|
||||
*
|
||||
* // Create a client
|
||||
* const clink = new CLINKClient({
|
||||
* nostrClient,
|
||||
* identity,
|
||||
* operatorPubkey,
|
||||
* relays: ['wss://relay.example.com'],
|
||||
* })
|
||||
*
|
||||
* // Create a noffer for the ATM (spontaneous payment)
|
||||
* const noffer = clink.createOffer({
|
||||
* priceType: 'spontaneous',
|
||||
* offerId: 'atm-cashout', // Opaque identifier for routing
|
||||
* })
|
||||
*
|
||||
* // Handle incoming offer requests
|
||||
* clink.onOfferRequest(async (request, senderPubkey) => {
|
||||
* const bolt11 = await generateInvoice(request.amount_sats)
|
||||
* return createOfferSuccess(bolt11)
|
||||
* })
|
||||
*
|
||||
* clink.startListening()
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Types
|
||||
export {
|
||||
// Event kinds
|
||||
CLINKEventKind,
|
||||
// Error codes (per CLINK spec)
|
||||
OfferErrorCode,
|
||||
GFYCode,
|
||||
type CLINKErrorCode, // deprecated alias
|
||||
// Offer types (Kind 21001)
|
||||
type OfferRequest,
|
||||
type OfferResponse,
|
||||
type OfferSuccessResponse,
|
||||
type OfferErrorResponse,
|
||||
// Debit types (Kind 21002)
|
||||
type DebitRequest,
|
||||
type DebitPaymentRequest,
|
||||
type DebitBudgetRequest,
|
||||
type DebitFrequency,
|
||||
type DebitResponse,
|
||||
type DebitSuccessResponse,
|
||||
type DebitFailureResponse,
|
||||
// Management types (Kind 21003)
|
||||
type ManagementRequest,
|
||||
type ManagementResponse,
|
||||
type ManagementSuccessResponse,
|
||||
type ManagementFailureResponse,
|
||||
type ManagementAction,
|
||||
type ManagementResource,
|
||||
// Beacon types (Kind 30078)
|
||||
type ServiceBeacon,
|
||||
// Noffer types
|
||||
type CLINKOffer,
|
||||
type PriceType,
|
||||
NofferTLV,
|
||||
// Ndebit types
|
||||
type DebitPointer,
|
||||
NdebitTLV,
|
||||
// Function types
|
||||
type GenerateInvoice,
|
||||
type PayInvoice,
|
||||
// Type guards
|
||||
isOfferError,
|
||||
isDebitFailure,
|
||||
isDebitPaymentRequest,
|
||||
isManagementFailure,
|
||||
isManagementSuccess,
|
||||
} from './types.js'
|
||||
|
||||
// Noffer encoding/decoding
|
||||
export { encodeNoffer, decodeNoffer, isValidNoffer, npubToHex, hexToNpub } from './noffer.js'
|
||||
|
||||
// Ndebit encoding/decoding
|
||||
export {
|
||||
encodeNdebit,
|
||||
decodeNdebit,
|
||||
isValidNdebit,
|
||||
formatNdebitUri,
|
||||
parseNdebitAmount,
|
||||
} from './ndebit.js'
|
||||
|
||||
// Client
|
||||
export {
|
||||
CLINKClient,
|
||||
type CLINKClientOptions,
|
||||
type OfferRequestHandler,
|
||||
type DebitRequestHandler,
|
||||
type ManagementHandler,
|
||||
// Response helpers
|
||||
createOfferSuccess,
|
||||
createOfferError,
|
||||
createDebitSuccess,
|
||||
createDebitFailure,
|
||||
} from './client.js'
|
||||
206
packages/clink/src/ndebit.ts
Normal file
206
packages/clink/src/ndebit.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* ndebit encoding/decoding
|
||||
*
|
||||
* ndebits are bech32-encoded debit pointers that contain
|
||||
* pubkey, relay, and optional pointer information.
|
||||
*
|
||||
* Format: ndebit1<bech32-encoded-tlv-data>
|
||||
*
|
||||
* For ATM cash-in flow, the URI format includes amount:
|
||||
* clink:ndebit1<bech32data>?amount=<sats>
|
||||
*/
|
||||
|
||||
import { bech32 } from '@scure/base'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import type { DebitPointer } from './types.js'
|
||||
import { NdebitTLV } from './types.js'
|
||||
|
||||
const NDEBIT_PREFIX = 'ndebit'
|
||||
const BECH32_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* Encode a debit pointer as an ndebit string
|
||||
*/
|
||||
export function encodeNdebit(pointer: DebitPointer): string {
|
||||
const tlvData: number[] = []
|
||||
|
||||
// Pubkey (TLV 0) - convert npub to hex if needed
|
||||
const hexPubkey = npubToHex(pointer.pubkey)
|
||||
const pubkeyBytes = hexToBytes(hexPubkey)
|
||||
writeTLV(tlvData, NdebitTLV.Pubkey, pubkeyBytes)
|
||||
|
||||
// Relay (TLV 1)
|
||||
const relayBytes = new TextEncoder().encode(pointer.relay)
|
||||
writeTLV(tlvData, NdebitTLV.Relay, relayBytes)
|
||||
|
||||
// Pointer (TLV 2) - if present
|
||||
if (pointer.pointer) {
|
||||
const pointerBytes = new TextEncoder().encode(pointer.pointer)
|
||||
writeTLV(tlvData, NdebitTLV.Pointer, pointerBytes)
|
||||
}
|
||||
|
||||
// Convert to Uint8Array and bech32 encode
|
||||
const bytes = new Uint8Array(tlvData)
|
||||
const words = bech32.toWords(bytes)
|
||||
|
||||
return bech32.encode(NDEBIT_PREFIX, words, BECH32_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an ndebit string to a debit pointer
|
||||
*/
|
||||
export function decodeNdebit(ndebit: string): DebitPointer {
|
||||
// Remove clink: prefix if present
|
||||
let cleaned = ndebit
|
||||
if (cleaned.toLowerCase().startsWith('clink:')) {
|
||||
cleaned = cleaned.slice(6)
|
||||
}
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.slice(10)
|
||||
}
|
||||
|
||||
// Remove query parameters if present
|
||||
const queryIndex = cleaned.indexOf('?')
|
||||
if (queryIndex !== -1) {
|
||||
cleaned = cleaned.slice(0, queryIndex)
|
||||
}
|
||||
|
||||
// Decode bech32 - cast to expected type (contains "1" separator)
|
||||
const { prefix, words } = bech32.decode(cleaned as `${string}1${string}`, BECH32_LIMIT)
|
||||
|
||||
if (prefix !== NDEBIT_PREFIX) {
|
||||
throw new Error(`Invalid ndebit prefix: ${prefix}`)
|
||||
}
|
||||
|
||||
const bytes = bech32.fromWords(words)
|
||||
const data = new Uint8Array(bytes)
|
||||
|
||||
// Parse TLV entries
|
||||
let pubkey: string | undefined
|
||||
let relay: string | undefined
|
||||
let pointer: string | undefined
|
||||
|
||||
let offset = 0
|
||||
while (offset < data.length) {
|
||||
const type = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset >= data.length) break
|
||||
|
||||
const length = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset + (length ?? 0) > data.length) {
|
||||
throw new Error('Invalid TLV: data truncated')
|
||||
}
|
||||
|
||||
const value = data.slice(offset, offset + (length ?? 0))
|
||||
offset += length ?? 0
|
||||
|
||||
switch (type) {
|
||||
case NdebitTLV.Pubkey:
|
||||
pubkey = bytesToHex(value)
|
||||
break
|
||||
case NdebitTLV.Relay:
|
||||
relay = new TextDecoder().decode(value)
|
||||
break
|
||||
case NdebitTLV.Pointer:
|
||||
pointer = new TextDecoder().decode(value)
|
||||
break
|
||||
// Ignore unknown TLV types for forward compatibility
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid ndebit: missing pubkey')
|
||||
}
|
||||
|
||||
if (!relay) {
|
||||
throw new Error('Invalid ndebit: missing relay')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relay,
|
||||
pointer,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an ndebit string without fully decoding
|
||||
*/
|
||||
export function isValidNdebit(ndebit: string): boolean {
|
||||
try {
|
||||
decodeNdebit(ndebit)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format an ndebit as a full clink: URI with amount
|
||||
*/
|
||||
export function formatNdebitUri(ndebit: string, amountSats: number): string {
|
||||
// Ensure ndebit doesn't already have clink: prefix
|
||||
let cleaned = ndebit
|
||||
if (cleaned.toLowerCase().startsWith('clink:')) {
|
||||
cleaned = cleaned.slice(6)
|
||||
}
|
||||
if (cleaned.toLowerCase().startsWith('lightning:')) {
|
||||
cleaned = cleaned.slice(10)
|
||||
}
|
||||
|
||||
return `clink:${cleaned}?amount=${amountSats}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse amount from ndebit URI query parameters
|
||||
*/
|
||||
export function parseNdebitAmount(uri: string): number | undefined {
|
||||
const match = uri.match(/[?&]amount=(\d+)/)
|
||||
if (match && match[1]) {
|
||||
return parseInt(match[1], 10)
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
// TLV helpers
|
||||
|
||||
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
|
||||
data.push(type)
|
||||
data.push(value.length)
|
||||
for (const byte of value) {
|
||||
data.push(byte)
|
||||
}
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
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
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert npub to hex pubkey
|
||||
* Accepts either npub1... or hex format, returns hex
|
||||
*/
|
||||
function npubToHex(pubkey: string): string {
|
||||
if (pubkey.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(pubkey)
|
||||
if (decoded.type !== 'npub') {
|
||||
throw new Error(`Invalid npub: expected npub type, got ${decoded.type}`)
|
||||
}
|
||||
return decoded.data
|
||||
}
|
||||
// Already hex
|
||||
return pubkey
|
||||
}
|
||||
246
packages/clink/src/noffer.ts
Normal file
246
packages/clink/src/noffer.ts
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
/**
|
||||
* noffer encoding/decoding
|
||||
*
|
||||
* noffers are bech32-encoded static payment codes that contain
|
||||
* pubkey, relays, and pricing information.
|
||||
*
|
||||
* Format: noffer1<bech32-encoded-tlv-data>
|
||||
*/
|
||||
|
||||
import { bech32 } from '@scure/base'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import type { CLINKOffer, PriceType } from './types.js'
|
||||
import { NofferTLV } from './types.js'
|
||||
|
||||
const NOFFER_PREFIX = 'noffer'
|
||||
const BECH32_LIMIT = 2000
|
||||
|
||||
/**
|
||||
* Encode a CLINK offer as a noffer string (per CLINK spec)
|
||||
*/
|
||||
export function encodeNoffer(offer: CLINKOffer): string {
|
||||
const tlvData: number[] = []
|
||||
|
||||
// TLV 0: Pubkey (32 bytes) - convert npub to hex if needed
|
||||
const hexPubkey = npubToHex(offer.pubkey)
|
||||
const pubkeyBytes = hexToBytes(hexPubkey)
|
||||
writeTLV(tlvData, NofferTLV.Pubkey, pubkeyBytes)
|
||||
|
||||
// TLV 1: Relay URLs - one entry per relay
|
||||
for (const relay of offer.relays) {
|
||||
const relayBytes = new TextEncoder().encode(relay)
|
||||
writeTLV(tlvData, NofferTLV.Relay, relayBytes)
|
||||
}
|
||||
|
||||
// TLV 2: Offer identifier (opaque string) - if present
|
||||
if (offer.offerId) {
|
||||
const offerIdBytes = new TextEncoder().encode(offer.offerId)
|
||||
writeTLV(tlvData, NofferTLV.OfferId, offerIdBytes)
|
||||
}
|
||||
|
||||
// TLV 3: Price type (0=fixed, 1=variable, 2=spontaneous)
|
||||
const priceTypeByte = encodePriceType(offer.priceType)
|
||||
writeTLV(tlvData, NofferTLV.PriceType, [priceTypeByte])
|
||||
|
||||
// TLV 4: Amount in sats - if present
|
||||
if (offer.amountSats !== undefined) {
|
||||
const amountBytes = encodeUint64BE(offer.amountSats)
|
||||
writeTLV(tlvData, NofferTLV.Amount, amountBytes)
|
||||
}
|
||||
|
||||
// TLV 5: Currency code - if present (requires variable pricing)
|
||||
if (offer.currency) {
|
||||
const currencyBytes = new TextEncoder().encode(offer.currency)
|
||||
writeTLV(tlvData, NofferTLV.Currency, currencyBytes)
|
||||
}
|
||||
|
||||
// Convert to Uint8Array and bech32 encode
|
||||
const bytes = new Uint8Array(tlvData)
|
||||
const words = bech32.toWords(bytes)
|
||||
|
||||
return bech32.encode(NOFFER_PREFIX, words, BECH32_LIMIT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a noffer string to a CLINK offer (per CLINK spec)
|
||||
*/
|
||||
export function decodeNoffer(noffer: string): CLINKOffer {
|
||||
// Decode bech32 - cast to expected type (contains "1" separator)
|
||||
const { prefix, words } = bech32.decode(noffer as `${string}1${string}`, BECH32_LIMIT)
|
||||
|
||||
if (prefix !== NOFFER_PREFIX) {
|
||||
throw new Error(`Invalid noffer prefix: ${prefix}`)
|
||||
}
|
||||
|
||||
const bytes = bech32.fromWords(words)
|
||||
const data = new Uint8Array(bytes)
|
||||
|
||||
// Parse TLV entries
|
||||
let pubkey: string | undefined
|
||||
const relays: string[] = []
|
||||
let offerId: string | undefined
|
||||
let priceType: PriceType = 'spontaneous' // Default per spec
|
||||
let amountSats: number | undefined
|
||||
let currency: string | undefined
|
||||
|
||||
let offset = 0
|
||||
while (offset < data.length) {
|
||||
const type = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset >= data.length) break
|
||||
|
||||
const length = data[offset]
|
||||
offset++
|
||||
|
||||
if (offset + (length ?? 0) > data.length) {
|
||||
throw new Error('Invalid TLV: data truncated')
|
||||
}
|
||||
|
||||
const value = data.slice(offset, offset + (length ?? 0))
|
||||
offset += length ?? 0
|
||||
|
||||
switch (type) {
|
||||
case NofferTLV.Pubkey:
|
||||
pubkey = bytesToHex(value)
|
||||
break
|
||||
case NofferTLV.Relay:
|
||||
relays.push(new TextDecoder().decode(value))
|
||||
break
|
||||
case NofferTLV.OfferId:
|
||||
offerId = new TextDecoder().decode(value)
|
||||
break
|
||||
case NofferTLV.PriceType:
|
||||
priceType = decodePriceType(value[0] ?? 2)
|
||||
break
|
||||
case NofferTLV.Amount:
|
||||
amountSats = decodeUint64BE(value)
|
||||
break
|
||||
case NofferTLV.Currency:
|
||||
currency = new TextDecoder().decode(value)
|
||||
break
|
||||
// Ignore unknown TLV types for forward compatibility (per NIP-19)
|
||||
}
|
||||
}
|
||||
|
||||
if (!pubkey) {
|
||||
throw new Error('Invalid noffer: missing pubkey')
|
||||
}
|
||||
|
||||
if (relays.length === 0) {
|
||||
throw new Error('Invalid noffer: no relays')
|
||||
}
|
||||
|
||||
return {
|
||||
pubkey,
|
||||
relays,
|
||||
offerId,
|
||||
priceType,
|
||||
amountSats,
|
||||
currency,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a noffer string without fully decoding
|
||||
*/
|
||||
export function isValidNoffer(noffer: string): boolean {
|
||||
try {
|
||||
decodeNoffer(noffer)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TLV helpers
|
||||
|
||||
function writeTLV(data: number[], type: number, value: number[] | Uint8Array): void {
|
||||
data.push(type)
|
||||
data.push(value.length)
|
||||
for (const byte of value) {
|
||||
data.push(byte)
|
||||
}
|
||||
}
|
||||
|
||||
function encodePriceType(priceType: PriceType): number {
|
||||
switch (priceType) {
|
||||
case 'fixed':
|
||||
return 0
|
||||
case 'variable':
|
||||
return 1
|
||||
case 'spontaneous':
|
||||
return 2
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
function decodePriceType(byte: number): PriceType {
|
||||
switch (byte) {
|
||||
case 0:
|
||||
return 'fixed'
|
||||
case 1:
|
||||
return 'variable'
|
||||
case 2:
|
||||
return 'spontaneous'
|
||||
default:
|
||||
return 'spontaneous'
|
||||
}
|
||||
}
|
||||
|
||||
function encodeUint64BE(value: number): number[] {
|
||||
// Use division instead of bit shifts because JS bitwise ops only work on 32-bit ints
|
||||
const bytes: number[] = new Array(8).fill(0)
|
||||
let remaining = value
|
||||
for (let i = 7; i >= 0 && remaining > 0; i--) {
|
||||
bytes[i] = remaining % 256
|
||||
remaining = Math.floor(remaining / 256)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
function decodeUint64BE(bytes: Uint8Array): number {
|
||||
let value = 0
|
||||
for (let i = 0; i < bytes.length && i < 8; i++) {
|
||||
value = value * 256 + (bytes[i] ?? 0)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
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
|
||||
}
|
||||
|
||||
function bytesToHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert npub to hex pubkey
|
||||
* Accepts either npub1... or hex format, returns hex
|
||||
*/
|
||||
export function npubToHex(pubkey: string): string {
|
||||
if (pubkey.startsWith('npub1')) {
|
||||
const decoded = nip19.decode(pubkey)
|
||||
if (decoded.type !== 'npub') {
|
||||
throw new Error(`Invalid npub: expected npub type, got ${decoded.type}`)
|
||||
}
|
||||
return decoded.data
|
||||
}
|
||||
// Already hex
|
||||
return pubkey
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex pubkey to npub
|
||||
*/
|
||||
export function hexToNpub(hex: string): string {
|
||||
return nip19.npubEncode(hex)
|
||||
}
|
||||
368
packages/clink/src/types.ts
Normal file
368
packages/clink/src/types.ts
Normal file
|
|
@ -0,0 +1,368 @@
|
|||
/**
|
||||
* CLINK Protocol type definitions
|
||||
*
|
||||
* CLINK is a Nostr-native Lightning payment protocol that enables
|
||||
* static payment codes (noffers) over Nostr relays.
|
||||
*
|
||||
* Event kinds:
|
||||
* - 21000: Generic RPC request/response
|
||||
* - 21001: Offer request/response
|
||||
* - 21002: Debit request/response
|
||||
* - 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 request/response */
|
||||
Manage = 21003,
|
||||
/** Service beacon (replaceable event) */
|
||||
Beacon = 30078,
|
||||
}
|
||||
|
||||
/** CLINK Offer error codes (Kind 21001) - per CLINK spec */
|
||||
export enum OfferErrorCode {
|
||||
/** Invalid Offer: The requested offer ID is invalid or no longer available */
|
||||
InvalidOffer = 1,
|
||||
/** Temporary Failure: The receiver is temporarily unable to process */
|
||||
TemporaryFailure = 2,
|
||||
/** Expired or Moved: The offer has expired, been replaced, or permanently moved */
|
||||
ExpiredOrMoved = 3,
|
||||
/** Unsupported Feature: The receiver doesn't support a requested feature */
|
||||
UnsupportedFeature = 4,
|
||||
/** Invalid Amount: The amount specified is too big or too small */
|
||||
InvalidAmount = 5,
|
||||
}
|
||||
|
||||
/** CLINK Debit/Manage GFY codes (Kind 21002, 21003) - per CLINK spec */
|
||||
export enum GFYCode {
|
||||
/** Request Denied: User or rule denied the request */
|
||||
RequestDenied = 1,
|
||||
/** Temporary Failure: Wallet service issue (e.g., node offline) */
|
||||
TemporaryFailure = 2,
|
||||
/** Expired Request: Request timestamp too old (e.g., >30s delta) */
|
||||
ExpiredRequest = 3,
|
||||
/** Rate Limited: Requestor sending too many requests */
|
||||
RateLimited = 4,
|
||||
/** Invalid Amount/Field: Amount outside acceptable range or invalid field */
|
||||
InvalidAmount = 5,
|
||||
/** Invalid Request: Malformed payload, missing fields, etc. */
|
||||
InvalidRequest = 6,
|
||||
}
|
||||
|
||||
/** @deprecated Use OfferErrorCode or GFYCode instead */
|
||||
export type CLINKErrorCode = OfferErrorCode | GFYCode
|
||||
|
||||
// ============================================================================
|
||||
// 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 (per CLINK Offers spec) */
|
||||
code: OfferErrorCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** Acceptable amount range (for InvalidAmount errors, code 5) */
|
||||
range?: { min: number; max: number }
|
||||
/** New noffer string (for ExpiredOrMoved errors, code 3) */
|
||||
latest?: string
|
||||
}
|
||||
|
||||
/** 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 for budget requests */
|
||||
export interface DebitFrequency {
|
||||
/** Number of intervals */
|
||||
number: number
|
||||
/** Interval unit */
|
||||
unit: 'day' | 'week' | 'month'
|
||||
}
|
||||
|
||||
/** Direct payment debit request (Kind 21002) */
|
||||
export interface DebitPaymentRequest {
|
||||
/** Pointer ID from ndebit (optional, routes to specific account) */
|
||||
pointer?: string
|
||||
/** Amount in satoshis (optional, wallet may require for rules) */
|
||||
amount_sats?: number
|
||||
/** BOLT11 invoice to pay */
|
||||
bolt11: string
|
||||
/** Optional description/app data */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Budget authorization debit request (Kind 21002) */
|
||||
export interface DebitBudgetRequest {
|
||||
/** Pointer ID from ndebit */
|
||||
pointer?: string
|
||||
/** Budget amount in satoshis */
|
||||
amount_sats: number
|
||||
/** Frequency for recurring budget (omit for one-time) */
|
||||
frequency?: DebitFrequency
|
||||
/** Optional description/app data */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Debit request (Kind 21002) - either direct payment or budget authorization */
|
||||
export type DebitRequest = DebitPaymentRequest | DebitBudgetRequest
|
||||
|
||||
/** Type guard for payment request */
|
||||
export function isDebitPaymentRequest(req: DebitRequest): req is DebitPaymentRequest {
|
||||
return 'bolt11' in req
|
||||
}
|
||||
|
||||
/** Debit success response (Kind 21002) */
|
||||
export interface DebitSuccessResponse {
|
||||
/** Success indicator */
|
||||
res: 'ok'
|
||||
/** Payment preimage (proof of payment) */
|
||||
preimage: string
|
||||
}
|
||||
|
||||
/** Debit failure response (Kind 21002) - GFY (General Failure to Yield) */
|
||||
export interface DebitFailureResponse {
|
||||
/** Failure indicator */
|
||||
res: 'GFY'
|
||||
/** GFY error code */
|
||||
code: GFYCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** For ExpiredRequest (code 3): time delta info */
|
||||
delta?: { max_delta_ms: number; actual_delta_ms: number }
|
||||
/** For RateLimited (code 4): when to retry */
|
||||
retry_after?: number
|
||||
/** For InvalidAmount (code 5): acceptable range */
|
||||
range?: { min: number; max: number }
|
||||
}
|
||||
|
||||
/** 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 resource types */
|
||||
export type ManagementResource = 'offer'
|
||||
|
||||
/** Management request (Kind 21003) - per CLINK Manage spec */
|
||||
export interface ManagementRequest {
|
||||
/** Resource type being managed */
|
||||
resource: ManagementResource
|
||||
/** Action to perform */
|
||||
action: ManagementAction
|
||||
/** Pointer ID (optional, for multi-account routing) */
|
||||
pointer?: string
|
||||
/** Offer object (for create/update/delete/get actions) */
|
||||
offer?: {
|
||||
/** Offer ID (required for update/delete/get, generated by server for create) */
|
||||
id?: string
|
||||
/** Human-readable label */
|
||||
label?: string
|
||||
/** Price in satoshis */
|
||||
price_sats?: number
|
||||
/** Callback URL for payment notifications */
|
||||
callback_url?: string
|
||||
/** Required payer data fields */
|
||||
payer_data?: string[]
|
||||
/** Fields to update (for update action only) */
|
||||
fields?: Omit<OfferConfig, 'id'>
|
||||
}
|
||||
}
|
||||
|
||||
/** Management success response (Kind 21003) - per CLINK Manage spec */
|
||||
export interface ManagementSuccessResponse {
|
||||
/** Success indicator */
|
||||
res: 'ok'
|
||||
/** Resource type */
|
||||
resource: ManagementResource
|
||||
/** Result details (offer object for create/update/get, array for list, omitted for delete) */
|
||||
details?: unknown
|
||||
}
|
||||
|
||||
/** Management failure response (Kind 21003) - GFY */
|
||||
export interface ManagementFailureResponse {
|
||||
/** Failure indicator */
|
||||
res: 'GFY'
|
||||
/** GFY error code */
|
||||
code: GFYCode
|
||||
/** Human-readable error message */
|
||||
error: string
|
||||
/** For ExpiredRequest (code 3): time delta info */
|
||||
delta?: { max_delta_ms: number; actual_delta_ms: number }
|
||||
/** For RateLimited (code 4): when to retry */
|
||||
retry_after?: number
|
||||
/** For InvalidField (code 5): field and range info */
|
||||
field?: string
|
||||
range?: { min: number; max: number }
|
||||
}
|
||||
|
||||
/** Management response */
|
||||
export type ManagementResponse = ManagementSuccessResponse | ManagementFailureResponse
|
||||
|
||||
/** Type guard for management failure */
|
||||
export function isManagementFailure(
|
||||
response: ManagementResponse
|
||||
): response is ManagementFailureResponse {
|
||||
return response.res === 'GFY'
|
||||
}
|
||||
|
||||
/** Type guard for management success */
|
||||
export function isManagementSuccess(
|
||||
response: ManagementResponse
|
||||
): response is ManagementSuccessResponse {
|
||||
return response.res === 'ok'
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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 */
|
||||
export interface CLINKOffer {
|
||||
/** Public key of the offer creator (receiving service) */
|
||||
pubkey: string
|
||||
/** Relays where the offer is accessible */
|
||||
relays: string[]
|
||||
/** Opaque offer identifier (defined by receiving service) */
|
||||
offerId?: string
|
||||
/** Pricing model (default: spontaneous if not specified) */
|
||||
priceType: PriceType
|
||||
/** Amount in satoshis (for fixed price, or display for variable) */
|
||||
amountSats?: number
|
||||
/** Currency code (e.g., "USD") - requires priceType: 'variable' */
|
||||
currency?: string
|
||||
}
|
||||
|
||||
/** noffer TLV types (per CLINK spec) */
|
||||
export enum NofferTLV {
|
||||
/** Public key (32 bytes) */
|
||||
Pubkey = 0,
|
||||
/** Relay URL (variable length string) */
|
||||
Relay = 1,
|
||||
/** Offer identifier string (opaque, defined by receiving service) */
|
||||
OfferId = 2,
|
||||
/** Price type (1 byte: 0=fixed, 1=variable, 2=spontaneous) */
|
||||
PriceType = 3,
|
||||
/** Amount in sats (8 bytes, big-endian) */
|
||||
Amount = 4,
|
||||
/** Currency code (e.g., "USD", "EUR") - requires price type 1 (variable) */
|
||||
Currency = 5,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Ndebit (Debit Pointer) encoding
|
||||
// ============================================================================
|
||||
|
||||
/** Debit pointer for ndebit encoding */
|
||||
export interface DebitPointer {
|
||||
/** Public key of the service that will pay (hex) */
|
||||
pubkey: string
|
||||
/** Relay URL for communication */
|
||||
relay: string
|
||||
/** Optional session/voucher identifier */
|
||||
pointer?: string
|
||||
}
|
||||
|
||||
/** ndebit TLV types */
|
||||
export enum NdebitTLV {
|
||||
/** Public key (32 bytes) */
|
||||
Pubkey = 0,
|
||||
/** Relay URL (variable length string) */
|
||||
Relay = 1,
|
||||
/** Pointer/session identifier (variable length string) */
|
||||
Pointer = 2,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Function types for service injection
|
||||
// ============================================================================
|
||||
|
||||
/** Invoice generation function type */
|
||||
export type GenerateInvoice = (amountSats: number, description?: string) => Promise<string>
|
||||
|
||||
/** Payment function type */
|
||||
export type PayInvoice = (invoice: string) => Promise<{ preimage: string } | { error: string }>
|
||||
22
packages/clink/tsconfig.json
Normal file
22
packages/clink/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/clink/vitest.config.ts
Normal file
8
packages/clink/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
50
packages/hal/Cargo.toml
Normal file
50
packages/hal/Cargo.toml
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
[package]
|
||||
name = "lamassu-hal"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Hardware Abstraction Layer for Lamassu ATM"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/lamassu/lamassu-next"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# napi-rs for Node.js bindings
|
||||
napi = { version = "2", features = ["async", "tokio_rt"] }
|
||||
napi-derive = "2"
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
# Serial port communication
|
||||
tokio-serial = "5"
|
||||
|
||||
# Error handling
|
||||
thiserror = "1"
|
||||
|
||||
# Async traits
|
||||
async-trait = "0.1"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[build-dependencies]
|
||||
napi-build = "2"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# Enable hardware drivers (for production builds)
|
||||
hardware = []
|
||||
# Mock-only build (for testing/development)
|
||||
mock-only = []
|
||||
5
packages/hal/build.rs
Normal file
5
packages/hal/build.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
extern crate napi_build;
|
||||
|
||||
fn main() {
|
||||
napi_build::setup();
|
||||
}
|
||||
57
packages/hal/package.json
Normal file
57
packages/hal/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@lamassu/hal",
|
||||
"version": "0.1.0",
|
||||
"description": "Hardware Abstraction Layer for Lamassu ATM devices",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
},
|
||||
"./validators": {
|
||||
"import": "./dist/validators/index.js",
|
||||
"types": "./dist/validators/index.d.ts"
|
||||
},
|
||||
"./dispensers": {
|
||||
"import": "./dist/dispensers/index.js",
|
||||
"types": "./dist/dispensers/index.d.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"clean": "rm -rf dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"lodash-es": "^4.17.21",
|
||||
"serialport": "^12.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/lodash-es": "^4.17.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src"
|
||||
],
|
||||
"keywords": [
|
||||
"lamassu",
|
||||
"atm",
|
||||
"hardware",
|
||||
"bill-validator",
|
||||
"bill-dispenser",
|
||||
"id003",
|
||||
"f56",
|
||||
"fujitsu"
|
||||
],
|
||||
"license": "MIT"
|
||||
}
|
||||
116
packages/hal/src/dispensers/f56/bills.ts
Normal file
116
packages/hal/src/dispensers/f56/bills.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
/**
|
||||
* Bill dimension data for F56 dispenser
|
||||
*
|
||||
* Each currency has:
|
||||
* - thickness: Paper thickness setting
|
||||
* - lengths: Map of denomination -> [length1, length2] bytes
|
||||
* - polymer: Whether bills are polymer (affects ODR setting)
|
||||
*/
|
||||
|
||||
export interface BillConfig {
|
||||
thickness: number
|
||||
lengths: Record<number, [number, number]>
|
||||
polymer: boolean
|
||||
}
|
||||
|
||||
export const bills: Record<string, BillConfig> = {
|
||||
AED: {
|
||||
thickness: 0x0c,
|
||||
lengths: {
|
||||
5: [0x99, 0x85],
|
||||
10: [0x9d, 0x89],
|
||||
20: [0x9f, 0x8b],
|
||||
50: [0xa1, 0x8d],
|
||||
100: [0xa5, 0x91],
|
||||
200: [0xa7, 0x93],
|
||||
500: [0xa9, 0x95],
|
||||
1000: [0xad, 0x99],
|
||||
},
|
||||
polymer: false,
|
||||
},
|
||||
AUD: {
|
||||
thickness: 0x0d,
|
||||
lengths: {
|
||||
5: [0x8c, 0x78],
|
||||
10: [0x93, 0x7f],
|
||||
20: [0x9a, 0x86],
|
||||
50: [0xa1, 0x8d],
|
||||
100: [0xa8, 0x94],
|
||||
},
|
||||
polymer: true,
|
||||
},
|
||||
CAD: {
|
||||
thickness: 0x0d,
|
||||
lengths: {
|
||||
5: [0xa2, 0x8e],
|
||||
10: [0xa2, 0x8e],
|
||||
20: [0xa2, 0x8e],
|
||||
50: [0xa2, 0x8e],
|
||||
100: [0xa2, 0x8e],
|
||||
},
|
||||
polymer: true,
|
||||
},
|
||||
CHF: {
|
||||
thickness: 0x0d,
|
||||
lengths: {
|
||||
10: [0x85, 0x71],
|
||||
20: [0x8c, 0x78],
|
||||
50: [0x93, 0x7f],
|
||||
100: [0x9a, 0x86],
|
||||
200: [0xa1, 0x8d],
|
||||
1000: [0xa8, 0x94],
|
||||
},
|
||||
polymer: false,
|
||||
},
|
||||
EUR: {
|
||||
thickness: 0x0c,
|
||||
lengths: {
|
||||
5: [0x82, 0x6e],
|
||||
10: [0x89, 0x75],
|
||||
20: [0x8f, 0x7b],
|
||||
50: [0x96, 0x82],
|
||||
100: [0x9d, 0x89],
|
||||
200: [0xa3, 0x8f],
|
||||
500: [0xaa, 0x96],
|
||||
},
|
||||
polymer: false,
|
||||
},
|
||||
GBP: {
|
||||
thickness: 0x0d,
|
||||
lengths: {
|
||||
5: [0x91, 0x7d],
|
||||
10: [0x98, 0x84],
|
||||
20: [0x95, 0x81],
|
||||
50: [0xa6, 0x92],
|
||||
},
|
||||
polymer: true,
|
||||
},
|
||||
MXN: {
|
||||
thickness: 0x0c,
|
||||
lengths: {
|
||||
20: [0x7d, 0x73],
|
||||
50: [0x82, 0x78],
|
||||
100: [0x89, 0x7f],
|
||||
200: [0x90, 0x86],
|
||||
500: [0x97, 0x8d],
|
||||
1000: [0x9e, 0x94],
|
||||
},
|
||||
polymer: true,
|
||||
},
|
||||
USD: {
|
||||
thickness: 0x0d,
|
||||
lengths: {
|
||||
1: [0xa6, 0x92],
|
||||
2: [0xa6, 0x92],
|
||||
5: [0xa6, 0x92],
|
||||
10: [0xa6, 0x92],
|
||||
20: [0xa6, 0x92],
|
||||
50: [0xa6, 0x92],
|
||||
100: [0xa6, 0x92],
|
||||
},
|
||||
polymer: false,
|
||||
},
|
||||
// Add more currencies as needed...
|
||||
}
|
||||
|
||||
export default bills
|
||||
78
packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts
Normal file
78
packages/hal/src/dispensers/f56/f56-dlevel-fsm.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
/**
|
||||
* F56 Data-Level State Machine
|
||||
*
|
||||
* Higher-level FSM that manages request/response flow for F56 commands.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
const RESPONSE_TIMEOUT = 40000
|
||||
|
||||
class F56DLevelFsm extends EventEmitter {
|
||||
private _state: string = 'Idle'
|
||||
private timerId: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
get state(): string {
|
||||
return this._state
|
||||
}
|
||||
|
||||
private transition(to: string): void {
|
||||
this.clearTimer()
|
||||
this._state = to
|
||||
this.onEnter(to)
|
||||
}
|
||||
|
||||
private onEnter(state: string): void {
|
||||
if (state === 'WaitForStatus' || state === 'WaitForResponse') {
|
||||
this.startTimer()
|
||||
}
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
this.clearTimer()
|
||||
this.timerId = setTimeout(() => this.handle('timeout'), RESPONSE_TIMEOUT)
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timerId) {
|
||||
clearTimeout(this.timerId)
|
||||
this.timerId = null
|
||||
}
|
||||
}
|
||||
|
||||
private conclude(status: string, data?: Buffer): void {
|
||||
this.emit('status', status, data)
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
handle(event: string, data?: Buffer): void {
|
||||
switch (this._state) {
|
||||
case 'Idle':
|
||||
if (event === 'waitForResponse') {
|
||||
this.transition('WaitForStatus')
|
||||
}
|
||||
break
|
||||
|
||||
case 'WaitForStatus':
|
||||
if (event === 'timeout') {
|
||||
this.conclude('Transmission Timeout')
|
||||
} else if (event === 'transmissionError') {
|
||||
this.conclude('TransmissionError')
|
||||
} else if (event === 'transmissionComplete') {
|
||||
this.transition('WaitForResponse')
|
||||
}
|
||||
break
|
||||
|
||||
case 'WaitForResponse':
|
||||
if (event === 'timeout') {
|
||||
this.conclude('Response Timeout')
|
||||
} else if (event === 'frame') {
|
||||
this.conclude('Response', data)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const dLevelFsm = new F56DLevelFsm()
|
||||
export default dLevelFsm
|
||||
349
packages/hal/src/dispensers/f56/f56-fsm.ts
Normal file
349
packages/hal/src/dispensers/f56/f56-fsm.ts
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
/**
|
||||
* F56 Protocol State Machine
|
||||
*
|
||||
* Handles the low-level DLE/STX/ETX framing for Fujitsu F53/F56 dispensers.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { compute as computeCrc } from '../../utils/crc.js'
|
||||
|
||||
const STX = 0x02
|
||||
const ETX = 0x03
|
||||
const ENQ = 0x05
|
||||
const ACK = 0x06
|
||||
const NAK = 0x15
|
||||
const DLE = 0x10
|
||||
|
||||
const DLE_STX = Buffer.from([DLE, STX])
|
||||
const DLE_ACK = Buffer.from([DLE, ACK])
|
||||
const DLE_NAK = Buffer.from([DLE, NAK])
|
||||
const DLE_ETX = Buffer.from([DLE, ETX])
|
||||
const DLE_ENQ = Buffer.from([DLE, ENQ])
|
||||
|
||||
const DATA_STATES = ['DataLength', 'DataBody', 'CRC']
|
||||
|
||||
const CONTROL_MAP: Record<number, string> = {
|
||||
0x02: 'STX',
|
||||
0x03: 'ETX',
|
||||
0x05: 'ENQ',
|
||||
0x06: 'ACK',
|
||||
0x15: 'NAK',
|
||||
0x10: 'DLE',
|
||||
}
|
||||
|
||||
type StateHandler = string | ((byte?: number) => void)
|
||||
|
||||
class F56Fsm extends EventEmitter {
|
||||
private _state: string = 'Idle'
|
||||
private timerId: ReturnType<typeof setTimeout> | null = null
|
||||
private retryDleAckCount: number = 0
|
||||
private retryAckCount: number = 0
|
||||
private transmitData: Buffer | null = null
|
||||
private dataLengthBuf: Buffer = Buffer.alloc(2)
|
||||
private dataLengthPointer: number = 0
|
||||
private data: Buffer = Buffer.alloc(0)
|
||||
private dataPointer: number = 0
|
||||
private crc: Buffer = Buffer.alloc(2)
|
||||
private crcPointer: number = 0
|
||||
|
||||
get state(): string {
|
||||
return this._state
|
||||
}
|
||||
|
||||
private transition(to: string): void {
|
||||
this._state = to
|
||||
this.onEnter(to)
|
||||
}
|
||||
|
||||
private onEnter(state: string): void {
|
||||
switch (state) {
|
||||
case 'Idle':
|
||||
this.retryDleAckCount = 0
|
||||
this.retryAckCount = 0
|
||||
this.transmitData = null
|
||||
this.dataLengthBuf = Buffer.alloc(2)
|
||||
break
|
||||
case 'ENQ':
|
||||
case 'DLE_STX':
|
||||
case 'STX':
|
||||
case 'DataLength':
|
||||
case 'DataBody':
|
||||
case 'DLE_ETX':
|
||||
case 'ETX':
|
||||
case 'CRC':
|
||||
case 'DLE_ACK':
|
||||
case 'ACK':
|
||||
case 'DLE_ACK_2':
|
||||
case 'ACK_2':
|
||||
this.startTimer()
|
||||
break
|
||||
case 'DLE_ENQ_T':
|
||||
this.emit('send', DLE_ENQ)
|
||||
this.transition('DLE_ACK')
|
||||
break
|
||||
case 'Transmit':
|
||||
this.resetRetry()
|
||||
this.retryAckCount = 0
|
||||
if (this.transmitData) {
|
||||
this.emit('send', this.transmitData)
|
||||
}
|
||||
this.transition('DLE_ACK_2')
|
||||
break
|
||||
case 'CRC_Check':
|
||||
this.checkCrc()
|
||||
break
|
||||
}
|
||||
|
||||
// Additional state-specific initialization
|
||||
if (state === 'DLE_STX') {
|
||||
this.dataLengthPointer = 0
|
||||
}
|
||||
if (state === 'ACK' || state === 'ACK_2') {
|
||||
this.retryDleAckCount = 0
|
||||
}
|
||||
}
|
||||
|
||||
private onExit(): void {
|
||||
this.clearTimer()
|
||||
}
|
||||
|
||||
private startTimer(): void {
|
||||
this.clearTimer()
|
||||
this.timerId = setTimeout(() => this.handle('Timeout'), 5000)
|
||||
}
|
||||
|
||||
private clearTimer(): void {
|
||||
if (this.timerId) {
|
||||
clearTimeout(this.timerId)
|
||||
this.timerId = null
|
||||
}
|
||||
}
|
||||
|
||||
private resetRetry(): void {
|
||||
this.retryDleAckCount = 0
|
||||
}
|
||||
|
||||
private nakStx(): void {
|
||||
this.emit('send', DLE_NAK)
|
||||
this.transition('DLE_STX')
|
||||
}
|
||||
|
||||
private nakEnq(): void {
|
||||
this.emit('NAK')
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
private retryDleAck(): void {
|
||||
this.retryDleAckCount++
|
||||
if (this.retryDleAckCount < 3) {
|
||||
this.transition('DLE_ENQ_T')
|
||||
return
|
||||
}
|
||||
this.emit('status', 'transmissionFailure')
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
private retryAck(): void {
|
||||
this.retryAckCount++
|
||||
if (this.retryAckCount < 3) {
|
||||
this.transition('DLE_ENQ_T')
|
||||
return
|
||||
}
|
||||
this.emit('status', 'transmissionFailure')
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
private retryDleAck2(): void {
|
||||
this.retryDleAckCount++
|
||||
if (this.retryDleAckCount < 3) {
|
||||
this.transition('Transmit')
|
||||
return
|
||||
}
|
||||
this.emit('status', 'transmissionFailure')
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
private retryAck2(): void {
|
||||
this.retryAckCount++
|
||||
if (this.retryAckCount < 3) {
|
||||
this.transition('Transmit')
|
||||
return
|
||||
}
|
||||
this.emit('status', 'transmissionFailure')
|
||||
this.transition('Idle')
|
||||
}
|
||||
|
||||
private checkCrc(): void {
|
||||
const buf = Buffer.concat([this.dataLengthBuf, this.data, DLE_ETX])
|
||||
const computedCrc = computeCrc(buf)
|
||||
|
||||
if (this.crc.readUInt16LE(0) === computedCrc) {
|
||||
this.emit('send', DLE_ACK)
|
||||
this.emit('frame', this.data)
|
||||
this.transition('Idle')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('DEBUG2: CRC failure')
|
||||
this.nakStx()
|
||||
}
|
||||
|
||||
handle(event: string, byte?: number): void {
|
||||
this.onExit()
|
||||
|
||||
switch (this._state) {
|
||||
case 'Idle':
|
||||
if (event === 'Send' && byte !== undefined) {
|
||||
// byte is actually the data buffer index, handled in tx()
|
||||
}
|
||||
if (event === 'DLE') this.transition('ENQ')
|
||||
if (event === 'LineError') this.nakEnq()
|
||||
break
|
||||
|
||||
case 'ENQ':
|
||||
if (event === 'ENQ') {
|
||||
this.emit('send', DLE_ACK)
|
||||
this.transition('DLE_STX')
|
||||
} else if (event === 'Timeout' || event === 'LineError') {
|
||||
this.nakEnq()
|
||||
} else {
|
||||
this.transition('Idle')
|
||||
}
|
||||
break
|
||||
|
||||
case 'DLE_STX':
|
||||
if (event === 'DLE') this.transition('STX')
|
||||
else if (event === 'Timeout') this.transition('Idle')
|
||||
else if (event === 'LineError') this.nakEnq()
|
||||
else this.transition('ENQ')
|
||||
break
|
||||
|
||||
case 'STX':
|
||||
if (event === 'DLE') this.transition('DLE_STX')
|
||||
else if (event === 'ENQ') {
|
||||
this.emit('send', DLE_ACK)
|
||||
this.transition('DLE_STX')
|
||||
} else if (event === 'STX') this.transition('DataLength')
|
||||
else this.nakEnq()
|
||||
break
|
||||
|
||||
case 'DataLength':
|
||||
if (event === 'Timeout' || event === 'LineError') {
|
||||
this.nakStx()
|
||||
} else if (event === 'Data' && byte !== undefined) {
|
||||
this.dataLengthBuf[this.dataLengthPointer++] = byte
|
||||
if (this.dataLengthPointer === 2) {
|
||||
const dataLength = this.dataLengthBuf.readUInt16BE(0)
|
||||
this.data = Buffer.alloc(dataLength)
|
||||
this.dataPointer = 0
|
||||
this.crc = Buffer.alloc(2)
|
||||
this.crcPointer = 0
|
||||
this.transition('DataBody')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'DataBody':
|
||||
if (event === 'Timeout' || event === 'LineError') {
|
||||
this.nakStx()
|
||||
} else if (event === 'Data' && byte !== undefined) {
|
||||
this.data[this.dataPointer++] = byte
|
||||
if (this.dataPointer === this.data.length) {
|
||||
this.transition('DLE_ETX')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'DLE_ETX':
|
||||
if (event === 'DLE') this.transition('ETX')
|
||||
else this.nakStx()
|
||||
break
|
||||
|
||||
case 'ETX':
|
||||
if (event === 'ETX') this.transition('CRC')
|
||||
else this.nakStx()
|
||||
break
|
||||
|
||||
case 'CRC':
|
||||
if (event === 'Timeout' || event === 'LineError') {
|
||||
this.nakStx()
|
||||
} else if (event === 'Data' && byte !== undefined) {
|
||||
this.crc[this.crcPointer++] = byte
|
||||
if (this.crcPointer === 2) {
|
||||
this.transition('CRC_Check')
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'DLE_ACK':
|
||||
if (event === 'DLE') this.transition('ACK')
|
||||
else if (event === 'Timeout' || event === 'LineError') {
|
||||
this.retryDleAck()
|
||||
}
|
||||
break
|
||||
|
||||
case 'ACK':
|
||||
if (event === 'ENQ') this.transition('DLE_ENQ_T')
|
||||
else if (event === 'ACK') this.transition('Transmit')
|
||||
else if (event === 'Timeout' || event === 'LineError') {
|
||||
this.retryAck()
|
||||
} else {
|
||||
this.transition('DLE_ACK')
|
||||
}
|
||||
break
|
||||
|
||||
case 'DLE_ACK_2':
|
||||
if (event === 'DLE') this.transition('ACK_2')
|
||||
else if (event === 'Timeout' || event === 'LineError') {
|
||||
this.retryDleAck2()
|
||||
}
|
||||
break
|
||||
|
||||
case 'ACK_2':
|
||||
if (event === 'ENQ') this.transition('Idle')
|
||||
else if (event === 'ACK') {
|
||||
this.emit('status', 'transmissionComplete')
|
||||
this.transition('Idle')
|
||||
} else if (event === 'NAK' || event === 'Timeout' || event === 'LineError') {
|
||||
this.retryAck2()
|
||||
} else {
|
||||
this.transition('DLE_ACK_2')
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
rx(byte: number): void {
|
||||
if (DATA_STATES.includes(this._state)) {
|
||||
this.handle('Data', byte)
|
||||
return
|
||||
}
|
||||
|
||||
const event = CONTROL_MAP[byte]
|
||||
if (event) {
|
||||
this.handle(event)
|
||||
return
|
||||
}
|
||||
|
||||
console.error('Unknown code: 0x%s', Buffer.from([byte]).toString('hex'))
|
||||
}
|
||||
|
||||
tx(packet: Buffer): void {
|
||||
this.transmitData = this.buildFrame(packet)
|
||||
this.transition('DLE_ENQ_T')
|
||||
}
|
||||
|
||||
private buildFrame(data: Buffer): Buffer {
|
||||
const buf = Buffer.alloc(8 + data.length)
|
||||
buf.writeUInt16BE(data.length, 2)
|
||||
DLE_STX.copy(buf)
|
||||
data.copy(buf, 4)
|
||||
DLE_ETX.copy(buf, data.length + 4)
|
||||
const crcInt = computeCrc(buf.subarray(2, data.length + 6))
|
||||
buf.writeUInt16LE(crcInt, data.length + 6)
|
||||
return buf
|
||||
}
|
||||
}
|
||||
|
||||
export const fsm = new F56Fsm()
|
||||
export default fsm
|
||||
254
packages/hal/src/dispensers/f56/f56-rs232.ts
Normal file
254
packages/hal/src/dispensers/f56/f56-rs232.ts
Normal file
|
|
@ -0,0 +1,254 @@
|
|||
/**
|
||||
* F56 RS232 Communication Layer
|
||||
*
|
||||
* Handles serial communication for Fujitsu F53/F56 dispensers.
|
||||
* Protocol: 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { SerialPort } from 'serialport'
|
||||
import { fsm } from './f56-fsm.js'
|
||||
import { dLevelFsm } from './f56-dlevel-fsm.js'
|
||||
import { bills, type BillConfig } from './bills.js'
|
||||
|
||||
const SERIAL_OPTIONS = {
|
||||
baudRate: 9600,
|
||||
parity: 'even' as const,
|
||||
dataBits: 8 as const,
|
||||
stopBits: 1 as const,
|
||||
autoOpen: false,
|
||||
}
|
||||
|
||||
const FS = 0x1c
|
||||
const MAX_SUPPORTED_CASSETTES = 4
|
||||
|
||||
class F56Rs232Emitter extends EventEmitter {}
|
||||
const emitter = new F56Rs232Emitter()
|
||||
|
||||
let serial: SerialPort | null = null
|
||||
|
||||
/**
|
||||
* Convert number to parity-encoded decimal digits
|
||||
*/
|
||||
function parity(x: number): number {
|
||||
let y = x ^ (x >> 1)
|
||||
y = y ^ (y >> 2)
|
||||
y = y ^ (y >> 4)
|
||||
y = y ^ (y >> 8)
|
||||
y = y ^ (y >> 16)
|
||||
return x + (y & 1) * 0x80
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a number as two parity-encoded decimal digits
|
||||
*/
|
||||
function D(n: number): [number, number] {
|
||||
let str = n.toString(10)
|
||||
if (str.length === 1) str = '0' + str
|
||||
return [parity(str.charCodeAt(0)), parity(str.charCodeAt(1))]
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode two parity-encoded bytes to a number
|
||||
*/
|
||||
function DP(buf: Buffer): number {
|
||||
const byte0 = buf[0] ?? 0
|
||||
const byte1 = buf[1] ?? 0
|
||||
const str = String.fromCharCode(byte0 & 0x7f, byte1 & 0x7f)
|
||||
return parseInt(str, 10)
|
||||
}
|
||||
|
||||
function prettyHex(buf: Buffer): string {
|
||||
const pairs: string[] = []
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
pairs.push(buf.subarray(i, i + 1).toString('hex'))
|
||||
}
|
||||
return pairs.join(' ')
|
||||
}
|
||||
|
||||
function parse(buf: Buffer): void {
|
||||
for (const byte of buf) {
|
||||
fsm.rx(byte)
|
||||
}
|
||||
}
|
||||
|
||||
export async function create(device: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const opts = { ...SERIAL_OPTIONS, path: device }
|
||||
serial = new SerialPort(opts)
|
||||
|
||||
serial.open((error) => {
|
||||
if (error) return reject(error)
|
||||
|
||||
serial!.on('data', (data: Buffer) => parse(data))
|
||||
serial!.on('close', () => emitter.emit('disconnected'))
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export async function initialize(currency: string, denominations: number[]): Promise<void> {
|
||||
const billData = bills[currency]
|
||||
if (!billData) {
|
||||
throw new Error(`Unsupported currency: ${currency}`)
|
||||
}
|
||||
|
||||
// Validate denominations
|
||||
for (let i = 0; i < denominations.length; i++) {
|
||||
const denom = denominations[i]
|
||||
if (denom !== undefined && !billData.lengths[denom]) {
|
||||
throw new Error(`Unsupported denomination: ${denom} for fiat code: ${currency}`)
|
||||
}
|
||||
}
|
||||
|
||||
const ODR = billData.polymer ? 0x40 : 0x00
|
||||
|
||||
// Build lengths array
|
||||
const lengths: number[] = []
|
||||
for (let i = 0; i < MAX_SUPPORTED_CASSETTES; i++) {
|
||||
const denom = denominations[i]
|
||||
if (denom !== undefined && billData.lengths[denom]) {
|
||||
lengths.push(...billData.lengths[denom])
|
||||
} else {
|
||||
lengths.push(0x00, 0x00)
|
||||
}
|
||||
}
|
||||
|
||||
// Build thicknesses array
|
||||
const thicknesses = Array(MAX_SUPPORTED_CASSETTES).fill(billData.thickness)
|
||||
|
||||
const command = Buffer.from([0x60, 0x02, 0x0d, ODR, ...lengths, ...thicknesses, FS])
|
||||
|
||||
const res = await request(command)
|
||||
|
||||
if (res[0] === 0xf0) {
|
||||
const errorCode = res.subarray(3, 5)
|
||||
throw new Error(`F56 error code: ${prettyHex(errorCode)}`)
|
||||
}
|
||||
|
||||
if (res[1] !== 0x02 || res[2] !== 0x34) {
|
||||
throw new Error('Invalid F56 response header')
|
||||
}
|
||||
}
|
||||
|
||||
export interface BillCountResult {
|
||||
bills: Array<{ dispensed: number; rejected: number }>
|
||||
error?: Error
|
||||
}
|
||||
|
||||
export async function billCount(counts: number[]): Promise<BillCountResult> {
|
||||
const actualCounts = Array(MAX_SUPPORTED_CASSETTES)
|
||||
.fill(0)
|
||||
.map((_, i) => counts[i] ?? 0)
|
||||
|
||||
const ODR = 0xe4
|
||||
const billCounts = [
|
||||
...D(actualCounts[0]!),
|
||||
...D(actualCounts[1]!),
|
||||
...D(actualCounts[2]!),
|
||||
...D(actualCounts[3]!),
|
||||
]
|
||||
const rejects = [...D(4), ...D(4), ...D(4), ...D(4)]
|
||||
const retries = [3, 3, 3, 3]
|
||||
|
||||
const command = Buffer.from([0x60, 0x03, 0x15, ODR, ...billCounts, ...rejects, ...retries, FS])
|
||||
|
||||
const res = await request(command)
|
||||
|
||||
if (res[1] !== 0x03 || res[2] !== 0x99) {
|
||||
throw new Error('Invalid F56 response header')
|
||||
}
|
||||
|
||||
const response: BillCountResult = {
|
||||
bills: [],
|
||||
}
|
||||
|
||||
for (let i = 0; i < counts.length; i++) {
|
||||
response.bills.push({
|
||||
dispensed: DP(res.subarray(0x27 + 2 * i, 0x29 + 2 * i)),
|
||||
rejected: DP(res.subarray(0x2f + 2 * i, 0x31 + 2 * i)),
|
||||
})
|
||||
}
|
||||
|
||||
if (res[0] === 0xf0) {
|
||||
console.log('response', res)
|
||||
const errorCode = res.subarray(3, 5)
|
||||
response.error = new Error(`Dispensing, code: ${prettyHex(errorCode)}`)
|
||||
console.error(`found error code: ${prettyHex(errorCode)}`)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
export async function billsPresent(): Promise<boolean> {
|
||||
const command = Buffer.from([0x00, 0x01, FS])
|
||||
|
||||
const res = await request(command)
|
||||
|
||||
if (res[0] === 0xf0) {
|
||||
const errorCode = res.subarray(3, 5)
|
||||
console.error(`F56 Error with code ${prettyHex(errorCode)}`)
|
||||
console.error(prettyHex(res))
|
||||
throw new Error('F56 Error')
|
||||
}
|
||||
|
||||
const sensorRegister = res.subarray(0x0c, 0x12)
|
||||
const byte2 = sensorRegister[2] ?? 0
|
||||
return (byte2 & 0x10) > 0
|
||||
}
|
||||
|
||||
async function request(command: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (dLevelFsm.state !== 'Idle') {
|
||||
const error = new Error("Can't send in state: " + dLevelFsm.state)
|
||||
;(error as Error & { code: string }).code = 'DLEVEL_FSM_ERROR'
|
||||
return reject(error)
|
||||
}
|
||||
|
||||
const rs232StatusHandler = (status: string) => dLevelFsm.handle(status)
|
||||
const rs232FrameHandler = (frame: Buffer) => dLevelFsm.handle('frame', frame)
|
||||
|
||||
fsm.on('status', rs232StatusHandler)
|
||||
fsm.on('frame', rs232FrameHandler)
|
||||
|
||||
const statusHandler = (status: string, frame?: Buffer) => {
|
||||
fsm.off('status', rs232StatusHandler)
|
||||
fsm.off('frame', rs232FrameHandler)
|
||||
dLevelFsm.off('status', statusHandler)
|
||||
|
||||
if (status === 'Response' && frame) {
|
||||
return resolve(frame)
|
||||
}
|
||||
if (status === 'Response Timeout') {
|
||||
const error = new Error('Response Timeout')
|
||||
;(error as Error & { code: string }).code = 'RESPONSE_TIMEOUT'
|
||||
return reject(error)
|
||||
}
|
||||
return reject(new Error(status))
|
||||
}
|
||||
|
||||
dLevelFsm.on('status', statusHandler)
|
||||
|
||||
fsm.tx(command)
|
||||
dLevelFsm.handle('waitForResponse')
|
||||
fsm.tx(command)
|
||||
})
|
||||
}
|
||||
|
||||
// Wire up fsm send event to serial write
|
||||
fsm.on('send', (data: Buffer) => {
|
||||
serial?.write(data)
|
||||
})
|
||||
|
||||
export function close(): void {
|
||||
serial?.close()
|
||||
serial = null
|
||||
}
|
||||
|
||||
export default {
|
||||
create,
|
||||
initialize,
|
||||
billCount,
|
||||
billsPresent,
|
||||
close,
|
||||
}
|
||||
106
packages/hal/src/dispensers/f56/index.ts
Normal file
106
packages/hal/src/dispensers/f56/index.ts
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
/**
|
||||
* F56 Bill Dispenser Driver
|
||||
*
|
||||
* Supports Fujitsu F53/F56 bill dispensers.
|
||||
* Used in: Lamassu Sintra, Sintra Forte, Tejo
|
||||
*
|
||||
* Protocol: RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*/
|
||||
|
||||
import * as f56 from './f56-rs232.js'
|
||||
import type {
|
||||
BillDispenser,
|
||||
DispenserConfig,
|
||||
DispenserInitData,
|
||||
DispenseResult,
|
||||
} from '../../types.js'
|
||||
|
||||
export class F56Dispenser implements BillDispenser {
|
||||
public type: string = 'F56'
|
||||
public initialized: boolean = false
|
||||
private initializing: boolean = false
|
||||
private device: string
|
||||
private fiatCode: string = ''
|
||||
public dispenseLimit: number = 20
|
||||
|
||||
constructor(config: DispenserConfig) {
|
||||
this.device = config.device
|
||||
}
|
||||
|
||||
static factory(config: DispenserConfig): F56Dispenser {
|
||||
return new F56Dispenser(config)
|
||||
}
|
||||
|
||||
async init(data: DispenserInitData): Promise<void> {
|
||||
if (this.initializing || this.initialized) return
|
||||
|
||||
this.initializing = true
|
||||
this.fiatCode = data.fiatCode
|
||||
|
||||
const denominations = data.cassettes.map((c) => c.denomination)
|
||||
|
||||
try {
|
||||
await f56.create(this.device)
|
||||
await f56.initialize(this.fiatCode, denominations)
|
||||
this.initialized = true
|
||||
this.initializing = false
|
||||
console.log('INFO F56 Connected')
|
||||
} catch (err) {
|
||||
this.initializing = false
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async dispense(notes: number[]): Promise<{ value: DispenseResult[]; error?: Error }> {
|
||||
try {
|
||||
const { bills, error } = await f56.billCount(notes)
|
||||
|
||||
if (error) {
|
||||
this.close()
|
||||
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
|
||||
;(error as Error & { statusCode: number }).statusCode = 570
|
||||
}
|
||||
|
||||
return { value: bills, error }
|
||||
} catch (err) {
|
||||
this.close()
|
||||
const error = err as Error
|
||||
;(error as Error & { name: string; statusCode: number }).name = 'F56DispenseError'
|
||||
;(error as Error & { statusCode: number }).statusCode = 570
|
||||
return { value: [], error }
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
f56.close()
|
||||
this.initialized = false
|
||||
}
|
||||
|
||||
async billsPresent(): Promise<boolean> {
|
||||
return f56.billsPresent()
|
||||
}
|
||||
|
||||
async waitForBillsRemoved(): Promise<boolean> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let retries = 3
|
||||
|
||||
const interval = setInterval(() => {
|
||||
this.billsPresent()
|
||||
.then((billsArePresent) => {
|
||||
if (!billsArePresent) {
|
||||
clearInterval(interval)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
.catch((err: Error & { code?: string }) => {
|
||||
if (err.code === 'DLEVEL_FSM_ERROR') return
|
||||
if (err.code === 'RESPONSE_TIMEOUT' && retries-- > 0) return
|
||||
clearInterval(interval)
|
||||
reject(err)
|
||||
})
|
||||
}, 1000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default F56Dispenser
|
||||
27
packages/hal/src/dispensers/index.ts
Normal file
27
packages/hal/src/dispensers/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Bill Dispenser Drivers
|
||||
*
|
||||
* Factory for creating bill dispenser instances based on device type.
|
||||
*/
|
||||
|
||||
export { F56Dispenser } from './f56/index.js'
|
||||
export type { DispenserConfig, BillDispenser, DispenserInitData, DispenseResult } from '../types.js'
|
||||
|
||||
import { F56Dispenser } from './f56/index.js'
|
||||
import type { DispenserConfig, BillDispenser } from '../types.js'
|
||||
|
||||
export type DispenserType = 'f56'
|
||||
|
||||
/**
|
||||
* Create a bill dispenser instance
|
||||
* @param type Dispenser type (e.g., 'f56')
|
||||
* @param config Dispenser configuration
|
||||
*/
|
||||
export function createDispenser(type: DispenserType, config: DispenserConfig): BillDispenser {
|
||||
switch (type) {
|
||||
case 'f56':
|
||||
return F56Dispenser.factory(config)
|
||||
default:
|
||||
throw new Error(`Unknown dispenser type: ${type}`)
|
||||
}
|
||||
}
|
||||
134
packages/hal/src/dispensers/mock.rs
Normal file
134
packages/hal/src/dispensers/mock.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Mock bill dispenser for testing
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::DispenserError;
|
||||
use super::traits::{BillDispenser, CassetteStatus};
|
||||
|
||||
/// Mock bill dispenser for development and testing
|
||||
pub struct MockDispenser {
|
||||
connected: bool,
|
||||
cassettes: Vec<CassetteStatus>,
|
||||
bills_at_exit: bool,
|
||||
}
|
||||
|
||||
impl MockDispenser {
|
||||
/// Create a new mock dispenser with default cassettes
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
connected: false,
|
||||
cassettes: vec![
|
||||
CassetteStatus {
|
||||
denomination: 20,
|
||||
count: 500,
|
||||
capacity: 500,
|
||||
},
|
||||
CassetteStatus {
|
||||
denomination: 50,
|
||||
count: 200,
|
||||
capacity: 200,
|
||||
},
|
||||
],
|
||||
bills_at_exit: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create with custom cassette configuration
|
||||
pub fn with_cassettes(cassettes: Vec<CassetteStatus>) -> Self {
|
||||
Self {
|
||||
connected: false,
|
||||
cassettes,
|
||||
bills_at_exit: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockDispenser {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillDispenser for MockDispenser {
|
||||
fn driver_name(&self) -> &'static str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> Result<(), DispenserError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
self.connected = true;
|
||||
tracing::info!("MockDispenser connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), DispenserError> {
|
||||
self.connected = false;
|
||||
tracing::info!("MockDispenser disconnected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_cassette_status(&self) -> Result<Vec<CassetteStatus>, DispenserError> {
|
||||
Ok(self.cassettes.clone())
|
||||
}
|
||||
|
||||
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError> {
|
||||
if !self.connected {
|
||||
return Err(DispenserError::ConnectionFailed("Not connected".into()));
|
||||
}
|
||||
|
||||
// Find cassette with this denomination
|
||||
let cassette = self
|
||||
.cassettes
|
||||
.iter_mut()
|
||||
.find(|c| c.denomination == denomination)
|
||||
.ok_or_else(|| {
|
||||
DispenserError::HardwareError(format!(
|
||||
"No cassette for denomination {}",
|
||||
denomination
|
||||
))
|
||||
})?;
|
||||
|
||||
// Check if we have enough bills
|
||||
if cassette.count < count {
|
||||
return Err(DispenserError::InsufficientBills(count, cassette.count));
|
||||
}
|
||||
|
||||
// Simulate dispense time
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(count as u64 * 200)).await;
|
||||
|
||||
// Update count
|
||||
cassette.count -= count;
|
||||
self.bills_at_exit = true;
|
||||
|
||||
tracing::info!(
|
||||
"MockDispenser dispensed {} x ${} bills ({} remaining)",
|
||||
count,
|
||||
denomination,
|
||||
cassette.count
|
||||
);
|
||||
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
async fn reset(&mut self) -> Result<(), DispenserError> {
|
||||
tracing::info!("MockDispenser reset");
|
||||
self.bills_at_exit = false;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn is_ready(&self) -> Result<bool, DispenserError> {
|
||||
Ok(self.connected && !self.bills_at_exit)
|
||||
}
|
||||
|
||||
async fn bills_present(&self) -> Result<bool, DispenserError> {
|
||||
Ok(self.bills_at_exit)
|
||||
}
|
||||
|
||||
async fn wait_for_bills_removed(&self) -> Result<(), DispenserError> {
|
||||
// Simulate customer taking bills
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
tracing::info!("MockDispenser: bills removed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
125
packages/hal/src/dispensers/mod.rs
Normal file
125
packages/hal/src/dispensers/mod.rs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
//! Bill dispenser drivers
|
||||
//!
|
||||
//! This module contains implementations for various bill dispenser protocols:
|
||||
//! - Puloon LCDM series
|
||||
//! - Fujitsu F53/F56
|
||||
//! - Genmega
|
||||
//! - HCM2 (Hitachi recycler)
|
||||
//! - GSR50 (recycler)
|
||||
|
||||
pub mod traits;
|
||||
pub mod mock;
|
||||
// pub mod puloon;
|
||||
// pub mod f56;
|
||||
// pub mod genmega;
|
||||
// pub mod hcm2;
|
||||
// pub mod gsr50;
|
||||
|
||||
pub use traits::*;
|
||||
pub use mock::MockDispenser;
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::{DispenserDriver, error::DispenserError};
|
||||
|
||||
/// Wrapper for bill dispenser that exposes napi-rs bindings
|
||||
#[napi]
|
||||
pub struct BillDispenserWrapper {
|
||||
inner: Box<dyn BillDispenser>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BillDispenserWrapper {
|
||||
/// Create a new bill dispenser instance
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
driver: DispenserDriver,
|
||||
port: Option<String>,
|
||||
fiat_code: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let _fiat = fiat_code.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let dispenser: Box<dyn BillDispenser> = match driver {
|
||||
DispenserDriver::Mock => Box::new(MockDispenser::new()),
|
||||
// TODO: Implement other drivers
|
||||
_ => {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Driver {:?} not yet implemented. Use Mock for development.",
|
||||
driver
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { inner: dispenser })
|
||||
}
|
||||
|
||||
/// Get the driver name
|
||||
#[napi(getter)]
|
||||
pub fn driver_name(&self) -> String {
|
||||
self.inner.driver_name().to_string()
|
||||
}
|
||||
|
||||
/// Connect to the dispenser
|
||||
#[napi]
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Disconnect from the dispenser
|
||||
#[napi]
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.disconnect()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Dispense bills
|
||||
#[napi]
|
||||
pub async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32> {
|
||||
self.inner
|
||||
.dispense(denomination, count)
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reset the dispenser
|
||||
#[napi]
|
||||
pub async fn reset(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.reset()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Check if dispenser is ready
|
||||
#[napi]
|
||||
pub async fn is_ready(&self) -> Result<bool> {
|
||||
self.inner
|
||||
.is_ready()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Check if bills are present at exit
|
||||
#[napi]
|
||||
pub async fn bills_present(&self) -> Result<bool> {
|
||||
self.inner
|
||||
.bills_present()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Wait for bills to be removed
|
||||
#[napi]
|
||||
pub async fn wait_for_bills_removed(&self) -> Result<()> {
|
||||
self.inner
|
||||
.wait_for_bills_removed()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
55
packages/hal/src/dispensers/traits.rs
Normal file
55
packages/hal/src/dispensers/traits.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
//! Bill dispenser trait definitions
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::error::DispenserError;
|
||||
|
||||
/// Status of a cassette in the dispenser
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CassetteStatus {
|
||||
/// Bill denomination in this cassette
|
||||
pub denomination: u32,
|
||||
/// Current bill count
|
||||
pub count: u32,
|
||||
/// Maximum capacity
|
||||
pub capacity: u32,
|
||||
}
|
||||
|
||||
/// Unified interface for all bill dispensers
|
||||
///
|
||||
/// Implementations: Puloon, F56, Genmega, HCM2, GSR50, Mock
|
||||
#[async_trait]
|
||||
pub trait BillDispenser: Send + Sync {
|
||||
/// Get dispenser driver name (for logging/debugging)
|
||||
fn driver_name(&self) -> &'static str;
|
||||
|
||||
/// Connect to the dispenser
|
||||
async fn connect(&mut self) -> Result<(), DispenserError>;
|
||||
|
||||
/// Disconnect from the dispenser
|
||||
async fn disconnect(&mut self) -> Result<(), DispenserError>;
|
||||
|
||||
/// Get status of all cassettes
|
||||
async fn get_cassette_status(&self) -> Result<Vec<CassetteStatus>, DispenserError>;
|
||||
|
||||
/// Dispense bills from a specific cassette
|
||||
///
|
||||
/// Returns the number of bills actually dispensed.
|
||||
async fn dispense(&mut self, denomination: u32, count: u32) -> Result<u32, DispenserError>;
|
||||
|
||||
/// Reset the dispenser after a jam or error
|
||||
async fn reset(&mut self) -> Result<(), DispenserError>;
|
||||
|
||||
/// Check if dispenser is ready to dispense
|
||||
async fn is_ready(&self) -> Result<bool, DispenserError>;
|
||||
|
||||
/// Check if bills are present at the exit (for customer to take)
|
||||
///
|
||||
/// Not all dispensers support this - some will always return true.
|
||||
async fn bills_present(&self) -> Result<bool, DispenserError>;
|
||||
|
||||
/// Wait for bills to be removed by customer
|
||||
///
|
||||
/// Returns immediately for dispensers that don't support detection.
|
||||
async fn wait_for_bills_removed(&self) -> Result<(), DispenserError>;
|
||||
}
|
||||
91
packages/hal/src/error.rs
Normal file
91
packages/hal/src/error.rs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
//! Error types for the HAL
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors that can occur during bill validator operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ValidatorError {
|
||||
/// Failed to connect to the validator
|
||||
#[error("Connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
/// Communication error during operation
|
||||
#[error("Communication error: {0}")]
|
||||
CommunicationError(String),
|
||||
|
||||
/// Bill was rejected by the validator
|
||||
#[error("Bill rejected: {0}")]
|
||||
BillRejected(String),
|
||||
|
||||
/// Stacker is full
|
||||
#[error("Stacker full")]
|
||||
StackerFull,
|
||||
|
||||
/// General hardware error
|
||||
#[error("Hardware error: {0}")]
|
||||
HardwareError(String),
|
||||
|
||||
/// Invalid state for requested operation
|
||||
#[error("Invalid state: {0}")]
|
||||
InvalidState(String),
|
||||
|
||||
/// Operation timed out
|
||||
#[error("Operation timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Errors that can occur during bill dispenser operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DispenserError {
|
||||
/// Failed to connect to the dispenser
|
||||
#[error("Connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
/// Communication error during operation
|
||||
#[error("Communication error: {0}")]
|
||||
CommunicationError(String),
|
||||
|
||||
/// Not enough bills to fulfill request
|
||||
#[error("Insufficient bills: need {0}, have {1}")]
|
||||
InsufficientBills(u32, u32),
|
||||
|
||||
/// Cassette is empty
|
||||
#[error("Cassette empty: denomination {0}")]
|
||||
CassetteEmpty(u32),
|
||||
|
||||
/// Bill jam detected
|
||||
#[error("Bill jam")]
|
||||
BillJam,
|
||||
|
||||
/// General hardware error
|
||||
#[error("Hardware error: {0}")]
|
||||
HardwareError(String),
|
||||
|
||||
/// Operation timed out
|
||||
#[error("Operation timed out")]
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Errors that can occur during printer operations
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PrinterError {
|
||||
/// Failed to connect to the printer
|
||||
#[error("Connection failed: {0}")]
|
||||
ConnectionFailed(String),
|
||||
|
||||
/// Communication error during operation
|
||||
#[error("Communication error: {0}")]
|
||||
CommunicationError(String),
|
||||
|
||||
/// Printer is out of paper
|
||||
#[error("Out of paper")]
|
||||
OutOfPaper,
|
||||
|
||||
/// Paper jam detected
|
||||
#[error("Paper jam")]
|
||||
PaperJam,
|
||||
|
||||
/// General hardware error
|
||||
#[error("Hardware error: {0}")]
|
||||
HardwareError(String),
|
||||
}
|
||||
56
packages/hal/src/index.ts
Normal file
56
packages/hal/src/index.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* @lamassu/hal - Hardware Abstraction Layer
|
||||
*
|
||||
* Provides drivers for Lamassu ATM hardware devices:
|
||||
* - Bill validators (JCM iVIZION via ID003 protocol)
|
||||
* - Bill dispensers (Fujitsu F53/F56)
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createValidator, createDispenser } from '@lamassu/hal'
|
||||
*
|
||||
* // Create a bill validator
|
||||
* const validator = createValidator('id003', {
|
||||
* rs232: { device: '/dev/ttyUSB0' },
|
||||
* fiatCode: 'USD'
|
||||
* })
|
||||
*
|
||||
* // Create a bill dispenser
|
||||
* const dispenser = createDispenser('f56', {
|
||||
* device: '/dev/ttyUSB1'
|
||||
* })
|
||||
*
|
||||
* // Initialize dispenser
|
||||
* await dispenser.init({
|
||||
* fiatCode: 'USD',
|
||||
* cassettes: [
|
||||
* { denomination: 20 },
|
||||
* { denomination: 100 }
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Validators
|
||||
export { Id003, createValidator, type ValidatorType } from './validators/index.js'
|
||||
|
||||
// Dispensers
|
||||
export { F56Dispenser, createDispenser, type DispenserType } from './dispensers/index.js'
|
||||
|
||||
// Types
|
||||
export type {
|
||||
BillValidator,
|
||||
BillDispenser,
|
||||
ValidatorConfig,
|
||||
DispenserConfig,
|
||||
DispenserInitData,
|
||||
CassetteConfig,
|
||||
DispenseResult,
|
||||
BillData,
|
||||
RejectionData,
|
||||
ValidatorFactory,
|
||||
DispenserFactory,
|
||||
} from './types.js'
|
||||
|
||||
// Utilities
|
||||
export { compute as computeCrc } from './utils/crc.js'
|
||||
94
packages/hal/src/lib.rs
Normal file
94
packages/hal/src/lib.rs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
//! Lamassu Hardware Abstraction Layer
|
||||
//!
|
||||
//! This crate provides Rust implementations of hardware drivers for bill validators,
|
||||
//! dispensers, printers, and other ATM peripherals. These are exposed to Node.js
|
||||
//! via napi-rs bindings.
|
||||
//!
|
||||
//! # Supported Hardware
|
||||
//!
|
||||
//! ## Bill Validators
|
||||
//! - ID003 (JCM) - Default validator protocol
|
||||
//! - CCNET (CashCode)
|
||||
//! - MEI CashFlow SC
|
||||
//! - MEI BNR Advance
|
||||
//! - Genmega
|
||||
//! - HCM2 (Hitachi recycler)
|
||||
//! - GSR50 (recycler)
|
||||
//!
|
||||
//! ## Bill Dispensers
|
||||
//! - Puloon LCDM series
|
||||
//! - Fujitsu F53/F56
|
||||
//! - Genmega
|
||||
//! - HCM2 (Hitachi recycler)
|
||||
//! - GSR50 (recycler)
|
||||
//!
|
||||
//! ## Printers
|
||||
//! - Nippon (ESC/POS)
|
||||
//! - Zebra (ZPL)
|
||||
//! - Genmega
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```typescript
|
||||
//! import { BillValidatorWrapper, ValidatorDriver } from '@lamassu/hal'
|
||||
//!
|
||||
//! const validator = new BillValidatorWrapper(ValidatorDriver.Id003, '/dev/ttyUSB0', 'USD')
|
||||
//! await validator.connect()
|
||||
//! await validator.enable()
|
||||
//! ```
|
||||
|
||||
#![deny(unsafe_code)]
|
||||
#![warn(missing_docs)]
|
||||
#![warn(clippy::all)]
|
||||
|
||||
use napi_derive::napi;
|
||||
|
||||
pub mod error;
|
||||
pub mod validators;
|
||||
pub mod dispensers;
|
||||
// pub mod printer;
|
||||
// pub mod scanner;
|
||||
// pub mod leds;
|
||||
// pub mod nfc;
|
||||
|
||||
/// Supported bill validator drivers
|
||||
#[napi]
|
||||
pub enum ValidatorDriver {
|
||||
/// JCM ID-003 protocol (default)
|
||||
Id003,
|
||||
/// CashCode CCNET protocol
|
||||
Ccnet,
|
||||
/// MEI CashFlow SC
|
||||
CashflowSc,
|
||||
/// MEI BNR Advance
|
||||
BnrAdvance,
|
||||
/// Genmega validator
|
||||
Genmega,
|
||||
/// Hitachi HCM2 recycler
|
||||
Hcm2,
|
||||
/// GSR50 recycler
|
||||
Gsr50,
|
||||
/// Mock validator for testing
|
||||
Mock,
|
||||
}
|
||||
|
||||
/// Supported bill dispenser drivers
|
||||
#[napi]
|
||||
pub enum DispenserDriver {
|
||||
/// Puloon LCDM series
|
||||
Puloon,
|
||||
/// Fujitsu F53/F56
|
||||
F56,
|
||||
/// Genmega dispenser
|
||||
Genmega,
|
||||
/// Hitachi HCM2 recycler
|
||||
Hcm2,
|
||||
/// GSR50 recycler
|
||||
Gsr50,
|
||||
/// Mock dispenser for testing
|
||||
Mock,
|
||||
}
|
||||
|
||||
// Re-exports for convenience
|
||||
pub use validators::BillValidatorWrapper;
|
||||
pub use dispensers::BillDispenserWrapper;
|
||||
206
packages/hal/src/types.ts
Normal file
206
packages/hal/src/types.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import { EventEmitter } from 'node:events'
|
||||
|
||||
/**
|
||||
* Bill denomination data returned when a bill is read
|
||||
*/
|
||||
export interface BillData {
|
||||
/** Denomination value (e.g., 20 for $20) */
|
||||
denomination: number | null
|
||||
/** Raw escrow code from the validator */
|
||||
code: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejection reason data
|
||||
*/
|
||||
export interface RejectionData {
|
||||
/** Human-readable rejection reason */
|
||||
reason: string
|
||||
/** Raw rejection code */
|
||||
code: number | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill validator configuration
|
||||
*/
|
||||
export interface ValidatorConfig {
|
||||
rs232: {
|
||||
/** Serial device path (e.g., '/dev/ttyUSB0') or array of paths to try */
|
||||
device: string | string[]
|
||||
/** Fiat currency code (e.g., 'USD', 'EUR') */
|
||||
fiatCode?: string
|
||||
}
|
||||
/** Fiat currency code */
|
||||
fiatCode?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill validator events
|
||||
*/
|
||||
export interface ValidatorEvents {
|
||||
/** Emitted when validator is enabled and ready */
|
||||
enabled: (data: unknown) => void
|
||||
/** Emitted when validator enters standby mode */
|
||||
standby: (data: unknown) => void
|
||||
/** Emitted when a bill is being accepted (inserted) */
|
||||
billsAccepted: () => void
|
||||
/** Emitted when a bill has been read and is in escrow */
|
||||
billsRead: (data: BillData) => void
|
||||
/** Emitted when a bill has been validated and stacked */
|
||||
billsValid: () => void
|
||||
/** Emitted when a bill has been rejected */
|
||||
billsRejected: (data?: RejectionData) => void
|
||||
/** Emitted when a bill is refused (unsupported denomination) */
|
||||
billRefused: () => void
|
||||
/** Emitted when the stacker/cash box is opened */
|
||||
stackerOpen: () => void
|
||||
/** Emitted on error */
|
||||
error: (err: Error) => void
|
||||
/** Emitted when device is disconnected */
|
||||
disconnected: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill validator interface
|
||||
* Implementations: id003 (JCM), ccnet, mei
|
||||
*/
|
||||
export interface BillValidator extends EventEmitter {
|
||||
/**
|
||||
* Initialize and start the validator
|
||||
* @param cb Callback when initialization is complete
|
||||
*/
|
||||
run(cb: (err?: Error) => void): void
|
||||
|
||||
/**
|
||||
* Close the connection and clean up
|
||||
* @param cb Callback when closed
|
||||
*/
|
||||
close(cb: (err?: Error) => void): void
|
||||
|
||||
/** Enable bill acceptance */
|
||||
enable(): void
|
||||
|
||||
/** Disable bill acceptance */
|
||||
disable(): void
|
||||
|
||||
/** Stack the bill currently in escrow */
|
||||
stack(): void
|
||||
|
||||
/** Reject/return the bill currently in escrow */
|
||||
reject(): void
|
||||
|
||||
/** Turn on the bill insertion light */
|
||||
lightOn(): void
|
||||
|
||||
/** Turn off the bill insertion light */
|
||||
lightOff(): void
|
||||
|
||||
/** Set the fiat currency code */
|
||||
setFiatCode(fiatCode: string): void
|
||||
|
||||
/**
|
||||
* Get the lowest bill denomination that is >= fiat amount
|
||||
* @param fiat Amount to compare
|
||||
*/
|
||||
lowestBill(fiat: { lte: (n: number) => boolean }): { toNumber: () => number }
|
||||
|
||||
/**
|
||||
* Get the highest bill denomination that is <= fiat amount
|
||||
* @param fiat Amount to compare
|
||||
*/
|
||||
highestBill(fiat: { gte: (n: number) => boolean }): { toNumber: () => number }
|
||||
|
||||
/** Check if denominations have been loaded from the device */
|
||||
hasDenominations(): boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill dispenser configuration
|
||||
*/
|
||||
export interface DispenserConfig {
|
||||
/** Serial device path */
|
||||
device: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Cassette configuration
|
||||
*/
|
||||
export interface CassetteConfig {
|
||||
/** Bill denomination in this cassette */
|
||||
denomination: number
|
||||
/** Number of bills loaded (optional) */
|
||||
count?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispense result for a single cassette
|
||||
*/
|
||||
export interface DispenseResult {
|
||||
/** Number of bills dispensed */
|
||||
dispensed: number
|
||||
/** Number of bills rejected during dispense */
|
||||
rejected: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill dispenser initialization data
|
||||
*/
|
||||
export interface DispenserInitData {
|
||||
/** Fiat currency code (e.g., 'USD') */
|
||||
fiatCode: string
|
||||
/** Cassette configuration */
|
||||
cassettes: CassetteConfig[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Bill dispenser interface
|
||||
* Implementations: f56 (Fujitsu), puloon
|
||||
*/
|
||||
export interface BillDispenser {
|
||||
/** Dispenser type identifier */
|
||||
type: string
|
||||
|
||||
/** Whether the dispenser is initialized */
|
||||
initialized: boolean
|
||||
|
||||
/**
|
||||
* Initialize the dispenser
|
||||
* @param data Initialization data including fiat code and cassettes
|
||||
*/
|
||||
init(data: DispenserInitData): Promise<void>
|
||||
|
||||
/**
|
||||
* Dispense bills
|
||||
* @param notes Array of bill counts per cassette [cassette1Count, cassette2Count, ...]
|
||||
* @returns Dispense results per cassette and any error
|
||||
*/
|
||||
dispense(notes: number[]): Promise<{
|
||||
value: DispenseResult[]
|
||||
error?: Error
|
||||
}>
|
||||
|
||||
/** Close the connection */
|
||||
close(): void
|
||||
|
||||
/**
|
||||
* Check if bills are present at the dispense outlet
|
||||
* @returns true if bills are waiting to be taken
|
||||
*/
|
||||
billsPresent(): Promise<boolean>
|
||||
|
||||
/**
|
||||
* Wait for customer to remove dispensed bills
|
||||
* @returns Resolves when bills are removed
|
||||
*/
|
||||
waitForBillsRemoved(): Promise<boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory function signature for validators
|
||||
*/
|
||||
export type ValidatorFactory = (config: ValidatorConfig) => BillValidator
|
||||
|
||||
/**
|
||||
* Factory function signature for dispensers
|
||||
*/
|
||||
export type DispenserFactory = (config: DispenserConfig) => BillDispenser
|
||||
49
packages/hal/src/utils/crc.ts
Normal file
49
packages/hal/src/utils/crc.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* CCITT-KERMIT CRC computation
|
||||
* Used by ID003 and F56 protocols
|
||||
*
|
||||
* Based on: http://stackoverflow.com/questions/5059268/c-sharp-crc-implementation
|
||||
*/
|
||||
|
||||
const TABLE: readonly number[] = [
|
||||
0x0000, 0x1189, 0x2312, 0x329b, 0x4624, 0x57ad, 0x6536, 0x74bf, 0x8c48, 0x9dc1, 0xaf5a, 0xbed3,
|
||||
0xca6c, 0xdbe5, 0xe97e, 0xf8f7, 0x1081, 0x0108, 0x3393, 0x221a, 0x56a5, 0x472c, 0x75b7, 0x643e,
|
||||
0x9cc9, 0x8d40, 0xbfdb, 0xae52, 0xdaed, 0xcb64, 0xf9ff, 0xe876, 0x2102, 0x308b, 0x0210, 0x1399,
|
||||
0x6726, 0x76af, 0x4434, 0x55bd, 0xad4a, 0xbcc3, 0x8e58, 0x9fd1, 0xeb6e, 0xfae7, 0xc87c, 0xd9f5,
|
||||
0x3183, 0x200a, 0x1291, 0x0318, 0x77a7, 0x662e, 0x54b5, 0x453c, 0xbdcb, 0xac42, 0x9ed9, 0x8f50,
|
||||
0xfbef, 0xea66, 0xd8fd, 0xc974, 0x4204, 0x538d, 0x6116, 0x709f, 0x0420, 0x15a9, 0x2732, 0x36bb,
|
||||
0xce4c, 0xdfc5, 0xed5e, 0xfcd7, 0x8868, 0x99e1, 0xab7a, 0xbaf3, 0x5285, 0x430c, 0x7197, 0x601e,
|
||||
0x14a1, 0x0528, 0x37b3, 0x263a, 0xdecd, 0xcf44, 0xfddf, 0xec56, 0x98e9, 0x8960, 0xbbfb, 0xaa72,
|
||||
0x6306, 0x728f, 0x4014, 0x519d, 0x2522, 0x34ab, 0x0630, 0x17b9, 0xef4e, 0xfec7, 0xcc5c, 0xddd5,
|
||||
0xa96a, 0xb8e3, 0x8a78, 0x9bf1, 0x7387, 0x620e, 0x5095, 0x411c, 0x35a3, 0x242a, 0x16b1, 0x0738,
|
||||
0xffcf, 0xee46, 0xdcdd, 0xcd54, 0xb9eb, 0xa862, 0x9af9, 0x8b70, 0x8408, 0x9581, 0xa71a, 0xb693,
|
||||
0xc22c, 0xd3a5, 0xe13e, 0xf0b7, 0x0840, 0x19c9, 0x2b52, 0x3adb, 0x4e64, 0x5fed, 0x6d76, 0x7cff,
|
||||
0x9489, 0x8500, 0xb79b, 0xa612, 0xd2ad, 0xc324, 0xf1bf, 0xe036, 0x18c1, 0x0948, 0x3bd3, 0x2a5a,
|
||||
0x5ee5, 0x4f6c, 0x7df7, 0x6c7e, 0xa50a, 0xb483, 0x8618, 0x9791, 0xe32e, 0xf2a7, 0xc03c, 0xd1b5,
|
||||
0x2942, 0x38cb, 0x0a50, 0x1bd9, 0x6f66, 0x7eef, 0x4c74, 0x5dfd, 0xb58b, 0xa402, 0x9699, 0x8710,
|
||||
0xf3af, 0xe226, 0xd0bd, 0xc134, 0x39c3, 0x284a, 0x1ad1, 0x0b58, 0x7fe7, 0x6e6e, 0x5cf5, 0x4d7c,
|
||||
0xc60c, 0xd785, 0xe51e, 0xf497, 0x8028, 0x91a1, 0xa33a, 0xb2b3, 0x4a44, 0x5bcd, 0x6956, 0x78df,
|
||||
0x0c60, 0x1de9, 0x2f72, 0x3efb, 0xd68d, 0xc704, 0xf59f, 0xe416, 0x90a9, 0x8120, 0xb3bb, 0xa232,
|
||||
0x5ac5, 0x4b4c, 0x79d7, 0x685e, 0x1ce1, 0x0d68, 0x3ff3, 0x2e7a, 0xe70e, 0xf687, 0xc41c, 0xd595,
|
||||
0xa12a, 0xb0a3, 0x8238, 0x93b1, 0x6b46, 0x7acf, 0x4854, 0x59dd, 0x2d62, 0x3ceb, 0x0e70, 0x1ff9,
|
||||
0xf78f, 0xe606, 0xd49d, 0xc514, 0xb1ab, 0xa022, 0x92b9, 0x8330, 0x7bc7, 0x6a4e, 0x58d5, 0x495c,
|
||||
0x3de3, 0x2c6a, 0x1ef1, 0x0f78,
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Compute CRC-16 CCITT-KERMIT checksum
|
||||
* @param buf Array of bytes or Buffer
|
||||
* @returns 16-bit CRC value
|
||||
*/
|
||||
export function compute(buf: ArrayLike<number>): number {
|
||||
let crc = 0x00
|
||||
const len = buf.length
|
||||
|
||||
for (let i = 0; i < len; i++) {
|
||||
const byte = buf[i]
|
||||
if (byte === undefined) continue
|
||||
crc = (crc >> 8) ^ (TABLE[(crc ^ byte) & 0xff] ?? 0)
|
||||
}
|
||||
|
||||
return crc
|
||||
}
|
||||
281
packages/hal/src/validators/id003/id003-fsm.ts
Normal file
281
packages/hal/src/validators/id003/id003-fsm.ts
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
/**
|
||||
* ID003 Protocol State Machine
|
||||
*
|
||||
* Manages the state transitions for the JCM ID003 bill validator protocol.
|
||||
* Based on the original lamassu-machine implementation.
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
|
||||
// Using a simple state machine implementation instead of the contrib library
|
||||
type StateHandler = (data?: unknown) => void
|
||||
type TransitionMap = Record<string, string | StateHandler>
|
||||
|
||||
const TRANSIENT_TIMEOUT = 60000
|
||||
|
||||
interface Id003FsmEvents {
|
||||
dispatch: (cmd: string, data?: unknown) => void
|
||||
denominations: () => void
|
||||
getEnabled: (data: unknown) => void
|
||||
setEnabled: (data: unknown) => void
|
||||
ready: () => void
|
||||
stale: () => void
|
||||
stuck: () => void
|
||||
billsAccepted: () => void
|
||||
billsRead: (data: unknown) => void
|
||||
billsValid: () => void
|
||||
billsRejected: (data?: unknown) => void
|
||||
billRefused: () => void
|
||||
standby: () => void
|
||||
stackerOpen: () => void
|
||||
failure: (args: unknown) => void
|
||||
powerUp: () => void
|
||||
error: (err: Error) => void
|
||||
}
|
||||
|
||||
export class Id003Fsm extends EventEmitter {
|
||||
private _state: string = 'Start'
|
||||
public disableFlag: boolean = false
|
||||
private stateTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
static factory(): Id003Fsm {
|
||||
return new Id003Fsm()
|
||||
}
|
||||
|
||||
get state(): string {
|
||||
return this._state
|
||||
}
|
||||
|
||||
is(state: string): boolean {
|
||||
return this._state === state
|
||||
}
|
||||
|
||||
private transition(to: string, event?: string, data?: unknown): void {
|
||||
const from = this._state
|
||||
this.clearStateTimeout()
|
||||
console.log('FSM: %s [ %s -> %s ]', event ?? 'transition', from, to)
|
||||
this._state = to
|
||||
this.onEnterState(to, from, data)
|
||||
}
|
||||
|
||||
private clearStateTimeout(): void {
|
||||
if (this.stateTimeout) {
|
||||
clearTimeout(this.stateTimeout)
|
||||
this.stateTimeout = null
|
||||
}
|
||||
}
|
||||
|
||||
private startTransientTimeout(): void {
|
||||
this.stateTimeout = setTimeout(() => {
|
||||
this.emit('stuck')
|
||||
}, TRANSIENT_TIMEOUT)
|
||||
}
|
||||
|
||||
private onEnterState(state: string, from: string, data?: unknown): void {
|
||||
switch (state) {
|
||||
case 'PowerUp':
|
||||
this.emit('powerUp')
|
||||
break
|
||||
case 'Connected':
|
||||
// Will emit 'ready' on leave
|
||||
break
|
||||
case 'Denominations':
|
||||
this.emit('denominations')
|
||||
break
|
||||
case 'GetEnabled':
|
||||
this.emit('getEnabled', data)
|
||||
break
|
||||
case 'SetEnabled':
|
||||
this.emit('setEnabled', data)
|
||||
break
|
||||
case 'Enable':
|
||||
if (this.disableFlag) {
|
||||
console.trace('FSM: delayed disable')
|
||||
this.disableFlag = false
|
||||
this._dispatch('inhibit')
|
||||
}
|
||||
break
|
||||
case 'Disable':
|
||||
this.disableFlag = false
|
||||
if (from === 'Initialize') {
|
||||
this.emit('standby')
|
||||
}
|
||||
break
|
||||
case 'Initialize':
|
||||
// Enable interrupt mode after 500ms
|
||||
setTimeout(() => {
|
||||
this._dispatch('interruptMode')
|
||||
}, 500)
|
||||
break
|
||||
case 'Accepting':
|
||||
this.startTransientTimeout()
|
||||
this.emit('billsAccepted')
|
||||
break
|
||||
case 'Rejecting':
|
||||
this.startTransientTimeout()
|
||||
console.log('Rejected bill: %s', (data as { reason?: string })?.reason)
|
||||
this.emit('billsRejected', data)
|
||||
break
|
||||
case 'Returning':
|
||||
this.startTransientTimeout()
|
||||
this.emit('billsRejected', { reason: 'Returned', code: null })
|
||||
break
|
||||
case 'Escrow':
|
||||
this.emit('billsRead', data)
|
||||
break
|
||||
case 'VendValid':
|
||||
if (from === 'Connected') {
|
||||
this._dispatch('reset')
|
||||
return
|
||||
}
|
||||
this._dispatch('ack')
|
||||
this.emit('billsValid')
|
||||
break
|
||||
case 'StackerOpen':
|
||||
this.emit('stackerOpen')
|
||||
break
|
||||
case 'Failure':
|
||||
this.emit('failure', data)
|
||||
break
|
||||
case 'Stacking':
|
||||
case 'Stacked':
|
||||
this.startTransientTimeout()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private _dispatch(cmd: string): void {
|
||||
this.emit('dispatch', cmd)
|
||||
}
|
||||
|
||||
// Event handlers called by the RS232 layer
|
||||
connect(): void {
|
||||
if (this._state === 'Start') {
|
||||
this.transition('Connected', 'connect')
|
||||
} else if (this._state === 'Refresh') {
|
||||
this.transition('Disable', 'connect')
|
||||
}
|
||||
}
|
||||
|
||||
badFrame(): void {
|
||||
this.transition('BadFrame', 'badFrame')
|
||||
}
|
||||
|
||||
powerUp(): void {
|
||||
this.transition('PowerUp', 'powerUp')
|
||||
}
|
||||
|
||||
powerUpAcceptor(): void {
|
||||
this.transition('PowerUp', 'powerUpAcceptor')
|
||||
}
|
||||
|
||||
denominations(): void {
|
||||
this.transition('Denominations', 'denominations')
|
||||
}
|
||||
|
||||
getEnabled(data?: unknown): void {
|
||||
this.transition('GetEnabled', 'getEnabled', data)
|
||||
}
|
||||
|
||||
setEnabled(data?: unknown): void {
|
||||
this.transition('SetEnabled', 'setEnabled', data)
|
||||
}
|
||||
|
||||
initialize(): void {
|
||||
this.transition('Initialize', 'initialize')
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
this.transition('Enable', 'enable')
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
this.transition('Disable', 'disable')
|
||||
}
|
||||
|
||||
escrow(data?: unknown): void {
|
||||
const validFrom = ['Paused', 'Enable', 'Accepting', 'Escrow']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Escrow', 'escrow', data)
|
||||
}
|
||||
}
|
||||
|
||||
returning(): void {
|
||||
const validFrom = ['Escrow', 'Returning', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Returning', 'returning')
|
||||
}
|
||||
}
|
||||
|
||||
stacking(): void {
|
||||
const validFrom = ['Escrow', 'Stacking', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Stacking', 'stacking')
|
||||
}
|
||||
}
|
||||
|
||||
vendValid(): void {
|
||||
const validFrom = ['Connected', 'Escrow', 'Stacking', 'VendValid', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('VendValid', 'vendValid')
|
||||
}
|
||||
}
|
||||
|
||||
stacked(): void {
|
||||
const validFrom = ['VendValid', 'Stacked', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Stacked', 'stacked')
|
||||
}
|
||||
}
|
||||
|
||||
rejecting(data?: unknown): void {
|
||||
const validFrom = ['Accepting', 'Rejecting', 'Escrow', 'Stacking', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Rejecting', 'rejecting', data)
|
||||
}
|
||||
}
|
||||
|
||||
stackerOpen(): void {
|
||||
this.transition('StackerOpen', 'stackerOpen')
|
||||
}
|
||||
|
||||
stackerFull(): void {
|
||||
const validFrom = ['StackerFull', 'Stacked', 'VendValid', 'Paused']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('StackerFull', 'stackerFull')
|
||||
}
|
||||
}
|
||||
|
||||
accepting(): void {
|
||||
const validFrom = ['Paused', 'Enable', 'Accepting']
|
||||
if (validFrom.includes(this._state)) {
|
||||
this.transition('Accepting', 'accepting')
|
||||
}
|
||||
}
|
||||
|
||||
failure(args?: unknown): void {
|
||||
this.transition('Failure', 'failure', args)
|
||||
}
|
||||
|
||||
acceptorJam(): void {
|
||||
this.transition('AcceptorJam', 'acceptorJam')
|
||||
}
|
||||
|
||||
stackerJam(): void {
|
||||
this.transition('StackerJam', 'stackerJam')
|
||||
}
|
||||
|
||||
cheated(): void {
|
||||
this.transition('Cheated', 'cheated')
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this.transition('Paused', 'pause')
|
||||
}
|
||||
}
|
||||
|
||||
export default Id003Fsm
|
||||
359
packages/hal/src/validators/id003/id003-rs232.ts
Normal file
359
packages/hal/src/validators/id003/id003-rs232.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
/**
|
||||
* ID003 RS232 Protocol Layer
|
||||
*
|
||||
* Handles serial communication for JCM ID003 bill validators.
|
||||
* Protocol: 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { SerialPort } from 'serialport'
|
||||
import * as fs from 'node:fs'
|
||||
import { compute as computeCrc } from '../../utils/crc.js'
|
||||
|
||||
const SYNC = 0xfc
|
||||
|
||||
// Command codes
|
||||
const CMD: Record<string, number[]> = {
|
||||
denominations: [0x8a],
|
||||
status: [0x11],
|
||||
stack: [0x41],
|
||||
ack: [0x50],
|
||||
inhibit: [0xc3, 0x01],
|
||||
unInhibit: [0xc3, 0x00],
|
||||
reset: [0x40],
|
||||
reject: [0x43],
|
||||
enableAll: [0xc0, 0x00, 0x00],
|
||||
getEnabled: [0x80],
|
||||
interruptMode: [0xc2, 0x01],
|
||||
}
|
||||
|
||||
// Response codes
|
||||
const RSP: Record<number, string> = {
|
||||
0x05: 'enq',
|
||||
0x40: 'powerUp', // Also powerUpAcceptor
|
||||
0x1b: 'initialize',
|
||||
0x1a: 'disable',
|
||||
0x11: 'enable',
|
||||
0x12: 'accepting',
|
||||
0x13: 'escrow',
|
||||
0x14: 'stacking',
|
||||
0x15: 'vendValid',
|
||||
0x16: 'stacked',
|
||||
0x17: 'rejecting',
|
||||
0x18: 'returning',
|
||||
0x43: 'stackerFull',
|
||||
0x44: 'stackerOpen',
|
||||
0x45: 'acceptorJam',
|
||||
0x46: 'stackerJam',
|
||||
0x47: 'pause',
|
||||
0x48: 'cheated',
|
||||
0x49: 'failure',
|
||||
0x4b: 'invalid',
|
||||
0x50: 'ack',
|
||||
0x80: 'getEnabled',
|
||||
0x88: 'version',
|
||||
0x8a: 'denominations',
|
||||
0xc0: 'setEnabled',
|
||||
0xc2: 'commMode',
|
||||
0xc3: 'inhibit',
|
||||
}
|
||||
|
||||
// Rejection reason codes
|
||||
const REJECTION_REASONS: Record<number, string> = {
|
||||
0x71: 'insertion',
|
||||
0x72: 'mug',
|
||||
0x73: 'head',
|
||||
0x74: 'calibration',
|
||||
0x75: 'conveying',
|
||||
0x76: 'discrimination',
|
||||
0x77: 'photoPattern',
|
||||
0x78: 'photoLevel',
|
||||
0x79: 'inhibit',
|
||||
0x7a: 'unknown',
|
||||
0x7b: 'operation',
|
||||
0x7c: 'stacker',
|
||||
0x7d: 'length',
|
||||
0x7e: 'photoPattern',
|
||||
0x7f: 'trueBill',
|
||||
}
|
||||
|
||||
// Currency codes (for multi-currency support)
|
||||
const CODES: Record<string, number> = {
|
||||
USD: 0x01,
|
||||
CAD: 0x08,
|
||||
}
|
||||
|
||||
export interface Id003Rs232Config {
|
||||
device: string | string[]
|
||||
fiatCode?: string
|
||||
}
|
||||
|
||||
export class Id003Rs232 extends EventEmitter {
|
||||
private fiatCode: string | undefined
|
||||
private buf: Buffer = Buffer.alloc(0)
|
||||
private config: Id003Rs232Config
|
||||
private serial: SerialPort | null = null
|
||||
private _denominations: Record<number, number> | null = null
|
||||
|
||||
constructor(config: Id003Rs232Config) {
|
||||
super()
|
||||
this.fiatCode = config.fiatCode
|
||||
this.config = config
|
||||
}
|
||||
|
||||
static factory(config: Id003Rs232Config): Id003Rs232 {
|
||||
return new Id003Rs232(config)
|
||||
}
|
||||
|
||||
private async _open(device: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const options = {
|
||||
path: device,
|
||||
baudRate: 9600,
|
||||
parity: 'even' as const,
|
||||
dataBits: 8 as const,
|
||||
stopBits: 1 as const,
|
||||
autoOpen: false,
|
||||
rtscts: false,
|
||||
}
|
||||
|
||||
const serial = new SerialPort(options)
|
||||
this.serial = serial
|
||||
|
||||
serial.on('error', (err) => this.emit('error', err))
|
||||
serial.on('open', (err?: Error | null) => {
|
||||
if (err) return reject(err)
|
||||
serial.on('readable', () => {
|
||||
const data = serial.read() as Buffer | null
|
||||
if (data) this._process(data)
|
||||
})
|
||||
serial.on('close', () => {
|
||||
this.emit('disconnected')
|
||||
})
|
||||
this.lightOff()
|
||||
resolve()
|
||||
})
|
||||
|
||||
serial.open()
|
||||
})
|
||||
}
|
||||
|
||||
async open(cb: (err?: Error) => void): Promise<void> {
|
||||
const devices = this.config.device
|
||||
if (!devices) {
|
||||
this.emit('error', 'No configured devices.')
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof devices === 'string') {
|
||||
try {
|
||||
await this._open(devices)
|
||||
cb()
|
||||
} catch (err) {
|
||||
cb(err as Error)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Try each device in order
|
||||
for (const device of devices) {
|
||||
try {
|
||||
const stats = fs.statSync(device)
|
||||
if (!stats.isCharacterDevice()) continue
|
||||
await this._open(device)
|
||||
cb()
|
||||
return
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
this.emit('error', 'No configured devices available.')
|
||||
}
|
||||
|
||||
send(command: string): void {
|
||||
const codes = CMD[command]
|
||||
if (!codes) throw new Error('Invalid command: ' + command)
|
||||
|
||||
const length = codes.length + 4
|
||||
const payload = [SYNC, length, ...codes]
|
||||
const buf = Buffer.from(payload)
|
||||
const crc = computeCrc(payload)
|
||||
const crcBuf = Buffer.alloc(2)
|
||||
crcBuf.writeUInt16LE(crc, 0)
|
||||
const outBuf = Buffer.concat([buf, crcBuf], length)
|
||||
this.serial?.write(outBuf)
|
||||
}
|
||||
|
||||
close(cb: (err?: Error | null) => void): void {
|
||||
this.serial?.close(cb)
|
||||
}
|
||||
|
||||
lightOn(): void {
|
||||
this.serial?.set({ rts: true }, (err) => {
|
||||
if (err) console.log('lightOn failed: %s', err)
|
||||
})
|
||||
}
|
||||
|
||||
lightOff(): void {
|
||||
this.serial?.set({ rts: false }, (err) => {
|
||||
if (err) console.log('lightOff failed: %s', err)
|
||||
})
|
||||
}
|
||||
|
||||
denominations(): Record<number, number> | null {
|
||||
return this._denominations
|
||||
}
|
||||
|
||||
private _acquireSync(data: Buffer): Buffer {
|
||||
for (let i = 0; i < data.length; i++) {
|
||||
if (data[i] === SYNC) {
|
||||
return data.subarray(i)
|
||||
}
|
||||
}
|
||||
return Buffer.alloc(0)
|
||||
}
|
||||
|
||||
private _crcVerify(payload: Buffer): void {
|
||||
const payloadCrc = payload.readUInt16LE(payload.length - 2)
|
||||
const verify = computeCrc(payload.subarray(0, -2)) === payloadCrc
|
||||
if (!verify) throw new Error('CRC error')
|
||||
}
|
||||
|
||||
private _parse(packet: Buffer): void {
|
||||
this._crcVerify(packet)
|
||||
const data = packet.length === 5 ? null : packet.subarray(3, -2)
|
||||
const commandCode = packet[2]
|
||||
if (commandCode !== undefined) {
|
||||
this._interpret(commandCode, data)
|
||||
}
|
||||
}
|
||||
|
||||
private _interpret(commandCode: number, rawData: Buffer | null): void {
|
||||
const command = RSP[commandCode]
|
||||
|
||||
if (!command) {
|
||||
this.emit('unknownCommand', commandCode)
|
||||
return
|
||||
}
|
||||
|
||||
const data = this._parseData(command, rawData)
|
||||
this.emit('message', command, data)
|
||||
}
|
||||
|
||||
private _parseData(command: string, rawData: Buffer | null): unknown {
|
||||
if (!rawData) return null
|
||||
|
||||
switch (command) {
|
||||
case 'escrow':
|
||||
return this._escrow(rawData)
|
||||
case 'version':
|
||||
return this._version(rawData)
|
||||
case 'rejecting':
|
||||
return this._rejecting(rawData)
|
||||
case 'denominations':
|
||||
return this._setDenominations(rawData)
|
||||
case 'getEnabled':
|
||||
case 'setEnabled':
|
||||
return this._enabled(rawData)
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private _escrow(rawData: Buffer): { denomination: number | null; code: number } {
|
||||
const fiatCode = rawData[0]
|
||||
if (fiatCode === undefined) {
|
||||
return { denomination: null, code: 0 }
|
||||
}
|
||||
const denomination = this._denominations?.[fiatCode] ?? null
|
||||
return { denomination, code: fiatCode }
|
||||
}
|
||||
|
||||
private _rejecting(rawData: Buffer): { reason: string; code: number } {
|
||||
const code = rawData[0] ?? 0
|
||||
const reason = REJECTION_REASONS[code] ?? 'unknown'
|
||||
return { reason, code }
|
||||
}
|
||||
|
||||
private _setDenominations(rawData: Buffer): void {
|
||||
// Last two bytes are boot version
|
||||
if (this._denominations) return
|
||||
|
||||
const denominations: Record<number, number> = {}
|
||||
const rawLength = rawData.length
|
||||
|
||||
for (let offset = 0; offset < rawLength; offset += 4) {
|
||||
const escrowCode = rawData[offset]
|
||||
const countryCode = rawData[offset + 1]
|
||||
|
||||
if (escrowCode === undefined || countryCode === undefined) continue
|
||||
|
||||
if (
|
||||
this.fiatCode &&
|
||||
Object.prototype.hasOwnProperty.call(CODES, this.fiatCode) &&
|
||||
countryCode !== CODES[this.fiatCode]
|
||||
) {
|
||||
console.log('Found a bill not matching the defined fiat code, rejecting...')
|
||||
this.emit('reject')
|
||||
}
|
||||
|
||||
const denominationInteger = rawData[offset + 2]
|
||||
if (denominationInteger === undefined || denominationInteger === 0x00) continue
|
||||
|
||||
const denominationExponent = rawData[offset + 3] ?? 0
|
||||
const denomination = denominationInteger * Math.pow(10, denominationExponent)
|
||||
denominations[escrowCode] = denomination
|
||||
}
|
||||
|
||||
this._denominations = denominations
|
||||
}
|
||||
|
||||
private _enabled(rawData: Buffer): { data1: number; data2: number } {
|
||||
return { data1: rawData[0] ?? 0, data2: rawData[1] ?? 0 }
|
||||
}
|
||||
|
||||
private _version(rawData: Buffer): { version: Buffer } {
|
||||
this._crcVerify(rawData)
|
||||
return { version: rawData.subarray(0, -2) }
|
||||
}
|
||||
|
||||
private _process(data: Buffer): void {
|
||||
this.buf = Buffer.concat([this.buf, data])
|
||||
while (this._processPacket()) {
|
||||
// Continue processing packets
|
||||
}
|
||||
}
|
||||
|
||||
private _processPacket(): boolean {
|
||||
if (this.buf.length === 0) return false
|
||||
this.buf = this._acquireSync(this.buf)
|
||||
|
||||
// Wait for size byte
|
||||
if (this.buf.length < 2) return false
|
||||
|
||||
const responseSize = this.buf[1]
|
||||
if (responseSize === undefined) return false
|
||||
|
||||
// Wait for whole packet
|
||||
if (this.buf.length < responseSize) return false
|
||||
|
||||
const packet = this.buf.subarray(0, responseSize)
|
||||
this.buf = this.buf.subarray(responseSize)
|
||||
|
||||
try {
|
||||
this._parse(packet)
|
||||
} catch (ex) {
|
||||
console.dir(ex)
|
||||
if (ex instanceof Error) {
|
||||
console.log(ex.stack)
|
||||
}
|
||||
this.emit('badFrame')
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export default Id003Rs232
|
||||
312
packages/hal/src/validators/id003/index.ts
Normal file
312
packages/hal/src/validators/id003/index.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
/**
|
||||
* ID003 Bill Validator Driver
|
||||
*
|
||||
* Supports JCM bill validators using the ID003 protocol.
|
||||
* Used by: JCM iVIZION, JCM UBA, JCM iPRO
|
||||
*
|
||||
* Protocol: RS-232, 9600 baud, 8 data bits, even parity, 1 stop bit
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { throttle } from 'lodash-es'
|
||||
import { Id003Rs232 } from './id003-rs232.js'
|
||||
import { Id003Fsm } from './id003-fsm.js'
|
||||
import type { BillValidator, ValidatorConfig, BillData } from '../../types.js'
|
||||
|
||||
const POLLING_INTERVAL = 100
|
||||
const IGNORE_RESPONSES = ['ack', 'inhibit', 'commMode', 'enq']
|
||||
|
||||
/**
|
||||
* BigNumber-like interface for bill comparisons
|
||||
*/
|
||||
interface BNLike {
|
||||
lte: (n: number) => boolean
|
||||
gte: (n: number) => boolean
|
||||
toNumber: () => number
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple BigNumber wrapper
|
||||
*/
|
||||
function BN(n: number): BNLike {
|
||||
return {
|
||||
lte: (other: number) => n <= other,
|
||||
gte: (other: number) => n >= other,
|
||||
toNumber: () => n,
|
||||
}
|
||||
}
|
||||
|
||||
export class Id003 extends EventEmitter implements BillValidator {
|
||||
private initialized: boolean = false
|
||||
private pollingInterval: ReturnType<typeof setInterval> | null = null
|
||||
private config: ValidatorConfig
|
||||
private fiatCode: string | null = null
|
||||
private _throttledError: (err: Error) => void
|
||||
private _startupCallback: ((err?: Error) => void) | null = null
|
||||
private rs232: Id003Rs232 | null = null
|
||||
private id003Fsm: Id003Fsm | null = null
|
||||
private disablePolling: boolean = false
|
||||
|
||||
constructor(config: ValidatorConfig) {
|
||||
super()
|
||||
this.config = config
|
||||
this._throttledError = throttle((err: Error) => this.emit('error', err), 2000)
|
||||
}
|
||||
|
||||
static factory(config: ValidatorConfig): Id003 {
|
||||
return new Id003(config)
|
||||
}
|
||||
|
||||
setFiatCode(fiatCode: string): void {
|
||||
this.fiatCode = fiatCode
|
||||
}
|
||||
|
||||
lightOn(): void {
|
||||
if (this.rs232) {
|
||||
console.log('lightOn')
|
||||
this.rs232.lightOn()
|
||||
}
|
||||
}
|
||||
|
||||
lightOff(): void {
|
||||
if (this.rs232) {
|
||||
console.log('lightOff')
|
||||
this.rs232.lightOff()
|
||||
}
|
||||
}
|
||||
|
||||
run(cb: (err?: Error) => void): void {
|
||||
this._startupCallback = cb
|
||||
this.id003Fsm = Id003Fsm.factory()
|
||||
|
||||
const config = this.config
|
||||
const rs232Config = {
|
||||
...config.rs232,
|
||||
fiatCode: config.fiatCode,
|
||||
}
|
||||
|
||||
this.disablePolling = false
|
||||
this.rs232 = Id003Rs232.factory(rs232Config)
|
||||
|
||||
// RS232 event handlers
|
||||
this.rs232.on('message', (cmd: string, data: unknown) => {
|
||||
if (cmd === 'invalid') {
|
||||
console.log('ERROR: invalid command')
|
||||
if (this.disablePolling) this._send('status')
|
||||
return
|
||||
}
|
||||
|
||||
if (!IGNORE_RESPONSES.includes(cmd) && this.id003Fsm) {
|
||||
// Call the appropriate FSM method
|
||||
const fsmMethod = (this.id003Fsm as unknown as Record<string, (data?: unknown) => void>)[
|
||||
cmd
|
||||
]
|
||||
if (typeof fsmMethod === 'function') {
|
||||
fsmMethod.call(this.id003Fsm, data)
|
||||
}
|
||||
}
|
||||
|
||||
if (cmd === 'commMode') {
|
||||
this._disablePolling()
|
||||
}
|
||||
if (cmd === 'enq') {
|
||||
this._send('status')
|
||||
}
|
||||
})
|
||||
|
||||
this.rs232.on('unknownCommand', (code: number) => {
|
||||
throw new Error('unknown code: ' + code.toString(16))
|
||||
})
|
||||
|
||||
this.rs232.on('error', (err: Error) => {
|
||||
this._throttledError(err)
|
||||
})
|
||||
|
||||
this.rs232.on('badFrame', () => {
|
||||
this.id003Fsm?.badFrame()
|
||||
if (this.disablePolling) this._send('status')
|
||||
})
|
||||
|
||||
// FSM event handlers
|
||||
this.id003Fsm.on('dispatch', (cmd: string) => {
|
||||
this._send(cmd)
|
||||
})
|
||||
|
||||
this.id003Fsm.on('denominations', () => {
|
||||
this._send('reset')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('getEnabled', (data: unknown) => {
|
||||
this._send('enableAll')
|
||||
this.emit('enabled', data)
|
||||
})
|
||||
|
||||
this.id003Fsm.on('setEnabled', (data: unknown) => {
|
||||
if (this._startupCallback) {
|
||||
this._startupCallback()
|
||||
this._startupCallback = null
|
||||
}
|
||||
this.emit('enabled', data)
|
||||
this.emit('standby', data)
|
||||
})
|
||||
|
||||
this.id003Fsm.on('ready', () => {
|
||||
this._send('denominations')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('stale', () => {
|
||||
this._send('reset')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('stuck', () => {
|
||||
this.emit('error', new Error('Bill validator stuck'))
|
||||
})
|
||||
|
||||
this.id003Fsm.on('billsAccepted', () => {
|
||||
this.emit('billsAccepted')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('billsRead', (data: unknown) => {
|
||||
const billData = data as BillData
|
||||
if (!billData.denomination) {
|
||||
console.log(
|
||||
'bill rejected: unsupported denomination. Code: 0x%s',
|
||||
billData.code.toString(16)
|
||||
)
|
||||
this._send('reject')
|
||||
return
|
||||
}
|
||||
this.emit('billsRead', data)
|
||||
})
|
||||
|
||||
this.id003Fsm.on('billsValid', () => {
|
||||
this.emit('billsValid')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('billsRejected', (data?: unknown) => {
|
||||
this.emit('billsRejected', data)
|
||||
})
|
||||
|
||||
this.id003Fsm.on('billRefused', () => {
|
||||
this.emit('billRefused')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('standby', () => {
|
||||
this._send('getEnabled')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('stackerOpen', () => {
|
||||
this.emit('stackerOpen')
|
||||
})
|
||||
|
||||
this.id003Fsm.on('failure', (args: unknown) => {
|
||||
this.emit('error', args)
|
||||
})
|
||||
|
||||
// Open serial connection
|
||||
this.rs232.open((err?: Error) => {
|
||||
if (err) {
|
||||
if (this._startupCallback) {
|
||||
this._startupCallback(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
this._startPolling()
|
||||
this.id003Fsm?.connect()
|
||||
})
|
||||
}
|
||||
|
||||
close(cb: (err?: Error) => void): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval)
|
||||
}
|
||||
this.rs232?.close((err) => {
|
||||
if (err) console.log(err)
|
||||
cb(err ?? undefined)
|
||||
})
|
||||
}
|
||||
|
||||
enable(): void {
|
||||
if (this.id003Fsm) {
|
||||
this.id003Fsm.disableFlag = false
|
||||
}
|
||||
this._send('unInhibit')
|
||||
}
|
||||
|
||||
disable(): void {
|
||||
// If the run command is not executed id003Fsm will be undefined
|
||||
if (!this.id003Fsm || this.id003Fsm.is('Disable')) return
|
||||
if (this.id003Fsm.is('Enable')) {
|
||||
this._send('inhibit')
|
||||
} else {
|
||||
this.id003Fsm.disableFlag = true
|
||||
}
|
||||
}
|
||||
|
||||
stack(): void {
|
||||
this._send('stack')
|
||||
}
|
||||
|
||||
reject(): void {
|
||||
this._send('reject')
|
||||
}
|
||||
|
||||
lowestBill(fiat: BNLike): BNLike {
|
||||
const bills = Object.values(this._denominations() ?? {})
|
||||
const filtered = bills.filter((bill) => fiat.lte(bill))
|
||||
if (filtered.length === 0) return BN(Math.min(...bills))
|
||||
return BN(Math.min(...filtered))
|
||||
}
|
||||
|
||||
highestBill(fiat: BNLike): BNLike {
|
||||
const bills = Object.values(this._denominations() ?? {})
|
||||
const filtered = bills.filter((bill) => fiat.gte(bill))
|
||||
if (filtered.length === 0) return BN(-Infinity)
|
||||
return BN(Math.max(...filtered))
|
||||
}
|
||||
|
||||
hasDenominations(): boolean {
|
||||
return this._denominations() !== null
|
||||
}
|
||||
|
||||
private _denominations(): Record<number, number> | null {
|
||||
return this.rs232?.denominations() ?? null
|
||||
}
|
||||
|
||||
private _send(command: string): void {
|
||||
if (this.disablePolling) {
|
||||
this.rs232?.send(command)
|
||||
return
|
||||
}
|
||||
|
||||
this._stopPolling()
|
||||
// Timeout to prevent interleaved commands
|
||||
setTimeout(() => {
|
||||
this.rs232?.send(command)
|
||||
if (!this.disablePolling) {
|
||||
this._startPolling()
|
||||
}
|
||||
}, POLLING_INTERVAL)
|
||||
}
|
||||
|
||||
private _startPolling(): void {
|
||||
this._stopPolling()
|
||||
this.pollingInterval = setInterval(() => {
|
||||
this.rs232?.send('status')
|
||||
}, POLLING_INTERVAL)
|
||||
}
|
||||
|
||||
private _stopPolling(): void {
|
||||
if (this.pollingInterval) {
|
||||
clearInterval(this.pollingInterval)
|
||||
this.pollingInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private _disablePolling(): void {
|
||||
this.disablePolling = true
|
||||
this._stopPolling()
|
||||
}
|
||||
}
|
||||
|
||||
export default Id003
|
||||
27
packages/hal/src/validators/index.ts
Normal file
27
packages/hal/src/validators/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* Bill Validator Drivers
|
||||
*
|
||||
* Factory for creating bill validator instances based on device type.
|
||||
*/
|
||||
|
||||
export { Id003 } from './id003/index.js'
|
||||
export type { ValidatorConfig, BillValidator, BillData } from '../types.js'
|
||||
|
||||
import { Id003 } from './id003/index.js'
|
||||
import type { ValidatorConfig, BillValidator } from '../types.js'
|
||||
|
||||
export type ValidatorType = 'id003'
|
||||
|
||||
/**
|
||||
* Create a bill validator instance
|
||||
* @param type Validator type (e.g., 'id003')
|
||||
* @param config Validator configuration
|
||||
*/
|
||||
export function createValidator(type: ValidatorType, config: ValidatorConfig): BillValidator {
|
||||
switch (type) {
|
||||
case 'id003':
|
||||
return Id003.factory(config)
|
||||
default:
|
||||
throw new Error(`Unknown validator type: ${type}`)
|
||||
}
|
||||
}
|
||||
147
packages/hal/src/validators/mock.rs
Normal file
147
packages/hal/src/validators/mock.rs
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
//! Mock bill validator for testing
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::broadcast;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use crate::error::ValidatorError;
|
||||
use super::traits::{BillValidator, BillEvent, BillEventType};
|
||||
|
||||
/// Mock bill validator for development and testing
|
||||
pub struct MockValidator {
|
||||
connected: bool,
|
||||
enabled: bool,
|
||||
escrowed_bill: Option<u32>,
|
||||
bill_count: u32,
|
||||
event_tx: broadcast::Sender<BillEvent>,
|
||||
fiat_code: String,
|
||||
}
|
||||
|
||||
impl MockValidator {
|
||||
/// Create a new mock validator
|
||||
pub fn new() -> Self {
|
||||
let (event_tx, _) = broadcast::channel(16);
|
||||
Self {
|
||||
connected: false,
|
||||
enabled: false,
|
||||
escrowed_bill: None,
|
||||
bill_count: 0,
|
||||
event_tx,
|
||||
fiat_code: "USD".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Simulate a bill being inserted (for testing)
|
||||
pub fn simulate_bill(&mut self, denomination: u32) {
|
||||
if self.enabled {
|
||||
self.escrowed_bill = Some(denomination);
|
||||
let _ = self.event_tx.send(BillEvent {
|
||||
denomination,
|
||||
currency: self.fiat_code.clone(),
|
||||
timestamp: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
event_type: BillEventType::Inserted,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn current_timestamp(&self) -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for MockValidator {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl BillValidator for MockValidator {
|
||||
fn driver_name(&self) -> &'static str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
async fn connect(&mut self) -> Result<(), ValidatorError> {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
self.connected = true;
|
||||
tracing::info!("MockValidator connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&mut self) -> Result<(), ValidatorError> {
|
||||
self.connected = false;
|
||||
self.enabled = false;
|
||||
tracing::info!("MockValidator disconnected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn enable(&mut self) -> Result<(), ValidatorError> {
|
||||
if !self.connected {
|
||||
return Err(ValidatorError::ConnectionFailed("Not connected".into()));
|
||||
}
|
||||
self.enabled = true;
|
||||
tracing::info!("MockValidator enabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disable(&mut self) -> Result<(), ValidatorError> {
|
||||
self.enabled = false;
|
||||
tracing::info!("MockValidator disabled");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn accept(&mut self) -> Result<(), ValidatorError> {
|
||||
match self.escrowed_bill.take() {
|
||||
Some(denomination) => {
|
||||
self.bill_count += 1;
|
||||
let _ = self.event_tx.send(BillEvent {
|
||||
denomination,
|
||||
currency: self.fiat_code.clone(),
|
||||
timestamp: self.current_timestamp(),
|
||||
event_type: BillEventType::Stacked,
|
||||
});
|
||||
tracing::info!("MockValidator accepted ${}", denomination);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ValidatorError::InvalidState("No bill escrowed".into())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn reject(&mut self) -> Result<(), ValidatorError> {
|
||||
match self.escrowed_bill.take() {
|
||||
Some(denomination) => {
|
||||
let _ = self.event_tx.send(BillEvent {
|
||||
denomination,
|
||||
currency: self.fiat_code.clone(),
|
||||
timestamp: self.current_timestamp(),
|
||||
event_type: BillEventType::Rejected,
|
||||
});
|
||||
tracing::info!("MockValidator rejected ${}", denomination);
|
||||
Ok(())
|
||||
}
|
||||
None => Err(ValidatorError::InvalidState("No bill escrowed".into())),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_bill_count(&self) -> Result<u32, ValidatorError> {
|
||||
Ok(self.bill_count)
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> broadcast::Receiver<BillEvent> {
|
||||
self.event_tx.subscribe()
|
||||
}
|
||||
|
||||
async fn run(&mut self) -> Result<(), ValidatorError> {
|
||||
// Mock validator doesn't need continuous polling
|
||||
// In real implementation, this would poll the hardware
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
119
packages/hal/src/validators/mod.rs
Normal file
119
packages/hal/src/validators/mod.rs
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
//! Bill validator drivers
|
||||
//!
|
||||
//! This module contains implementations for various bill validator protocols:
|
||||
//! - ID003 (JCM) - Default protocol
|
||||
//! - CCNET (CashCode)
|
||||
//! - MEI CashFlow SC
|
||||
//! - MEI BNR Advance
|
||||
//! - Genmega
|
||||
//! - HCM2 (Hitachi recycler)
|
||||
//! - GSR50 (recycler)
|
||||
|
||||
pub mod traits;
|
||||
pub mod mock;
|
||||
// pub mod id003;
|
||||
// pub mod ccnet;
|
||||
// pub mod mei_cashflow;
|
||||
// pub mod genmega;
|
||||
// pub mod hcm2;
|
||||
// pub mod gsr50;
|
||||
|
||||
pub use traits::*;
|
||||
pub use mock::MockValidator;
|
||||
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::{ValidatorDriver, error::ValidatorError};
|
||||
|
||||
/// Wrapper for bill validator that exposes napi-rs bindings
|
||||
#[napi]
|
||||
pub struct BillValidatorWrapper {
|
||||
inner: Box<dyn BillValidator>,
|
||||
}
|
||||
|
||||
#[napi]
|
||||
impl BillValidatorWrapper {
|
||||
/// Create a new bill validator instance
|
||||
#[napi(constructor)]
|
||||
pub fn new(
|
||||
driver: ValidatorDriver,
|
||||
port: Option<String>,
|
||||
fiat_code: Option<String>,
|
||||
) -> Result<Self> {
|
||||
let _fiat = fiat_code.unwrap_or_else(|| "USD".to_string());
|
||||
|
||||
let validator: Box<dyn BillValidator> = match driver {
|
||||
ValidatorDriver::Mock => Box::new(MockValidator::new()),
|
||||
// TODO: Implement other drivers
|
||||
_ => {
|
||||
return Err(Error::from_reason(format!(
|
||||
"Driver {:?} not yet implemented. Use Mock for development.",
|
||||
driver
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Self { inner: validator })
|
||||
}
|
||||
|
||||
/// Get the driver name
|
||||
#[napi(getter)]
|
||||
pub fn driver_name(&self) -> String {
|
||||
self.inner.driver_name().to_string()
|
||||
}
|
||||
|
||||
/// Connect to the validator
|
||||
#[napi]
|
||||
pub async fn connect(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Disconnect from the validator
|
||||
#[napi]
|
||||
pub async fn disconnect(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.disconnect()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Enable bill acceptance
|
||||
#[napi]
|
||||
pub async fn enable(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.enable()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Disable bill acceptance
|
||||
#[napi]
|
||||
pub async fn disable(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.disable()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Accept the currently escrowed bill
|
||||
#[napi]
|
||||
pub async fn accept(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.accept()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
|
||||
/// Reject the currently escrowed bill
|
||||
#[napi]
|
||||
pub async fn reject(&mut self) -> Result<()> {
|
||||
self.inner
|
||||
.reject()
|
||||
.await
|
||||
.map_err(|e| Error::from_reason(e.to_string()))
|
||||
}
|
||||
}
|
||||
73
packages/hal/src/validators/traits.rs
Normal file
73
packages/hal/src/validators/traits.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
//! Bill validator trait definitions
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::error::ValidatorError;
|
||||
|
||||
/// Event types for bill validator operations
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BillEventType {
|
||||
/// Bill detected and validated
|
||||
Inserted,
|
||||
/// Bill accepted into stacker
|
||||
Accepted,
|
||||
/// Bill rejected (returned to customer)
|
||||
Rejected,
|
||||
/// Bill successfully stacked
|
||||
Stacked,
|
||||
/// Bill jammed
|
||||
Jammed,
|
||||
}
|
||||
|
||||
/// Event emitted by bill validators
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BillEvent {
|
||||
/// Denomination of the bill
|
||||
pub denomination: u32,
|
||||
/// Currency code (e.g., "USD")
|
||||
pub currency: String,
|
||||
/// Unix timestamp
|
||||
pub timestamp: u64,
|
||||
/// Type of event
|
||||
pub event_type: BillEventType,
|
||||
}
|
||||
|
||||
/// Unified interface for all bill validators
|
||||
///
|
||||
/// Implementations: ID003, CCNET, MEI CashFlow, MEI BNR, Genmega, HCM2, GSR50, Mock
|
||||
#[async_trait]
|
||||
pub trait BillValidator: Send + Sync {
|
||||
/// Get validator driver name (for logging/debugging)
|
||||
fn driver_name(&self) -> &'static str;
|
||||
|
||||
/// Connect to the validator
|
||||
async fn connect(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Disconnect from the validator
|
||||
async fn disconnect(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Enable bill acceptance
|
||||
async fn enable(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Disable bill acceptance
|
||||
async fn disable(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Accept the currently held bill into stacker
|
||||
async fn accept(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Reject the currently held bill
|
||||
async fn reject(&mut self) -> Result<(), ValidatorError>;
|
||||
|
||||
/// Get current bill count in stacker (if supported)
|
||||
async fn get_bill_count(&self) -> Result<u32, ValidatorError>;
|
||||
|
||||
/// Subscribe to bill events
|
||||
fn subscribe(&self) -> broadcast::Receiver<BillEvent>;
|
||||
|
||||
/// Run the validator state machine (poll for events)
|
||||
///
|
||||
/// Most validators need continuous polling. This method should be
|
||||
/// called in a separate task and will run indefinitely.
|
||||
async fn run(&mut self) -> Result<(), ValidatorError>;
|
||||
}
|
||||
22
packages/hal/tsconfig.json
Normal file
22
packages/hal/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "**/*.test.ts"]
|
||||
}
|
||||
513
packages/lightning/TROUBLESHOOTING.md
Normal file
513
packages/lightning/TROUBLESHOOTING.md
Normal file
|
|
@ -0,0 +1,513 @@
|
|||
# Lightning.Pub Integration Troubleshooting
|
||||
|
||||
This document captures hard-won lessons from debugging the Lightning.Pub RPC integration. Read this before debugging payment issues.
|
||||
|
||||
## Quick Checklist
|
||||
|
||||
If payments are "hanging" (no response), check in this order:
|
||||
|
||||
1. **Is the payment actually going through?** Check Lightning.Pub logs:
|
||||
|
||||
```bash
|
||||
docker logs lamassu-lightning-pub --tail 20
|
||||
```
|
||||
|
||||
Look for "invoice paid X sats" - if you see this, the payment worked but the response isn't reaching your client.
|
||||
|
||||
2. **Are you receiving ANY events?** Add logging to your subscription's `onEvent` callback. If nothing fires, it's a subscription issue (see Issue #3 below).
|
||||
|
||||
3. **Is the response for you?** Check if events are being filtered correctly by `#p` tags and `requestId`.
|
||||
|
||||
---
|
||||
|
||||
## Issue #1: RPC Request Format
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
ERROR NewInvoiceRequest::root.: object is not an instance of an object
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub expects a specific RPC request structure. Missing fields cause cryptic errors.
|
||||
|
||||
### Solution
|
||||
|
||||
Always include ALL fields in the RPC request:
|
||||
|
||||
```typescript
|
||||
const request = {
|
||||
rpcName: 'PayInvoice', // Method name
|
||||
params: {}, // URL params (usually empty)
|
||||
query: {}, // Query params (usually empty)
|
||||
body: { invoice, amount }, // Method-specific data
|
||||
authIdentifier: identity.publicKey, // Your pubkey
|
||||
requestId: uniqueId, // For matching responses
|
||||
}
|
||||
```
|
||||
|
||||
**Common mistake:** Putting method params directly in the request object instead of inside `body`.
|
||||
|
||||
---
|
||||
|
||||
## Issue #2: PayInvoice Amount Field
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
ERROR PayInvoiceRequest::root..amount: is not a number
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
ERROR invoice has value, do not provide amount in the request
|
||||
```
|
||||
|
||||
### Cause
|
||||
|
||||
The `amount` field has confusing semantics:
|
||||
|
||||
- It's ALWAYS required (despite the second error message suggesting otherwise)
|
||||
- For invoices WITH amounts: send `amount: 0` (meaning "use invoice amount")
|
||||
- For amountless invoices: send the actual amount
|
||||
|
||||
### Solution
|
||||
|
||||
```typescript
|
||||
const body = {
|
||||
invoice: paymentRequest,
|
||||
amount: invoiceHasAmount ? 0 : amountSats,
|
||||
}
|
||||
```
|
||||
|
||||
**Why this is confusing:** The error "do not provide amount" is misleading. You must provide it, but set it to 0.
|
||||
|
||||
---
|
||||
|
||||
## Issue #3: Subscription Not Receiving Events
|
||||
|
||||
### Symptom
|
||||
|
||||
- Payment succeeds (visible in Lightning.Pub logs)
|
||||
- Client subscription's `onEvent` never fires
|
||||
- EOSE is received but no real-time events
|
||||
|
||||
### Cause
|
||||
|
||||
`SimplePool.subscribeMany()` from nostr-tools doesn't reliably deliver real-time events. It works for historical queries but not for waiting on responses.
|
||||
|
||||
### Solution
|
||||
|
||||
Use direct `Relay.subscribe()` instead of the pool:
|
||||
|
||||
```typescript
|
||||
// DON'T use pool for real-time subscriptions
|
||||
const sub = pool.subscribeMany(urls, filters, { onevent: ... })
|
||||
|
||||
// DO use direct relay connection
|
||||
const relay = await Relay.connect(url)
|
||||
const sub = relay.subscribe(filters, { onevent: ... })
|
||||
```
|
||||
|
||||
If using a client wrapper, ensure subscriptions go through the connected Relay instances, not through SimplePool.
|
||||
|
||||
---
|
||||
|
||||
## Issue #4: Race Condition - Missing Responses
|
||||
|
||||
### Symptom
|
||||
|
||||
- First payment always times out
|
||||
- Subsequent payments sometimes work
|
||||
- Response arrives before subscription is ready
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub responds VERY fast. If you publish the request before setting up the subscription, the response arrives before you're listening.
|
||||
|
||||
### Solution
|
||||
|
||||
Always set up the subscription BEFORE publishing:
|
||||
|
||||
```typescript
|
||||
// WRONG - race condition
|
||||
await nostrClient.publish(event)
|
||||
const response = await waitForResponse(requestId)
|
||||
|
||||
// RIGHT - subscribe first
|
||||
const responsePromise = waitForResponse(requestId) // Sets up subscription
|
||||
await nostrClient.publish(event) // Then publish
|
||||
return responsePromise // Then wait
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #5: Tag Filters Not Working
|
||||
|
||||
### Symptom
|
||||
|
||||
- Subscription with `#p` filter receives no events
|
||||
- Same subscription without `#p` receives events
|
||||
|
||||
### Cause
|
||||
|
||||
Some Nostr relays (including strfry in some configurations) don't properly support tag filters in subscriptions.
|
||||
|
||||
### Solution
|
||||
|
||||
Subscribe to a broader filter and manually check tags:
|
||||
|
||||
```typescript
|
||||
// Instead of relying on relay to filter by #p
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [lightningPubPubkey],
|
||||
// '#p': [myPubkey], // DON'T rely on this
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent: (event) => {
|
||||
// Manually check p-tags
|
||||
const pTags = event.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === myPubkey)) {
|
||||
return // Not for us
|
||||
}
|
||||
// Process event...
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #6: Response Matching with #e Tag
|
||||
|
||||
### Symptom
|
||||
|
||||
- Using `#e` filter to match responses to requests
|
||||
- No events received
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub doesn't include an `e` tag referencing the request event in its responses. It only uses `p` tags.
|
||||
|
||||
### Solution
|
||||
|
||||
Match responses by `requestId` in the decrypted content, not by event tags:
|
||||
|
||||
```typescript
|
||||
onevent: (event) => {
|
||||
const response = decryptJSON(identity, pubkey, event.content)
|
||||
|
||||
// Match by requestId, not by #e tag
|
||||
if (response.requestId !== expectedRequestId) {
|
||||
return // Not our response
|
||||
}
|
||||
// Process response...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tools
|
||||
|
||||
### Test Script
|
||||
|
||||
Use a standalone test script to isolate issues:
|
||||
|
||||
```javascript
|
||||
// test-pay.mjs
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
|
||||
const relay = await Relay.connect('ws://192.168.1.122:7777')
|
||||
|
||||
// Subscribe BEFORE publishing
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
console.log('Got event:', evt.id)
|
||||
// Decrypt and check requestId...
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await relay.publish(signedEvent)
|
||||
```
|
||||
|
||||
### Lightning.Pub Logs
|
||||
|
||||
```bash
|
||||
# Watch for payment activity
|
||||
docker logs -f lamassu-lightning-pub 2>&1 | grep -E "pay|invoice|ERROR"
|
||||
```
|
||||
|
||||
### Relay Logs
|
||||
|
||||
```bash
|
||||
# Watch relay traffic
|
||||
docker logs -f lamassu-relay
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Summary of Correct Implementation
|
||||
|
||||
```typescript
|
||||
async sendRPC<T>(rpcName: string, body: unknown): Promise<T> {
|
||||
const requestId = generateUniqueId()
|
||||
|
||||
const request = {
|
||||
rpcName,
|
||||
params: {},
|
||||
query: {},
|
||||
body,
|
||||
authIdentifier: this.identity.publicKey,
|
||||
requestId,
|
||||
}
|
||||
|
||||
const encrypted = encryptNIP44(this.identity, targetPubkey, request)
|
||||
const event = finalizeEvent({
|
||||
kind: 21000,
|
||||
content: encrypted,
|
||||
tags: [['p', targetPubkey]],
|
||||
created_at: now(),
|
||||
}, this.identity.privateKey)
|
||||
|
||||
// 1. Subscribe FIRST (before publishing)
|
||||
const responsePromise = new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('Timeout')), 30000)
|
||||
|
||||
// 2. Use direct relay, not pool
|
||||
const sub = this.relay.subscribe([{
|
||||
kinds: [21000],
|
||||
authors: [targetPubkey],
|
||||
// 3. Don't use #p filter - check manually
|
||||
}], {
|
||||
onevent: (evt) => {
|
||||
// 4. Manual p-tag check
|
||||
if (!evt.tags.some(t => t[0] === 'p' && t[1] === myPubkey)) return
|
||||
|
||||
const response = decrypt(evt.content)
|
||||
|
||||
// 5. Match by requestId, not #e tag
|
||||
if (response.requestId !== requestId) return
|
||||
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
|
||||
if (response.status === 'ERROR') {
|
||||
reject(new Error(response.reason))
|
||||
} else {
|
||||
resolve(response)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// 6. Publish AFTER subscription is set up
|
||||
await this.relay.publish(event)
|
||||
|
||||
return responsePromise
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #7: NewInvoice Response Field Name
|
||||
|
||||
### Symptom
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'slice')
|
||||
```
|
||||
|
||||
When accessing `response.payment_request` after calling `NewInvoice`.
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub's `NewInvoice` RPC returns `invoice`, not `payment_request`:
|
||||
|
||||
```json
|
||||
{ "invoice": "lnbcrt510u1p5hw7vz..." }
|
||||
```
|
||||
|
||||
Not:
|
||||
|
||||
```json
|
||||
{ "payment_request": "lnbcrt510u1p5hw7vz..." }
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
Use `response.invoice` instead of `response.payment_request`:
|
||||
|
||||
```typescript
|
||||
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {...})
|
||||
|
||||
// WRONG
|
||||
return { paymentRequest: response.payment_request }
|
||||
|
||||
// RIGHT
|
||||
return { paymentRequest: response.invoice }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Issue #8: No LookupInvoice RPC
|
||||
|
||||
### Symptom
|
||||
|
||||
- Lightning.Pub logs show: `ERROR unknown rpc call name from nostr event:LookupInvoice`
|
||||
- Trying to check payment status using standard LND-style lookup
|
||||
|
||||
### Cause
|
||||
|
||||
Lightning.Pub doesn't have a `LookupInvoice` RPC method. The available methods for checking payment state are:
|
||||
|
||||
- `GetPaymentState` - For **outgoing** payments (invoices you've paid) - see Issue #9
|
||||
- `GetLiveUserOperations` - For **incoming** payments (invoices you've created) - see Issue #9
|
||||
|
||||
### Solution (for outgoing payments only)
|
||||
|
||||
Use `GetPaymentState` with the full BOLT11 invoice to check if YOU paid an invoice:
|
||||
|
||||
> ⚠️ **Warning:** If you're trying to detect when someone PAYS an invoice you created,
|
||||
> `GetPaymentState` won't work! See **Issue #9** for the correct approach.
|
||||
|
||||
```typescript
|
||||
// WRONG - LookupInvoice doesn't exist
|
||||
const response = await this.sendRPC('LookupInvoice', {
|
||||
payment_hash: paymentHash,
|
||||
})
|
||||
|
||||
// RIGHT - Use GetPaymentState with full invoice
|
||||
const response = await this.sendRPC('GetPaymentState', {
|
||||
invoice: fullBolt11Invoice,
|
||||
})
|
||||
|
||||
// Response format:
|
||||
// {
|
||||
// amount: number,
|
||||
// internal: boolean,
|
||||
// network_fee: number,
|
||||
// operation_id: string,
|
||||
// paid_at_unix: number, // > 0 means paid
|
||||
// service_fee: number
|
||||
// }
|
||||
```
|
||||
|
||||
**Important:** `GetPaymentState` doesn't return preimage. If you need the preimage, you'll need to get it from another source.
|
||||
|
||||
---
|
||||
|
||||
## Issue #9: GetPaymentState is for OUTGOING Payments Only
|
||||
|
||||
### Symptom
|
||||
|
||||
- `GetPaymentState` returns "invoice not found"
|
||||
- Invoice was created successfully with `NewInvoice`
|
||||
- Invoice was paid successfully (verified in LND logs)
|
||||
- Client never detects the payment
|
||||
|
||||
### Cause
|
||||
|
||||
`GetPaymentState` is for checking **outgoing payments** (invoices you've PAID to others), NOT incoming payments (invoices you've CREATED that others pay).
|
||||
|
||||
Looking at Lightning.Pub's code:
|
||||
|
||||
```typescript
|
||||
// paymentManager.ts - GetPaymentState
|
||||
const invoice = await this.storage.paymentStorage.GetPaymentOwner(req.invoice)
|
||||
// GetPaymentOwner looks in the PAYMENT storage, not the INVOICE storage!
|
||||
```
|
||||
|
||||
### Solution
|
||||
|
||||
For detecting when an invoice you created has been paid, use the `GetLiveUserOperations` subscription. Lightning.Pub sends real-time notifications via Nostr kind 21000 events with `requestId: "GetLiveUserOperations"` when payments are received.
|
||||
|
||||
```typescript
|
||||
// Subscribe to kind 21000 events from Lightning.Pub
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [lightningPubPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent: (event) => {
|
||||
// Check p-tags to ensure it's for us
|
||||
const pTags = event.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === myPubkey)) return
|
||||
|
||||
const response = decrypt(event.content)
|
||||
|
||||
// Check if this is a LiveUserOperation
|
||||
if (response.requestId !== 'GetLiveUserOperations') return
|
||||
|
||||
const op = response.operation
|
||||
if (op.type !== 'INCOMING_INVOICE') return
|
||||
|
||||
// Match by invoice string
|
||||
if (op.identifier === ourInvoice) {
|
||||
console.log('Invoice paid! Amount:', op.amount)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**LiveUserOperation format:**
|
||||
|
||||
```json
|
||||
{
|
||||
"requestId": "GetLiveUserOperations",
|
||||
"status": "OK",
|
||||
"operation": {
|
||||
"type": "INCOMING_INVOICE",
|
||||
"identifier": "lnbcrt510u1p5hw7vz...", // The full invoice
|
||||
"amount": 51000,
|
||||
"paidAtUnix": 1706300000,
|
||||
"inbound": true
|
||||
},
|
||||
"latest_balance": 150000
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** Lightning.Pub has two different storages:
|
||||
|
||||
- `paymentStorage.GetPaymentOwner()` - For outgoing payments you've made
|
||||
- `paymentStorage.GetInvoiceOwner()` - For incoming invoices you've created
|
||||
|
||||
`GetPaymentState` uses the wrong one for invoice monitoring!
|
||||
|
||||
---
|
||||
|
||||
## Time Spent on Each Issue
|
||||
|
||||
| Issue | Time to Diagnose | Root Cause |
|
||||
| ------------------ | ---------------- | --------------------------------------------- |
|
||||
| RPC format | ~30 min | Missing `params`, `query` fields |
|
||||
| Amount field | ~45 min | Confusing `0` vs actual amount semantics |
|
||||
| SimplePool | ~60 min | Pool doesn't deliver real-time events |
|
||||
| Race condition | ~15 min | Subscribe before publish |
|
||||
| Tag filters | ~30 min | Relay doesn't support #p filter properly |
|
||||
| #e matching | ~20 min | Lightning.Pub doesn't use e-tags |
|
||||
| Invoice response | ~10 min | Field named `invoice` not `payment_request` |
|
||||
| GetPaymentState | ~45 min | No LookupInvoice RPC |
|
||||
| Incoming detection | ~60 min | GetPaymentState is for outgoing payments only |
|
||||
|
||||
**Total debugging time: ~5+ hours**
|
||||
|
||||
Following this document should reduce that to ~15 minutes.
|
||||
32
packages/lightning/package.json
Normal file
32
packages/lightning/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "@lamassu/lightning",
|
||||
"version": "0.1.0",
|
||||
"description": "Lightning.Pub client for Nostr-native Lightning operations",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lamassu/nostr-client": "workspace:*",
|
||||
"@lamassu/clink": "workspace:*",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
95
packages/lightning/src/__tests__/client.test.ts
Normal file
95
packages/lightning/src/__tests__/client.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { LightningPubClient } from '../client.js'
|
||||
|
||||
describe('LightningPubClient', () => {
|
||||
const config = {
|
||||
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()
|
||||
})
|
||||
|
||||
it('should accept optional timeout and appId', () => {
|
||||
const client = new LightningPubClient({
|
||||
...config,
|
||||
timeout: 60000,
|
||||
appId: 'test-app',
|
||||
})
|
||||
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)
|
||||
})
|
||||
|
||||
it('should return default rate for unknown currency', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const rate = await client.getExchangeRate('XYZ')
|
||||
|
||||
expect(rate.currency).toBe('XYZ')
|
||||
expect(rate.satsPerUnit).toBe(2500) // default rate
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeInvoice', () => {
|
||||
it('should decode invoice with micro-BTC amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbc100u1...')
|
||||
|
||||
expect(result).toBeDefined()
|
||||
expect(result.amountSats).toBe(10000) // 100 micro-BTC = 10000 sats
|
||||
expect(result.expiresAt).toBeGreaterThan(Date.now() / 1000)
|
||||
})
|
||||
|
||||
it('should decode invoice with milli-BTC amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbc1m1...')
|
||||
|
||||
expect(result.amountSats).toBe(100000) // 1 milli-BTC = 100000 sats
|
||||
})
|
||||
|
||||
it('should return null amount for invoice without amount', () => {
|
||||
const client = new LightningPubClient(config)
|
||||
const result = client.decodeInvoice('lnbcrt1...')
|
||||
|
||||
expect(result.amountSats).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('initialize', () => {
|
||||
it('should require nostrClient and identity', async () => {
|
||||
const client = new LightningPubClient(config)
|
||||
|
||||
// Without initialization, operations should fail
|
||||
await 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()
|
||||
})
|
||||
})
|
||||
})
|
||||
671
packages/lightning/src/client.ts
Normal file
671
packages/lightning/src/client.ts
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
/**
|
||||
* Lightning.Pub Client
|
||||
*
|
||||
* Client for interacting with Lightning.Pub - a Nostr-native
|
||||
* account system that wraps LND.
|
||||
*
|
||||
* Lightning.Pub provides:
|
||||
* - 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, 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 {
|
||||
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,
|
||||
type LnurlLinkResponse,
|
||||
isRPCError,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Lightning.Pub client for ATM operations
|
||||
*
|
||||
* Uses Kind 21000 for RPC communication with Lightning.Pub service.
|
||||
*/
|
||||
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
|
||||
private requestCounter = 0
|
||||
|
||||
constructor(config: LightningPubConfig) {
|
||||
this.config = {
|
||||
timeout: 30000,
|
||||
appId: 'lamassu-atm',
|
||||
...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.sendRPC<AccountInfo>('GetInfo', {})
|
||||
|
||||
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.sendRPC<BalanceResponse>('GetBalance', {})
|
||||
return { balanceSats: response.balance ?? 0 }
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Lightning invoice
|
||||
*/
|
||||
async createInvoice(params: CreateInvoiceParams): Promise<Invoice> {
|
||||
console.log('[LightningPub] Creating invoice for', params.amountSats, 'sats')
|
||||
|
||||
const response = await this.sendRPC<CreateInvoiceResponse>('NewInvoice', {
|
||||
amountSats: params.amountSats,
|
||||
memo: params.description || 'ATM Payment',
|
||||
expiry: params.expirySecs || 3600,
|
||||
private: params.privateHints ?? false,
|
||||
})
|
||||
|
||||
console.log('[LightningPub] NewInvoice response:', JSON.stringify(response))
|
||||
|
||||
if (!response || !response.invoice) {
|
||||
console.error('[LightningPub] Invalid response - missing invoice')
|
||||
throw new Error('Invalid invoice response from Lightning.Pub')
|
||||
}
|
||||
|
||||
// Extract payment hash from the invoice if not provided
|
||||
const decoded = this.decodeInvoice(response.invoice)
|
||||
|
||||
return {
|
||||
paymentRequest: response.invoice,
|
||||
paymentHash: response.payment_hash || decoded.paymentHash,
|
||||
amountSats: params.amountSats,
|
||||
description: params.description,
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
expiresAt: response.expires_at || decoded.expiresAt,
|
||||
status: 'pending',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a Lightning invoice
|
||||
*
|
||||
* @param paymentRequest - BOLT11 invoice to pay
|
||||
* @param amountSats - Amount to pay in satoshis. Required for amountless invoices.
|
||||
* If provided, this overrides any amount in the invoice.
|
||||
*/
|
||||
async payInvoice(paymentRequest: string, amountSats?: number): Promise<PaymentResult> {
|
||||
try {
|
||||
// Decode invoice to check if it has an embedded amount
|
||||
const decoded = this.decodeInvoice(paymentRequest)
|
||||
const invoiceAmount = decoded.amountSats
|
||||
|
||||
// Determine the amount to send
|
||||
// Lightning.Pub requires 'amount' field always:
|
||||
// - For invoices with amounts: use 0 (meaning "use invoice amount")
|
||||
// - For amountless invoices: use the calculated amount
|
||||
let payAmount: number
|
||||
|
||||
if (invoiceAmount !== null && invoiceAmount > 0) {
|
||||
// Invoice has amount - verify it doesn't exceed calculated sats
|
||||
if (amountSats !== undefined && invoiceAmount > amountSats) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Invoice amount (${invoiceAmount} sats) exceeds available amount (${amountSats} sats)`,
|
||||
}
|
||||
}
|
||||
// Use 0 to indicate "pay the invoice amount"
|
||||
payAmount = 0
|
||||
} else {
|
||||
// Amountless invoice - must provide amount
|
||||
if (!amountSats) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Amount required for amountless invoice',
|
||||
}
|
||||
}
|
||||
payAmount = amountSats
|
||||
}
|
||||
|
||||
// Build request body - amount is always required
|
||||
const body: Record<string, unknown> = {
|
||||
invoice: paymentRequest,
|
||||
amount: payAmount,
|
||||
}
|
||||
|
||||
const response = await this.sendRPC<PayInvoiceResponse>('PayInvoice', body)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
preimage: response.preimage,
|
||||
feeSats: response.fee_paid,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Payment failed',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an LNURL-withdraw link
|
||||
*
|
||||
* Lightning.Pub hosts the LNURL-withdraw endpoint. Returns a bech32-encoded
|
||||
* LNURL that wallets can scan to withdraw funds.
|
||||
*
|
||||
* The k1 is a secret that identifies this specific withdrawal request.
|
||||
*/
|
||||
async getLnurlWithdrawLink(): Promise<LnurlLinkResponse> {
|
||||
const response = await this.sendRPC<LnurlLinkResponse>('GetLnurlWithdrawLink', {})
|
||||
return {
|
||||
k1: response.k1,
|
||||
lnurl: response.lnurl,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get payment state for an invoice
|
||||
*
|
||||
* Lightning.Pub uses GetPaymentState with the full invoice string,
|
||||
* not LookupInvoice with a payment hash.
|
||||
*/
|
||||
async getPaymentState(invoice: string): Promise<{ paid: boolean; paidAt?: number } | null> {
|
||||
try {
|
||||
console.log('[LightningPub] Getting payment state for invoice:', invoice.slice(0, 32) + '...')
|
||||
const response = await this.sendRPC<{
|
||||
amount: number
|
||||
internal: boolean
|
||||
network_fee: number
|
||||
operation_id: string
|
||||
paid_at_unix: number
|
||||
service_fee: number
|
||||
}>('GetPaymentState', {
|
||||
invoice,
|
||||
})
|
||||
|
||||
console.log('[LightningPub] GetPaymentState response:', JSON.stringify(response))
|
||||
|
||||
return {
|
||||
paid: response.paid_at_unix > 0,
|
||||
paidAt: response.paid_at_unix > 0 ? response.paid_at_unix : undefined,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[LightningPub] GetPaymentState error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an invoice by payment hash
|
||||
* @deprecated Use getPaymentState(invoice) instead - Lightning.Pub doesn't support LookupInvoice
|
||||
*/
|
||||
async lookupInvoice(paymentHash: string): Promise<Invoice | null> {
|
||||
console.warn('[LightningPub] lookupInvoice is deprecated - use getPaymentState instead')
|
||||
// This method is kept for backwards compatibility but won't work with Lightning.Pub
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch for invoice payment using GetLiveUserOperations subscription
|
||||
*
|
||||
* Lightning.Pub sends LiveUserOperation events via Nostr when payments
|
||||
* are received. This is more reliable than polling GetPaymentState
|
||||
* (which is for outgoing payments, not incoming invoices).
|
||||
*
|
||||
* @param invoice - The full BOLT11 invoice string (not payment hash)
|
||||
* @param callback - Called when payment is detected
|
||||
* @returns Cleanup function to stop watching
|
||||
*/
|
||||
watchInvoice(invoice: string, callback: InvoiceCallback): () => void {
|
||||
if (!this.nostrClient || !this.identity) {
|
||||
console.error('[LightningPub] Cannot watch invoice: client not initialized')
|
||||
return () => {}
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Starting invoice watch for:', invoice.slice(0, 32) + '...')
|
||||
|
||||
// Use invoice as key
|
||||
const invoiceKey = invoice.slice(0, 64)
|
||||
this.invoiceCallbacks.set(invoiceKey, callback)
|
||||
|
||||
// Subscribe to kind 21000 events from Lightning.Pub
|
||||
// LiveUserOperation events have requestId: "GetLiveUserOperations"
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
authors: [this.config.accountPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if this event is for us (has our pubkey in p tags)
|
||||
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
|
||||
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = decryptJSON<{
|
||||
requestId: string
|
||||
status: string
|
||||
operation?: {
|
||||
type: string
|
||||
identifier: string
|
||||
amount: number
|
||||
paidAtUnix: number
|
||||
inbound: boolean
|
||||
}
|
||||
latest_balance?: number
|
||||
}>(this.identity!, this.config.accountPubkey, event.content)
|
||||
|
||||
// Check if this is a LiveUserOperation for incoming invoice
|
||||
if (response.requestId !== 'GetLiveUserOperations') {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Received LiveUserOperation:', JSON.stringify(response))
|
||||
|
||||
if (!response.operation) {
|
||||
return
|
||||
}
|
||||
|
||||
const op = response.operation
|
||||
|
||||
// Check if this is an incoming invoice payment matching our invoice
|
||||
if (op.type !== 'INCOMING_INVOICE') {
|
||||
console.log('[LightningPub] Operation type:', op.type, '(not INCOMING_INVOICE)')
|
||||
return
|
||||
}
|
||||
|
||||
// Match invoice - the identifier is the full invoice string
|
||||
if (!op.identifier || !op.identifier.toLowerCase().startsWith('ln')) {
|
||||
console.log('[LightningPub] Invalid identifier:', op.identifier?.slice(0, 20))
|
||||
return
|
||||
}
|
||||
|
||||
// Compare invoices (case-insensitive, matching prefix)
|
||||
const opInvoiceKey = op.identifier.slice(0, 64).toLowerCase()
|
||||
const watchedInvoiceKey = invoice.slice(0, 64).toLowerCase()
|
||||
|
||||
if (opInvoiceKey !== watchedInvoiceKey) {
|
||||
console.log(
|
||||
'[LightningPub] Invoice mismatch, waiting for:',
|
||||
watchedInvoiceKey.slice(0, 20)
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[LightningPub] Invoice payment detected! Amount:', op.amount, 'sats')
|
||||
|
||||
// Clean up subscription
|
||||
this.nostrClient!.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
|
||||
// Extract payment hash from invoice for the callback
|
||||
const decoded = this.decodeInvoice(invoice)
|
||||
|
||||
callback({
|
||||
paymentRequest: invoice,
|
||||
paymentHash: decoded.paymentHash,
|
||||
amountSats: op.amount,
|
||||
description: decoded.description,
|
||||
createdAt: op.paidAtUnix - 60, // approximate creation time
|
||||
expiresAt: decoded.expiresAt,
|
||||
status: 'paid',
|
||||
preimage: 'paid-via-live-operation', // LiveUserOperation doesn't include preimage
|
||||
})
|
||||
} catch {
|
||||
// Decryption failed - might not be for us, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Clean up after expiry (10 minutes)
|
||||
const timeoutId = setTimeout(
|
||||
() => {
|
||||
console.log('[LightningPub] Invoice watch timeout, cleaning up')
|
||||
this.nostrClient?.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
},
|
||||
10 * 60 * 1000
|
||||
)
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
console.log('[LightningPub] Stopping invoice watch')
|
||||
clearTimeout(timeoutId)
|
||||
this.nostrClient?.unsubscribe(subId)
|
||||
this.invoiceCallbacks.delete(invoiceKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watching an invoice
|
||||
*/
|
||||
unwatchInvoice(paymentHash: string): void {
|
||||
this.invoiceCallbacks.delete(paymentHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a BOLT11 invoice (basic parsing)
|
||||
*
|
||||
* For production use, consider using a proper bolt11 library.
|
||||
*
|
||||
* BOLT-11 format: ln + network + [amount][multiplier] + 1 + data
|
||||
* - lnbc1... = mainnet, amountless
|
||||
* - lnbcrt20u1... = regtest, 20 micro-BTC = 2000 sats
|
||||
*/
|
||||
decodeInvoice(paymentRequest: string): {
|
||||
amountSats: number | null
|
||||
paymentHash: string
|
||||
description: string
|
||||
expiresAt: number
|
||||
} {
|
||||
const invoice = paymentRequest.toLowerCase()
|
||||
|
||||
// Match amount BEFORE the '1' separator
|
||||
// Group 1: amount (optional), Group 2: multiplier (optional)
|
||||
const amountMatch = invoice.match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
|
||||
let amountSats: number | null = null
|
||||
|
||||
if (amountMatch && amountMatch[1]) {
|
||||
const [, amount, multiplier] = amountMatch
|
||||
const baseAmount = parseInt(amount ?? '0', 10)
|
||||
switch (multiplier) {
|
||||
case 'm':
|
||||
amountSats = baseAmount * 100_000 // milli-BTC to sats
|
||||
break
|
||||
case 'u':
|
||||
amountSats = baseAmount * 100 // micro-BTC to sats
|
||||
break
|
||||
case 'n':
|
||||
amountSats = Math.floor(baseAmount / 10) // nano-BTC to sats
|
||||
break
|
||||
case 'p':
|
||||
amountSats = Math.floor(baseAmount / 10_000) // pico-BTC to sats
|
||||
break
|
||||
default:
|
||||
amountSats = baseAmount * 100_000_000 // BTC to sats
|
||||
}
|
||||
}
|
||||
|
||||
// Extract payment hash from tagged data
|
||||
// Data section starts after '1', first 7 chars are timestamp
|
||||
// Then tagged fields: type (1 char) + length (2 chars) + data
|
||||
// Payment hash tag type is 'p' (value 1), length is typically 'p5' (52 5-bit chars = 32 bytes)
|
||||
let paymentHash = ''
|
||||
const separatorIdx = invoice.lastIndexOf('1')
|
||||
if (separatorIdx > 0) {
|
||||
const data = invoice.slice(separatorIdx + 1)
|
||||
// Skip timestamp (7 chars), look for payment hash tag
|
||||
const afterTimestamp = data.slice(7)
|
||||
// Find 'p' tag (payment hash) - it's usually the first tag
|
||||
if (afterTimestamp.startsWith('pp')) {
|
||||
// pp = type 'p' + length starts with 'p'
|
||||
// Length is 2 chars after type: positions 1-2
|
||||
// Data starts at position 3
|
||||
const hashData = afterTimestamp.slice(3, 3 + 52) // 52 5-bit chars = 260 bits for 256-bit hash
|
||||
paymentHash = this.bech32ToHex(hashData)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
amountSats,
|
||||
paymentHash,
|
||||
description: '',
|
||||
expiresAt: Math.floor(Date.now() / 1000) + 3600,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert bech32-encoded 5-bit values to hex string
|
||||
*/
|
||||
private bech32ToHex(data: string): string {
|
||||
const BECH32_CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l'
|
||||
|
||||
// Convert each char to 5-bit value
|
||||
const bits: number[] = []
|
||||
for (const char of data) {
|
||||
const idx = BECH32_CHARSET.indexOf(char)
|
||||
if (idx === -1) return ''
|
||||
bits.push(idx)
|
||||
}
|
||||
|
||||
// Combine 5-bit values into 8-bit bytes
|
||||
let buffer = 0
|
||||
let bufferBits = 0
|
||||
const bytes: number[] = []
|
||||
|
||||
for (const value of bits) {
|
||||
buffer = (buffer << 5) | value
|
||||
bufferBits += 5
|
||||
if (bufferBits >= 8) {
|
||||
bufferBits -= 8
|
||||
bytes.push((buffer >> bufferBits) & 0xff)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to hex
|
||||
return bytes.map((b) => b.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exchange rate (mock implementation)
|
||||
*
|
||||
* For production, integrate with price feeds or Lightning.Pub's rate service.
|
||||
*/
|
||||
async getExchangeRate(currency: string): Promise<ExchangeRate> {
|
||||
// Mock rates - in production, fetch from service
|
||||
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 RPC responses
|
||||
*/
|
||||
private startListening(): void {
|
||||
if (!this.nostrClient || !this.identity) return
|
||||
if (this.subscriptionId) return
|
||||
|
||||
this.subscriptionId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
'#p': [this.identity.publicKey],
|
||||
authors: [this.config.accountPubkey],
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => this.handleResponse(event),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming response events
|
||||
*/
|
||||
private handleResponse(_event: Event): void {
|
||||
// Responses are handled by waitForResponse
|
||||
// This is for async notifications if needed
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an RPC request to Lightning.Pub
|
||||
*/
|
||||
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()
|
||||
|
||||
// Lightning.Pub expects: rpcName, params, query, body, authIdentifier, requestId
|
||||
const request: RPCRequest = {
|
||||
rpcName,
|
||||
params: {},
|
||||
query: {},
|
||||
body: body as Record<string, unknown>,
|
||||
authIdentifier: this.identity.publicKey,
|
||||
requestId,
|
||||
}
|
||||
|
||||
const content = encryptContent(this.identity, this.config.accountPubkey, request)
|
||||
|
||||
// finalizeEvent derives pubkey from the secret key
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: LightningPubEventKind.RPC,
|
||||
content,
|
||||
tags: [['p', this.config.accountPubkey]],
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
},
|
||||
this.identity.privateKey
|
||||
)
|
||||
|
||||
// IMPORTANT: Set up subscription BEFORE publishing to avoid race condition
|
||||
// Lightning.Pub can respond very fast, so we need to be listening first
|
||||
const responsePromise = this.waitForResponse<T>(requestId)
|
||||
|
||||
await this.nostrClient.publish(event)
|
||||
|
||||
return responsePromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a response to a specific request
|
||||
*
|
||||
* Note: Lightning.Pub doesn't include an 'e' tag referencing the request event,
|
||||
* so we filter by '#p' and match on requestId in the decrypted content.
|
||||
*/
|
||||
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)
|
||||
|
||||
// Subscribe to ALL RPC events from Lightning.Pub
|
||||
// We filter by #p tag manually because some relays don't support tag filters well
|
||||
const subId = this.nostrClient.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [LightningPubEventKind.RPC],
|
||||
authors: [this.config.accountPubkey],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (event) => {
|
||||
// Check if this event is for us (has our pubkey in p tags)
|
||||
const pTags = event.tags.filter((t: string[]) => t[0] === 'p')
|
||||
if (!pTags.some((t: string[]) => t[1] === this.identity!.publicKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = decryptJSON<RPCResponse<T>>(
|
||||
this.identity!,
|
||||
this.config.accountPubkey,
|
||||
event.content
|
||||
)
|
||||
|
||||
// Match on requestId to find our response
|
||||
if (response.requestId !== requestId) {
|
||||
return // Not our response, keep waiting
|
||||
}
|
||||
|
||||
clearTimeout(timeout)
|
||||
this.nostrClient!.unsubscribe(subId)
|
||||
|
||||
if (isRPCError(response)) {
|
||||
reject(new Error(response.reason))
|
||||
} else {
|
||||
// Extract result from response (excluding status and requestId)
|
||||
const { status, requestId: _rid, ...result } = response
|
||||
resolve(result as T)
|
||||
}
|
||||
} catch {
|
||||
// Decryption failed - might not be for us, ignore
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique request ID
|
||||
*/
|
||||
private generateRequestId(): string {
|
||||
this.requestCounter++
|
||||
return `${Date.now()}-${this.requestCounter}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
81
packages/lightning/src/index.ts
Normal file
81
packages/lightning/src/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
/**
|
||||
* @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 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({
|
||||
* accountPubkey: 'hex-pubkey...',
|
||||
* relays: ['wss://relay.example.com'],
|
||||
* })
|
||||
*
|
||||
* // Initialize with Nostr client
|
||||
* client.initialize(nostrClient, machineIdentity)
|
||||
*
|
||||
* // Create an invoice
|
||||
* const invoice = await client.createInvoice({
|
||||
* amountSats: 100,
|
||||
* 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 {
|
||||
// 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,
|
||||
// LNURL types
|
||||
type LnurlLinkResponse,
|
||||
// Exchange types
|
||||
type ExchangeRate,
|
||||
// Config
|
||||
type LightningPubConfig,
|
||||
// Callbacks
|
||||
type InvoiceCallback,
|
||||
type PaymentCallback,
|
||||
} from './types.js'
|
||||
230
packages/lightning/src/types.ts
Normal file
230
packages/lightning/src/types.ts
Normal file
|
|
@ -0,0 +1,230 @@
|
|||
/**
|
||||
* Lightning.Pub client type definitions
|
||||
*
|
||||
* 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 (empty object for most methods) */
|
||||
params: Record<string, string>
|
||||
/** Query params (empty object for most methods) */
|
||||
query: Record<string, string>
|
||||
/** Request body containing method-specific parameters */
|
||||
body: Record<string, unknown>
|
||||
/** Pubkey of requester (for validation) */
|
||||
authIdentifier: string
|
||||
/** Unique request identifier */
|
||||
requestId: string
|
||||
}
|
||||
|
||||
/** RPC success response */
|
||||
export interface RPCSuccessResponse<T = unknown> {
|
||||
/** Success status */
|
||||
status: 'OK'
|
||||
/** Request ID echoed back */
|
||||
requestId: string
|
||||
/** Result data - spread into the response object */
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** RPC error response */
|
||||
export interface RPCErrorResponse {
|
||||
/** Error status */
|
||||
status: 'ERROR'
|
||||
/** Request ID echoed back */
|
||||
requestId: string
|
||||
/** 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 */
|
||||
pubkey: string
|
||||
/** Account balance in satoshis */
|
||||
balanceSats: number
|
||||
/** Maximum send amount */
|
||||
maxSendSats: number
|
||||
/** Maximum receive amount */
|
||||
maxReceiveSats: number
|
||||
}
|
||||
|
||||
/** Balance response */
|
||||
export interface BalanceResponse {
|
||||
/** Balance in satoshis */
|
||||
balance: number
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Invoices
|
||||
// ============================================================================
|
||||
|
||||
/** 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 satoshis */
|
||||
amountSats: number
|
||||
/** Invoice description */
|
||||
description?: string
|
||||
/** Creation timestamp */
|
||||
createdAt: number
|
||||
/** Expiry timestamp */
|
||||
expiresAt: number
|
||||
/** Current status */
|
||||
status: InvoiceStatus
|
||||
/** Payment preimage (if paid) */
|
||||
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 invoice string */
|
||||
invoice: string
|
||||
/** Payment hash (hex) - may not be returned, extract from invoice if needed */
|
||||
payment_hash?: string
|
||||
/** Expiry timestamp - may not be returned */
|
||||
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 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LNURL
|
||||
// ============================================================================
|
||||
|
||||
/** LNURL-withdraw link response (from GetLnurlWithdrawLink) */
|
||||
export interface LnurlLinkResponse {
|
||||
/** Secret k1 for the LNURL-withdraw */
|
||||
k1: string
|
||||
/** Bech32-encoded LNURL string */
|
||||
lnurl: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Exchange Rates
|
||||
// ============================================================================
|
||||
|
||||
/** 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
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Client Configuration
|
||||
// ============================================================================
|
||||
|
||||
/** Lightning.Pub client configuration */
|
||||
export interface LightningPubConfig {
|
||||
/** Lightning.Pub account pubkey */
|
||||
accountPubkey: string
|
||||
/** Nostr relays for communication */
|
||||
relays: string[]
|
||||
/** Request timeout in ms (default: 30000) */
|
||||
timeout?: number
|
||||
/** Application identifier */
|
||||
appId?: string
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Callbacks
|
||||
// ============================================================================
|
||||
|
||||
/** Invoice callback for watching payments */
|
||||
export type InvoiceCallback = (invoice: Invoice) => void
|
||||
|
||||
/** Payment status update callback */
|
||||
export type PaymentCallback = (result: PaymentResult) => void
|
||||
22
packages/lightning/tsconfig.json
Normal file
22
packages/lightning/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/lightning/vitest.config.ts
Normal file
8
packages/lightning/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
270
packages/nostr-client/atm-debit-agent.mjs
Normal file
270
packages/nostr-client/atm-debit-agent.mjs
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* ATM Debit Authorization Agent
|
||||
*
|
||||
* This script demonstrates how an ATM can act as the authorization authority
|
||||
* for CLINK debit requests, similar to an "admin macaroon" for Lightning.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Generate keypair for ATM (or load from secure storage)
|
||||
* 2. Link keypair to Lightning.Pub user account
|
||||
* 3. Subscribe to live debit requests
|
||||
* 4. Auto-approve requests (within configured limits)
|
||||
*
|
||||
* Prerequisites:
|
||||
* - Get a linking token from Lightning.Pub HTTP API
|
||||
* - Run: curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \
|
||||
* -H "Authorization: Bearer $APP_TOKEN" \
|
||||
* -H "Content-Type: application/json" \
|
||||
* -d '{"user_identifier": "YOUR_USER_IDENTIFIER"}'
|
||||
*/
|
||||
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools'
|
||||
import * as nip44v1 from './nip44v1.mjs'
|
||||
|
||||
// Configuration
|
||||
const LINKING_TOKEN = process.argv[2]
|
||||
const LIGHTNING_PUB_PUBKEY = '6c59284e3da31b776cb1c06324c25f4a0b0308177af9f8aec5ebef07b44c3fdf'
|
||||
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||
|
||||
// Generate ATM keypair (in production, this would be stored securely)
|
||||
const ATM_PRIVATE_KEY = generateSecretKey()
|
||||
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
||||
const ATM_PRIVATE_KEY_HEX = Buffer.from(ATM_PRIVATE_KEY).toString('hex')
|
||||
|
||||
if (!LINKING_TOKEN) {
|
||||
console.log('ATM Debit Authorization Agent')
|
||||
console.log('==============================')
|
||||
console.log('')
|
||||
console.log('Usage: node atm-debit-agent.mjs <linking-token>')
|
||||
console.log('')
|
||||
console.log('Get a linking token:')
|
||||
console.log(' curl -X POST "http://localhost:1776/api/app/user/npub/token/reset" \\')
|
||||
console.log(' -H "Authorization: Bearer $APP_TOKEN" \\')
|
||||
console.log(' -H "Content-Type: application/json" \\')
|
||||
console.log(' -d \'{"user_identifier": "YOUR_USER_IDENTIFIER"}\'')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('=== ATM Debit Authorization Agent ===')
|
||||
console.log('')
|
||||
console.log('ATM Pubkey:', ATM_PUBLIC_KEY)
|
||||
console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||
console.log('Linking Token:', LINKING_TOKEN.substring(0, 16) + '...')
|
||||
console.log('')
|
||||
|
||||
async function main() {
|
||||
// Connect to relay
|
||||
console.log('Connecting to relay...')
|
||||
const relay = await Relay.connect(RELAY_URL)
|
||||
console.log('Connected!')
|
||||
console.log('')
|
||||
|
||||
// Create conversation key for NIP-44 v1 encryption (used by Kind 21000 RPC)
|
||||
const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY)
|
||||
|
||||
// Step 1: Link NPub through token
|
||||
console.log('Step 1: Linking ATM keypair to user account...')
|
||||
|
||||
const linkRequest = {
|
||||
rpcName: 'LinkNPubThroughToken',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {
|
||||
token: LINKING_TOKEN,
|
||||
},
|
||||
}
|
||||
|
||||
const linkEvent = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: nip44v1.encrypt(JSON.stringify(linkRequest), conversationKey),
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
// Subscribe for response
|
||||
let linkingComplete = false
|
||||
const linkSub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
'#p': [ATM_PUBLIC_KEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
try {
|
||||
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
|
||||
const response = JSON.parse(decrypted)
|
||||
console.log('Link response:', JSON.stringify(response))
|
||||
if (response.status === 'OK') {
|
||||
linkingComplete = true
|
||||
console.log('Keypair linked successfully!')
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to decrypt link response:', err.message)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await relay.publish(linkEvent)
|
||||
console.log(
|
||||
'Link request sent (event id:',
|
||||
linkEvent.id.substring(0, 16) + '...), waiting for confirmation...'
|
||||
)
|
||||
|
||||
// Wait for linking to complete
|
||||
for (let i = 0; i < 10 && !linkingComplete; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
if (i % 3 === 2) console.log('Still waiting for link confirmation...')
|
||||
}
|
||||
linkSub.close()
|
||||
|
||||
if (!linkingComplete) {
|
||||
console.log('Warning: Did not receive linking confirmation, continuing anyway...')
|
||||
}
|
||||
console.log('')
|
||||
|
||||
// Step 2: Subscribe to live debit requests
|
||||
console.log('Step 2: Subscribing to live debit requests...')
|
||||
|
||||
const subscribeRequest = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const subEvent = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey),
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
// Subscribe for debit requests and responses
|
||||
console.log('Listening for debit requests...')
|
||||
console.log('(Scan the ndebit QR code with ShockWallet to test)')
|
||||
console.log('')
|
||||
|
||||
const debitSub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
'#p': [ATM_PUBLIC_KEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
async onevent(evt) {
|
||||
try {
|
||||
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
|
||||
const message = JSON.parse(decrypted)
|
||||
|
||||
// Check if this is a debit request (has request_id and debit fields)
|
||||
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||
console.log('')
|
||||
console.log('========================================')
|
||||
console.log('Received debit request!')
|
||||
console.log(' Request ID:', message.request_id)
|
||||
console.log(' From npub:', message.npub)
|
||||
console.log(' Debit type:', message.debit.type)
|
||||
|
||||
if (message.debit.type === 'invoice' && message.debit.invoice) {
|
||||
console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...')
|
||||
|
||||
// Auto-approve by responding with INVOICE type
|
||||
console.log('')
|
||||
console.log('Auto-approving debit request...')
|
||||
|
||||
const approveRequest = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: message.debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const approveEvent = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey),
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(approveEvent)
|
||||
console.log('Approval sent! (event id:', approveEvent.id.substring(0, 16) + '...)')
|
||||
} else if (message.debit.type === 'budget') {
|
||||
console.log(' Budget request - ignoring for now')
|
||||
} else if (message.debit.type === 'fullAccess') {
|
||||
console.log(' Full access request - ignoring for now')
|
||||
}
|
||||
|
||||
console.log('========================================')
|
||||
console.log('')
|
||||
} else if (message.rpcName) {
|
||||
// This is a response to our RPC request
|
||||
console.log('RPC response:', message.rpcName, ':', message.status || 'received')
|
||||
} else {
|
||||
// Log other messages for debugging
|
||||
console.log('Message received:', JSON.stringify(message).substring(0, 100))
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore decryption failures (may be messages for other clients)
|
||||
if (!err.message.includes('Unsupported')) {
|
||||
console.log('Error processing message:', err.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await relay.publish(subEvent)
|
||||
console.log('Subscription request sent (event id:', subEvent.id.substring(0, 16) + '...)')
|
||||
console.log('')
|
||||
|
||||
// Keep running
|
||||
console.log('ATM Debit Agent running. Press Ctrl+C to exit.')
|
||||
console.log('')
|
||||
|
||||
// Handle graceful shutdown
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down...')
|
||||
debitSub.close()
|
||||
relay.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Keep alive with heartbeat
|
||||
let heartbeatCount = 0
|
||||
while (true) {
|
||||
await new Promise((r) => setTimeout(r, 10000))
|
||||
heartbeatCount++
|
||||
if (heartbeatCount % 6 === 0) {
|
||||
// Every minute
|
||||
console.log('Still listening... (' + heartbeatCount * 10 + 's)')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err.message)
|
||||
console.error(err.stack)
|
||||
process.exit(1)
|
||||
})
|
||||
114
packages/nostr-client/fund-dev.mjs
Normal file
114
packages/nostr-client/fund-dev.mjs
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from './dist/index.js'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const DEV_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const LIGHTNING_PUB_PUBKEY =
|
||||
process.env.LIGHTNING_PUB_PUBKEY ||
|
||||
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
|
||||
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'ws://localhost:7777'
|
||||
const FUND_AMOUNT = parseInt(process.env.FUND_AMOUNT || '100000', 10)
|
||||
|
||||
async function main() {
|
||||
const identity = loadIdentityFromHex(DEV_PRIVATE_KEY)
|
||||
console.log('Using identity:', identity.publicKey)
|
||||
|
||||
const client = new NostrClient({
|
||||
relays: [{ url: RELAY_URL }],
|
||||
identity,
|
||||
})
|
||||
|
||||
await client.connect()
|
||||
console.log('Connected to relay')
|
||||
|
||||
const requestId = randomUUID()
|
||||
|
||||
// Correct RPC structure per Lightning.Pub documentation
|
||||
const rpcRequest = {
|
||||
rpcName: 'NewInvoice',
|
||||
params: {},
|
||||
query: {},
|
||||
body: {
|
||||
amountSats: FUND_AMOUNT,
|
||||
memo: `Fund dev account (${FUND_AMOUNT} sats)`,
|
||||
},
|
||||
authIdentifier: identity.publicKey,
|
||||
requestId,
|
||||
}
|
||||
|
||||
console.log('Creating invoice for', FUND_AMOUNT, 'sats')
|
||||
|
||||
console.log('Request structure:', JSON.stringify(rpcRequest, null, 2))
|
||||
|
||||
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
identity.privateKey
|
||||
)
|
||||
|
||||
console.log('Publishing NewInvoice request (event id:', event.id, ')...')
|
||||
|
||||
// Subscribe to responses before publishing
|
||||
let responseReceived = false
|
||||
const subId = client.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onEvent: (evt) => {
|
||||
console.log('Got event from Lightning.Pub:', evt.id.substring(0, 8) + '...')
|
||||
// Check if it's for us
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
const eTags = evt.tags.filter((t) => t[0] === 'e')
|
||||
const isForUs = pTags.some((t) => t[1] === identity.publicKey)
|
||||
const isReplyToOurEvent = eTags.some((t) => t[1] === event.id)
|
||||
console.log(
|
||||
' Tags p:',
|
||||
pTags.map((t) => t[1].substring(0, 8)),
|
||||
'e:',
|
||||
eTags.map((t) => t[1].substring(0, 8))
|
||||
)
|
||||
console.log(' For us:', isForUs, 'Reply to our event:', isReplyToOurEvent)
|
||||
|
||||
if (isForUs) {
|
||||
try {
|
||||
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
|
||||
console.log('Decrypted response:', JSON.stringify(response, null, 2))
|
||||
responseReceived = true
|
||||
} catch (err) {
|
||||
console.log('Failed to decrypt response:', err.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await client.publish(event)
|
||||
console.log('Request published, waiting for response...')
|
||||
|
||||
// Wait up to 10 seconds for response
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
if (responseReceived) break
|
||||
}
|
||||
|
||||
if (!responseReceived) {
|
||||
console.log('No response received within timeout')
|
||||
}
|
||||
|
||||
client.unsubscribe(subId)
|
||||
client.disconnect()
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
84
packages/nostr-client/generate-ndebit.mjs
Normal file
84
packages/nostr-client/generate-ndebit.mjs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
/**
|
||||
* Generate an ndebit string for ShockWallet to scan
|
||||
*
|
||||
* Usage: node generate-ndebit.mjs [pointer]
|
||||
*
|
||||
* The ndebit allows ShockWallet to send an invoice that Lightning.Pub will pay.
|
||||
* This is the LNURL-withdraw equivalent for CLINK.
|
||||
*/
|
||||
|
||||
import { bech32 } from '@scure/base'
|
||||
|
||||
const LIGHTNING_PUB_PUBKEY =
|
||||
process.env.LIGHTNING_PUB_PUBKEY ||
|
||||
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
|
||||
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'wss://strfry.shock.network'
|
||||
const POINTER = process.argv[2] || 'atm-cashin-' + Date.now()
|
||||
|
||||
function hexToBytes(hex) {
|
||||
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
|
||||
}
|
||||
|
||||
function encodeTLV(tlv) {
|
||||
const entries = []
|
||||
Object.entries(tlv)
|
||||
.reverse()
|
||||
.forEach(([t, vs]) => {
|
||||
vs.forEach((v) => {
|
||||
const entry = new Uint8Array(v.length + 2)
|
||||
entry.set([parseInt(t)], 0)
|
||||
entry.set([v.length], 1)
|
||||
entry.set(v, 2)
|
||||
entries.push(entry)
|
||||
})
|
||||
})
|
||||
|
||||
// Concatenate all entries
|
||||
const totalLength = entries.reduce((sum, e) => sum + e.length, 0)
|
||||
const result = new Uint8Array(totalLength)
|
||||
let offset = 0
|
||||
for (const entry of entries) {
|
||||
result.set(entry, offset)
|
||||
offset += entry.length
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function ndebitEncode(debit) {
|
||||
const encoder = new TextEncoder()
|
||||
|
||||
const tlv = {
|
||||
0: [hexToBytes(debit.pubkey)],
|
||||
1: [encoder.encode(debit.relay)],
|
||||
}
|
||||
|
||||
if (debit.pointer) {
|
||||
tlv[2] = [encoder.encode(debit.pointer)]
|
||||
}
|
||||
|
||||
const data = encodeTLV(tlv)
|
||||
const words = bech32.toWords(data)
|
||||
return bech32.encode('ndebit', words, 5000)
|
||||
}
|
||||
|
||||
// Generate ndebit
|
||||
const ndebit = ndebitEncode({
|
||||
pubkey: LIGHTNING_PUB_PUBKEY,
|
||||
relay: RELAY_URL,
|
||||
pointer: POINTER,
|
||||
})
|
||||
|
||||
console.log('=== NDEBIT for ShockWallet ===')
|
||||
console.log('')
|
||||
console.log('Pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||
console.log('Relay:', RELAY_URL)
|
||||
console.log('Pointer:', POINTER)
|
||||
console.log('')
|
||||
console.log('ndebit string:')
|
||||
console.log(ndebit)
|
||||
console.log('')
|
||||
console.log('ShockWallet should scan this QR code to receive sats from the ATM.')
|
||||
833
packages/nostr-client/mock-machine.mjs
Normal file
833
packages/nostr-client/mock-machine.mjs
Normal file
|
|
@ -0,0 +1,833 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Mock ATM Machine - Simulates the ATM cash-in flow with CLINK
|
||||
*
|
||||
* Usage: node mock-machine.mjs
|
||||
*
|
||||
* This creates a web server that:
|
||||
* 1. Displays the ndebit QR code for customers to scan
|
||||
* 2. Runs the ATM debit agent to authorize payments
|
||||
* 3. Shows real-time status updates
|
||||
*/
|
||||
|
||||
import http from 'http'
|
||||
import { WebSocketServer } from 'ws'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { getConversationKey, encrypt, decrypt } from './nip44v1.mjs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import QRCode from 'qrcode'
|
||||
import { decodeBech32, ndebitEncode } from '@shocknet/clink-sdk'
|
||||
|
||||
const PORT = 3456
|
||||
const RELAY_URL = 'ws://localhost:7777'
|
||||
// Relay URL for browser access (different from Docker internal strfry:7777)
|
||||
const BROWSER_RELAY_URL = 'ws://localhost:7777'
|
||||
const LIGHTNING_PUB_HTTP = 'http://localhost:1776'
|
||||
const ADMIN_TOKEN = 'lamassu-dev-admin-token'
|
||||
|
||||
// Lightning.Pub pubkey - fetched dynamically from the ATM user's ndebit
|
||||
let LIGHTNING_PUB_PUBKEY = null
|
||||
|
||||
// ATM keypair (persistent for this session)
|
||||
const ATM_PRIVATE_KEY = generateSecretKey()
|
||||
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
||||
|
||||
// Fake exchange rate: sats per USD (approximately $100k/BTC)
|
||||
const SATS_PER_USD = 1000
|
||||
|
||||
// ATM states
|
||||
const ATM_STATE = {
|
||||
IDLE: 'idle',
|
||||
CASH_INSERTED: 'cash_inserted',
|
||||
WAITING_FOR_SCAN: 'waiting_for_scan',
|
||||
PROCESSING: 'processing',
|
||||
COMPLETE: 'complete',
|
||||
}
|
||||
|
||||
// State
|
||||
let relay = null
|
||||
let appToken = null
|
||||
let ndebit = null
|
||||
let ndebitQR = null
|
||||
let atmBalance = 0
|
||||
let wsClients = []
|
||||
let isLinked = false
|
||||
let withdrawAmount = 0 // Amount in sats (calculated from cash)
|
||||
let cashInserted = 0 // Amount in USD
|
||||
let atmState = ATM_STATE.IDLE
|
||||
|
||||
// Rewrite ndebit relay for browser access (strfry:7777 -> localhost:7777)
|
||||
function rewriteNdebitRelay(ndebitString) {
|
||||
try {
|
||||
const decoded = decodeBech32(ndebitString)
|
||||
if (decoded.type !== 'ndebit') return ndebitString
|
||||
|
||||
// Replace Docker internal relay with browser-accessible relay
|
||||
const data = {
|
||||
pubkey: decoded.data.pubkey,
|
||||
relay: BROWSER_RELAY_URL,
|
||||
pointer: decoded.data.pointer,
|
||||
}
|
||||
return ndebitEncode(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to rewrite ndebit relay:', e)
|
||||
return ndebitString
|
||||
}
|
||||
}
|
||||
|
||||
// Format ndebit with clink: prefix and amount parameter
|
||||
// Using clink: instead of lightning: because CLINK is protocol-agnostic
|
||||
// (could work with Cashu/Fedimint, not just Lightning)
|
||||
function formatNdebitUri(ndebitString, amount) {
|
||||
const rewritten = rewriteNdebitRelay(ndebitString)
|
||||
return `clink:${rewritten}?amount=${amount}`
|
||||
}
|
||||
|
||||
function broadcast(type, data) {
|
||||
const msg = JSON.stringify({ type, ...data })
|
||||
wsClients.forEach((ws) => {
|
||||
if (ws.readyState === 1) ws.send(msg)
|
||||
})
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
const timestamp = new Date().toLocaleTimeString()
|
||||
console.log(`[${timestamp}] ${message}`)
|
||||
broadcast('log', { message: `[${timestamp}] ${message}` })
|
||||
}
|
||||
|
||||
async function getAppToken() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/admin/app/auth`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${ADMIN_TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ name: 'wallet' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.auth_token
|
||||
}
|
||||
|
||||
async function getAtmUser() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/get`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||
})
|
||||
return res.json()
|
||||
}
|
||||
|
||||
async function getLinkingToken() {
|
||||
const res = await fetch(`${LIGHTNING_PUB_HTTP}/api/app/user/npub/token/reset`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${appToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ user_identifier: 'atm' }),
|
||||
})
|
||||
const data = await res.json()
|
||||
return data.token
|
||||
}
|
||||
|
||||
async function sendRPC(rpcName, body) {
|
||||
const requestId = randomUUID()
|
||||
const request = {
|
||||
rpcName,
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body,
|
||||
}
|
||||
|
||||
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('RPC timeout')), 30000)
|
||||
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||
|
||||
try {
|
||||
const response = JSON.parse(decrypt(evt.content, conversationKey))
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
resolve(response)
|
||||
} catch (e) {}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
relay.publish(event)
|
||||
})
|
||||
}
|
||||
|
||||
async function linkKeypair(token) {
|
||||
log('Linking ATM keypair to user account...')
|
||||
const response = await sendRPC('LinkNPubThroughToken', { token })
|
||||
if (response.status === 'OK') {
|
||||
log('[OK] Keypair linked successfully!')
|
||||
isLinked = true
|
||||
return true
|
||||
} else {
|
||||
log(`[ERROR] Link failed: ${response.reason}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToDebitRequests() {
|
||||
log('Subscribing to debit requests...')
|
||||
|
||||
const conversationKey = getConversationKey(ATM_PRIVATE_KEY, LIGHTNING_PUB_PUBKEY)
|
||||
|
||||
// Subscribe to Kind 21000 messages from Lightning.Pub
|
||||
relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === ATM_PUBLIC_KEY)) return
|
||||
|
||||
try {
|
||||
const message = JSON.parse(decrypt(evt.content, conversationKey))
|
||||
|
||||
if (message.debit) {
|
||||
handleDebitRequest(message, conversationKey)
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Send GetLiveDebitRequests to start the stream
|
||||
const request = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify(request), conversationKey)
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
log('[OK] Listening for debit requests...')
|
||||
}
|
||||
|
||||
async function handleDebitRequest(message, conversationKey) {
|
||||
const { debit } = message
|
||||
atmState = ATM_STATE.PROCESSING
|
||||
broadcastState()
|
||||
|
||||
log(`[ALERT] DEBIT REQUEST RECEIVED!`)
|
||||
log(` Amount: ${debit.amount || 'invoice amount'} sats`)
|
||||
log(` Type: ${debit.type}`)
|
||||
|
||||
broadcast('debit_request', { debit })
|
||||
|
||||
// Auto-approve after 1 second
|
||||
setTimeout(async () => {
|
||||
log('[OK] Auto-approving debit request...')
|
||||
|
||||
const approval = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const encryptedContent = encrypt(JSON.stringify(approval), conversationKey)
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(event)
|
||||
log('[OK] Approval sent! Payment complete.')
|
||||
|
||||
// Mark as complete
|
||||
atmState = ATM_STATE.COMPLETE
|
||||
broadcastState()
|
||||
|
||||
// Update balance and reset after delay
|
||||
setTimeout(async () => {
|
||||
await updateBalance()
|
||||
// Auto-reset after showing success
|
||||
setTimeout(resetAtm, 3000)
|
||||
}, 2000)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
async function updateBalance() {
|
||||
const user = await getAtmUser()
|
||||
if (user.status === 'OK') {
|
||||
atmBalance = user.info.balance
|
||||
ndebit = user.info.ndebit
|
||||
broadcastState()
|
||||
log(`Balance updated: ${atmBalance} sats`)
|
||||
}
|
||||
}
|
||||
|
||||
async function regenerateQR() {
|
||||
if (ndebit) {
|
||||
const uri = formatNdebitUri(ndebit, withdrawAmount)
|
||||
ndebitQR = await QRCode.toDataURL(uri, { width: 300, margin: 2 })
|
||||
log(`QR code generated for ${withdrawAmount} sats`)
|
||||
}
|
||||
}
|
||||
|
||||
function setWithdrawAmount(amount) {
|
||||
withdrawAmount = amount
|
||||
regenerateQR()
|
||||
broadcastState()
|
||||
log(`Withdrawal amount set to ${amount} sats`)
|
||||
}
|
||||
|
||||
async function insertCash(usdAmount) {
|
||||
cashInserted = usdAmount
|
||||
withdrawAmount = usdAmount * SATS_PER_USD
|
||||
atmState = ATM_STATE.CASH_INSERTED
|
||||
log(`[CASH] $${usdAmount} inserted → ${withdrawAmount.toLocaleString()} sats`)
|
||||
|
||||
// Brief delay then show QR
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
atmState = ATM_STATE.WAITING_FOR_SCAN
|
||||
await regenerateQR()
|
||||
broadcastState()
|
||||
log(`[QR] Scan to receive ${withdrawAmount.toLocaleString()} sats`)
|
||||
}
|
||||
|
||||
function resetAtm() {
|
||||
atmState = ATM_STATE.IDLE
|
||||
cashInserted = 0
|
||||
withdrawAmount = 0
|
||||
ndebitQR = null
|
||||
broadcastState()
|
||||
log('[RESET] ATM ready for next customer')
|
||||
}
|
||||
|
||||
function broadcastState() {
|
||||
broadcast('status', {
|
||||
balance: atmBalance,
|
||||
ndebit,
|
||||
ndebitQR,
|
||||
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||
withdrawAmount,
|
||||
cashInserted,
|
||||
atmState,
|
||||
satsPerUsd: SATS_PER_USD,
|
||||
})
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
log('Starting Mock ATM Machine...')
|
||||
|
||||
// Get app token
|
||||
appToken = await getAppToken()
|
||||
log('Got app token')
|
||||
|
||||
// Get ATM user info
|
||||
const user = await getAtmUser()
|
||||
if (user.status === 'OK') {
|
||||
atmBalance = user.info.balance
|
||||
ndebit = user.info.ndebit
|
||||
|
||||
// Extract Lightning.Pub pubkey from ndebit
|
||||
const decoded = decodeBech32(ndebit)
|
||||
LIGHTNING_PUB_PUBKEY = decoded.data.pubkey
|
||||
log(`ATM user found: ${atmBalance} sats balance`)
|
||||
log(`Lightning.Pub pubkey: ${LIGHTNING_PUB_PUBKEY.substring(0, 16)}...`)
|
||||
} else {
|
||||
log('ATM user not found - please create one first')
|
||||
return
|
||||
}
|
||||
|
||||
// Connect to relay
|
||||
log('Connecting to relay...')
|
||||
relay = await Relay.connect(RELAY_URL)
|
||||
log('Connected to relay')
|
||||
|
||||
// Get linking token and link keypair
|
||||
const token = await getLinkingToken()
|
||||
await linkKeypair(token)
|
||||
|
||||
// Subscribe to debit requests
|
||||
await subscribeToDebitRequests()
|
||||
|
||||
// Start in IDLE state (no QR until cash inserted)
|
||||
atmState = ATM_STATE.IDLE
|
||||
broadcastState()
|
||||
|
||||
log('[READY] Mock ATM Machine ready!')
|
||||
log(` Open http://localhost:${PORT} to use the ATM`)
|
||||
}
|
||||
|
||||
// HTML page
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Mock ATM Machine</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container { max-width: 500px; margin: 0 auto; }
|
||||
h1 { text-align: center; margin-bottom: 8px; color: #00d4ff; font-size: 28px; }
|
||||
.subtitle { text-align: center; opacity: 0.6; margin-bottom: 20px; font-size: 14px; }
|
||||
.card {
|
||||
background: rgba(255,255,255,0.1);
|
||||
border-radius: 16px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
.balance-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 16px;
|
||||
background: rgba(0,0,0,0.2);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.balance-bar .label { opacity: 0.7; }
|
||||
.balance-bar .value { color: #00ff88; font-weight: bold; }
|
||||
.exchange-rate { font-size: 12px; opacity: 0.5; }
|
||||
|
||||
/* ATM Screen */
|
||||
.atm-screen {
|
||||
min-height: 400px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
}
|
||||
.screen-title {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.screen-subtitle {
|
||||
opacity: 0.7;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
/* Cash buttons */
|
||||
.cash-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
}
|
||||
.cash-btn {
|
||||
background: linear-gradient(135deg, #2d5a27 0%, #1e3d1a 100%);
|
||||
border: 2px solid #3d7a35;
|
||||
border-radius: 12px;
|
||||
padding: 24px 16px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
text-align: center;
|
||||
}
|
||||
.cash-btn:hover {
|
||||
transform: scale(1.05);
|
||||
border-color: #00ff88;
|
||||
box-shadow: 0 4px 20px rgba(0,255,136,0.3);
|
||||
}
|
||||
.cash-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
.cash-btn .amount {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
}
|
||||
.cash-btn .sats {
|
||||
font-size: 14px;
|
||||
opacity: 0.8;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* QR Display */
|
||||
#qr-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
#qr-container img { max-width: 250px; }
|
||||
.amount-display {
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #00ff88;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.amount-usd {
|
||||
font-size: 18px;
|
||||
opacity: 0.7;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.ndebit-code {
|
||||
font-family: monospace;
|
||||
font-size: 9px;
|
||||
word-break: break-all;
|
||||
background: rgba(0,0,0,0.3);
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
margin-top: 16px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.cancel-btn {
|
||||
background: transparent;
|
||||
border: 2px solid rgba(255,255,255,0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px 32px;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
border-color: #ff6b6b;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Processing */
|
||||
.spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 4px solid rgba(255,255,255,0.2);
|
||||
border-top-color: #00d4ff;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* Success */
|
||||
.success-icon {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
background: #00ff88;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
/* Logs */
|
||||
.logs-card { margin-top: 8px; }
|
||||
.logs-card h3 { font-size: 14px; margin-bottom: 8px; opacity: 0.7; }
|
||||
.logs {
|
||||
background: rgba(0,0,0,0.3);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
height: 150px;
|
||||
overflow-y: auto;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
}
|
||||
.log-entry { margin: 4px 0; }
|
||||
.log-entry.alert { color: #00d4ff; }
|
||||
.log-entry.success { color: #00ff88; }
|
||||
|
||||
.hidden { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Mock ATM</h1>
|
||||
<p class="subtitle">Simulated Bitcoin ATM for testing</p>
|
||||
|
||||
<div class="balance-bar">
|
||||
<span class="label">ATM Balance:</span>
|
||||
<span class="value"><span id="balance">0</span> sats</span>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="atm-screen">
|
||||
<!-- IDLE State -->
|
||||
<div id="screen-idle">
|
||||
<div class="screen-title">Insert Cash</div>
|
||||
<div class="screen-subtitle">Select amount to withdraw as Bitcoin</div>
|
||||
<div class="cash-buttons">
|
||||
<button class="cash-btn" data-usd="20">
|
||||
<div class="amount">$20</div>
|
||||
<div class="sats" id="sats-20">20,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="50">
|
||||
<div class="amount">$50</div>
|
||||
<div class="sats" id="sats-50">50,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="100">
|
||||
<div class="amount">$100</div>
|
||||
<div class="sats" id="sats-100">100,000 sats</div>
|
||||
</button>
|
||||
<button class="cash-btn" data-usd="200">
|
||||
<div class="amount">$200</div>
|
||||
<div class="sats" id="sats-200">200,000 sats</div>
|
||||
</button>
|
||||
</div>
|
||||
<p class="exchange-rate">Rate: <span id="rate">1,000</span> sats/$</p>
|
||||
</div>
|
||||
|
||||
<!-- WAITING_FOR_SCAN State -->
|
||||
<div id="screen-scan" class="hidden">
|
||||
<div class="screen-title">Scan QR Code</div>
|
||||
<div class="screen-subtitle">Open your wallet and scan to receive</div>
|
||||
<div class="amount-display"><span id="display-sats">0</span> sats</div>
|
||||
<div class="amount-usd">($<span id="display-usd">0</span>)</div>
|
||||
<div id="qr-container">
|
||||
<img id="qr" alt="QR Code" />
|
||||
</div>
|
||||
<div class="ndebit-code" id="ndebit-code"></div>
|
||||
<button class="cancel-btn" id="cancel-btn">Cancel Transaction</button>
|
||||
</div>
|
||||
|
||||
<!-- PROCESSING State -->
|
||||
<div id="screen-processing" class="hidden">
|
||||
<div class="spinner"></div>
|
||||
<div class="screen-title">Processing...</div>
|
||||
<div class="screen-subtitle">Verifying payment request</div>
|
||||
</div>
|
||||
|
||||
<!-- COMPLETE State -->
|
||||
<div id="screen-complete" class="hidden">
|
||||
<div class="success-icon">✓</div>
|
||||
<div class="screen-title">Success!</div>
|
||||
<div class="screen-subtitle">
|
||||
<span id="complete-sats">0</span> sats sent to your wallet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card logs-card">
|
||||
<h3>Activity Log</h3>
|
||||
<div class="logs" id="logs"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const ws = new WebSocket('ws://localhost:3456')
|
||||
const logs = document.getElementById('logs')
|
||||
|
||||
// Screen elements
|
||||
const screens = {
|
||||
idle: document.getElementById('screen-idle'),
|
||||
scan: document.getElementById('screen-scan'),
|
||||
processing: document.getElementById('screen-processing'),
|
||||
complete: document.getElementById('screen-complete'),
|
||||
}
|
||||
|
||||
function showScreen(name) {
|
||||
Object.values(screens).forEach(s => s.classList.add('hidden'))
|
||||
if (screens[name]) screens[name].classList.remove('hidden')
|
||||
}
|
||||
|
||||
// Cash buttons
|
||||
document.querySelectorAll('.cash-btn').forEach(btn => {
|
||||
btn.onclick = () => {
|
||||
const usd = parseInt(btn.dataset.usd, 10)
|
||||
ws.send(JSON.stringify({ type: 'insert_cash', amount: usd }))
|
||||
}
|
||||
})
|
||||
|
||||
// Cancel button
|
||||
document.getElementById('cancel-btn').onclick = () => {
|
||||
ws.send(JSON.stringify({ type: 'reset' }))
|
||||
}
|
||||
|
||||
function addLog(message, type = '') {
|
||||
const entry = document.createElement('div')
|
||||
entry.className = 'log-entry' + (type ? ' ' + type : '')
|
||||
entry.textContent = message
|
||||
logs.appendChild(entry)
|
||||
logs.scrollTop = logs.scrollHeight
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data)
|
||||
|
||||
if (data.type === 'log') {
|
||||
const isAlert = data.message.includes('[ALERT]')
|
||||
const isSuccess = data.message.includes('[OK]') || data.message.includes('Success')
|
||||
addLog(data.message, isAlert ? 'alert' : isSuccess ? 'success' : '')
|
||||
}
|
||||
|
||||
if (data.type === 'status') {
|
||||
// Update balance
|
||||
document.getElementById('balance').textContent = data.balance.toLocaleString()
|
||||
|
||||
// Update exchange rate display
|
||||
if (data.satsPerUsd) {
|
||||
document.getElementById('rate').textContent = data.satsPerUsd.toLocaleString()
|
||||
document.getElementById('sats-20').textContent = (20 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-50').textContent = (50 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-100').textContent = (100 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
document.getElementById('sats-200').textContent = (200 * data.satsPerUsd).toLocaleString() + ' sats'
|
||||
}
|
||||
|
||||
// Update amounts
|
||||
if (data.withdrawAmount !== undefined) {
|
||||
document.getElementById('display-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||
document.getElementById('complete-sats').textContent = data.withdrawAmount.toLocaleString()
|
||||
}
|
||||
if (data.cashInserted !== undefined) {
|
||||
document.getElementById('display-usd').textContent = data.cashInserted
|
||||
}
|
||||
|
||||
// Update QR
|
||||
if (data.ndebitQR) {
|
||||
document.getElementById('qr').src = data.ndebitQR
|
||||
}
|
||||
if (data.ndebitUri) {
|
||||
document.getElementById('ndebit-code').textContent = data.ndebitUri
|
||||
}
|
||||
|
||||
// Show correct screen based on state
|
||||
switch (data.atmState) {
|
||||
case 'idle':
|
||||
showScreen('idle')
|
||||
break
|
||||
case 'cash_inserted':
|
||||
case 'waiting_for_scan':
|
||||
showScreen('scan')
|
||||
break
|
||||
case 'processing':
|
||||
showScreen('processing')
|
||||
break
|
||||
case 'complete':
|
||||
showScreen('complete')
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (data.type === 'debit_request') {
|
||||
addLog('[DEBIT] Request received from wallet', 'alert')
|
||||
}
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
addLog('Connected to ATM server')
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
addLog('Connection error - is the server running?')
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
|
||||
// Create HTTP server
|
||||
const server = http.createServer((req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' })
|
||||
res.end(html)
|
||||
})
|
||||
|
||||
// Create WebSocket server
|
||||
const wss = new WebSocketServer({ server })
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
wsClients.push(ws)
|
||||
ws.on('close', () => {
|
||||
wsClients = wsClients.filter((c) => c !== ws)
|
||||
})
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const msg = JSON.parse(data)
|
||||
if (msg.type === 'insert_cash' && typeof msg.amount === 'number') {
|
||||
insertCash(msg.amount)
|
||||
} else if (msg.type === 'reset') {
|
||||
resetAtm()
|
||||
}
|
||||
} catch (e) {}
|
||||
})
|
||||
|
||||
// Send current state
|
||||
if (ndebit) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'status',
|
||||
balance: atmBalance,
|
||||
ndebit,
|
||||
ndebitQR,
|
||||
ndebitUri: withdrawAmount > 0 ? formatNdebitUri(ndebit, withdrawAmount) : null,
|
||||
withdrawAmount,
|
||||
cashInserted,
|
||||
atmState,
|
||||
satsPerUsd: SATS_PER_USD,
|
||||
isLinked,
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// Start server
|
||||
server.listen(PORT, async () => {
|
||||
console.log(`Mock ATM Machine running at http://localhost:${PORT}`)
|
||||
await initialize()
|
||||
})
|
||||
111
packages/nostr-client/nip44v1.mjs
Normal file
111
packages/nostr-client/nip44v1.mjs
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
/**
|
||||
* NIP-44 v1 Implementation
|
||||
*
|
||||
* This is the XChaCha20-based encryption used by Lightning.Pub for Kind 21000 RPC events.
|
||||
* It differs from standard NIP-44 v2 (used in nostr-tools) which uses ChaCha20-Poly1305.
|
||||
*/
|
||||
|
||||
import { base64 } from '@scure/base'
|
||||
import { randomBytes } from '@noble/hashes/utils.js'
|
||||
import { streamXOR as xchacha20 } from '@stablelib/xchacha20'
|
||||
import { secp256k1 } from '@noble/curves/secp256k1.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
|
||||
const XCHACHA20_VERSION = 1
|
||||
|
||||
/**
|
||||
* Convert hex string to Uint8Array
|
||||
* @param {string} hex - Hex string
|
||||
* @returns {Uint8Array}
|
||||
*/
|
||||
function hexToBytes(hex) {
|
||||
const bytes = new Uint8Array(hex.length / 2)
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16)
|
||||
}
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Get shared secret for NIP-44 v1 encryption
|
||||
* @param {Uint8Array|string} privateKey - Private key (32 bytes or hex string)
|
||||
* @param {string} publicKey - Public key (32 bytes hex string, no prefix)
|
||||
* @returns {Uint8Array} - 32-byte shared secret
|
||||
*/
|
||||
export function getConversationKey(privateKey, publicKey) {
|
||||
// Convert private key to Uint8Array if it's a hex string
|
||||
const privKeyBytes = typeof privateKey === 'string' ? hexToBytes(privateKey) : privateKey
|
||||
|
||||
// Convert public key (with 02 prefix) to Uint8Array
|
||||
const pubKeyBytes = hexToBytes('02' + publicKey)
|
||||
|
||||
// Get ECDH shared point
|
||||
const sharedPoint = secp256k1.getSharedSecret(privKeyBytes, pubKeyBytes)
|
||||
|
||||
// Hash the x-coordinate of the shared point
|
||||
return sha256(sharedPoint.slice(1, 33))
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt content using NIP-44 v1 (XChaCha20)
|
||||
* @param {string} content - Plaintext content to encrypt
|
||||
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
||||
* @returns {string} - Base64-encoded encrypted payload
|
||||
*/
|
||||
export function encrypt(content, conversationKey) {
|
||||
const nonce = randomBytes(24)
|
||||
const plaintext = new TextEncoder().encode(content)
|
||||
|
||||
// XChaCha20 stream cipher - encrypts in place
|
||||
const ciphertext = new Uint8Array(plaintext.length)
|
||||
xchacha20(conversationKey, nonce, plaintext, ciphertext)
|
||||
|
||||
// Encode: version byte + nonce + ciphertext
|
||||
return base64.encode(new Uint8Array([XCHACHA20_VERSION, ...nonce, ...ciphertext]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt content using NIP-44 v1 (XChaCha20)
|
||||
* @param {string} content - Base64-encoded encrypted payload
|
||||
* @param {Uint8Array} conversationKey - 32-byte conversation key from getConversationKey
|
||||
* @returns {string} - Decrypted plaintext
|
||||
*/
|
||||
export function decrypt(content, conversationKey) {
|
||||
const payload = decodePayload(content)
|
||||
|
||||
// XChaCha20 stream cipher - decrypts in place
|
||||
const plaintext = new Uint8Array(payload.ciphertext.length)
|
||||
xchacha20(conversationKey, payload.nonce, payload.ciphertext, plaintext)
|
||||
|
||||
return new TextDecoder().decode(plaintext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode encrypted payload (supports both formats)
|
||||
* @param {string} content - Base64-encoded or JSON-encoded payload
|
||||
* @returns {{nonce: Uint8Array, ciphertext: Uint8Array}}
|
||||
*/
|
||||
function decodePayload(content) {
|
||||
// Check for JSON format
|
||||
if (content.startsWith('{') && content.endsWith('}')) {
|
||||
const parsed = JSON.parse(content)
|
||||
if (parsed.v !== XCHACHA20_VERSION) {
|
||||
throw new Error(`Unsupported encryption version: ${parsed.v}`)
|
||||
}
|
||||
return {
|
||||
nonce: base64.decode(parsed.nonce),
|
||||
ciphertext: base64.decode(parsed.ciphertext),
|
||||
}
|
||||
}
|
||||
|
||||
// Binary format: version byte + nonce (24 bytes) + ciphertext
|
||||
const buf = base64.decode(content)
|
||||
if (buf[0] !== XCHACHA20_VERSION) {
|
||||
throw new Error(`Unsupported encryption version: ${buf[0]}`)
|
||||
}
|
||||
|
||||
return {
|
||||
nonce: buf.subarray(1, 25),
|
||||
ciphertext: buf.subarray(25),
|
||||
}
|
||||
}
|
||||
726
packages/nostr-client/package-lock.json
generated
Normal file
726
packages/nostr-client/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,726 @@
|
|||
{
|
||||
"name": "@lamassu/nostr-client",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@lamassu/nostr-client",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@shocknet/clink-sdk": "^1.5.4",
|
||||
"@stablelib/xchacha20": "^2.0.1",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@noble+curves@2.0.1/node_modules/@noble/curves": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@paulmillr/jsbt": "0.4.4",
|
||||
"@types/node": "24.2.1",
|
||||
"fast-check": "4.2.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@noble+hashes@2.0.1/node_modules/@noble/hashes": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@paulmillr/jsbt": "0.4.4",
|
||||
"@types/node": "24.2.1",
|
||||
"fast-check": "4.2.0",
|
||||
"prettier": "3.6.2",
|
||||
"typescript": "5.9.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@scure+base@1.2.6/node_modules/@scure/base": {
|
||||
"version": "1.2.6",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@noble/hashes": "1.8.0",
|
||||
"@paulmillr/jsbt": "0.3.3",
|
||||
"@types/node": "22.15.23",
|
||||
"fast-check": "4.1.1",
|
||||
"micro-bmark": "0.4.2",
|
||||
"micro-should": "0.5.3",
|
||||
"prettier": "3.5.3",
|
||||
"typescript": "5.8.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@shocknet+clink-sdk@1.5.4/node_modules/@shocknet/clink-sdk": {
|
||||
"version": "1.5.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@noble/hashes": "^1.8.0",
|
||||
"@scure/base": "^1.2.5",
|
||||
"nostr-tools": "2.15.1",
|
||||
"rimraf": "^6.0.1",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.15.2"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@stablelib+xchacha20@2.0.1/node_modules/@stablelib/xchacha20": {
|
||||
"version": "2.0.1",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@stablelib/binary": "^2.0.1",
|
||||
"@stablelib/chacha": "^2.0.1",
|
||||
"@stablelib/wipe": "^2.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stablelib/benchmark": "^2.0.0",
|
||||
"@stablelib/hex": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/@types+node@22.19.7/node_modules/@types/node": {
|
||||
"version": "22.19.7",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/nostr-tools@2.19.4_typescript@5.9.3/node_modules/nostr-tools": {
|
||||
"version": "2.19.4",
|
||||
"license": "Unlicense",
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "^0.5.1",
|
||||
"@noble/curves": "1.2.0",
|
||||
"@noble/hashes": "1.3.1",
|
||||
"@scure/base": "1.1.1",
|
||||
"@scure/bip32": "1.3.1",
|
||||
"@scure/bip39": "1.2.1",
|
||||
"nostr-wasm": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^18.13.0",
|
||||
"@types/node-fetch": "^2.6.3",
|
||||
"@typescript-eslint/eslint-plugin": "^6.5.0",
|
||||
"@typescript-eslint/parser": "^6.5.0",
|
||||
"bun-types": "^1.0.18",
|
||||
"esbuild": "0.16.9",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.0.0",
|
||||
"events": "^3.3.0",
|
||||
"mitata": "^0.1.6",
|
||||
"mock-socket": "^9.3.1",
|
||||
"node-fetch": "^2.6.9",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.8.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"typescript": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@dprint/formatter": "^0.4.1",
|
||||
"@dprint/typescript": "0.93.4",
|
||||
"@esfx/canceltoken": "^1.0.0",
|
||||
"@eslint/js": "^9.20.0",
|
||||
"@octokit/rest": "^21.1.1",
|
||||
"@types/chai": "^4.3.20",
|
||||
"@types/diff": "^7.0.1",
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/ms": "^0.7.34",
|
||||
"@types/node": "latest",
|
||||
"@types/source-map-support": "^0.5.10",
|
||||
"@types/which": "^3.0.4",
|
||||
"@typescript-eslint/rule-tester": "^8.24.1",
|
||||
"@typescript-eslint/type-utils": "^8.24.1",
|
||||
"@typescript-eslint/utils": "^8.24.1",
|
||||
"azure-devops-node-api": "^14.1.0",
|
||||
"c8": "^10.1.3",
|
||||
"chai": "^4.5.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"diff": "^7.0.0",
|
||||
"dprint": "^0.49.0",
|
||||
"esbuild": "^0.25.0",
|
||||
"eslint": "^9.20.1",
|
||||
"eslint-formatter-autolinkable-stylish": "^1.4.0",
|
||||
"eslint-plugin-regexp": "^2.7.0",
|
||||
"fast-xml-parser": "^4.5.2",
|
||||
"glob": "^10.4.5",
|
||||
"globals": "^15.15.0",
|
||||
"hereby": "^1.10.0",
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"knip": "^5.44.4",
|
||||
"minimist": "^1.2.8",
|
||||
"mocha": "^10.8.2",
|
||||
"mocha-fivemat-progress-reporter": "^0.1.0",
|
||||
"monocart-coverage-reports": "^2.12.1",
|
||||
"ms": "^2.1.3",
|
||||
"picocolors": "^1.1.1",
|
||||
"playwright": "^1.50.1",
|
||||
"source-map-support": "^0.5.21",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.24.1",
|
||||
"which": "^3.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"../../node_modules/.pnpm/vitest@2.1.9_@types+node@22.19.7_lightningcss@1.30.2/node_modules/vitest": {
|
||||
"version": "2.1.9",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "2.1.9",
|
||||
"@vitest/mocker": "2.1.9",
|
||||
"@vitest/pretty-format": "^2.1.9",
|
||||
"@vitest/runner": "2.1.9",
|
||||
"@vitest/snapshot": "2.1.9",
|
||||
"@vitest/spy": "2.1.9",
|
||||
"@vitest/utils": "2.1.9",
|
||||
"chai": "^5.1.2",
|
||||
"debug": "^4.3.7",
|
||||
"expect-type": "^1.1.0",
|
||||
"magic-string": "^0.30.12",
|
||||
"pathe": "^1.1.2",
|
||||
"std-env": "^3.8.0",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^0.3.1",
|
||||
"tinypool": "^1.0.1",
|
||||
"tinyrainbow": "^1.2.0",
|
||||
"vite": "^5.0.0",
|
||||
"vite-node": "2.1.9",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@ampproject/remapping": "^2.3.0",
|
||||
"@antfu/install-pkg": "^0.4.1",
|
||||
"@edge-runtime/vm": "^4.0.4",
|
||||
"@sinonjs/fake-timers": "11.1.0",
|
||||
"@types/debug": "^4.1.12",
|
||||
"@types/estree": "^1.0.6",
|
||||
"@types/istanbul-lib-coverage": "^2.0.6",
|
||||
"@types/istanbul-reports": "^3.0.4",
|
||||
"@types/jsdom": "^21.1.7",
|
||||
"@types/micromatch": "^4.0.9",
|
||||
"@types/node": "^22.9.0",
|
||||
"@types/prompts": "^2.4.9",
|
||||
"@types/sinonjs__fake-timers": "^8.1.5",
|
||||
"acorn-walk": "^8.3.4",
|
||||
"birpc": "0.2.19",
|
||||
"cac": "^6.7.14",
|
||||
"chai-subset": "^1.6.0",
|
||||
"cli-truncate": "^4.0.0",
|
||||
"fast-glob": "3.3.2",
|
||||
"find-up": "^6.3.0",
|
||||
"flatted": "^3.3.1",
|
||||
"get-tsconfig": "^4.8.1",
|
||||
"happy-dom": "^15.11.4",
|
||||
"jsdom": "^25.0.1",
|
||||
"local-pkg": "^0.5.0",
|
||||
"log-update": "^5.0.1",
|
||||
"micromatch": "^4.0.8",
|
||||
"pretty-format": "^29.7.0",
|
||||
"prompts": "^2.4.2",
|
||||
"strip-literal": "^2.1.0",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@types/node": "^18.0.0 || >=20.0.0",
|
||||
"@vitest/browser": "2.1.9",
|
||||
"@vitest/ui": "2.1.9",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/curves": {
|
||||
"resolved": "../../node_modules/.pnpm/@noble+curves@2.0.1/node_modules/@noble/curves",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"resolved": "../../node_modules/.pnpm/@noble+hashes@2.0.1/node_modules/@noble/hashes",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@scure/base": {
|
||||
"resolved": "../../node_modules/.pnpm/@scure+base@1.2.6/node_modules/@scure/base",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@shocknet/clink-sdk": {
|
||||
"resolved": "../../node_modules/.pnpm/@shocknet+clink-sdk@1.5.4/node_modules/@shocknet/clink-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@stablelib/xchacha20": {
|
||||
"resolved": "../../node_modules/.pnpm/@stablelib+xchacha20@2.0.1/node_modules/@stablelib/xchacha20",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"resolved": "../../node_modules/.pnpm/@types+node@22.19.7/node_modules/@types/node",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/nostr-tools": {
|
||||
"resolved": "../../node_modules/.pnpm/nostr-tools@2.19.4_typescript@5.9.3/node_modules/nostr-tools",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"resolved": "../../node_modules/.pnpm/tsx@4.21.0/node_modules/tsx",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"resolved": "../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"resolved": "../../node_modules/.pnpm/vitest@2.1.9_@types+node@22.19.7_lightningcss@1.30.2/node_modules/vitest",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.19.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz",
|
||||
"integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"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
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
42
packages/nostr-client/package.json
Normal file
42
packages/nostr-client/package.json
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
{
|
||||
"name": "@lamassu/nostr-client",
|
||||
"version": "0.1.0",
|
||||
"description": "Nostr client library for Lamassu ATM",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/",
|
||||
"validate-schemas": "tsx scripts/validate-schemas.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^2.0.1",
|
||||
"@noble/hashes": "^2.0.1",
|
||||
"@scure/base": "^1.2.6",
|
||||
"@shocknet/clink-sdk": "^1.5.4",
|
||||
"@stablelib/xchacha20": "^2.0.1",
|
||||
"nostr-tools": "^2.10.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.7",
|
||||
"qrcode": "^1.5.4",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5.0.0"
|
||||
}
|
||||
}
|
||||
221
packages/nostr-client/run-debit-agent.mjs
Normal file
221
packages/nostr-client/run-debit-agent.mjs
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* ATM Debit Approval Agent (TESTING ONLY)
|
||||
*
|
||||
* ⚠️ WARNING: This script auto-approves ALL debit requests without validation!
|
||||
* ⚠️ DO NOT use in production - use the integrated debit approval service instead.
|
||||
*
|
||||
* Purpose:
|
||||
* - Standalone debugging tool for testing the GetLiveDebitRequests subscription
|
||||
* - Helps diagnose relay connectivity and message decryption issues
|
||||
* - Useful when the integrated service isn't receiving events
|
||||
*
|
||||
* Usage:
|
||||
* # Set environment variables (or create .env file in apps/machine/)
|
||||
* export ATM_PRIVATE_KEY=<hex-private-key>
|
||||
* export LIGHTNING_PUB_PUBKEY=<hex-pubkey>
|
||||
* export RELAY_URL=ws://localhost:7777
|
||||
*
|
||||
* # Run the script
|
||||
* node run-debit-agent.mjs
|
||||
*
|
||||
* Production alternative:
|
||||
* The ATM app (apps/machine) includes an integrated debit approval service
|
||||
* with session-based single-use protection. See:
|
||||
* - apps/machine/src/services/lightning.ts (startDebitApprovalService)
|
||||
* - docs/ndebit-cash-in-flow.md
|
||||
*/
|
||||
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
import * as nip44v1 from './nip44v1.mjs'
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// Load .env file from apps/machine if it exists
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const envPath = path.join(__dirname, '../../apps/machine/.env')
|
||||
if (fs.existsSync(envPath)) {
|
||||
const envContent = fs.readFileSync(envPath, 'utf-8')
|
||||
for (const line of envContent.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed && !trimmed.startsWith('#')) {
|
||||
const [key, ...valueParts] = trimmed.split('=')
|
||||
if (key && valueParts.length > 0) {
|
||||
// Map VITE_ prefixed vars to non-prefixed
|
||||
const envKey = key.replace(/^VITE_/, '')
|
||||
process.env[envKey] = valueParts.join('=')
|
||||
}
|
||||
}
|
||||
}
|
||||
console.log('[Config] Loaded .env from:', envPath)
|
||||
}
|
||||
|
||||
// Configuration from environment
|
||||
const ATM_PRIVATE_KEY_HEX = process.env.ATM_PRIVATE_KEY
|
||||
const LIGHTNING_PUB_PUBKEY = process.env.LIGHTNING_PUB_PUBKEY
|
||||
const RELAY_URL = process.env.RELAY_URL || 'ws://localhost:7777'
|
||||
|
||||
// Validate required config
|
||||
if (!ATM_PRIVATE_KEY_HEX) {
|
||||
console.error('ERROR: ATM_PRIVATE_KEY environment variable is required')
|
||||
console.error('Set it directly or create apps/machine/.env with VITE_ATM_PRIVATE_KEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
if (!LIGHTNING_PUB_PUBKEY) {
|
||||
console.error('ERROR: LIGHTNING_PUB_PUBKEY environment variable is required')
|
||||
console.error('Set it directly or create apps/machine/.env with VITE_LIGHTNING_PUB_PUBKEY')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const ATM_PRIVATE_KEY = Uint8Array.from(Buffer.from(ATM_PRIVATE_KEY_HEX, 'hex'))
|
||||
const ATM_PUBLIC_KEY = getPublicKey(ATM_PRIVATE_KEY)
|
||||
|
||||
console.log('')
|
||||
console.log('='.repeat(60))
|
||||
console.log(' ATM Debit Approval Agent (TESTING ONLY)')
|
||||
console.log('='.repeat(60))
|
||||
console.log('')
|
||||
console.log('⚠️ WARNING: Auto-approves ALL requests without validation!')
|
||||
console.log('⚠️ For production, use the integrated service in apps/machine')
|
||||
console.log('')
|
||||
console.log('ATM Pubkey:', ATM_PUBLIC_KEY)
|
||||
console.log('Lightning.Pub Pubkey:', LIGHTNING_PUB_PUBKEY)
|
||||
console.log('Relay URL:', RELAY_URL)
|
||||
console.log('')
|
||||
|
||||
async function main() {
|
||||
// Connect to relay
|
||||
console.log('Connecting to relay...')
|
||||
const relay = await Relay.connect(RELAY_URL)
|
||||
console.log('Connected!')
|
||||
console.log('')
|
||||
|
||||
// Create conversation key for NIP-44 v1 encryption
|
||||
const conversationKey = nip44v1.getConversationKey(ATM_PRIVATE_KEY_HEX, LIGHTNING_PUB_PUBKEY)
|
||||
|
||||
// Subscribe to GetLiveDebitRequests
|
||||
console.log('Subscribing to live debit requests...')
|
||||
|
||||
const subscribeRequest = {
|
||||
rpcName: 'GetLiveDebitRequests',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {},
|
||||
}
|
||||
|
||||
const subEvent = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: nip44v1.encrypt(JSON.stringify(subscribeRequest), conversationKey),
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
// Listen for debit requests
|
||||
console.log('Listening for debit requests...')
|
||||
console.log('(Test by scanning ndebit QR with ShockWallet)')
|
||||
console.log('')
|
||||
|
||||
const debitSub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
'#p': [ATM_PUBLIC_KEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
async onevent(evt) {
|
||||
try {
|
||||
const decrypted = nip44v1.decrypt(evt.content, conversationKey)
|
||||
const message = JSON.parse(decrypted)
|
||||
|
||||
// Check if this is a live debit request
|
||||
if (message.requestId === 'GetLiveDebitRequests' && message.debit) {
|
||||
console.log('')
|
||||
console.log('========================================')
|
||||
console.log('DEBIT REQUEST RECEIVED!')
|
||||
console.log(' Request ID:', message.request_id)
|
||||
console.log(' From npub:', message.npub?.substring(0, 16) + '...')
|
||||
console.log(' Debit type:', message.debit.type)
|
||||
|
||||
if (message.debit.invoice) {
|
||||
console.log(' Invoice:', message.debit.invoice.substring(0, 50) + '...')
|
||||
|
||||
// Auto-approve by responding with INVOICE type
|
||||
console.log('')
|
||||
console.log('⚠️ AUTO-APPROVING debit request (NO VALIDATION)...')
|
||||
|
||||
const approveRequest = {
|
||||
rpcName: 'RespondToDebit',
|
||||
authIdentifier: ATM_PUBLIC_KEY,
|
||||
body: {
|
||||
npub: message.npub,
|
||||
request_id: message.request_id,
|
||||
response: {
|
||||
type: 'invoice',
|
||||
invoice: message.debit.invoice,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const approveEvent = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: nip44v1.encrypt(JSON.stringify(approveRequest), conversationKey),
|
||||
},
|
||||
ATM_PRIVATE_KEY
|
||||
)
|
||||
|
||||
await relay.publish(approveEvent)
|
||||
console.log('APPROVED! (event id:', approveEvent.id.substring(0, 16) + '...)')
|
||||
}
|
||||
|
||||
console.log('========================================')
|
||||
console.log('')
|
||||
} else if (message.rpcName) {
|
||||
console.log('RPC response:', message.rpcName, ':', message.status || 'received')
|
||||
} else if (message.requestId) {
|
||||
console.log('Live subscription active:', message.status)
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore decryption failures for events not meant for us
|
||||
if (!err.message?.includes('Unsupported')) {
|
||||
// Uncomment for debugging:
|
||||
// console.log('Decryption error:', err.message)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await relay.publish(subEvent)
|
||||
console.log('Subscription sent, waiting for debit requests...')
|
||||
console.log('')
|
||||
|
||||
// Keep running
|
||||
process.on('SIGINT', () => {
|
||||
console.log('\nShutting down...')
|
||||
debitSub.close()
|
||||
relay.close()
|
||||
process.exit(0)
|
||||
})
|
||||
|
||||
// Heartbeat
|
||||
while (true) {
|
||||
await new Promise((r) => setTimeout(r, 30000))
|
||||
console.log('Still listening...')
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('Error:', err.message)
|
||||
process.exit(1)
|
||||
})
|
||||
14
packages/nostr-client/scripts/validate-schemas.ts
Normal file
14
packages/nostr-client/scripts/validate-schemas.ts
Normal 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)
|
||||
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 }
|
||||
}
|
||||
182
packages/nostr-client/test-debit.mjs
Normal file
182
packages/nostr-client/test-debit.mjs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
/**
|
||||
* Test CLINK Debit flow
|
||||
*
|
||||
* This simulates what ShockWallet does when scanning an ndebit:
|
||||
* 1. Decode the ndebit to get pubkey, relay, pointer
|
||||
* 2. Create a bolt11 invoice (we'll get one from Alice)
|
||||
* 3. Send Kind 21002 debit request to Lightning.Pub
|
||||
* 4. Wait for payment response
|
||||
*/
|
||||
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent, getPublicKey } from 'nostr-tools'
|
||||
import { nip44 } from 'nostr-tools'
|
||||
import { bech32 } from '@scure/base'
|
||||
import { randomBytes } from 'crypto'
|
||||
|
||||
// Generate a random keypair for this test (simulates ShockWallet)
|
||||
const WALLET_PRIVATE_KEY = randomBytes(32)
|
||||
const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY)
|
||||
|
||||
// The ndebit to test
|
||||
const NDEBIT = process.argv[2]
|
||||
// The bolt11 invoice to be paid
|
||||
const BOLT11 = process.argv[3]
|
||||
|
||||
if (!NDEBIT || !BOLT11) {
|
||||
console.log('Usage: node test-debit.mjs <ndebit> <bolt11>')
|
||||
console.log('')
|
||||
console.log('Example:')
|
||||
console.log(' # First create an invoice on Alice:')
|
||||
console.log(' docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000')
|
||||
console.log('')
|
||||
console.log(' # Then test the debit:')
|
||||
console.log(' node test-debit.mjs ndebit1... lnbcrt...')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function decodeNdebit(ndebit) {
|
||||
const { prefix, words } = bech32.decode(ndebit, 5000)
|
||||
if (prefix !== 'ndebit') throw new Error('Invalid ndebit prefix')
|
||||
|
||||
const data = new Uint8Array(bech32.fromWords(words))
|
||||
|
||||
let pubkey, relay, pointer
|
||||
let offset = 0
|
||||
|
||||
while (offset < data.length) {
|
||||
const type = data[offset]
|
||||
const length = data[offset + 1]
|
||||
const value = data.slice(offset + 2, offset + 2 + length)
|
||||
|
||||
switch (type) {
|
||||
case 0:
|
||||
pubkey = Buffer.from(value).toString('hex')
|
||||
break
|
||||
case 1:
|
||||
relay = new TextDecoder().decode(value)
|
||||
break
|
||||
case 2:
|
||||
pointer = new TextDecoder().decode(value)
|
||||
break
|
||||
}
|
||||
|
||||
offset += 2 + length
|
||||
}
|
||||
|
||||
return { pubkey, relay, pointer }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('=== CLINK Debit Test ===')
|
||||
console.log('')
|
||||
console.log('Test wallet pubkey:', WALLET_PUBLIC_KEY)
|
||||
console.log('')
|
||||
|
||||
// Decode ndebit
|
||||
const debit = decodeNdebit(NDEBIT)
|
||||
console.log('Decoded ndebit:')
|
||||
console.log(' Pubkey:', debit.pubkey)
|
||||
console.log(' Relay:', debit.relay)
|
||||
console.log(' Pointer:', debit.pointer || '(none)')
|
||||
console.log('')
|
||||
|
||||
// Connect to relay (override Docker internal hostnames with localhost for local testing)
|
||||
const relayUrl = debit.relay
|
||||
.replace('host.docker.internal', 'localhost')
|
||||
.replace('ws://strfry:', 'ws://localhost:')
|
||||
console.log('Connecting to relay:', relayUrl)
|
||||
const relay = await Relay.connect(relayUrl)
|
||||
console.log('Connected!')
|
||||
console.log('')
|
||||
|
||||
// Build debit request payload
|
||||
const requestPayload = {
|
||||
pointer: debit.pointer,
|
||||
bolt11: BOLT11,
|
||||
}
|
||||
|
||||
console.log('Request payload:', JSON.stringify(requestPayload, null, 2))
|
||||
console.log('')
|
||||
|
||||
// Encrypt with NIP-44
|
||||
const conversationKey = nip44.getConversationKey(WALLET_PRIVATE_KEY, debit.pubkey)
|
||||
const encryptedContent = nip44.encrypt(JSON.stringify(requestPayload), conversationKey)
|
||||
|
||||
// Create Kind 21002 event
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21002,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', debit.pubkey],
|
||||
['clink_version', '1'],
|
||||
],
|
||||
content: encryptedContent,
|
||||
},
|
||||
WALLET_PRIVATE_KEY
|
||||
)
|
||||
|
||||
console.log('Publishing debit request (event id:', event.id.substring(0, 16) + '...)...')
|
||||
|
||||
// Subscribe to responses
|
||||
let responseReceived = false
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21002],
|
||||
authors: [debit.pubkey],
|
||||
'#p': [WALLET_PUBLIC_KEY],
|
||||
'#e': [event.id],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
console.log('')
|
||||
console.log('Got response event:', evt.id.substring(0, 16) + '...')
|
||||
try {
|
||||
const decrypted = nip44.decrypt(evt.content, conversationKey)
|
||||
const response = JSON.parse(decrypted)
|
||||
console.log('Response:', JSON.stringify(response, null, 2))
|
||||
|
||||
if (response.res === 'ok') {
|
||||
console.log('')
|
||||
console.log('✅ DEBIT SUCCESS!')
|
||||
if (response.preimage) {
|
||||
console.log('Preimage:', response.preimage)
|
||||
}
|
||||
} else if (response.res === 'GFY') {
|
||||
console.log('')
|
||||
console.log('❌ DEBIT FAILED:', response.error)
|
||||
}
|
||||
|
||||
responseReceived = true
|
||||
} catch (err) {
|
||||
console.log('Failed to decrypt:', err.message)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Publish request
|
||||
await relay.publish(event)
|
||||
console.log('Request published, waiting for response...')
|
||||
|
||||
// Wait for response
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
if (responseReceived) break
|
||||
if (i % 5 === 4) console.log('Still waiting... (' + (i + 1) + 's)')
|
||||
}
|
||||
|
||||
if (!responseReceived) {
|
||||
console.log('')
|
||||
console.log('❌ No response received within timeout')
|
||||
}
|
||||
|
||||
sub.close()
|
||||
relay.close()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
181
packages/nostr-client/test-ndebit.mjs
Normal file
181
packages/nostr-client/test-ndebit.mjs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Test script to simulate a wallet sending an ndebit claim request
|
||||
* This tests whether Lightning.Pub sends Kind 21002 responses after the fix
|
||||
*/
|
||||
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { nip44, finalizeEvent, generateSecretKey, getPublicKey } from 'nostr-tools'
|
||||
import { decodeBech32 } from '@shocknet/clink-sdk'
|
||||
|
||||
const { getConversationKey, encrypt, decrypt } = nip44
|
||||
|
||||
const NDEBIT =
|
||||
'ndebit1qgpkzardqyg8wue69uhhxarjvee8jw3hxumnwqpqf05wyqarxsdm9d62fh9lsa6wqc2r0a37cgd00mp3gnydpf5w9uusavytcn'
|
||||
const RELAY_URL = 'ws://localhost:7777'
|
||||
const AMOUNT_SATS = 5000 // Small test amount
|
||||
|
||||
// Generate a wallet keypair for this test
|
||||
const WALLET_PRIVATE_KEY = generateSecretKey()
|
||||
const WALLET_PUBLIC_KEY = getPublicKey(WALLET_PRIVATE_KEY)
|
||||
|
||||
async function main() {
|
||||
console.log('🔧 Test: ndebit claim flow (NIP-44 v2)')
|
||||
console.log('='.repeat(50))
|
||||
|
||||
// Decode ndebit to get Lightning.Pub pubkey and pointer
|
||||
const decoded = decodeBech32(NDEBIT)
|
||||
const LPUB_PUBKEY = decoded.data.pubkey
|
||||
const POINTER = decoded.data.pointer
|
||||
console.log(`\n📍 Lightning.Pub pubkey: ${LPUB_PUBKEY.slice(0, 16)}...`)
|
||||
console.log(`🔑 Pointer (user ID): ${POINTER.slice(0, 16)}...`)
|
||||
console.log(`👛 Test wallet pubkey: ${WALLET_PUBLIC_KEY.slice(0, 16)}...`)
|
||||
console.log(`💰 Amount: ${AMOUNT_SATS} sats`)
|
||||
|
||||
// Connect to relay
|
||||
console.log(`\n🔌 Connecting to relay: ${RELAY_URL}`)
|
||||
const relay = await Relay.connect(RELAY_URL)
|
||||
console.log('✅ Connected!')
|
||||
|
||||
// Build the debit request data (NdebitData format)
|
||||
// Using newNdebitFullAccessRequest format with amount
|
||||
const debitData = {
|
||||
amount_sats: AMOUNT_SATS,
|
||||
pointer: POINTER,
|
||||
}
|
||||
|
||||
// Encrypt using NIP-44 v2
|
||||
const conversationKey = getConversationKey(WALLET_PRIVATE_KEY, LPUB_PUBKEY)
|
||||
const encryptedContent = encrypt(JSON.stringify(debitData), conversationKey)
|
||||
|
||||
// Build event with correct tags (including clink_version)
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21002,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [
|
||||
['p', LPUB_PUBKEY],
|
||||
['clink_version', '1'],
|
||||
],
|
||||
content: encryptedContent,
|
||||
},
|
||||
WALLET_PRIVATE_KEY
|
||||
)
|
||||
|
||||
console.log(`\n📤 Sending Kind 21002 debit request`)
|
||||
console.log(` Event ID: ${event.id.slice(0, 16)}...`)
|
||||
console.log(` Content length: ${encryptedContent.length} chars`)
|
||||
|
||||
// Subscribe for responses BEFORE sending the request
|
||||
let responseReceived = false
|
||||
const startTime = Date.now()
|
||||
|
||||
// Filter for Kind 21002 responses from Lightning.Pub that reference our event
|
||||
const sub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21002],
|
||||
authors: [LPUB_PUBKEY],
|
||||
'#p': [WALLET_PUBLIC_KEY],
|
||||
'#e': [event.id],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
console.log(`\n📥 Received Kind 21002 response!`)
|
||||
console.log(` Event ID: ${evt.id.slice(0, 16)}...`)
|
||||
console.log(` Author: ${evt.pubkey.slice(0, 16)}...`)
|
||||
|
||||
// Check #e tag (should reference our original event)
|
||||
const eTag = evt.tags.find((t) => t[0] === 'e')
|
||||
if (eTag) {
|
||||
console.log(` #e tag: ${eTag[1].slice(0, 16)}...`)
|
||||
if (eTag[1] === event.id) {
|
||||
console.log(' ✅ Correctly references our original event!')
|
||||
}
|
||||
} else {
|
||||
console.log(' ⚠️ No #e tag found')
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(decrypt(evt.content, conversationKey))
|
||||
console.log(`\n📋 Response content:`)
|
||||
console.log(JSON.stringify(response, null, 2))
|
||||
|
||||
if (response.res === 'OK') {
|
||||
console.log('\n✅✅✅ SUCCESS! Lightning.Pub sent Kind 21002 response correctly!')
|
||||
console.log(' The fix is working!')
|
||||
} else if (response.res === 'GFY' || response.error) {
|
||||
console.log(`\n⚠️ Response indicates error: ${response.error || 'unknown'}`)
|
||||
console.log(' (Expected if payment denied or auth required)')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(' ❌ Could not decrypt response:', e.message)
|
||||
}
|
||||
|
||||
responseReceived = true
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
// Publish the debit request
|
||||
await relay.publish(event)
|
||||
console.log('✅ Request published!')
|
||||
|
||||
// Wait for response with timeout
|
||||
console.log('\n⏳ Waiting for Kind 21002 response (30s timeout)...')
|
||||
const timeout = 30000
|
||||
const checkInterval = 1000
|
||||
|
||||
while (!responseReceived && Date.now() - startTime < timeout) {
|
||||
await new Promise((r) => setTimeout(r, checkInterval))
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000)
|
||||
process.stdout.write(`\r ${elapsed}s elapsed...`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
|
||||
if (!responseReceived) {
|
||||
console.log('\n❌❌❌ TIMEOUT! No Kind 21002 response received.')
|
||||
console.log(' This means the fix did NOT work or there was another issue.')
|
||||
|
||||
// Let's check what Kind 21002 events exist
|
||||
console.log('\n🔍 Checking for any Kind 21002 events on relay...')
|
||||
let foundEvents = 0
|
||||
const allDebitSub = relay.subscribe(
|
||||
[
|
||||
{
|
||||
kinds: [21002],
|
||||
limit: 10,
|
||||
},
|
||||
],
|
||||
{
|
||||
onevent(evt) {
|
||||
foundEvents++
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p').map((t) => t[1].slice(0, 8) + '...')
|
||||
const eTags = evt.tags.filter((t) => t[0] === 'e').map((t) => t[1].slice(0, 8) + '...')
|
||||
console.log(
|
||||
` ${foundEvents}. id=${evt.id.slice(0, 12)}... by=${evt.pubkey.slice(0, 8)}... #p=${pTags.join(',')} #e=${eTags.join(',')}`
|
||||
)
|
||||
},
|
||||
oneose() {
|
||||
console.log(` (Found ${foundEvents} Kind 21002 events total)`)
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
await new Promise((r) => setTimeout(r, 3000))
|
||||
allDebitSub.close()
|
||||
}
|
||||
|
||||
sub.close()
|
||||
relay.close()
|
||||
console.log('\n🏁 Test complete')
|
||||
process.exit(responseReceived ? 0 : 1)
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('Fatal error:', e)
|
||||
process.exit(1)
|
||||
})
|
||||
116
packages/nostr-client/test-pay.mjs
Normal file
116
packages/nostr-client/test-pay.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { NostrClient, loadIdentityFromHex, encryptContent, decryptJSON } from './dist/index.js'
|
||||
import { Relay } from 'nostr-tools/relay'
|
||||
import { finalizeEvent } from 'nostr-tools'
|
||||
import { randomUUID } from 'crypto'
|
||||
|
||||
const DEV_PRIVATE_KEY = '0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'
|
||||
const LIGHTNING_PUB_PUBKEY =
|
||||
process.env.LIGHTNING_PUB_PUBKEY ||
|
||||
'4a72e400a254bf74a70cc711ab97e461b8d4fd9738b1ac3b5194cdeb3192ab91'
|
||||
const RELAY_URL = process.env.NOSTR_RELAY_URL || 'ws://localhost:7777'
|
||||
|
||||
// Get a fresh invoice from Alice first:
|
||||
// docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000
|
||||
const TEST_INVOICE = process.argv[2]
|
||||
|
||||
if (!TEST_INVOICE) {
|
||||
console.log('Usage: node test-pay.mjs <invoice>')
|
||||
console.log(
|
||||
'Generate invoice: docker exec lamassu-lnd-alice lncli --network=regtest addinvoice --amt 1000'
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const identity = loadIdentityFromHex(DEV_PRIVATE_KEY)
|
||||
console.log('Using identity:', identity.publicKey)
|
||||
|
||||
console.log('Connecting to relay...')
|
||||
const relay = await Relay.connect(RELAY_URL)
|
||||
console.log('Connected!')
|
||||
|
||||
const requestId = randomUUID()
|
||||
|
||||
// Check if invoice has amount (look for pattern before '1' separator)
|
||||
const amountMatch = TEST_INVOICE.toLowerCase().match(/ln(?:bc|tb|bcrt)(\d+)?([munp])?1/)
|
||||
const hasAmount = amountMatch && amountMatch[1]
|
||||
console.log('Invoice amount match:', amountMatch ? amountMatch.slice(0, 3) : null)
|
||||
console.log('Has embedded amount:', hasAmount)
|
||||
|
||||
// Build body - amount is always required (use 0 for invoices with embedded amounts)
|
||||
const body = {
|
||||
invoice: TEST_INVOICE,
|
||||
amount: hasAmount ? 0 : 2000, // 0 means "use invoice amount"
|
||||
}
|
||||
console.log('Body amount:', body.amount, hasAmount ? '(use invoice amount)' : '(explicit amount)')
|
||||
|
||||
const rpcRequest = {
|
||||
rpcName: 'PayInvoice',
|
||||
params: {},
|
||||
query: {},
|
||||
body,
|
||||
authIdentifier: identity.publicKey,
|
||||
requestId,
|
||||
}
|
||||
|
||||
console.log('\nRequest structure:', JSON.stringify(rpcRequest, null, 2))
|
||||
|
||||
const encryptedContent = encryptContent(identity, LIGHTNING_PUB_PUBKEY, rpcRequest)
|
||||
|
||||
const event = finalizeEvent(
|
||||
{
|
||||
kind: 21000,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [['p', LIGHTNING_PUB_PUBKEY]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
identity.privateKey
|
||||
)
|
||||
|
||||
console.log('\nPublishing PayInvoice request (event id:', event.id.substring(0, 16) + '...)...')
|
||||
|
||||
// Subscribe to responses
|
||||
const filter = {
|
||||
kinds: [21000],
|
||||
authors: [LIGHTNING_PUB_PUBKEY],
|
||||
since: Math.floor(Date.now() / 1000) - 5,
|
||||
}
|
||||
|
||||
let responseReceived = false
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent(evt) {
|
||||
// Check if for us
|
||||
const pTags = evt.tags.filter((t) => t[0] === 'p')
|
||||
if (!pTags.some((t) => t[1] === identity.publicKey)) return
|
||||
|
||||
console.log('\nGot response event:', evt.id.substring(0, 16) + '...')
|
||||
try {
|
||||
const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content)
|
||||
console.log('Response:', JSON.stringify(response, null, 2))
|
||||
if (response.requestId === requestId) {
|
||||
responseReceived = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('Failed to decrypt:', err.message)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
await relay.publish(event)
|
||||
console.log('Request published, waiting for response...')
|
||||
|
||||
// Wait for response
|
||||
for (let i = 0; i < 20; i++) {
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
if (responseReceived) break
|
||||
}
|
||||
|
||||
if (!responseReceived) {
|
||||
console.log('\nNo response received for our requestId within timeout')
|
||||
}
|
||||
|
||||
sub.close()
|
||||
relay.close()
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
22
packages/nostr-client/tsconfig.json
Normal file
22
packages/nostr-client/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/nostr-client/vitest.config.ts
Normal file
8
packages/nostr-client/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
30
packages/state-machine/package.json
Normal file
30
packages/state-machine/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "@lamassu/state-machine",
|
||||
"version": "0.1.0",
|
||||
"description": "XState v5 state machine for ATM transaction flows",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"xstate": "^5.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^2.1.0"
|
||||
}
|
||||
}
|
||||
277
packages/state-machine/src/__tests__/machine.test.ts
Normal file
277
packages/state-machine/src/__tests__/machine.test.ts
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { createActor } from 'xstate'
|
||||
import { createATMMachine } from '../machine.js'
|
||||
import type { ATMServices, OfferRequestEvent } from '../types.js'
|
||||
|
||||
describe('ATM State Machine', () => {
|
||||
const mockServices: ATMServices = {
|
||||
generateClinkOffer: vi.fn().mockResolvedValue('noffer1test'),
|
||||
generateInvoice: vi.fn().mockResolvedValue('lnbc1test'),
|
||||
generateLnurlWithdraw: vi.fn().mockResolvedValue('lnurl1test'),
|
||||
generateNdebit: vi.fn().mockResolvedValue('clink:ndebit1test?amount=1000'),
|
||||
sendNostrReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
dispenseCash: vi.fn().mockResolvedValue(undefined),
|
||||
getExchangeRate: vi.fn().mockResolvedValue(2500), // 2500 sats per USD
|
||||
// noffer cash-out services (legacy)
|
||||
generateNoffer: vi.fn().mockResolvedValue('noffer1atmtest'),
|
||||
sendOfferResponse: vi.fn().mockResolvedValue(undefined),
|
||||
validateDispenseAmount: vi.fn().mockResolvedValue(7500), // returns fiat cents
|
||||
// New cash-out services
|
||||
watchInvoice: vi.fn().mockReturnValue(() => {}),
|
||||
getInventory: vi.fn().mockResolvedValue({ 20: 50 }), // 50 x $20 bills
|
||||
}
|
||||
|
||||
describe('initial state', () => {
|
||||
it('should start in idle state', () => {
|
||||
const machine = createATMMachine()
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
})
|
||||
|
||||
it('should have initial context values', () => {
|
||||
const machine = createATMMachine()
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
const context = actor.getSnapshot().context
|
||||
expect(context.fiatAmount).toBe(0)
|
||||
expect(context.satsAmount).toBe(0)
|
||||
expect(context.billsInserted).toEqual([])
|
||||
expect(context.error).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('cash-in flow', () => {
|
||||
it('should transition to cashIn on SELECT_CASH_IN', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
|
||||
// Wait for state to settle
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashIn: expect.any(String) })
|
||||
expect(state.context.txid).not.toBeNull()
|
||||
expect(state.context.startedAt).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should accumulate bills and calculate sats', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
|
||||
// Wait for rate fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||
actor.send({ type: 'BILL_INSERTED', denomination: 10 })
|
||||
|
||||
const context = actor.getSnapshot().context
|
||||
expect(context.billsInserted).toEqual([20, 10])
|
||||
expect(context.fiatAmount).toBe(3000) // $30 in cents
|
||||
})
|
||||
|
||||
it('should return to idle on CANCEL', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
actor.send({ type: 'CANCEL' })
|
||||
|
||||
expect(actor.getSnapshot().value).toBe('idle')
|
||||
})
|
||||
})
|
||||
|
||||
describe('cash-out flow (ATM-driven amount selection)', () => {
|
||||
it('should transition to cashOut on SELECT_CASH_OUT', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashOut: expect.any(String) })
|
||||
})
|
||||
|
||||
it('should go to selectingAmount after fetching rate', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
|
||||
// Wait for rate fetch
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
|
||||
expect(state.context.exchangeRate).toBe(2500)
|
||||
// Should have mock inventory
|
||||
expect(state.context.inventory).toEqual({ 20: 50 })
|
||||
})
|
||||
|
||||
it('should add and remove denominations', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Add denominations
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
|
||||
let state = actor.getSnapshot()
|
||||
expect(state.context.cashOutSelection).toEqual([20, 20, 20])
|
||||
expect(state.context.fiatAmount).toBe(6000) // $60 in cents
|
||||
|
||||
// Remove one
|
||||
actor.send({ type: 'REMOVE_DENOMINATION', denomination: 20 })
|
||||
|
||||
state = actor.getSnapshot()
|
||||
expect(state.context.cashOutSelection).toEqual([20, 20])
|
||||
expect(state.context.fiatAmount).toBe(4000) // $40 in cents
|
||||
})
|
||||
|
||||
it('should calculate sats amount from fiat with fee', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Add $20
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
// $20 at 2500 sats/USD = 50,000 sats
|
||||
// Plus 2% fee = 51,000 sats
|
||||
expect(state.context.satsAmount).toBe(51000)
|
||||
})
|
||||
|
||||
it('should generate invoice after confirming amount', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashOut: 'displayingInvoice' })
|
||||
expect(state.context.invoice).toBe('lnbc1test')
|
||||
expect(state.context.paymentMethod).toBe('invoice')
|
||||
})
|
||||
|
||||
it('should dispense cash after payment received', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
actor.send({ type: 'ADD_DENOMINATION', denomination: 20 })
|
||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Simulate payment
|
||||
actor.send({ type: 'PAYMENT_RECEIVED', preimage: 'preimage123' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.context.paymentStatus).toBe('paid')
|
||||
expect(state.context.preimage).toBe('preimage123')
|
||||
// Should be in dispensing or later state
|
||||
expect(state.context.dispenseAmounts).toContainEqual({ denomination: 20, count: 1 })
|
||||
})
|
||||
|
||||
it('should not allow confirm without selection', async () => {
|
||||
const machine = createATMMachine(mockServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_OUT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
|
||||
// Try to confirm with no selection
|
||||
actor.send({ type: 'CONFIRM_AMOUNT' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
// Should still be in selectingAmount
|
||||
expect(state.value).toMatchObject({ cashOut: 'selectingAmount' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should transition to error state on service failure', async () => {
|
||||
const failingServices: ATMServices = {
|
||||
...mockServices,
|
||||
getExchangeRate: vi.fn().mockRejectedValue(new Error('Rate fetch failed')),
|
||||
}
|
||||
|
||||
const machine = createATMMachine(failingServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashIn: 'error' })
|
||||
})
|
||||
|
||||
it('should allow retry on error', async () => {
|
||||
let callCount = 0
|
||||
const retryServices: ATMServices = {
|
||||
...mockServices,
|
||||
getExchangeRate: vi.fn().mockImplementation(() => {
|
||||
callCount++
|
||||
if (callCount === 1) {
|
||||
return Promise.reject(new Error('First call fails'))
|
||||
}
|
||||
return Promise.resolve(2500)
|
||||
}),
|
||||
}
|
||||
|
||||
const machine = createATMMachine(retryServices)
|
||||
const actor = createActor(machine)
|
||||
actor.start()
|
||||
|
||||
actor.send({ type: 'SELECT_CASH_IN' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Should be in error state
|
||||
expect(actor.getSnapshot().value).toMatchObject({ cashIn: 'error' })
|
||||
|
||||
// Retry
|
||||
actor.send({ type: 'RETRY' })
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// Should have retried and succeeded
|
||||
const state = actor.getSnapshot()
|
||||
expect(state.value).toMatchObject({ cashIn: 'insertingBills' })
|
||||
})
|
||||
})
|
||||
})
|
||||
57
packages/state-machine/src/index.ts
Normal file
57
packages/state-machine/src/index.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/**
|
||||
* @lamassu/state-machine
|
||||
*
|
||||
* XState v5 state machine for ATM transaction flows.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* import { createATMMachine, createActor } from '@lamassu/state-machine'
|
||||
* import { createActor } from 'xstate'
|
||||
*
|
||||
* const machine = createATMMachine({
|
||||
* generateClinkOffer: async (context) => {
|
||||
* // Generate offer for the current amount
|
||||
* return 'noffer1...'
|
||||
* },
|
||||
* generateInvoice: async (amountMsat) => {
|
||||
* // Generate Lightning invoice
|
||||
* return 'lnbc...'
|
||||
* },
|
||||
* getExchangeRate: async (currency) => {
|
||||
* // Get current rate in sats per fiat unit
|
||||
* return 2500 // Example: 2500 sats per USD
|
||||
* },
|
||||
* })
|
||||
*
|
||||
* const actor = createActor(machine)
|
||||
* actor.start()
|
||||
*
|
||||
* // User selects cash-in
|
||||
* actor.send({ type: 'SELECT_CASH_IN' })
|
||||
*
|
||||
* // Bill inserted
|
||||
* actor.send({ type: 'BILL_INSERTED', denomination: 20 })
|
||||
*
|
||||
* // Subscribe to state changes
|
||||
* actor.subscribe((snapshot) => {
|
||||
* console.log('State:', snapshot.value)
|
||||
* console.log('Context:', snapshot.context)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
|
||||
// Machine factory and default machine
|
||||
export { createATMMachine, atmMachine, type ATMMachine } from './machine.js'
|
||||
|
||||
// Types
|
||||
export {
|
||||
type ATMContext,
|
||||
type ATMEvent,
|
||||
type ATMServices,
|
||||
type PaymentStatus,
|
||||
type PaymentMethod,
|
||||
initialContext,
|
||||
} from './types.js'
|
||||
|
||||
// Re-export useful xstate utilities
|
||||
export { createActor, type ActorRefFrom, type SnapshotFrom } from 'xstate'
|
||||
651
packages/state-machine/src/machine.ts
Normal file
651
packages/state-machine/src/machine.ts
Normal file
|
|
@ -0,0 +1,651 @@
|
|||
/**
|
||||
* ATM State Machine
|
||||
*
|
||||
* XState v5 machine for ATM transaction flows.
|
||||
* Handles both cash-in (buy bitcoin) and cash-out (sell bitcoin) flows.
|
||||
*/
|
||||
|
||||
import { setup, assign, fromPromise, fromCallback } from 'xstate'
|
||||
import {
|
||||
type ATMContext,
|
||||
type ATMEvent,
|
||||
initialContext,
|
||||
type ATMServices,
|
||||
type OfferRequestEvent,
|
||||
} from './types.js'
|
||||
|
||||
/**
|
||||
* Create the ATM state machine with injected services
|
||||
*/
|
||||
export function createATMMachine(services: Partial<ATMServices> = {}) {
|
||||
return setup({
|
||||
types: {
|
||||
context: {} as ATMContext,
|
||||
events: {} as ATMEvent,
|
||||
},
|
||||
actors: {
|
||||
generateClinkOffer: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||
if (!services.generateClinkOffer) {
|
||||
throw new Error('generateClinkOffer service not provided')
|
||||
}
|
||||
return services.generateClinkOffer(input)
|
||||
}),
|
||||
generateNdebit: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||
if (!services.generateNdebit) {
|
||||
throw new Error('generateNdebit service not provided')
|
||||
}
|
||||
return services.generateNdebit(input)
|
||||
}),
|
||||
generateLnurlWithdraw: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||
if (!services.generateLnurlWithdraw) {
|
||||
throw new Error('generateLnurlWithdraw service not provided')
|
||||
}
|
||||
return services.generateLnurlWithdraw(input)
|
||||
}),
|
||||
generateInvoice: fromPromise(async ({ input }: { input: number }) => {
|
||||
if (!services.generateInvoice) {
|
||||
throw new Error('generateInvoice service not provided')
|
||||
}
|
||||
return services.generateInvoice(input)
|
||||
}),
|
||||
sendNostrReceipt: fromPromise(async ({ input }: { input: ATMContext }) => {
|
||||
if (!services.sendNostrReceipt) {
|
||||
throw new Error('sendNostrReceipt service not provided')
|
||||
}
|
||||
return services.sendNostrReceipt(input)
|
||||
}),
|
||||
dispenseCash: fromPromise(
|
||||
async ({ input }: { input: { denomination: number; count: number }[] }) => {
|
||||
if (!services.dispenseCash) {
|
||||
throw new Error('dispenseCash service not provided')
|
||||
}
|
||||
return services.dispenseCash(input)
|
||||
}
|
||||
),
|
||||
getExchangeRate: fromPromise(async ({ input }: { input: string }) => {
|
||||
if (!services.getExchangeRate) {
|
||||
throw new Error('getExchangeRate service not provided')
|
||||
}
|
||||
return services.getExchangeRate(input)
|
||||
}),
|
||||
generateNoffer: fromPromise(async () => {
|
||||
if (!services.generateNoffer) {
|
||||
throw new Error('generateNoffer service not provided')
|
||||
}
|
||||
return services.generateNoffer()
|
||||
}),
|
||||
sendOfferResponse: fromPromise(
|
||||
async ({ input }: { input: { request: OfferRequestEvent; invoice: string } }) => {
|
||||
if (!services.sendOfferResponse) {
|
||||
throw new Error('sendOfferResponse service not provided')
|
||||
}
|
||||
return services.sendOfferResponse(input.request, input.invoice)
|
||||
}
|
||||
),
|
||||
validateDispenseAmount: fromPromise(
|
||||
async ({ input }: { input: { amountSats: number; exchangeRate: number } }) => {
|
||||
if (!services.validateDispenseAmount) {
|
||||
throw new Error('validateDispenseAmount service not provided')
|
||||
}
|
||||
return services.validateDispenseAmount(input.amountSats, input.exchangeRate)
|
||||
}
|
||||
),
|
||||
getInventory: fromPromise(async () => {
|
||||
if (!services.getInventory) {
|
||||
// Return mock inventory if service not provided
|
||||
return { 20: 50 } as Record<number, number>
|
||||
}
|
||||
return services.getInventory()
|
||||
}),
|
||||
/**
|
||||
* Callback actor that watches an invoice for payment
|
||||
* Sends PAYMENT_RECEIVED when the invoice is paid
|
||||
*/
|
||||
watchInvoicePayment: fromCallback<ATMEvent, { invoice: string }>(({ sendBack, input }) => {
|
||||
if (!services.watchInvoice) {
|
||||
console.warn('watchInvoice service not provided')
|
||||
return () => {}
|
||||
}
|
||||
// Extract payment hash from invoice (simplified - real impl would decode BOLT11)
|
||||
// The service handles the actual extraction
|
||||
const cleanup = services.watchInvoice(input.invoice, (preimage: string) => {
|
||||
sendBack({ type: 'PAYMENT_RECEIVED', preimage })
|
||||
})
|
||||
return cleanup
|
||||
}),
|
||||
/**
|
||||
* Callback actor that subscribes to Kind 21001 offer requests
|
||||
* The parent machine must provide an onOfferRequest callback via services
|
||||
*/
|
||||
subscribeToOfferRequests: fromCallback<ATMEvent, { pubkey: string }>(({ sendBack }) => {
|
||||
// This is a placeholder - actual implementation is injected via services
|
||||
// The callback will receive OFFER_REQUEST_RECEIVED events
|
||||
// In production, this subscribes to Nostr Kind 21001 events tagged with ATM pubkey
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
// Unsubscribe from Nostr events
|
||||
}
|
||||
}),
|
||||
},
|
||||
actions: {
|
||||
resetContext: assign(() => ({
|
||||
...initialContext,
|
||||
cashInSessionId: null,
|
||||
})),
|
||||
setStartTime: assign({
|
||||
startedAt: () => Date.now(),
|
||||
txid: () => generateTxId(),
|
||||
cashInSessionId: () => generateSessionId(),
|
||||
}),
|
||||
addBill: assign({
|
||||
billsInserted: ({ context, event }) => {
|
||||
if (event.type !== 'BILL_INSERTED') return context.billsInserted
|
||||
return [...context.billsInserted, event.denomination]
|
||||
},
|
||||
fiatAmount: ({ context, event }) => {
|
||||
if (event.type !== 'BILL_INSERTED') return context.fiatAmount
|
||||
// denomination is in dollars, fiatAmount is in cents
|
||||
return context.fiatAmount + event.denomination * 100
|
||||
},
|
||||
}),
|
||||
calculateSats: assign({
|
||||
satsAmount: ({ context }) => {
|
||||
if (context.exchangeRate === 0) return 0
|
||||
const fiatUnits = context.fiatAmount / 100 // cents to dollars
|
||||
const grossSats = Math.floor(fiatUnits * context.exchangeRate)
|
||||
const fee = Math.floor(grossSats * context.feePercent)
|
||||
return grossSats - fee
|
||||
},
|
||||
}),
|
||||
calculateDispenseAmounts: assign({
|
||||
dispenseAmounts: ({ context }) => {
|
||||
// Calculate bills to dispense for cash-out
|
||||
// Simple algorithm: use largest denominations first
|
||||
const denominations = [100, 50, 20, 10, 5, 1]
|
||||
let remaining = context.fiatAmount / 100 // cents to dollars
|
||||
const amounts: { denomination: number; count: number }[] = []
|
||||
|
||||
for (const denom of denominations) {
|
||||
if (remaining >= denom) {
|
||||
const count = Math.floor(remaining / denom)
|
||||
amounts.push({ denomination: denom, count })
|
||||
remaining -= count * denom
|
||||
}
|
||||
}
|
||||
|
||||
return amounts
|
||||
},
|
||||
}),
|
||||
setUserNpub: assign({
|
||||
userNpub: ({ event }) => {
|
||||
if (event.type !== 'USER_SCANNED_NPUB') return null
|
||||
return event.npub
|
||||
},
|
||||
}),
|
||||
setError: assign({
|
||||
error: ({ event }) => {
|
||||
if (
|
||||
event.type === 'ERROR' ||
|
||||
event.type === 'PAYMENT_FAILED' ||
|
||||
event.type === 'DISPENSE_ERROR'
|
||||
) {
|
||||
return event.error
|
||||
}
|
||||
return null
|
||||
},
|
||||
}),
|
||||
incrementRetry: assign({
|
||||
retryCount: ({ context }) => context.retryCount + 1,
|
||||
}),
|
||||
setExchangeRate: assign({
|
||||
exchangeRate: ({ event }) => {
|
||||
if (event.type !== 'EXCHANGE_RATE_UPDATED') return 0
|
||||
return event.rate
|
||||
},
|
||||
}),
|
||||
setOffer: assign({
|
||||
clinkOffer: ({ event }) => {
|
||||
if (event.type !== 'OFFER_GENERATED') return null
|
||||
return event.offer
|
||||
},
|
||||
paymentMethod: () => 'clink_offer' as const,
|
||||
}),
|
||||
setInvoice: assign({
|
||||
invoice: ({ event }) => {
|
||||
if (event.type !== 'INVOICE_GENERATED') return null
|
||||
return event.invoice
|
||||
},
|
||||
}),
|
||||
setPaymentReceived: assign({
|
||||
paymentStatus: () => 'paid' as const,
|
||||
preimage: ({ event }) => {
|
||||
if (event.type !== 'PAYMENT_RECEIVED') return null
|
||||
return event.preimage
|
||||
},
|
||||
}),
|
||||
setPaymentFailed: assign({
|
||||
paymentStatus: () => 'failed' as const,
|
||||
}),
|
||||
setCashDispensed: assign({
|
||||
cashDispensed: () => true,
|
||||
}),
|
||||
setAmount: assign({
|
||||
fiatAmount: ({ event }) => {
|
||||
if (event.type !== 'SELECT_AMOUNT') return 0
|
||||
return event.amount * 100 // dollars to cents
|
||||
},
|
||||
}),
|
||||
setNoffer: assign({
|
||||
nofferString: ({ event }) => {
|
||||
if (event.type !== 'NOFFER_GENERATED') return null
|
||||
return event.noffer
|
||||
},
|
||||
paymentMethod: () => 'clink_offer' as const,
|
||||
}),
|
||||
// Cash-out selection actions
|
||||
addDenomination: assign({
|
||||
cashOutSelection: ({ context, event }) => {
|
||||
if (event.type !== 'ADD_DENOMINATION') return context.cashOutSelection
|
||||
return [...context.cashOutSelection, event.denomination]
|
||||
},
|
||||
fiatAmount: ({ context, event }) => {
|
||||
if (event.type !== 'ADD_DENOMINATION') return context.fiatAmount
|
||||
return context.fiatAmount + event.denomination * 100 // dollars to cents
|
||||
},
|
||||
}),
|
||||
removeDenomination: assign({
|
||||
cashOutSelection: ({ context, event }) => {
|
||||
if (event.type !== 'REMOVE_DENOMINATION') return context.cashOutSelection
|
||||
const idx = context.cashOutSelection.lastIndexOf(event.denomination)
|
||||
if (idx === -1) return context.cashOutSelection
|
||||
const newSelection = [...context.cashOutSelection]
|
||||
newSelection.splice(idx, 1)
|
||||
return newSelection
|
||||
},
|
||||
fiatAmount: ({ context, event }) => {
|
||||
if (event.type !== 'REMOVE_DENOMINATION') return context.fiatAmount
|
||||
if (!context.cashOutSelection.includes(event.denomination)) return context.fiatAmount
|
||||
return context.fiatAmount - event.denomination * 100 // dollars to cents
|
||||
},
|
||||
}),
|
||||
clearCashOutSelection: assign({
|
||||
cashOutSelection: () => [],
|
||||
fiatAmount: () => 0,
|
||||
satsAmount: () => 0,
|
||||
}),
|
||||
calculateSatsFromFiat: assign({
|
||||
satsAmount: ({ context }) => {
|
||||
if (context.exchangeRate === 0) return 0
|
||||
const fiatDollars = context.fiatAmount / 100 // cents to dollars
|
||||
const grossSats = Math.floor(fiatDollars * context.exchangeRate)
|
||||
// For cash-out, user pays the sats, so fee is added
|
||||
const fee = Math.floor(grossSats * context.feePercent)
|
||||
return grossSats + fee
|
||||
},
|
||||
}),
|
||||
calculateDispenseFromSelection: assign({
|
||||
dispenseAmounts: ({ context }) => {
|
||||
// Group selected denominations by value
|
||||
const counts = new Map<number, number>()
|
||||
for (const denom of context.cashOutSelection) {
|
||||
counts.set(denom, (counts.get(denom) || 0) + 1)
|
||||
}
|
||||
return Array.from(counts.entries()).map(([denomination, count]) => ({
|
||||
denomination,
|
||||
count,
|
||||
}))
|
||||
},
|
||||
}),
|
||||
setOfferRequest: assign({
|
||||
pendingOfferRequest: ({ event }) => {
|
||||
if (event.type !== 'OFFER_REQUEST_RECEIVED') return null
|
||||
return event.request
|
||||
},
|
||||
}),
|
||||
setAmountFromOfferRequest: assign({
|
||||
satsAmount: ({ event }) => {
|
||||
if (event.type !== 'OFFER_REQUEST_RECEIVED') return 0
|
||||
return event.request.amountSats
|
||||
},
|
||||
}),
|
||||
clearOfferRequest: assign({
|
||||
pendingOfferRequest: () => null,
|
||||
}),
|
||||
},
|
||||
guards: {
|
||||
hasInsertedBills: ({ context }) => context.billsInserted.length > 0,
|
||||
hasSufficientAmount: ({ context }) => context.fiatAmount >= 100, // $1 minimum
|
||||
hasExchangeRate: ({ context }) => context.exchangeRate > 0,
|
||||
canRetry: ({ context }) => context.retryCount < 3,
|
||||
hasUserNpub: ({ context }) => context.userNpub !== null,
|
||||
hasOfferRequest: ({ context }) => context.pendingOfferRequest !== null,
|
||||
// Cash-out guards
|
||||
hasSelectedAmount: ({ context }) => context.cashOutSelection.length > 0,
|
||||
canAddDenomination: ({ context, event }) => {
|
||||
if (event.type !== 'ADD_DENOMINATION') return false
|
||||
const denom = event.denomination
|
||||
const available = context.inventory[denom] || 0
|
||||
const alreadySelected = context.cashOutSelection.filter((d) => d === denom).length
|
||||
return alreadySelected < available
|
||||
},
|
||||
canRemoveDenomination: ({ context, event }) => {
|
||||
if (event.type !== 'REMOVE_DENOMINATION') return false
|
||||
return context.cashOutSelection.includes(event.denomination)
|
||||
},
|
||||
},
|
||||
delays: {
|
||||
TIMEOUT_MS: 300000, // 5 minutes
|
||||
COMPLETE_DELAY: 3000,
|
||||
},
|
||||
}).createMachine({
|
||||
id: 'atm',
|
||||
initial: 'idle',
|
||||
context: initialContext,
|
||||
states: {
|
||||
idle: {
|
||||
entry: 'resetContext',
|
||||
on: {
|
||||
SELECT_CASH_IN: {
|
||||
target: 'cashIn',
|
||||
actions: 'setStartTime',
|
||||
},
|
||||
SELECT_CASH_OUT: {
|
||||
target: 'cashOut',
|
||||
actions: 'setStartTime',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// === CASH IN (Buy Bitcoin) ===
|
||||
cashIn: {
|
||||
initial: 'fetchingRate',
|
||||
states: {
|
||||
fetchingRate: {
|
||||
invoke: {
|
||||
src: 'getExchangeRate',
|
||||
input: ({ context }) => context.currency,
|
||||
onDone: {
|
||||
target: 'insertingBills',
|
||||
actions: assign({
|
||||
exchangeRate: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'error',
|
||||
actions: 'setError',
|
||||
},
|
||||
},
|
||||
},
|
||||
insertingBills: {
|
||||
on: {
|
||||
BILL_INSERTED: {
|
||||
actions: ['addBill', 'calculateSats'],
|
||||
},
|
||||
BILL_REJECTED: {
|
||||
// Stay in state, maybe show message
|
||||
},
|
||||
FINISH_INSERTING: {
|
||||
guard: 'hasInsertedBills',
|
||||
target: 'generatingNdebit',
|
||||
},
|
||||
CANCEL: '#atm.idle',
|
||||
TIMEOUT: '#atm.idle',
|
||||
},
|
||||
},
|
||||
generatingNdebit: {
|
||||
invoke: {
|
||||
src: 'generateNdebit',
|
||||
input: ({ context }) => context,
|
||||
onDone: {
|
||||
target: 'displayingQR',
|
||||
actions: assign({
|
||||
ndebitUri: ({ event }) => event.output,
|
||||
paymentMethod: () => 'clink_offer' as const,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'error',
|
||||
actions: 'setError',
|
||||
},
|
||||
},
|
||||
},
|
||||
displayingQR: {
|
||||
on: {
|
||||
PAYMENT_RECEIVED: {
|
||||
target: 'askForReceipt',
|
||||
actions: 'setPaymentReceived',
|
||||
},
|
||||
PAYMENT_FAILED: {
|
||||
target: 'error',
|
||||
actions: ['setError', 'setPaymentFailed'],
|
||||
},
|
||||
TIMEOUT: '#atm.idle',
|
||||
CANCEL: '#atm.idle',
|
||||
},
|
||||
},
|
||||
askForReceipt: {
|
||||
on: {
|
||||
USER_SCANNED_NPUB: {
|
||||
target: 'sendingReceipt',
|
||||
actions: 'setUserNpub',
|
||||
},
|
||||
SKIP_RECEIPT: 'complete',
|
||||
CANCEL: 'complete',
|
||||
TIMEOUT: 'complete',
|
||||
},
|
||||
},
|
||||
sendingReceipt: {
|
||||
invoke: {
|
||||
src: 'sendNostrReceipt',
|
||||
input: ({ context }) => context,
|
||||
onDone: 'complete',
|
||||
onError: 'complete', // Don't fail transaction for receipt
|
||||
},
|
||||
},
|
||||
complete: {
|
||||
after: {
|
||||
COMPLETE_DELAY: '#atm.idle',
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
RETRY: {
|
||||
guard: 'canRetry',
|
||||
target: 'fetchingRate',
|
||||
actions: 'incrementRetry',
|
||||
},
|
||||
CANCEL: '#atm.idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// === CASH OUT (Sell Bitcoin) ===
|
||||
// ATM-driven flow: user selects fiat amount on ATM, pays invoice, receives cash
|
||||
// 1. ATM fetches exchange rate
|
||||
// 2. User selects denominations on ATM screen
|
||||
// 3. ATM generates BOLT11 invoice for calculated sats
|
||||
// 4. User scans QR and pays from any Lightning wallet
|
||||
// 5. ATM watches invoice, dispenses cash when paid
|
||||
cashOut: {
|
||||
initial: 'fetchingRate',
|
||||
states: {
|
||||
fetchingRate: {
|
||||
invoke: {
|
||||
src: 'getExchangeRate',
|
||||
input: ({ context }) => context.currency,
|
||||
onDone: {
|
||||
target: 'selectingAmount',
|
||||
actions: assign({
|
||||
exchangeRate: ({ event }) => event.output,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'error',
|
||||
actions: 'setError',
|
||||
},
|
||||
},
|
||||
},
|
||||
selectingAmount: {
|
||||
// User selects denomination buttons to build up the cash amount
|
||||
// UI shows: available denominations, running total, sats equivalent
|
||||
entry: 'clearCashOutSelection',
|
||||
on: {
|
||||
ADD_DENOMINATION: {
|
||||
guard: 'canAddDenomination',
|
||||
actions: ['addDenomination', 'calculateSatsFromFiat'],
|
||||
},
|
||||
REMOVE_DENOMINATION: {
|
||||
guard: 'canRemoveDenomination',
|
||||
actions: ['removeDenomination', 'calculateSatsFromFiat'],
|
||||
},
|
||||
CLEAR_SELECTION: {
|
||||
actions: 'clearCashOutSelection',
|
||||
},
|
||||
CONFIRM_AMOUNT: {
|
||||
guard: 'hasSelectedAmount',
|
||||
target: 'generatingInvoice',
|
||||
actions: 'calculateDispenseFromSelection',
|
||||
},
|
||||
CANCEL: '#atm.idle',
|
||||
TIMEOUT: '#atm.idle',
|
||||
},
|
||||
},
|
||||
generatingInvoice: {
|
||||
invoke: {
|
||||
src: 'generateInvoice',
|
||||
input: ({ context }) => context.satsAmount * 1000, // sats to msats
|
||||
onDone: {
|
||||
target: 'displayingInvoice',
|
||||
actions: assign({
|
||||
invoice: ({ event }) => event.output,
|
||||
paymentMethod: () => 'invoice' as const,
|
||||
}),
|
||||
},
|
||||
onError: {
|
||||
target: 'selectingAmount',
|
||||
actions: 'setError',
|
||||
},
|
||||
},
|
||||
},
|
||||
displayingInvoice: {
|
||||
// Show QR code with BOLT11 invoice, watch for payment
|
||||
invoke: {
|
||||
src: 'watchInvoicePayment',
|
||||
input: ({ context }) => ({ invoice: context.invoice! }),
|
||||
},
|
||||
on: {
|
||||
PAYMENT_RECEIVED: {
|
||||
target: 'dispensingCash',
|
||||
actions: 'setPaymentReceived',
|
||||
},
|
||||
PAYMENT_FAILED: {
|
||||
target: 'selectingAmount',
|
||||
actions: ['setError', 'setPaymentFailed'],
|
||||
},
|
||||
TIMEOUT: {
|
||||
target: 'selectingAmount',
|
||||
},
|
||||
CANCEL: '#atm.idle',
|
||||
},
|
||||
},
|
||||
dispensingCash: {
|
||||
invoke: {
|
||||
src: 'dispenseCash',
|
||||
input: ({ context }) => context.dispenseAmounts,
|
||||
onDone: {
|
||||
target: 'waitingForCashTaken',
|
||||
actions: 'setCashDispensed',
|
||||
},
|
||||
onError: {
|
||||
target: 'dispenseError',
|
||||
actions: 'setError',
|
||||
},
|
||||
},
|
||||
},
|
||||
waitingForCashTaken: {
|
||||
on: {
|
||||
CASH_DISPENSED: 'askForReceipt',
|
||||
TIMEOUT: 'askForReceipt', // Assume taken
|
||||
},
|
||||
},
|
||||
askForReceipt: {
|
||||
on: {
|
||||
USER_SCANNED_NPUB: {
|
||||
target: 'sendingReceipt',
|
||||
actions: 'setUserNpub',
|
||||
},
|
||||
SKIP_RECEIPT: 'complete',
|
||||
CANCEL: 'complete',
|
||||
TIMEOUT: 'complete',
|
||||
},
|
||||
},
|
||||
sendingReceipt: {
|
||||
invoke: {
|
||||
src: 'sendNostrReceipt',
|
||||
input: ({ context }) => context,
|
||||
onDone: 'complete',
|
||||
onError: 'complete',
|
||||
},
|
||||
},
|
||||
complete: {
|
||||
after: {
|
||||
COMPLETE_DELAY: '#atm.idle',
|
||||
},
|
||||
},
|
||||
dispenseError: {
|
||||
// Critical error - payment received but cash not dispensed
|
||||
// Requires manual intervention
|
||||
on: {
|
||||
RETRY: {
|
||||
guard: 'canRetry',
|
||||
target: 'dispensingCash',
|
||||
actions: 'incrementRetry',
|
||||
},
|
||||
},
|
||||
},
|
||||
error: {
|
||||
on: {
|
||||
RETRY: {
|
||||
guard: 'canRetry',
|
||||
target: 'fetchingRate',
|
||||
actions: 'incrementRetry',
|
||||
},
|
||||
CANCEL: '#atm.idle',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique transaction ID
|
||||
*/
|
||||
function generateTxId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `tx_${timestamp}_${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique session ID for cash-in ndebit single-use protection
|
||||
* This is used in the ndebit pointer field to link debit requests to specific transactions
|
||||
*/
|
||||
function generateSessionId(): string {
|
||||
const timestamp = Date.now().toString(36)
|
||||
const random = Math.random().toString(36).substring(2, 12)
|
||||
return `${timestamp}${random}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Default ATM machine (no services - for type checking)
|
||||
*/
|
||||
export const atmMachine = createATMMachine()
|
||||
|
||||
/**
|
||||
* Type of the ATM machine
|
||||
*/
|
||||
export type ATMMachine = typeof atmMachine
|
||||
191
packages/state-machine/src/types.ts
Normal file
191
packages/state-machine/src/types.ts
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* ATM State Machine type definitions
|
||||
*/
|
||||
|
||||
/** Payment status */
|
||||
export type PaymentStatus = 'pending' | 'paid' | 'failed' | null
|
||||
|
||||
/** Payment methods supported */
|
||||
export type PaymentMethod = 'clink_offer' | 'lnurl_withdraw' | 'invoice' | 'cashu'
|
||||
|
||||
/** Incoming offer request from a user's wallet (Kind 21001) */
|
||||
export interface OfferRequestEvent {
|
||||
/** Event ID for referencing in response */
|
||||
eventId: string
|
||||
/** Payer's pubkey */
|
||||
payerPubkey: string
|
||||
/** Requested amount in satoshis */
|
||||
amountSats: number
|
||||
/** Optional payer-provided description */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** ATM machine context */
|
||||
export interface ATMContext {
|
||||
// Transaction details
|
||||
/** Fiat amount in cents */
|
||||
fiatAmount: number
|
||||
/** Satoshi amount */
|
||||
satsAmount: number
|
||||
/** Fiat currency code */
|
||||
currency: string
|
||||
/** Exchange rate (sats per fiat unit) */
|
||||
exchangeRate: number
|
||||
/** Fee percentage (0.02 = 2%) */
|
||||
feePercent: number
|
||||
|
||||
// Payment
|
||||
/** BOLT11 invoice for payment */
|
||||
invoice: string | null
|
||||
/** CLINK offer string (noffer) - for cash-out */
|
||||
clinkOffer: string | null
|
||||
/** noffer string displayed to user for cash-out */
|
||||
nofferString: string | null
|
||||
/** ndebit URI for cash-in (clink:ndebit1...?amount=X) */
|
||||
ndebitUri: string | null
|
||||
/** LNURL-withdraw string - for cash-in (customer receives sats) - legacy */
|
||||
lnurlWithdraw: string | null
|
||||
/** Current payment status */
|
||||
paymentStatus: PaymentStatus
|
||||
/** Payment preimage (proof of payment) */
|
||||
preimage: string | null
|
||||
/** Payment method used */
|
||||
paymentMethod: PaymentMethod | null
|
||||
/** Pending offer request (Kind 21001 from user's wallet) */
|
||||
pendingOfferRequest: OfferRequestEvent | null
|
||||
|
||||
// Hardware state
|
||||
/** Bills inserted during cash-in */
|
||||
billsInserted: number[]
|
||||
/** Whether cash has been dispensed */
|
||||
cashDispensed: boolean
|
||||
/** Dispense amounts for cash-out */
|
||||
dispenseAmounts: { denomination: number; count: number }[]
|
||||
/** Cash-out: selected denominations (each entry is one bill) */
|
||||
cashOutSelection: number[]
|
||||
/** Available inventory: denomination -> count available */
|
||||
inventory: Record<number, number>
|
||||
|
||||
// User
|
||||
/** User's npub for receipt */
|
||||
userNpub: string | null
|
||||
|
||||
// Error handling
|
||||
/** Current error message */
|
||||
error: string | null
|
||||
/** Retry count for recoverable errors */
|
||||
retryCount: number
|
||||
|
||||
// Transaction metadata
|
||||
/** Unique transaction ID */
|
||||
txid: string | null
|
||||
/** Transaction start time */
|
||||
startedAt: number | null
|
||||
|
||||
// Cash-in session (for ndebit single-use protection)
|
||||
/** Unique session ID for this cash-in transaction (used in ndebit pointer) */
|
||||
cashInSessionId: string | null
|
||||
}
|
||||
|
||||
/** ATM events */
|
||||
export type ATMEvent =
|
||||
// User actions
|
||||
| { type: 'SELECT_CASH_IN' }
|
||||
| { type: 'SELECT_CASH_OUT' }
|
||||
| { type: 'CANCEL' }
|
||||
| { type: 'SELECT_AMOUNT'; amount: number }
|
||||
| { type: 'FINISH_INSERTING' }
|
||||
| { type: 'USER_SCANNED_NPUB'; npub: string }
|
||||
| { type: 'SKIP_RECEIPT' }
|
||||
| { type: 'RETRY' }
|
||||
// Cash-out amount selection
|
||||
| { type: 'ADD_DENOMINATION'; denomination: number }
|
||||
| { type: 'REMOVE_DENOMINATION'; denomination: number }
|
||||
| { type: 'CLEAR_SELECTION' }
|
||||
| { type: 'CONFIRM_AMOUNT' }
|
||||
// Hardware events
|
||||
| { type: 'BILL_INSERTED'; denomination: number }
|
||||
| { type: 'BILL_REJECTED'; reason: string }
|
||||
| { type: 'CASH_DISPENSED' }
|
||||
| { type: 'DISPENSE_ERROR'; error: string }
|
||||
// Payment events
|
||||
| { type: 'PAYMENT_RECEIVED'; preimage: string }
|
||||
| { type: 'PAYMENT_FAILED'; error: string }
|
||||
| { type: 'INVOICE_GENERATED'; invoice: string }
|
||||
| { type: 'OFFER_GENERATED'; offer: string }
|
||||
| { type: 'NOFFER_GENERATED'; noffer: string }
|
||||
| { type: 'OFFER_REQUEST_RECEIVED'; request: OfferRequestEvent }
|
||||
| { type: 'INVOICE_SENT'; invoice: string }
|
||||
// System events
|
||||
| { type: 'TIMEOUT' }
|
||||
| { type: 'ERROR'; error: string }
|
||||
| { type: 'EXCHANGE_RATE_UPDATED'; rate: number }
|
||||
|
||||
/** Initial context values */
|
||||
export const initialContext: ATMContext = {
|
||||
fiatAmount: 0,
|
||||
satsAmount: 0,
|
||||
currency: 'USD',
|
||||
exchangeRate: 0,
|
||||
feePercent: 0.02,
|
||||
invoice: null,
|
||||
clinkOffer: null,
|
||||
nofferString: null,
|
||||
ndebitUri: null,
|
||||
lnurlWithdraw: null,
|
||||
paymentStatus: null,
|
||||
preimage: null,
|
||||
paymentMethod: null,
|
||||
pendingOfferRequest: null,
|
||||
billsInserted: [],
|
||||
cashDispensed: false,
|
||||
dispenseAmounts: [],
|
||||
cashOutSelection: [],
|
||||
// Mock inventory: $20 bills only for Phase 1
|
||||
inventory: { 20: 50 },
|
||||
userNpub: null,
|
||||
error: null,
|
||||
retryCount: 0,
|
||||
txid: null,
|
||||
startedAt: null,
|
||||
cashInSessionId: null,
|
||||
}
|
||||
|
||||
/** Service inputs for actors */
|
||||
export interface ATMServices {
|
||||
/** Generate a CLINK offer (for cash-out) */
|
||||
generateClinkOffer: (context: ATMContext) => Promise<string>
|
||||
/** Generate an ndebit URI for cash-in (clink:ndebit1...?amount=X) */
|
||||
generateNdebit: (context: ATMContext) => Promise<string>
|
||||
/** Generate an LNURL-withdraw link (for cash-in - customer receives sats) - legacy */
|
||||
generateLnurlWithdraw: (context: ATMContext) => Promise<string>
|
||||
/** Generate a Lightning invoice */
|
||||
generateInvoice: (amountMsat: number) => Promise<string>
|
||||
/** Send receipt via Nostr */
|
||||
sendNostrReceipt: (context: ATMContext) => Promise<void>
|
||||
/** Dispense cash */
|
||||
dispenseCash: (amounts: { denomination: number; count: number }[]) => Promise<void>
|
||||
/** Get current exchange rate */
|
||||
getExchangeRate: (currency: string) => Promise<number>
|
||||
/** Generate noffer string for cash-out (static payment code) */
|
||||
generateNoffer: () => Promise<string>
|
||||
/**
|
||||
* Send Kind 21001 invoice response to payer's wallet
|
||||
* Returns the BOLT11 invoice that was sent
|
||||
*/
|
||||
sendOfferResponse: (request: OfferRequestEvent, invoice: string) => Promise<void>
|
||||
/**
|
||||
* Validate that requested amount can be dispensed
|
||||
* Returns the fiat amount in cents, or throws if invalid
|
||||
*/
|
||||
validateDispenseAmount: (amountSats: number, exchangeRate: number) => Promise<number>
|
||||
/**
|
||||
* Watch an invoice for payment (polling-based)
|
||||
* Calls the callback when paid, returns cleanup function
|
||||
*/
|
||||
watchInvoice: (paymentHash: string, callback: (preimage: string) => void) => () => void
|
||||
/**
|
||||
* Get available inventory: denomination -> count
|
||||
*/
|
||||
getInventory: () => Promise<Record<number, number>>
|
||||
}
|
||||
22
packages/state-machine/tsconfig.json
Normal file
22
packages/state-machine/tsconfig.json
Normal 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"]
|
||||
}
|
||||
8
packages/state-machine/vitest.config.ts
Normal file
8
packages/state-machine/vitest.config.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: false,
|
||||
},
|
||||
})
|
||||
40
packages/ui-shared/package.json
Normal file
40
packages/ui-shared/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "@lamassu/ui-shared",
|
||||
"version": "0.1.0",
|
||||
"description": "Shared Vue 3 components for Lamassu ATM and dashboard",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./components/*": {
|
||||
"types": "./dist/components/*.d.ts",
|
||||
"import": "./dist/components/*.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"dev": "vite build --watch",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"@vitejs/plugin-vue": "^5.2.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^2.1.0",
|
||||
"vue-tsc": "^2.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "^3.5.0"
|
||||
}
|
||||
}
|
||||
17
packages/ui-shared/src/__tests__/index.test.ts
Normal file
17
packages/ui-shared/src/__tests__/index.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { version } from '../index.js'
|
||||
|
||||
describe('@lamassu/ui-shared', () => {
|
||||
describe('exports', () => {
|
||||
it('should export version', () => {
|
||||
expect(version).toBe('0.1.0')
|
||||
})
|
||||
})
|
||||
|
||||
// TODO: Add tests when Vue components are implemented
|
||||
describe('placeholder', () => {
|
||||
it('should pass placeholder test', () => {
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
13
packages/ui-shared/src/index.ts
Normal file
13
packages/ui-shared/src/index.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
/**
|
||||
* @lamassu/ui-shared
|
||||
*
|
||||
* Shared Vue 3 components for Lamassu ATM and dashboard.
|
||||
* This package will contain common UI components like:
|
||||
* - QR code display
|
||||
* - Number pad
|
||||
* - Amount display
|
||||
* - Status indicators
|
||||
*/
|
||||
|
||||
// Placeholder export - components will be added as needed
|
||||
export const version = '0.1.0'
|
||||
22
packages/ui-shared/tsconfig.json
Normal file
22
packages/ui-shared/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
22
packages/ui-shared/vite.config.ts
Normal file
22
packages/ui-shared/vite.config.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, 'src/index.ts'),
|
||||
name: 'LamassuUIShared',
|
||||
fileName: 'index',
|
||||
},
|
||||
rollupOptions: {
|
||||
external: ['vue'],
|
||||
output: {
|
||||
globals: {
|
||||
vue: 'Vue',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue