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
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,
|
||||
},
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue