bitspire/packages/nostr-client/dev/atm-debit-agent.mjs
Patrick Mulligan 5448a620a9 chore(nostr-client): move dev scripts to dev/ folder
Move 9 development/testing .mjs scripts out of the package root into
dev/ to keep the published package clean. Update relative imports
and dev.sh reference.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 17:48:49 -05:00

270 lines
8.7 KiB
JavaScript

/**
* 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)
})