From e6e77f808e68152878084d1c95ee4f625e4fb1f8 Mon Sep 17 00:00:00 2001 From: Patrick Mulligan Date: Mon, 16 Feb 2026 16:58:32 -0500 Subject: [PATCH] feat(docker): add fund command and Nostr RPC funding script - Add './dev.sh fund [amount]' for funding ATM via Nostr RPC - Create invoices via NIP-44 encrypted RPC instead of HTTP API - Support existing users without fail_if_exists errors - Add --fund flag to './dev.sh up' for one-command setup Co-Authored-By: Claude Opus 4.6 --- docker/dev.sh | 153 ++++++++++++++++++++++++----- packages/nostr-client/fund-dev.mjs | 113 ++++++++++++--------- 2 files changed, 194 insertions(+), 72 deletions(-) diff --git a/docker/dev.sh b/docker/dev.sh index 56e5656..54d7589 100755 --- a/docker/dev.sh +++ b/docker/dev.sh @@ -39,6 +39,7 @@ PUBKEY_FILE="$STATE_DIR/lightning-pub-pubkey" NPROFILE_FILE="$STATE_DIR/lightning-pub-nprofile" APP_TOKEN_FILE="$STATE_DIR/atm-app-token" APP_ID_FILE="$STATE_DIR/atm-app-id" +LINKING_TOKEN_FILE="$STATE_DIR/atm-linking-token" MODE_FILE="$STATE_DIR/compose-mode" # "standalone" or "unified" # Get current compose file based on last used mode @@ -68,12 +69,63 @@ success() { echo -e "${GREEN}✓${NC} $1"; } # Ensure state directory exists mkdir -p "$STATE_DIR" +# Get ATM pubkey from its private key +get_atm_pubkey() { + local env_file="$PROJECT_DIR/apps/machine/.env" + if [[ ! -f "$env_file" ]]; then + return 1 + fi + + local privkey=$(grep "VITE_ATM_PRIVATE_KEY=" "$env_file" | cut -d= -f2) + if [[ -z "$privkey" ]]; then + return 1 + fi + + # Use node with @noble/curves to derive pubkey from privkey + cd "$PROJECT_DIR" && node -e " + const { secp256k1 } = require('./node_modules/.pnpm/@noble+curves@2.0.1/node_modules/@noble/curves/secp256k1.js'); + const privkeyHex = '$privkey'; + const privkey = Uint8Array.from(Buffer.from(privkeyHex, 'hex')); + const pubkeyFull = secp256k1.getPublicKey(privkey, true); + const pubkey = Buffer.from(pubkeyFull.slice(1)).toString('hex'); + console.log(pubkey); + " 2>/dev/null +} + ############################################################################# # Helper Functions ############################################################################# get_local_ip() { - ip route get 1 2>/dev/null | awk '{print $7; exit}' || hostname -I | awk '{print $1}' + # Allow explicit override via env var + if [[ -n "${LAMASSU_HOST_IP:-}" ]]; then + echo "$LAMASSU_HOST_IP" + return + fi + + # Linux: use ip route + if command -v ip &>/dev/null; then + local ip=$(ip route get 1 2>/dev/null | awk '{print $7; exit}') + if [[ -n "$ip" ]]; then + echo "$ip" + return + fi + fi + + # macOS: use route + ifconfig + if [[ "$(uname)" == "Darwin" ]]; then + local iface=$(route get default 2>/dev/null | awk '/interface:/ {print $2}') + if [[ -n "$iface" ]]; then + local ip=$(ifconfig "$iface" 2>/dev/null | awk '/inet / {print $2}') + if [[ -n "$ip" ]]; then + echo "$ip" + return + fi + fi + fi + + # Fallback: hostname -I (Linux) or hostname (macOS) + hostname -I 2>/dev/null | awk '{print $1}' || hostname 2>/dev/null } is_regtest_running() { @@ -435,10 +487,19 @@ EOF } setup_atm_app() { - log "Creating ATM app..." + # Use a fixed app name so we reuse the same app across restarts + local app_name="lamassu-atm-dev" - # Generate unique app name to avoid conflicts - local app_name="atm-$(date +%s)" + # Check if we already have valid app state + if [[ -f "$APP_ID_FILE" ]] && [[ -f "$APP_TOKEN_FILE" ]]; then + local existing_app_id=$(cat "$APP_ID_FILE") + if [[ -n "$existing_app_id" ]]; then + log "Using existing ATM app: ${existing_app_id:0:16}..." + return 0 + fi + fi + + log "Creating ATM app..." local response=$(curl -s -X POST http://localhost:1776/api/admin/app/add \ -H "Content-Type: application/json" \ @@ -452,7 +513,7 @@ setup_atm_app() { echo "$app_id" > "$APP_ID_FILE" echo "$app_token" > "$APP_TOKEN_FILE" - # Update ATM .env with app ID + # Update ATM .env with app ID and token local env_file="$PROJECT_DIR/apps/machine/.env" if [[ -f "$env_file" ]]; then if grep -q "VITE_APP_ID" "$env_file"; then @@ -462,6 +523,11 @@ setup_atm_app() { echo "# ATM App ID for LNURL-withdraw" >> "$env_file" echo "VITE_APP_ID=$app_id" >> "$env_file" fi + if grep -q "VITE_APP_TOKEN" "$env_file"; then + sed -i "s|VITE_APP_TOKEN=.*|VITE_APP_TOKEN=$app_token|" "$env_file" + else + echo "VITE_APP_TOKEN=$app_token" >> "$env_file" + fi fi success "Created ATM app: ${app_id:0:16}..." @@ -750,31 +816,64 @@ cmd_fund() { exit 1 fi - # Check for app token - if [[ ! -f "$APP_TOKEN_FILE" ]]; then - error "ATM app not configured. Run './dev.sh up' first." + # Check for Lightning.Pub pubkey + if [[ ! -f "$PUBKEY_FILE" ]]; then + error "Lightning.Pub not configured. Run './dev.sh up' first." exit 1 fi - local app_token=$(cat "$APP_TOKEN_FILE") + local lp_pubkey=$(cat "$PUBKEY_FILE") + local env_file="$PROJECT_DIR/apps/machine/.env" - log "Creating invoice for $amount sats..." - - # Create invoice for app owner (using unique payer_identifier) - local payer_id="funder-$(date +%s)" - local response=$(curl -s -X POST "http://localhost:1776/api/app/add/invoice" \ - -H "Authorization: Bearer $app_token" \ - -H "Content-Type: application/json" \ - -d "{\"payer_identifier\": \"$payer_id\", \"http_callback_url\": \"\", \"invoice_req\": {\"amountSats\": $amount, \"memo\": \"ATM funding\"}}") - - local invoice=$(echo "$response" | grep -oP '"invoice":"\K[^"]+') - - if [[ -z "$invoice" ]]; then - error "Failed to create invoice" - echo "Response: $response" + # Get ATM private key from .env + if [[ ! -f "$env_file" ]]; then + error "ATM not configured. Run './dev.sh up' first." exit 1 fi + local atm_privkey=$(grep "VITE_ATM_PRIVATE_KEY=" "$env_file" | cut -d= -f2) + if [[ -z "$atm_privkey" ]]; then + error "VITE_ATM_PRIVATE_KEY not found in .env" + exit 1 + fi + + # Get app ID - required to ensure funds go to the same user as LNURL-withdraw + local app_id="" + if [[ -f "$APP_ID_FILE" ]]; then + app_id=$(cat "$APP_ID_FILE") + else + app_id=$(grep "VITE_APP_ID=" "$env_file" | cut -d= -f2) + fi + + if [[ -z "$app_id" ]]; then + error "No app ID found. Run './dev.sh up' first to create the ATM app." + exit 1 + fi + + log "Creating invoice via Nostr RPC for $amount sats..." + log "Using app ID: ${app_id:0:16}..." + + # Use the Nostr-based funding script (the Lightning.Pub way) + # This creates an invoice for the ATM's Nostr user under the correct app, + # ensuring balance is shared with LNURL-withdraw (Extension API) + local invoice=$(cd "$PROJECT_DIR/packages/nostr-client" && \ + VITE_ATM_PRIVATE_KEY="$atm_privkey" \ + VITE_LIGHTNING_PUB_PUBKEY="$lp_pubkey" \ + VITE_RELAY_URL="ws://localhost:7777" \ + VITE_APP_ID="$app_id" \ + node fund-dev.mjs "$amount" 2>&1) + + # Extract just the invoice (last line, starts with lnbc) + local bolt11=$(echo "$invoice" | grep -E "^lnbc") + + if [[ -z "$bolt11" ]]; then + error "Failed to create invoice via Nostr" + echo "Output: $invoice" + exit 1 + fi + + log "Got invoice: ${bolt11:0:30}..." + log "Paying invoice from lnd-3..." # Source regtest helpers and pay @@ -782,7 +881,7 @@ cmd_fund() { ( cd "$REGTEST_DIR" source docker-scripts.sh 2>/dev/null - lncli-sim 3 payinvoice --force "$invoice" + lncli-sim 3 payinvoice --force "$bolt11" ) if [[ $? -eq 0 ]]; then success "Funded ATM with $amount sats" @@ -792,7 +891,7 @@ cmd_fund() { fi else # Fallback: try direct docker exec - docker exec lnbits-lnd-3-1 lncli --network=regtest --rpcserver=lnd-3:10009 payinvoice --force "$invoice" + docker exec lnbits-lnd-3-1 lncli --network=regtest --rpcserver=lnd-3:10009 payinvoice --force "$bolt11" if [[ $? -eq 0 ]]; then success "Funded ATM with $amount sats" else @@ -978,6 +1077,9 @@ case "${1:-help}" in echo " --fund= Auto-fund ATM with specific amount" echo " --machine Launch ATM app after startup" echo "" + echo "Environment variables:" + echo " LAMASSU_HOST_IP Override auto-detected LAN IP (for VPN/multi-NIC)" + echo "" echo "Examples:" echo " $0 up # Start (uses shared regtest)" echo " $0 up --standalone # Start self-contained" @@ -989,5 +1091,6 @@ case "${1:-help}" in echo " $0 fund 200000 # Add 200k more sats" echo " $0 logs lightning-pub" echo " $0 reset && $0 up --fund # Fresh start with funding" + echo " LAMASSU_HOST_IP=192.168.1.50 $0 up # Override LAN IP" ;; esac diff --git a/packages/nostr-client/fund-dev.mjs b/packages/nostr-client/fund-dev.mjs index 636dca2..3edb0fb 100644 --- a/packages/nostr-client/fund-dev.mjs +++ b/packages/nostr-client/fund-dev.mjs @@ -1,17 +1,44 @@ +#!/usr/bin/env node +/** + * Fund the ATM's Lightning.Pub account via Nostr RPC + * + * This creates an invoice for the ATM user (authenticated via Nostr), + * so the funds go directly to the user that will be queried for balance. + * + * Usage: + * VITE_ATM_PRIVATE_KEY=xxx VITE_LIGHTNING_PUB_PUBKEY=yyy node fund-dev.mjs [amount] + */ 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) +const ATM_PRIVATE_KEY = process.env.VITE_ATM_PRIVATE_KEY +const LIGHTNING_PUB_PUBKEY = process.env.VITE_LIGHTNING_PUB_PUBKEY +const RELAY_URL = process.env.VITE_RELAY_URL || 'ws://localhost:7777' +const APP_ID = process.env.VITE_APP_ID || '' +const FUND_AMOUNT = parseInt(process.argv[2] || process.env.FUND_AMOUNT || '100000', 10) + +if (!ATM_PRIVATE_KEY) { + console.error('Error: VITE_ATM_PRIVATE_KEY environment variable required') + process.exit(1) +} + +if (!LIGHTNING_PUB_PUBKEY) { + console.error('Error: VITE_LIGHTNING_PUB_PUBKEY environment variable required') + process.exit(1) +} + +if (!APP_ID) { + console.error('Error: VITE_APP_ID environment variable required') + console.error('This ensures funds go to the same user as LNURL-withdraw uses') + process.exit(1) +} async function main() { - const identity = loadIdentityFromHex(DEV_PRIVATE_KEY) - console.log('Using identity:', identity.publicKey) + const identity = loadIdentityFromHex(ATM_PRIVATE_KEY) + console.error('[fund-dev] ATM pubkey:', identity.publicKey) + console.error('[fund-dev] Lightning.Pub pubkey:', LIGHTNING_PUB_PUBKEY) + console.error('[fund-dev] Creating invoice for', FUND_AMOUNT, 'sats') const client = new NostrClient({ relays: [{ url: RELAY_URL }], @@ -19,27 +46,27 @@ async function main() { }) await client.connect() - console.log('Connected to relay') + console.error('[fund-dev] Connected to relay:', RELAY_URL) const requestId = randomUUID() + console.error('[fund-dev] App ID:', APP_ID) + // Correct RPC structure per Lightning.Pub documentation + // appId ensures the Nostr user is created under the same app as HTTP API users const rpcRequest = { rpcName: 'NewInvoice', params: {}, query: {}, body: { amountSats: FUND_AMOUNT, - memo: `Fund dev account (${FUND_AMOUNT} sats)`, + memo: `ATM funding (${FUND_AMOUNT} sats)`, }, authIdentifier: identity.publicKey, requestId, + appId: APP_ID, } - 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( @@ -52,63 +79,55 @@ async function main() { identity.privateKey ) - console.log('Publishing NewInvoice request (event id:', event.id, ')...') - // Subscribe to responses before publishing - let responseReceived = false + let invoice = null const subId = client.subscribe( [ { kinds: [21000], authors: [LIGHTNING_PUB_PUBKEY], + '#p': [identity.publicKey], 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) + try { + const response = decryptJSON(identity, LIGHTNING_PUB_PUBKEY, evt.content) + if (response.requestId === requestId && response.invoice) { + invoice = response.invoice + console.error('[fund-dev] Got invoice!') } + } catch (err) { + // Ignore decrypt errors for other messages } }, } ) await client.publish(event) - console.log('Request published, waiting for response...') + console.error('[fund-dev] Request published, waiting for invoice...') - // Wait up to 10 seconds for response - for (let i = 0; i < 20; i++) { + // Wait up to 15 seconds for response + for (let i = 0; i < 30; i++) { await new Promise((resolve) => setTimeout(resolve, 500)) - if (responseReceived) break - } - - if (!responseReceived) { - console.log('No response received within timeout') + if (invoice) break } client.unsubscribe(subId) client.disconnect() + + if (!invoice) { + console.error('[fund-dev] Error: No invoice received within timeout') + process.exit(1) + } + + // Output just the invoice to stdout (for piping to payment command) + console.log(invoice) process.exit(0) } -main().catch(console.error) +main().catch((err) => { + console.error('[fund-dev] Error:', err.message) + process.exit(1) +})