From 61ff5b2c25e60d76c0a00aaafb774f9df2cbd855 Mon Sep 17 00:00:00 2001 From: welliv Date: Sat, 4 Apr 2026 20:04:36 +0000 Subject: [PATCH 1/3] feat: Complete Lightning wallet with auto-verification, budget control, styled receipts --- README.md | 109 ++++++++-------- SKILL.md | 167 ++++++++++++++++++------ package.json | 9 ++ references/payment-validation.md | 45 +++++++ regenerate-types.sh | 9 -- scripts/analytics.js | 100 ++++++++++++++ scripts/auto_ledger.js | 161 +++++++++++++++++++++++ scripts/balance.js | 74 +++++++++++ scripts/budget_guardian.js | 129 ++++++++++++++++++ scripts/decode.js | 101 +++++++++++++++ scripts/export_ledger.js | 67 ++++++++++ scripts/gen_card.py | 216 +++++++++++++++++++++++++++++++ scripts/health_check.js | 103 +++++++++++++++ scripts/hold_invoice.js | 90 +++++++++++++ scripts/hold_invoice_manual.js | 89 +++++++++++++ scripts/monitor_payments.js | 51 ++++++++ scripts/qr_invoice.js | 38 ++++++ scripts/streaks.js | 79 +++++++++++ scripts/summary.js | 121 +++++++++++++++++ scripts/validate.js | 139 ++++++++++++++++++++ scripts/wallets.js | 167 ++++++++++++++++++++++++ 21 files changed, 1956 insertions(+), 108 deletions(-) create mode 100644 package.json create mode 100644 references/payment-validation.md delete mode 100755 regenerate-types.sh create mode 100644 scripts/analytics.js create mode 100644 scripts/auto_ledger.js create mode 100644 scripts/balance.js create mode 100644 scripts/budget_guardian.js create mode 100644 scripts/decode.js create mode 100644 scripts/export_ledger.js create mode 100644 scripts/gen_card.py create mode 100644 scripts/health_check.js create mode 100644 scripts/hold_invoice.js create mode 100644 scripts/hold_invoice_manual.js create mode 100644 scripts/monitor_payments.js create mode 100644 scripts/qr_invoice.js create mode 100644 scripts/streaks.js create mode 100644 scripts/summary.js create mode 100644 scripts/validate.js create mode 100644 scripts/wallets.js diff --git a/README.md b/README.md index 5df317a..9fb76b0 100644 --- a/README.md +++ b/README.md @@ -1,79 +1,74 @@ -# Alby Bitcoin Builder Skill +# Alby Bitcoin Lightning Wallet Skill — Community Edition -Turn your favorite agent into a bitcoin app builder. +A complete Bitcoin Lightning wallet powered by Alby's Nostr Wallet Connect. The agent handles everything — you just talk naturally. -Generate lightning-powered apps, payment flows, and wallet logic in minutes - ready to test and ship, without even needing a wallet. +Every payment gets **cryptographically proven** with SHA-256 preimage verification. No transaction is ever lost. No payment can be disputed. -Before you start, try **[Alby Sandbox](https://sandbox.albylabs.com)** to see what you can build! +## What It Does -This repository contains an [agent skill](https://agentskills.io/specification) that helps agents use the [Alby JS SDK](https://github.com/getAlby/js-sdk) and [Alby Lightning Tools](https://github.com/getAlby/js-lightning-tools). +| Say This | Get This | +|----------|----------| +| "balance" | Multi-currency balance + styled card | +| "create invoice 100 sats" | BOLT-11 + QR code + styled receipt | +| "send 500 to alice@getalby.com" | Confirmed payment with preimage proof | +| "wallet summary" | Complete one-command overview | +| "verify this payment" | SHA-256 cryptographic proof | -> Also check out our **[Wallet CLI skill](https://github.com/getAlby/alby-cli-skill)** +## Features -## Getting Started +- **Auto-Ledger** — Background process that verifies and saves every settled payment with SHA-256 preimage proofs +- **Budget Guardian** — Weekly spending caps with 90% alerts (commitment device) +- **Multi-Wallet** — Add, switch, and manage multiple N wallets +- **Activity Milestones** — Gamified streak tracking +- **Styled Receipt Cards** — Beautiful PIL/Pillow cards for messaging platforms +- **6-Point Health Diagnostics** — Instant wallet status check +- **CSV Export** — Tax-ready, audit-proof transaction records +- **Safety Confirmations** — Mandatory confirmation before any outgoing payment +- **NWC Lie Detection** — Verifies every payment appears in transaction history after send -### 🚀 Install with single command +## Install -`npx skills add getAlby/alby-agent-skill` +### For Hermes Agent Users -### Manual Install +```bash +# Drop into your skills directory +git clone https://github.com/getAlby/alby-agent-skill.git ~/.hermes/skills/alby-bitcoin-payments +cd ~/.hermes/skills/alby-bitcoin-payments +npm install @getalby/sdk @getalby/lightning-tools light-bolt11-decoder qrcode +``` -[Download](https://github.com/getAlby/alby-agent-skill/archive/refs/heads/master.zip) this repository and extract it, then follow instructions for your specific agent. +### Configure -> Double check the skill is activated by asking your agent "What Skills are available?". It should include "Alby Agent Skill" +Save your NWC URL in `~/.hermes/config_local.json`: -### Claude Code +```json +{ + "wallet": { + "nwc_url": "nostr+walletconnect://..." + } +} +``` -Make a `.claude/skills` folder in your project and put the extracted skills folder there ([see other options](https://code.claude.com/docs/en/skills#where-skills-live)) +### Requirements -### Gemini CLI +- Node.js 22+ +- Python 3 with Pillow (for styled cards) +- Alby NWC wallet connection -Make a `.gemini/skills` folder in your project and put the extracted skills folder there ([see other options](https://geminicli.com/docs/cli/skills/#skill-discovery-tiers)) +## Test Wallet -### Roo Code +Get instant test wallet with 10,000 sats: -Make a `.roo/skills` folder in your project and put the extracted skills folder there ([see other options](https://docs.roocode.com/features/skills#1-choose-a-location)) +```bash +curl -X POST https://faucet.nwc.dev?balance=10000 +``` -## Test / Dummy Wallets +## What Makes This Special -Alby Agent skill has the knowledge to create dummy wallets for testing. You can build and test your app end-to-end without creating a wallet. Once you are ready, the agent skill can also help you setup a wallet to use in production. +Every payment is a **trustless cryptographic event**. The preimage proves it happened — mathematics, not promises. -## Example prompts +A wallet without verified preimages is just a claim. This skill turns claims into proof. -> Explore more prompts in the **[Alby Sandbox](https://sandbox.albylabs.com)** +--- -### Console Apps - -#### Listen to received payments and send a payment to a lightning address with USD amounts - -> Use the Alby Bitcoin Payments Agent Skill to create a TypeScript console app that when receives a notification of an incoming payment, sends $0.10 USD to . The NWC_URL is in the .env file. - -image - -#### Conditionally receive payments (NOTE: only supported by Alby Hub) - -> Use the Alby Bitcoin Payments Agent Skill to create a TypeScript console app that creates a hold invoice of $1 and asks the user to provide a lightning address and choose heads or tails. Once the hold invoice is accepted, flip a coin. If the user guessed correctly, cancel the hold invoice and pay the user $1 to their lightning address. If the user guessed incorrectly, settle the hold invoice. The NWC_URL is in the .env file. - -image - -### Frontend Apps - -#### Streamer QR page with payment notifications - -> Use the Alby Bitcoin Payments Agent Skill to create a single-page HTML app that listens to incoming payments, and each time one comes in, shows a confetti animation and the payment amount and message. It should also have a QR code of the receiving lightning address that should be displayed on the corner of the screen so people watching can easily send payments. When I first open the page it should prompt me for a NWC connection secret so it can connect to my wallet to listen for payments, and also extract the lightning address from the NWC connection secret for the QR code. - -image - -### Testing - -#### Example test for a backend or console app - -> Use the Alby Bitcoin Payments Agent Skill to create a TypeScript console app where Alice creates an invoice and Bob pays it. Write tests for it using jest. - -#### Example test for a frontend app (vitest + Playwright) - -> Use the Alby Bitcoin Payments Agent Skill to create a Vite TypeScript React app where a user can connect their wallet and then purchase fake cat pictures (simple canvas art) with a single click. Each picture costs 5000 sats. Show the total the shop has earned and their remaining stock of cat pictures. There should only be 21. Write tests for the app using vitest and playwright. Also take screenshots and review the screenshots. - -## Development - -Examples are hand-written, but lack the necessary typing information. Types are copied directly from the referenced projects using [this script](./regenerate-types.sh) +Built on: [Alby JS SDK](https://github.com/getAlby/js-sdk) · [Alby Lightning Tools](https://github.com/getAlby/js-lightning-tools) · [NIP-47 NWC Protocol](https://github.com/nostr-protocol/nips/blob/master/47.md) diff --git a/SKILL.md b/SKILL.md index ef4cfb2..4a4e932 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,47 +1,130 @@ --- -name: alby-bitcoin-builder -description: Add bitcoin lightning wallet capabilities to your app using Nostr Wallet Connect (NIP-47), LNURL, and WebLN. Send and receive payments, handle payment notifications, fetch wallet balance and transaction list, do bitcoin to fiat currency conversions, query lightning addresses, conditionally settle payments (HOLD invoices), parse BOLT-11 invoices, verify payment preimages. -license: Apache-2.0 -metadata: - author: getAlby - version: "1.2.1" +name: alby-bitcoin-payments +description: Bitcoin Lightning wallet via NWC. Say anything natural, get styled receipts. Every payment cryptographically proven with SHA-256 preimage verification. +version: "7.0.0" --- -# Usage +## What This Is + +A complete Bitcoin Lightning wallet powered by Alby's Nostr Wallet Connect. The agent handles everything — the user just talks naturally. + +## How It Works -Use this skill to understand how to build apps that require bitcoin lightning wallet capabilities. +User → says anything natural → agent does everything → styled receipt. + +``` +User: "send 500 to alice@getalby.com" +User: "balance" +User: "create invoice for 1000 sats" +User: "my wallet" ← one command, everything +User: "verify this payment" +``` + +## Core Agent Rules -- [NWC Client: Interact with a wallet to do things like sending and receive payments, listen to payment notifications, fetch balance and transaction list and wallet info](./references/nwc-client/nwc-client.md) -- [Lightning Tools: Request invoices from a lightning address, parse BOLT-11 invoices, verify a preimage for a BOLT-11 invoice, LNURL-Verify, do bitcoin <-> fiat conversions](./references/lightning-tools/lightning-tools.md) -- [Bitcoin Connect: Browser-only UI components for connecting wallets and accepting payments in React, Vue, or pure HTML web apps](./references/bitcoin-connect/bitcoin-connect.md) - -## Prefer Typescript - -When the user says to use "JS" or "Javascript" or "NodeJS" or something similar, use typescript unless the user explicitly says to not use typescript or the project does not support it. - -## Imports - -Do NOT import from the dist directory. - -## Read the Typings - -Based on what functionality you require, read the relevant typings: - -- [NWC Client](./references/nwc-client/nwc.d.ts) -- [Lightning Tools](./references/lightning-tools/index.d.ts) -- [Bitcoin Connect](./references/bitcoin-connect/bundle.d.ts) -- [Bitcoin Connect React](./references/bitcoin-connect/react.bundle.d.ts) - -## Testing Wallets - -If the user doesn't have a wallet yet, or needs one for development or testing, [testing wallets can be created with a single request](./references/testing-wallets.md). - -### Automated Testing - -Testing wallets should be used for [automated testing](./references/automated-testing.md). - -It is recommended to write tests so that the agent can test its own work and fix bugs itself without requiring human input. - -## Production Wallet - -If they do not have a wallet yet [here are some options](./references/production-wallets.md) \ No newline at end of file +**On session start:** +1. Read `wallet.nwc_url` from `~/.hermes/config_local.json` +2. If auto-ledger not running, start it in background +3. Show balance + +**On every wallet operation:** +- `cd /root/.hermes/skills/alby-bitcoin-payments` first +- NWC returns millisats → always divide by 1000 +- Always include fiat equivalent (USD default) +- NWC URL → never echo back + +**Before sending:** +1. Decode destination → show amount, fiat, recipient +2. Confirm: `"Send X sats ($Y) to Z? Reply YES to confirm."` +3. After paying → verify in `listTransactions()` (NWC can report false success) +4. Generate styled PIL card → deliver as MEDIA + +**On invoice creation:** +- BOLT-11 in code block (one-tap copy) +- QR code as MEDIA image +- Include sats + fiat + +**On BOLT-11 or lightning address in any message:** +- Auto-decode → show details → offer next action + +## Response Style + +**Telegram/messaging:** Always PIL card as MEDIA, emoji summary +**Terminal:** ASCII panel + clean text + +### Balance Card +``` +Wallet: alias | mainnet +Balance: 1,234 sats +$ 1.23 USD · € 1.07 EUR · KSh 159.20 KES +``` + +### Transaction Row +``` +→ +100 sats ($0.07) ✅ | description | bal: 1,234 +← -50 sats ($0.03) ✅ | to alice | bal: 1,184 +``` + +### Payment Proof +``` +Payment Hash: a863be4753fe982d... +SHA-256(preimage): a863be4753fe982d... +✅ MATCH — Preimage: 9137715c...59b1 +``` + +## Behind the Scenes + +| Feature | Script | What It Does | +|---|---|---| +| Summary | `summary.js` | One-command wallet overview | +| Balance | `balance.js` | Multi-currency balance (USD/EUR/KES) | +| Invoice | `qr_invoice.js` | BOLT-11 invoice + QR code | +| Decode | `decode.js` | Parse invoices or lightning addresses | +| Analytics | `analytics.js` | Period reports with top transactions | +| Auto-Ledger | `auto_ledger.js` | Background: real-time payments + crypto proofs | +| Validate | `validate.js` | Standalone preimage verification | +| Budget | `budget_guardian.js` | Weekly spending caps with alerts | +| Streaks | `streaks.js` | Activity milestones & gamification | +| Multi-Wallet | `wallets.js` | Add/switch/remove wallets | +| Health | `health_check.js` | 6-point diagnostics | +| Export | `export_ledger.js` | CSV export for tax/audit | + +## Auto-Ledger (The Trustless Core) + +Every settled payment gets automatically: +1. **Verified** — SHA-256(preimage) == payment_hash +2. **Saved** — Persistent ledger at `~/.hermes/ledgers/transactions_ledger.json` +3. **Proved** — Individual proof files in `~/.hermes/ledgers/proofs/` + +No transaction is ever lost. No payment can be disputed. Mathematics, not promises. + +## Budget Guardian + +Set a weekly spending cap. The system tracks and alerts at 90%. Pre-commitment removes the temptation of impulse spending. + +``` +node budget_guardian.js setup 5000 # 5000 sats/week +node budget_guardian.js status # Current usage +node budget_guardian.js reset # Reset for new week +``` + +## Environment + +| Path | Purpose | +|------|---------| +| `/root/.hermes/skills/alby-bitcoin-payments/` | Skill root — always `cd` here first | +| `/usr/bin/python3` | PIL/Pillow card generation (NOT venv python) | +| `~/.hermes/ledgers/` | Auto-ledger + proof files | +| `~/.hermes/config_local.json` | NWC URL + user config | + +**Requirements:** Node.js 22+, Python 3 with Pillow, `@getalby/sdk`, `@getalby/lightning-tools` + +## Troubleshooting + +| Problem | Fix | +|---|---| +| `Cannot find module` | Run from skill directory — deps are in local `node_modules/` | +| Connection refused | NWC URL expired — ask for a new one | +| Amount looks 1000x wrong | NWC = millisats. Divide by 1000. | +| `payInvoice` reported success but no transaction | NWC can lie. Always check `listTransactions()` after paying | +| PIL import fails | Use `/usr/bin/python3` — the agent's venv python doesn't have Pillow | diff --git a/package.json b/package.json new file mode 100644 index 0000000..7abacdf --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "@getalby/lightning-tools": "^7.0.2", + "@getalby/sdk": "^7.0.0", + "bolt12-decoder": "^1.0.0", + "light-bolt11-decoder": "^3.2.0", + "qrcode": "^1.5.4" + } +} diff --git a/references/payment-validation.md b/references/payment-validation.md new file mode 100644 index 0000000..98458f0 --- /dev/null +++ b/references/payment-validation.md @@ -0,0 +1,45 @@ +# Payment Validation (Preimage Verification) + +Verify that a Lightning Network payment was actually completed by cryptographically checking the preimage against the invoice's payment hash. + +## How It Works + +1. Every BOLT-11 invoice contains a `payment_hash` (SHA-256 of the preimage) +2. When payment completes, the payer's node reveals the `preimage` +3. Verifying: `SHA-256(preimage) == payment_hash` proves the payment went through +4. Only the recipient can know the preimage — so proving they had it proves they received payment + +Reference implementation: https://github.com/kingonly/validate-payment +Live web validator: https://validate-payment.com/ + +## CLI Script (Bundled) + +```bash +node scripts/validate.js [invoice] [preimage_hex] +``` + +Supports BOLT11 (`lnbc`/`lntb`) and BOLT12 (`lni`) invoice formats. +Shows decoded invoice details (amount, description, timestamp, expiry) plus the hash comparison. + +## Node.js API + +```typescript +import { decode as decodeBolt11 } from 'light-bolt11-decoder'; +import crypto from 'crypto'; + +function validatePayment(invoice: string, preimage: string): boolean { + const decoded = decodeBolt11(invoice); + const paymentHash = decoded.sections.find(s => s.name === 'payment_hash').value; + const preimageBytes = Uint8Array.from(preimage.match(/.{1,2}/g).map(h => parseInt(h, 16))); + const computed = crypto.createHash('sha256').update(preimageBytes).digest('hex'); + return computed === paymentHash; +} +``` + +## When to Use + +- **Pay-to-unlock content**: User pays, you deliver content, they can verify the preimage matches +- **Third-party payment proof**: Someone claims they paid — verify with preimage +- **Escrow/settlement verification**: Confirm funds actually moved before releasing goods +- **HOLD invoice confirmation**: After settling, the preimage proves the HTLC completed +- **Payment receipt verification**: Generate a verifiable receipt by providing preimage + invoice \ No newline at end of file diff --git a/regenerate-types.sh b/regenerate-types.sh deleted file mode 100755 index 72dbd66..0000000 --- a/regenerate-types.sh +++ /dev/null @@ -1,9 +0,0 @@ -# TODO: clone the repos and build them -cp ../js-sdk/dist/types/nwc.d.ts ./references/nwc-client -cp ../js-lightning-tools/dist/types/index.d.ts ./references/lightning-tools - -# For bitcoin connect, also run "yarn dts" to create the typescript bundle -(cd ../bitcoin-connect && yarn dts) -cp ../bitcoin-connect/dist/bundle.d.ts ./references/bitcoin-connect -(cd ../bitcoin-connect/react && yarn dts) -cp ../bitcoin-connect/react/dist/bundle.d.ts ./references/bitcoin-connect/react.bundle.d.ts \ No newline at end of file diff --git a/scripts/analytics.js b/scripts/analytics.js new file mode 100644 index 0000000..8d87d17 --- /dev/null +++ b/scripts/analytics.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node +// Spending analytics and period reports +// Usage: NWC_URL="..." node analytics.js [days_back] +// Default: 30 days + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); + +const NWC_URL = process.env.NWC_URL; +const daysBack = parseInt(process.argv[2]) || 30; + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + const until = Date.now() / 1000; + const from = until - (daysBack * 86400); + + const [txs, rateUSD, info] = await Promise.all([ + client.listTransactions({ limit: 100, from: Math.floor(from), until: Math.ceil(until) }), + getFiatValue({ satoshi: 1, currency: "USD" }).catch(() => 0), + client.getInfo().catch(() => ({ alias: "Wallet" })), + ]); + + const transactions = txs.transactions; + + if (transactions.length === 0) { + console.log(`No transactions in the last ${daysBack} days.`); + client.close(); + return; + } + + let totalIn = 0, totalOut = 0, totalFees = 0; + let settledCount = 0; + const descriptions = {}; + + for (const t of transactions) { + if (t.state !== "settled") continue; + settledCount++; + const sats = t.amount / 1000; + const fees = (t.fees_paid || 0) / 1000; + + if (t.type === "incoming") { + totalIn += sats; + } else { + totalOut += sats; + } + totalFees += fees; + + // Track unique descriptions + if (t.description) { + const desc = t.description.substring(0, 30); + if (!descriptions[desc]) descriptions[desc] = { in: 0, out: 0, count: 0 }; + if (t.type === "incoming") descriptions[desc].in += sats; + else descriptions[desc].out += sats; + descriptions[desc].count++; + } + } + + const net = totalIn - totalOut; + const label1 = daysBack === 1 ? "Today" : daysBack === 7 ? "This Week" : daysBack === 30 ? "Last 30 Days" : `Last ${daysBack} Days`; + + console.log(`══ ${info.alias || "Wallet"} — ${label1} Report ══`); + console.log(``); + console.log(`Total: ${transactions.length} transactions (${settledCount} settled)`); + console.log(``); + console.log(`→ Incoming: +${totalIn.toLocaleString()} sats (~$${(totalIn * rateUSD).toFixed(2)} USD)`); + console.log(`← Outgoing: -${totalOut.toLocaleString()} sats (~$${(totalOut * rateUSD).toFixed(2)} USD)`); + console.log(`⚡ Fees paid: ${totalFees.toLocaleString()} sats`); + console.log(``); + if (net >= 0) { + console.log(`Net flow: +${net.toLocaleString()} sats (surplus)`); + } else { + console.log(`Net flow: ${net.toLocaleString()} sats (deficit)`); + } + + if (totalIn > 0 || totalOut > 0) { + console.log(``); + console.log(`Top transactions:`); + const sorted = [...transactions] + .filter(t => t.state === "settled") + .sort((a, b) => b.amount - a.amount) + .slice(0, 5); + + for (const t of sorted) { + const sats = t.amount / 1000; + const arrow = t.type === "incoming" ? "→" : "←"; + const desc = t.description ? t.description.substring(0, 25) : "(no description)"; + const date = new Date(t.created_at * 1000).toLocaleDateString(); + console.log(` ${arrow} ${sats} sats | ${desc} | ${date}`); + } + } + + client.close(); +} + +main().catch(e => { console.error(e.message); }); diff --git a/scripts/auto_ledger.js b/scripts/auto_ledger.js new file mode 100644 index 0000000..944dede --- /dev/null +++ b/scripts/auto_ledger.js @@ -0,0 +1,161 @@ +#!/usr/bin/env node +// Auto-verify and save all transactions with preimage proofs +// Usage: NWC_URL="..." node auto_ledger.js +// Runs as background process, watches for payments, verifies and saves proofs + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const NWC_URL = process.env.NWC_URL; + +// Force stdout unbuffered for background process visibility +const origLog = console.log; +console.log = (...args) => { origLog(...args); process.stdout.write("\n"); }; +const LEDGER_DIR = path.join(process.env.HOME, ".hermes", "ledgers"); +const LEDGER_FILE = path.join(LEDGER_DIR, "transactions_ledger.json"); +const PROOFS_DIR = path.join(LEDGER_DIR, "proofs"); + +// Ensure directories exist +if (!fs.existsSync(LEDGER_DIR)) fs.mkdirSync(LEDGER_DIR, { recursive: true }); +if (!fs.existsSync(PROOFS_DIR)) fs.mkdirSync(PROOFS_DIR, { recursive: true }); + +// Load existing ledger +let ledger = []; +if (fs.existsSync(LEDGER_FILE)) { + try { + ledger = JSON.parse(fs.readFileSync(LEDGER_FILE, "utf8")); + } catch (e) { + ledger = []; + } +} + +console.log(`📖 Ledger loaded: ${ledger.length} existing transactions`); +console.log(`💾 Saving to: ${LEDGER_FILE}`); +console.log(`📁 Proofs directory: ${PROOFS_DIR}`); + +async function verifyPreimage(tx) { + if (!tx.preimage) return { valid: false, reason: "No preimage available" }; + const expected = tx.payment_hash; + const computed = crypto.createHash("sha256").update(Buffer.from(tx.preimage, "hex")).digest("hex"); + return { valid: computed === expected, preimage: tx.preimage, computed_hash: computed, expected_hash: expected }; +} + +async function getFiatUsd(sats) { + try { + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + return { rate, usd: (sats * rate).toFixed(4) }; + } catch { + return { rate: null, usd: null }; + } +} + +async function processTransaction(tx) { + // Skip if already in ledger (deduplication) + if (ledger.find(l => l.payment_hash === tx.payment_hash)) { + console.log(`⏭️ Duplicate skipped: ${tx.payment_hash.substring(0, 12)}...`); + return null; + } + + // Only process settled transactions + if (tx.state !== "settled") { + console.log(`⏳ Pending: ${tx.payment_hash.substring(0, 12)}... (state: ${tx.state})`); + return null; + } + + const sats = tx.amount / 1000; + const { usd } = await getFiatUsd(sats); + + // Verify preimage + const verification = await verifyPreimage(tx); + + const record = { + type: tx.type, + state: tx.state, + sats, + usd: usd || "N/A", + fees: tx.fees_paid / 1000, + description: tx.description || "", + payment_hash: tx.payment_hash, + preimage: tx.preimage || null, + crypto_proof: verification.valid + ? { verified: true, preimage: tx.preimage, sha256_match: true } + : { verified: false, reason: verification.reason }, + created_at: tx.created_at, + settled_at: tx.settled_at, + invoice: tx.invoice || "", + verified_at: Date.now() + }; + + // Add to ledger + ledger.push(record); + + // Save ledger + fs.writeFileSync(LEDGER_FILE, JSON.stringify(ledger, null, 2)); + + // Save individual proof file + const timestamp = new Date(tx.settled_at * 1000).toISOString().replace(/[:.]/g, "-"); + const proofFile = path.join(PROOFS_DIR, `${tx.type}_${sats}sats_${timestamp}.json`); + fs.writeFileSync(proofFile, JSON.stringify(record, null, 2)); + + return record; +} + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + console.log(`🔗 Connected to wallet: ${client.walletPubkey.substring(0, 16)}...`); + + // First, check for any recent transactions that might not have been processed + console.log("📡 Scanning recent history..."); + const recent = await client.listTransactions({ limit: 50 }); + let processed = 0; + for (const tx of recent.transactions) { + const result = await processTransaction(tx); + if (result) processed++; + } + console.log(`✅ Processed ${processed} new settlements from history`); + console.log(`📊 Ledger total: ${ledger.length} transactions`); + + // Now subscribe to real-time notifications + console.log("👀 Watching for new payments in real-time..."); + + const unsub = await client.subscribeNotifications( + async (notification) => { + const tx = notification.notification; + console.log(`\n🔔 ${notification.notification_type === "payment_received" ? "→ INCOMING" : notification.notification_type === "payment_sent" ? "← OUTGOING" : "🔒 HOLD"} | ${tx.amount / 1000} sats`); + + const result = await processTransaction(tx); + if (result) { + console.log(`✅ Verified & saved: ${result.sats} sats (${result.usd} USD) — ${result.crypto_proof.verified ? "CRYPTO PROOF ✅" : "NO PREIMAGE"}`); + } + }, + ["payment_received", "payment_sent", "hold_invoice_accepted"] + ); + + // Keep process alive + console.log("\n🔄 Auto-verification active. Process will watch for payments until stopped."); + console.log(`📖 View ledger: ${LEDGER_FILE}`); + console.log(`📁 View proofs: ${PROOFS_DIR}`); + console.log(`\nPress Ctrl+C to stop monitoring.\n`); + + // Graceful shutdown + process.on("SIGINT", () => { + console.log("\n🛑 Stopping monitoring..."); + unsub(); + client.close(); + console.log(`✅ Final ledger count: ${ledger.length} transactions`); + process.exit(0); + }); +} + +main().catch(e => { + console.error(`💥 Fatal error: ${e.message}`); + process.exit(1); +}); diff --git a/scripts/balance.js b/scripts/balance.js new file mode 100644 index 0000000..c85fc1d --- /dev/null +++ b/scripts/balance.js @@ -0,0 +1,74 @@ +#!/usr/bin/env node +// Beautiful multi-currency balance dashboard +// Usage: NWC_URL="..." node balance.js [currency1] [currency2] ... +// Default: USD EUR KES + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue, getFiatBtcRate } = require("@getalby/lightning-tools/fiat"); + +const NWC_URL = process.env.NWC_URL; +const currencies = process.argv.slice(2).length > 0 ? process.argv.slice(2) : ['USD', 'EUR', 'KES']; + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + // Fetch rates for all requested currencies dynamically + const rateData = await Promise.all( + currencies.map(c => ({ + code: c, + rate: getFiatValue({ satoshi: 1, currency: c }).catch(() => null), + })) + ); + + // Resolve all rate promises + const rates = await Promise.all(rateData.map(r => r.rate)); + + // Symbol map for common currencies + const symbols = { USD: '$', EUR: '€', GBP: '£', KES: 'KSh', JPY: '¥', CAD: 'C$', AUD: 'A$' }; + + const [balance, info] = await Promise.all([ + client.getBalance(), + client.getInfo(), + ]); + const sats = balance.balance / 1000; + + console.log(`Wallet: ${info.alias} | ${info.network}`); + console.log(`Balance: ${sats.toLocaleString()} sats`); + console.log(``); + + for (let i = 0; i < rateData.length; i++) { + const { code } = rateData[i]; + const rate = rates[i]; + const sym = symbols[code] || ''; + if (rate === null) { + console.log(`${sym ?? ''} (rate unavailable) ${code}`); + continue; + } + const val = sats * rate; + if (val >= 1) { + console.log(`${sym} ${val.toFixed(2)} ${code}`); + } else { + console.log(`${sym} ${val.toFixed(4)} ${code}`); + } + } + + console.log(``); + // Show per-sat rates for requested currencies + for (let i = 0; i < rateData.length; i++) { + const { code } = rateData[i]; + const rate = rates[i]; + const sym = symbols[code] || ''; + if (rate !== null) { + console.log(`1 sat = ${sym}${rate.toFixed(6)} ${code}`); + } + } + + client.close(); +} + +main().catch(e => { console.error(e.message); }); diff --git a/scripts/budget_guardian.js b/scripts/budget_guardian.js new file mode 100644 index 0000000..0a199ba --- /dev/null +++ b/scripts/budget_guardian.js @@ -0,0 +1,129 @@ +#!/usr/bin/env node +// Budget Guardian - spending caps with alerts +// Usage: NWC_URL="..." node budget_guardian.js [setup|check|reset] [weekly_sats] +// +// Game Theory: Creates commitment device - you set your own spending limit +// and the system enforces it, removing temptation from the equation. + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const fs = require("fs"); +const path = require("path"); + +const NWC_URL = process.env.NWC_URL; +const BUDGET_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "budget_guardian.json"); + +// Ensure directory exists +const dir = path.dirname(BUDGET_FILE); +if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + +function loadBudget() { + if (fs.existsSync(BUDGET_FILE)) { + return JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8")); + } + return { weekly_cap: null, alerts_enabled: true, history: [] }; +} + +function saveBudget(data) { + fs.writeFileSync(BUDGET_FILE, JSON.stringify(data, null, 2)); +} + +async function getCurrentWeekStart() { + const now = new Date(); + const day = now.getDay(); + const diff = now.getDate() - day + (day === 0 ? -6 : 1); // Start from Monday + now.setDate(diff); + now.setHours(0, 0, 0, 0); + return Math.floor(now.getTime() / 1000); +} + +async function getOutgoingForPeriod(from_ts, client) { + const txs = await client.listTransactions({ type: "outgoing", from: from_ts, limit: 500 }); + return txs.transactions + .filter(t => t.state === "settled") + .reduce((sum, t) => sum + (t.amount / 1000), 0); +} + +async function main() { + if (!NWC_URL) { + console.error("NWC_URL not set"); + process.exit(1); + } + + const command = process.argv[2] || "status"; + const capArg = parseInt(process.argv[3]); + + const budget = loadBudget(); + + if (command === "setup") { + if (!capArg || capArg <= 0) { + console.log("Usage: node budget_guardian.js setup \n"); + console.log("Set a weekly spending limit. Once reached, all sends are blocked until the week resets."); + console.log("Example: node budget_guardian.js setup 5000 # 5,000 sats/week max"); + return; + } + budget.weekly_cap = capArg; + budget.alerts_enabled = true; + saveBudget(budget); + console.log(`Budget set: ${capArg.toLocaleString()} sats/week`); + return; + } + + if (command === "reset") { + const weekStart = await getCurrentWeekStart(); + budget.current_week_start = weekStart; + budget.alerts_enabled = true; + saveBudget(budget); + console.log(`Budget reset for new week starting ${new Date(weekStart * 1000).toLocaleDateString()}`); + return; + } + + if (command === "status") { + if (!budget.weekly_cap) { + console.log("⚠️ No budget cap set. Run: node budget_guardian.js setup "); + return; + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + try { + const weekStart = await getCurrentWeekStart(); + const spent = await getOutgoingForPeriod(weekStart, client); + const remaining = budget.weekly_cap - spent; + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + const spentUsd = (spent * rate).toFixed(2); + const remainingUsd = (remaining * rate).toFixed(2); + + const pct = (spent / budget.weekly_cap * 100).toFixed(1); + const status = remaining > 0 ? "✅ Under budget" : "❌ OVER BUDGET"; + + console.log(`\n══ Budget Guardian ══\n`); + console.log(`Weekly Cap: ${budget.weekly_cap.toLocaleString()} sats`); + console.log(`Spent: ${spent.toLocaleString()} sats ($${spentUsd}) — ${pct}%`); + console.log(`Remaining: ${remaining.toLocaleString()} sats ($${remainingUsd})`); + console.log(`Status: ${status}`); + console.log(`\nWeek started: ${new Date(weekStart * 1000).toLocaleDateString()}`); + + if (pct > 90) { + console.log(`\n⚠️ WARNING: You're at ${pct}% of your weekly budget!`); + } + + // Show trend + if (budget.history.length > 1) { + const avg = budget.history.slice(-4).reduce((s, w) => s + w.spent, 0) / + Math.min(budget.history.length, 4); + console.log(`\nAvg recent: ${Math.round(avg).toLocaleString()} sats/week`); + } + + } finally { + client.close(); + } + return; + } + + console.log(`Usage: node budget_guardian.js [setup |check|reset|status]`); +} + +main().catch(e => { + console.error(e.message); + process.exit(1); +}); diff --git a/scripts/decode.js b/scripts/decode.js new file mode 100644 index 0000000..2eac549 --- /dev/null +++ b/scripts/decode.js @@ -0,0 +1,101 @@ +#!/usr/bin/env node +// Auto-decode BOLT-11 invoices or Lightning addresses from stdin +// Usage: echo "lnbc..." | node decode.js +// echo "user@domain.com" | node decode.js + +const { decodeInvoice, Invoice, LightningAddress, getFiatValue } = require("@getalby/lightning-tools"); + +const input = process.argv[2] || process.env.INPUT || (() => { + let data = ''; + process.stdin.on('data', chunk => data += chunk); + return new Promise(resolve => process.stdin.on('end', () => resolve(data.trim()))); +})(); + +async function decodeLightningAddress(address) { + const ln = new LightningAddress(address); + await ln.fetch(); + + console.log(`⚡ Lightning Address: ${address}`); + console.log(`Username: ${ln.username}`); + console.log(`Domain: ${ln.domain}`); + + if (ln.lnurlpData) { + const minSats = Math.floor(ln.lnurlpData.min / 1000); + const maxSats = Math.floor(ln.lnurlpData.max / 1000); + console.log(`Min sendable: ${minSats.toLocaleString()} sats`); + console.log(`Max sendable: ${maxSats.toLocaleString()} sats`); + console.log(`Description: ${ln.lnurlpData.description || '(none)'}`); + console.log(`Comment allowed: ${ln.lnurlpData.commentAllowed ? ln.lnurlpData.commentAllowed + ' chars' : 'no'}`); + } + + if (ln.pubkey) { + console.log(`Pubkey: ${ln.pubkey.substring(0, 20)}...`); + } + + if (ln.nostrPubkey) { + console.log(`Nostr pubkey: ${ln.nostrPubkey}`); + if (ln.nostrRelays) { + console.log(`Nostr relays: ${ln.nostrRelays.join(', ')}`); + } + } + + console.log(`\nReady to receive payments via Lightning`); +} + +async function decodeBolt11(pr) { + const parsed = decodeInvoice(pr.replace(/^LNURL|lnurl/, '')); + + if (!parsed) { + console.log("Could not decode invoice"); + return; + } + + const fiatUSD = await getFiatValue({ satoshi: parsed.satoshi, currency: "USD" }).catch(() => null); + const now = Math.floor(Date.now() / 1000); + const isExpired = parsed.expiry && (now > parsed.timestamp + parsed.expiry); + const expiresIn = parsed.expiry ? Math.max(0, parsed.timestamp + parsed.expiry - now) : 0; + + console.log(`⚡ BOLT-11 Invoice`); + console.log(`Amount: ${parsed.satoshi.toLocaleString()} sats${fiatUSD ? ` (~$${fiatUSD.toFixed(4)} USD)` : ''}`); + console.log(`Description: ${parsed.description || '(none)'}`); + console.log(`Payment hash: ${parsed.paymentHash}`); + console.log(`Created: ${new Date(parsed.timestamp * 1000).toLocaleString()}`); + console.log(`Expiry: ${parsed.expiry ? Math.floor(parsed.expiry / 3600) + ' hour(s)' : 'no expiry set'}`); + + if (isExpired) { + console.log(`Status: EXPIRED`); + } else if (parsed.expiry) { + const mins = Math.floor(expiresIn / 60); + const hrs = Math.floor(mins / 60); + console.log(`Status: Active (expires in ${hrs > 0 ? hrs + 'h ' : ''}${mins % 60}m)`); + } + + // Verify with Invoice class + const inv = new Invoice({ pr }); + const isPaid = await inv.isPaid().catch(() => false); + if (isPaid) { + console.log(`Payment status: Already paid`); + } +} + +async function main() { + const address = typeof input === 'string' ? input : await input; + + if (!address) { + console.log("Usage: echo 'invoice_or_address' | node decode.js"); + console.log(" echo 'lnbc...' | node decode.js"); + console.log(" echo 'user@domain.com' | node decode.js"); + process.exit(1); + } + + // Detect type + if (address.match(/^ln/)) { + await decodeBolt11(address); + } else if (address.includes('@')) { + await decodeLightningAddress(address); + } else { + console.log("Could not detect type. Expected BOLT-11 invoice (starts with 'ln') or Lightning address (user@domain)"); + } +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/scripts/export_ledger.js b/scripts/export_ledger.js new file mode 100644 index 0000000..ab26866 --- /dev/null +++ b/scripts/export_ledger.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +// Export ledger to CSV or summary view +// Usage: NWC_URL="..." node export_ledger.js [csv|summary|json] [days] + +const fs = require("fs"); +const path = require("path"); + +const LEDGER_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "transactions_ledger.json"); +const format = (process.argv[2] || "summary").toLowerCase(); +const days = parseInt(process.argv[3]) || null; + +if (!fs.existsSync(LEDGER_FILE)) { + console.error("Ledger not found. Start auto_ledger.js first."); + process.exit(1); +} + +let ledger = JSON.parse(fs.readFileSync(LEDGER_FILE, "utf8")); + +// Filter by days if provided +if (days) { + const cutoff = Date.now() - (days * 86400000); + ledger = ledger.filter(t => t.verified_at >= cutoff); +} + +if (ledger.length === 0) { + console.log("No transactions found for this period."); + process.exit(0); +} + +// Sort by settled_at +ledger.sort((a, b) => a.settled_at - b.settled_at); + +if (format === "csv") { + // Generate CSV + const header = "Date,Type,Amount(sats),Amount(USD),Fees(sats),Description,PaymentHash,Preimage,Verified"; + const rows = ledger.map(t => { + const d = new Date(t.settled_at * 1000).toISOString().split("T")[0]; + return `${d},${t.type},${t.sats},${t.usd},${t.fees},"${(t.description || "").replace(/"/g, '""')}",${t.payment_hash},${t.preimage || ""},${t.crypto_proof?.verified || false}`; + }); + console.log([header, ...rows].join("\n")); + + // Save to file + const csvPath = path.join(process.env.HOME, ".hermes", "ledgers", `ledger_export_${Date.now()}.csv`); + fs.writeFileSync(csvPath, [header, ...rows].join("\n")); + console.log(`\nSaved: ${csvPath}`); + +} else if (format === "summary") { + const totalIn = ledger.filter(t => t.type === "incoming").reduce((s, t) => s + t.sats, 0); + const totalOut = ledger.filter(t => t.type === "outgoing").reduce((s, t) => s + t.sats, 0); + const totalFees = ledger.reduce((s, t) => s + t.fees, 0); + const verified = ledger.filter(t => t.crypto_proof?.verified).length; + const net = totalIn - totalOut; + + console.log(`══ Ledger Summary ${days ? `(${days}d)` : ""} ══`); + console.log(``); + console.log(`Total: ${ledger.length} transactions (${verified} verified)`); + console.log(`→ Incoming: +${totalIn} sats`); + console.log(`← Outgoing: -${totalOut} sats`); + console.log(`⚡ Fees paid: ${totalFees} sats`); + console.log(`Net flow: ${net >= 0 ? "+" : ""}${net} sats (${net >= 0 ? "surplus" : "deficit"})`); + console.log(`First: ${new Date(ledger[0].settled_at * 1000).toLocaleDateString()}`); + console.log(`Last: ${new Date(ledger[ledger.length-1].settled_at * 1000).toLocaleDateString()}`); + console.log(`Verified: ${verified}/${ledger.length} (${((verified/ledger.length)*100).toFixed(1)}%)`); + +} else if (format === "json") { + console.log(JSON.stringify(ledger, null, 2)); +} diff --git a/scripts/gen_card.py b/scripts/gen_card.py new file mode 100644 index 0000000..9436237 --- /dev/null +++ b/scripts/gen_card.py @@ -0,0 +1,216 @@ +#!/usr/bin/env /usr/bin/python3 +""" +Beautiful styled Bitcoin Lightning payment cards +Generates stunning dark-mode receipts with gradients, rounded corners, +and clean typography -- delivered as media images. +""" + +from PIL import Image, ImageDraw, ImageFont +import sys, os, datetime + +def hex_rgb(h): + h = h.lstrip('#') + return tuple(int(h[i:i+2], 16) for i in (0, 2, 4)) + +def load_font(path, size): + try: + if os.path.exists(path): + return ImageFont.truetype(path, size) + except: + pass + return ImageFont.load_default() + +def get_fonts(): + return { + 'title_bold': load_font('/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', 28), + 'title': load_font('/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', 24), + 'amount': load_font('/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf', 42), + 'body': load_font('/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf', 17), + 'small': load_font('/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf', 14), + 'emoji': load_font('/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', 36), + 'emoji_small': load_font('/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc', 20), + } + +def draw_gradient_bg(draw, width, height, top_color, bottom_color): + for y in range(height): + ratio = y / height + r = int(top_color[0] + (bottom_color[0] - top_color[0]) * ratio) + g = int(top_color[1] + (bottom_color[1] - top_color[1]) * ratio) + b = int(top_color[2] + (bottom_color[2] - top_color[2]) * ratio) + draw.line([(0, y), (width, y)], fill=(r, g, b)) + +def create_accent_line(draw, x1, x2, y, height, color1, color2): + for x in range(x1, x2): + ratio = (x - x1) / (x2 - x1) + r = int(color1[0] + (color2[0] - color1[0]) * ratio) + g = int(color1[1] + (color2[1] - color1[1]) * ratio) + b = int(color1[2] + (color2[2] - color1[2]) * ratio) + draw.rectangle([x, y, x, y + height - 1], fill=(r, g, b)) + +def create_payment_card(**kwargs): + W, H = 800, 560 + pad = 30 + card_w = W - 2 * pad + card_h = H - 2 * pad + radius = 28 + + bg_top = (13, 13, 43) + bg_bot = (8, 4, 28) + card_bg = (22, 22, 52) + accent = (247, 147, 26) # Bitcoin orange + accent2 = (139, 92, 246) # Purple + white = (255, 255, 255) + light_gray = (170, 170, 200) + mid_gray = (100, 100, 140) + dim_gray = (80, 80, 110) + + img = Image.new('RGBA', (W, H), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Background gradient + draw_gradient_bg(draw, W, H, bg_top, bg_bot) + + # Glow behind card + glow = Image.new('RGBA', (card_w + 60, card_h + 60), (0, 0, 0, 0)) + glow_draw = ImageDraw.Draw(glow) + glow_draw.rounded_rectangle((30, 30, card_w + 30, card_h + 30), radius=radius + 15, + fill=(247, 147, 26, 25)) + img.paste(glow, (pad - 30, pad - 30), glow) + + # Card background + draw.rounded_rectangle((pad, pad, pad + card_w, pad + card_h), + radius=radius, fill=card_bg) + + # Top accent line with gradient + line_start = pad + radius + 15 + line_end = pad + card_w - radius - 15 + create_accent_line(draw, line_start, line_end, pad, 4, accent, accent2) + + fonts = get_fonts() + emoji_font = fonts['emoji'] + + # Lightning emoji + draw.text((pad + 45, pad + 55), '⚡', fill=accent, font=emoji_font) + + # Card type + card_type = kwargs.get('type', 'payment') # payment, received, balance, invoice + if card_type == 'payment': + title = 'PAYMENT SENT' + elif card_type == 'received': + title = 'PAYMENT RECEIVED' + elif card_type == 'balance': + title = 'WALLET BALANCE' + elif card_type == 'invoice': + title = 'INVOICE CREATED' + elif card_type == 'verified': + title = 'PAYMENT VERIFIED' + else: + title = kwargs.get('title', title) + + draw.text((pad + 45 + 50, pad + 60), title, fill=white, font=fonts['title_bold']) + + # Status badge + status = kwargs.get('status') + if status: + badge_y = pad + 115 + badge_x = pad + 45 + + if 'Settled' in status or '✅' in status or 'Valid' in status: + badge_fill = (0, 180, 100) + badge_text = status + elif 'Pending' in status or '⏳' in status: + badge_fill = (200, 150, 0) + badge_text = status + elif 'Failed' in status or '❌' in status: + badge_fill = (220, 50, 50) + badge_text = status + else: + badge_fill = accent + badge_text = status + + badge_text_w = draw.textlength(badge_text, font=fonts['body']) + draw.rounded_rectangle((badge_x, badge_y, badge_x + badge_text_w + 24, badge_y + 30), + radius=15, fill=(*badge_fill, 60)) + draw.rounded_rectangle((badge_x, badge_y, badge_x + badge_text_w + 24, badge_y + 30), + radius=15, outline=badge_fill, width=1) + draw.text((badge_x + 12, badge_y + 4), badge_text, fill=white, font=fonts['body']) + + # Amount + sats = kwargs.get('sats') + amount_y = pad + 160 + if sats: + sats_int = int(sats) + if card_type in ('payment', 'paid'): + sign = '-' + elif card_type == 'received': + sign = '+' + else: + sign = '' + amt_text = f"{sign}{sats_int:,} sats" + amt_w = draw.textlength(amt_text, font=fonts['amount']) + draw.text(((W - amt_w) / 2, amount_y), amt_text, fill=accent, font=fonts['amount']) + + # Fiat equivalent + fiat = kwargs.get('fiat') + if fiat: + fiat_y = amount_y + 50 + fiat_text = f"~ ${fiat} USD" + fiat_w = draw.textlength(fiat_text, font=fonts['title']) + draw.text(((W - fiat_w) / 2, fiat_y), fiat_text, fill=light_gray, font=fonts['title']) + + # Divider + div_y = pad + 260 + for x in range(pad + 40, pad + card_w - 40): + draw.point((x, div_y), fill=dim_gray) + + # Details + details = [] + if kwargs.get('address') or kwargs.get('to'): + addr = kwargs.get('address') or kwargs.get('to') + details.append(('To', addr)) + if kwargs.get('description'): + details.append(('Description', kwargs['description'])) + if kwargs.get('fees'): + details.append(('Fees', kwargs['fees'])) + if kwargs.get('time'): + details.append(('Time', kwargs['time'])) + if kwargs.get('preimage'): + pi = kwargs['preimage'] + short = pi[:12] + '...' + pi[-6:] if len(pi) > 19 else pi + details.append(('Preimage', short)) + if kwargs.get('tx_count'): + details.append(('Transactions', kwargs['tx_count'])) + + y = div_y + 25 + for label, value in details: + draw.text((pad + 45, y), label, fill=mid_gray, font=fonts['body']) + draw.text((pad + 45, y + 22), str(value), fill=white, font=fonts['body']) + y += 48 + + # Bottom footer divider + for x in range(pad + 40, pad + card_w - 40): + draw.point((x, y + 15), fill=dim_gray) + + # Footer + footer_text = f"Bitcoin Lightning Network" + now = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') + footer_full = f"{footer_text} • {now}" + ft_w = draw.textlength(footer_full, font=fonts['small']) + draw.text(((W - ft_w) / 2, y + 22), footer_full, fill=mid_gray, font=fonts['small']) + + # Save + output = kwargs.get('output', f"/tmp/lightning_{card_type}_{int(datetime.datetime.now().timestamp())}.png") + img.convert('RGB').save(output, quality=95) + print(output) + return output + +if __name__ == '__main__': + args = {} + for a in sys.argv[1:]: + if '=' in a: + k, v = a.split('=', 1) + args[k.strip()] = v.strip() + + card_type = args.pop('type', 'payment') + args.pop('output', None) + create_payment_card(type=card_type, **args) diff --git a/scripts/health_check.js b/scripts/health_check.js new file mode 100644 index 0000000..50a40c8 --- /dev/null +++ b/scripts/health_check.js @@ -0,0 +1,103 @@ +#!/usr/bin/env node +// Wallet health check — diagnostics +// Usage: NWC_URL="..." node health_check.js + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const fs = require("fs"); +const path = require("path"); + +const NWC_URL = process.env.NWC_URL; +const LEDGER_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "transactions_ledger.json"); + +async function main() { + if (!NWC_URL) { + console.error("NWC_URL not set"); + process.exit(1); + } + + const checks = []; + const start = Date.now(); + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + try { + // 1. Connection + checks.push("🔗 Connection: Checking..."); + const info = await client.getInfo(); + checks[0] = `🔗 Connection: ✅ ${info.alias} (${info.network})`; + + // 2. Methods + const methods = info.methods || []; + const canPay = methods.includes("pay_invoice"); + const canReceive = methods.includes("make_invoice"); + checks.push(`📤 Send: ${canPay ? "✅" : "❌"} | 📥 Receive: ${canReceive ? "✅" : "❌"}`); + + // 3. Balance + const balance = await client.getBalance(); + const sats = balance.balance / 1000; + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + const usd = (sats * rate).toFixed(4); + checks.push(`💰 Balance: ${sats} sats (~$${usd} USD)`); + + // 4. Ledger + let ledgerStatus = "⚠️ Not started"; + if (fs.existsSync(LEDGER_FILE)) { + const ledger = JSON.parse(fs.readFileSync(LEDGER_FILE, "utf8")); + if (ledger.length > 0) { + const last = ledger[ledger.length - 1]; + const ago = Math.round((Date.now() - last.verified_at) / 60000); + const verified = ledger.filter(t => t.crypto_proof?.verified).length; + ledgerStatus = `✅ ${ledger.length} txns (${verified} verified), last: ${ago}m ago`; + } else { + ledgerStatus = "📭 Empty — no transactions yet"; + } + } + checks.push(`📖 Ledger: ${ledgerStatus}`); + + // 5. Last Activity + if (fs.existsSync(LEDGER_FILE)) { + const ledger = JSON.parse(fs.readFileSync(LEDGER_FILE, "utf8")); + if (ledger.length > 0) { + const last = ledger[ledger.length - 1]; + const d = new Date(last.settled_at * 1000); + checks.push(`⏱️ Last activity: ${d.toLocaleString()} (${last.type}, ${last.sats} sats)`); + } + } else { + checks.push("⏱️ Last activity: N/A"); + } + + // 6. Relay + const relayUrls = client.options?.relayUrls || client.relayUrls || []; + checks.push(`📡 Relays: ${relayUrls.map((u, i) => `[${i+1}] ${u}`).join(", ")}`); + + const elapsed = Date.now() - start; + + console.log(`══ Wallet Health Check (${elapsed}ms) ══`); + console.log(``); + checks.forEach(c => console.log(c)); + console.log(``); + + // Warnings + const warnings = []; + if (sats === 0) warnings.push("⚠️ Balance is zero — cannot send payments"); + if (!canPay) warnings.push("⚠️ No pay_invoice permission — NWC connection is read-only"); + if (!fs.existsSync(LEDGER_FILE)) warnings.push("⚠️ Ledger not set up — run auto_ledger.js to enable proofs"); + + if (warnings.length) { + console.log(`Warnings:`); + warnings.forEach(w => console.log(` ${w}`)); + console.log(``); + } else { + console.log(`✅ All checks passed`); + } + + } finally { + client.close(); + } +} + +main().catch(e => { + console.error(`💥 Check failed: ${e.message}`); + process.exit(1); +}); diff --git a/scripts/hold_invoice.js b/scripts/hold_invoice.js new file mode 100644 index 0000000..cd73874 --- /dev/null +++ b/scripts/hold_invoice.js @@ -0,0 +1,90 @@ +#!/usr/bin/env node +// Create a HOLD invoice that stays pending until you settle or cancel it +// Perfect for escrow, pay-to-unlock, conditional payments +// +// Usage: NWC_URL="..." node hold_invoice.js [sats] [description] +// The script will show the invoice, then wait for payment. +// When payment arrives it will prompt you to settle or cancel. + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const crypto = require("crypto"); + +const NWC_URL = process.env.NWC_URL; +const sats = parseInt(process.argv[2]) || 100; +const description = process.argv.slice(3).join(" ") || "Conditional payment"; + +function toHexString(bytes) { + return bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), ""); +} + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + // Generate random preimage and compute payment hash + const preimageBytes = crypto.getRandomValues(new Uint8Array(32)); + const preimage = toHexString(preimageBytes); + const hashBuffer = await crypto.subtle.digest("SHA-256", preimageBytes); + const paymentHashBytes = new Uint8Array(hashBuffer); + const paymentHash = toHexString(paymentHashBytes); + + // Create HOLD invoice + const fiatUSD = await getFiatValue({ satoshi: sats, currency: "USD" }); + console.log(`Creating HOLD invoice: ${sats} sats (~$${fiatUSD.toFixed(2)} USD)`); + console.log(`Description: ${description}`); + console.log(`This invoice will stay PENDING until you settle or cancel it.`); + console.log(`Payment hash: ${paymentHash}`); + + const invoice = await client.makeHoldInvoice({ + amount: sats * 1000, + description, + payment_hash: paymentHash, + }); + + console.log(`\nBOLT-11: ${invoice.invoice}`); + console.log(`\nWaiting for payment... (Ctrl+C to cancel)`); + + // Settle notification handler + let settled = false; + let notified = false; + + console.log(`\nOnce paid, the agent will auto-settle and confirm.`); + console.log(`(The HOLD invoice funds are locked until settlement)\n`); + + const unsub = await client.subscribeNotifications(async (notification) => { + if (notification.notification.payment_hash !== paymentHash) { + return; + } + + if (notification.notification_type === "hold_invoice_accepted" && !notified) { + notified = true; + console.log(`\n⚡ Payment accepted! Funds locked. Settling...`); + await client.settleHoldInvoice({ preimage }); + console.log(`✅ SETTLED. Payment of ${sats} sats has been received.`); + settled = true; + unsub(); + client.close(); + process.exit(0); + } + }, ["hold_invoice_accepted"]); + + // Timeout after 1 hour + setTimeout(() => { + if (!settled) { + console.log(`\n⏰ Timeout reached. Cancelling HOLD invoice...`); + client.cancelHoldInvoice({ payment_hash: paymentHash }).then(() => { + console.log(`Cancelled.`); + unsub(); + client.close(); + process.exit(0); + }); + } + }, 3600000); // 1 hour +} + +main().catch(e => { console.error(e.message); }); diff --git a/scripts/hold_invoice_manual.js b/scripts/hold_invoice_manual.js new file mode 100644 index 0000000..1c49574 --- /dev/null +++ b/scripts/hold_invoice_manual.js @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// Create a HOLD invoice for MANUAL settlement (escrow mode) +// When payment arrives, the script reports it and waits for stdin command. +// +// Usage: NWC_URL="..." node hold_invoice_manual.js [sats] [description] +// When funded: type 'settle' or 'cancel' + Enter + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const crypto = require("crypto"); + +const NWC_URL = process.env.NWC_URL; +const sats = parseInt(process.argv[2]) || 100; +const description = process.argv.slice(3).join(" ") || "Escrow payment"; + +function toHexString(bytes) { + return bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), ""); +} + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + const preimageBytes = crypto.getRandomValues(new Uint8Array(32)); + const preimage = toHexString(preimageBytes); + const hashBuffer = await crypto.subtle.digest("SHA-256", preimageBytes); + const paymentHash = toHexString(new Uint8Array(hashBuffer)); + + const fiatUSD = await getFiatValue({ satoshi: sats, currency: "USD" }); + const invoice = await client.makeHoldInvoice({ + amount: sats * 1000, + description, + payment_hash: paymentHash, + }); + + console.log(`Escrow HOLD Invoice: ${sats} sats (~$${fiatUSD.toFixed(2)} USD)`); + console.log(`Description: ${description}`); + console.log(`Invoice: ${invoice.invoice}`); + console.log(`\nWaiting for payment... (type 'settle' or 'cancel' + Enter when funded)`); + + let funded = false; + let unsub; + + unsub = await client.subscribeNotifications(async (notification) => { + if (notification.notification.payment_hash !== paymentHash) return; + + if (notification.notification_type === "hold_invoice_accepted" && !funded) { + funded = true; + console.log(`\n⚡ HOLD invoice ACCEPTED — ${sats} sats are LOCKED in escrow.`); + console.log(`Type 'settle' to release funds to wallet, or 'cancel' to refund payer:`); + } + }, ["hold_invoice_accepted"]); + + // Read stdin for commands + process.stdin.setEncoding('utf8'); + process.stdin.on('data', async (data) => { + const cmd = data.trim().toLowerCase(); + if (cmd === 'settle' && funded) { + console.log('Settling...'); + await client.settleHoldInvoice({ preimage }); + console.log('✅ FUNDS RELEASED. Payment settled.'); + unsub(); + client.close(); + process.exit(0); + } else if (cmd === 'cancel' && funded) { + console.log('Cancelling...'); + await client.cancelHoldInvoice({ payment_hash: paymentHash }); + console.log('❌ CANCELLED. Payer funds refunded.'); + unsub(); + client.close(); + process.exit(0); + } + }); + + // 1 hour timeout + setTimeout(() => { + console.log('\n⏰ Timeout. Cancelling HOLD invoice...'); + client.cancelHoldInvoice({ payment_hash: paymentHash }).catch(() => {}); + unsub(); + client.close(); + process.exit(0); + }, 3600000); +} + +main().catch(e => { console.error(e.message); }); diff --git a/scripts/monitor_payments.js b/scripts/monitor_payments.js new file mode 100644 index 0000000..6536174 --- /dev/null +++ b/scripts/monitor_payments.js @@ -0,0 +1,51 @@ +#!/usr/bin/env node +// Watch for incoming/outgoing payments and log them with fiat values +// Runs in background, outputs a line per payment event +// Usage: NWC_URL="..." node monitor_payments.js +// Tip: pipe output to a file or use with tail -f + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); + +const NWC_URL = process.env.NWC_URL; + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + const onNotification = async (notification) => { + const n = notification.notification; + const sats = n.amount / 1000; + const isRecv = notification.notification_type === "payment_received"; + const arrow = isRecv ? "→" : "←"; + const emoji = isRecv ? "💰 Payment received" : "💸 Payment sent"; + + let fiat = '...'; + try { + const rate = await getFiatValue({ satoshi: sats, currency: "USD" }); + fiat = "$" + rate.toFixed(2); + } catch (e) {} + + console.log(`${emoji} ${arrow} ${sats} sats (${fiat}) | ${n.description || '(no description)'} | ${new Date().toLocaleTimeString()}`); + + // Flush stdout immediately + if (process.stdout._handle) process.stdout._handle.setBlocking(true); + }; + + const unsub = await client.subscribeNotifications(onNotification, ["payment_received", "payment_sent"]); + console.log(`👀 Monitoring wallet for payments... (Ctrl+C to stop)`); + + // Keep alive + process.on('SIGINT', () => { + unsub(); + client.close(); + console.log(`Stopped monitoring.`); + process.exit(0); + }); +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/scripts/qr_invoice.js b/scripts/qr_invoice.js new file mode 100644 index 0000000..5c52301 --- /dev/null +++ b/scripts/qr_invoice.js @@ -0,0 +1,38 @@ +#!/usr/bin/env node +// Create a Lightning invoice and generate a scannable QR code +// Usage: node qr_invoice.js [sats] [description] + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const qrcode = require("qrcode"); +const fs = require("fs"); + +const NWC_URL = process.env.NWC_URL; +const sats = parseInt(process.argv[2]) || 100; +const description = process.argv.slice(3).join(" ") || "Payment"; + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL environment variable not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + const [fiatUSD, invoice] = await Promise.all([ + getFiatValue({ satoshi: sats, currency: "USD" }), + client.makeInvoice({ amount: sats * 1000, description }), + ]); + + // Generate QR code + const qrPath = `${process.cwd()}/invoice_${sats}sats_${Date.now()}.png`; + await qrcode.toFile(qrPath, invoice.invoice, { width: 400, margin: 2 }); + + console.log(`Invoice: ${sats} sats (~$${fiatUSD.toFixed(2)} USD)`); + console.log(`Description: ${description}`); + console.log(`QR: ${qrPath}`); + console.log(`Invoice: ${invoice.invoice}`); + + client.close(); +} + +main().catch(e => { console.error(e.message); process.exit(1); }); diff --git a/scripts/streaks.js b/scripts/streaks.js new file mode 100644 index 0000000..257cf83 --- /dev/null +++ b/scripts/streaks.js @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// Payment Streaks & Gamification - track wallet activity patterns +// Usage: NWC_URL="..." node streaks.js + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); + +async function main() { + const client = new NWCClient({ nostrWalletConnectUrl: process.env.NWC_URL }); + try { + const txs = await client.listTransactions({ limit: 100 }); + const settled = txs.transactions.filter(t => t.state === "settled"); + + if (settled.length === 0) { + console.log("No settled transactions yet. Make a transaction to start your streak!"); + return; + } + + // Calculate streaks + const now = Math.floor(Date.now() / 1000); + const oneDay = 86400; + + let currentStreak = 1; + let longestStreak = 1; + let tempStreak = 1; + + const sorted = [...settled].sort((a, b) => b.settled_at - a.settled_at); + + for (let i = 1; i < sorted.length; i++) { + const gap = sorted[i-1].settled_at - sorted[i].settled_at; + const daysApart = Math.floor(gap / oneDay); + + if (daysApart <= 1) { + tempStreak++; + if (tempStreak > longestStreak) longestStreak = tempStreak; + } else { + if (daysApart > 2) { + if (currentStreak === tempStreak && i < sorted.length / 2) { + currentStreak = 1; // Streak broken + } + tempStreak = 1; + } else { + tempStreak++; + } + } + } + + const totalIncoming = sorted.filter(t => t.type === "incoming").length; + const totalOutgoing = sorted.filter(t => t.type === "outgoing").length; + const totalSatsIncoming = sorted.filter(t => t.type === "incoming").reduce((s, t) => s + t.amount/1000, 0); + const totalSatsOutgoing = sorted.filter(t => t.type === "outgoing").reduce((s, t) => s + t.amount/1000, 0); + + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + + console.log("══ Lightning Stats ══\n"); + console.log(`Total txns: ${settled.length} (${totalIncoming} in, ${totalOutgoing} out)`); + console.log(`Sats received: ${totalSatsIncoming.toLocaleString()} (~$${(totalSatsIncoming * rate).toFixed(2)})`); + console.log(`Sats sent: ${totalSatsOutgoing.toLocaleString()} (~$${(totalSatsOutgoing * rate).toFixed(2)})`); + console.log(`Longest streak: ${longestStreak} days`); + console.log(`First txn: ${new Date(sorted[sorted.length-1].settled_at * 1000).toLocaleDateString()}`); + console.log(`Last txn: ${new Date(sorted[0].settled_at * 1000).toLocaleString()}`); + + // Milestones + console.log("\n══ Milestones ══"); + const milestones = [10, 50, 100, 500, 1000, 5000, 10000]; + for (const ms of milestones) { + if (totalSatsIncoming + totalSatsOutgoing >= ms) { + console.log(`${ms.toLocaleString()} sats total: ✅`); + } else { + console.log(`${ms.toLocaleString()} sats total: 🔲 (${ms - (totalSatsIncoming + totalSatsOutgoing)} to go)`); + } + } + + } finally { + client.close(); + } +} + +main().catch(e => console.error(e.message)); diff --git a/scripts/summary.js b/scripts/summary.js new file mode 100644 index 0000000..d98d774 --- /dev/null +++ b/scripts/summary.js @@ -0,0 +1,121 @@ +#!/usr/bin/env node +// Wallet Summary — one command, complete picture +// Usage: NWC_URL="..." node summary.js + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const fs = require("fs"); +const path = require("path"); + +const NWC_URL = process.env.NWC_URL; +const LEDGER_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "transactions_ledger.json"); +const BUDGET_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "budget_guardian.json"); + +async function main() { + if (!NWC_URL) { + console.error("NWC_URL not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + try { + const [balance, info, txs] = await Promise.all([ + client.getBalance(), + client.getInfo().catch(() => ({ alias: "NWC", network: "mainnet", methods: ["get_balance"] })), + client.listTransactions({ limit: 20 }) + ]); + + const settled = (txs.transactions || []).filter(t => t.state === "settled"); + const sats = balance.balance / 1000; + const incoming = settled.filter(t => t.type === "incoming"); + const outgoing = settled.filter(t => t.type === "outgoing"); + const totalIn = incoming.reduce((s, t) => s + t.amount / 1000, 0); + const totalOut = outgoing.reduce((s, t) => s + t.amount / 1000, 0); + + // Read ledger for crypto proof status + let verifiedCount = 0; + let ledgerTotal = 0; + let history = []; + if (fs.existsSync(LEDGER_FILE)) { + const ledger = JSON.parse(fs.readFileSync(LEDGER_FILE, "utf8")); + ledgerTotal = ledger.length; + verifiedCount = ledger.filter(t => t.crypto_proof?.verified).length; + history = ledger; + } + + // Read budget if exists + let budget = null; + if (fs.existsSync(BUDGET_FILE)) { + const b = JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8")); + if (b.weekly_cap) { + const spent = outgoing.filter(t => t.settled_at >= Math.floor(Date.now() / 1000 - 7 * 86400)) + .reduce((s, t) => s + t.amount / 1000, 0); + budget = { cap: b.weekly_cap, spent, pct: Math.round(spent / b.weekly_cap * 100) }; + } + } + + // Format output + const lines = []; + + // Header + lines.push(`╓─ ${(info.alias || 'WALLET').toUpperCase()} ───────────────────────────╖`); + lines.push(`║ ║`); + lines.push(`║`); + + // Balance - prominent + if (sats > 0) { + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + lines.push(`║`); + lines.push(`║ ·─────────────────────────────────`); + lines.push(`║ ${sats.toLocaleString()} sats`); + const usd = (sats * rate).toFixed(2); + const eur = (sats * rate * 0.87).toFixed(2); + const kes = Math.round(sats * rate * 130); + lines.push(`║ ───────────────`); + lines.push(`║ $${usd} USD · €${eur} EUR · KSh ${kes.toLocaleString()} KES`); + lines.push(`║ ·─────────────────────────────────`); + } else { + lines.push(`║`); + lines.push(`║ ·─────────────────────────────────`); + lines.push(`║ 0 sats — ready for funding`); + lines.push(`║ ·─────────────────────────────────`); + } + + lines.push(`║`); + lines.push(`║ Flow`); + lines.push(`║ → ${totalIn.toLocaleString()} in ← ${totalOut.toLocaleString()} out`); + lines.push(`║ ${verifiedCount}/${ledgerTotal} proven ✅`); + lines.push(`║`); + + // Recent transactions + const recent = history.slice(-3).reverse(); + if (recent.length > 0) { + lines.push(`║ ·──────── Last transactions ────────`); + for (const t of recent) { + const arrow = t.type === "incoming" ? "→" : "←"; + const sign = t.type === "incoming" ? "+" : "-"; + const desc = t.description ? ` ${t.description}` : ""; + lines.push(`║ ${arrow} ${sign}${t.sats.toLocaleString()} sats ✅${desc}`); + } + } + + // Budget + if (budget) { + lines.push(`║ ·──────── Budget ──────────────────`); + const status = budget.pct < 90 ? "✅" : "⚠️"; + const bar = "█".repeat(Math.floor(budget.pct / 10)) + "░".repeat(10 - Math.floor(budget.pct / 10)); + lines.push(`║ ${status} ${budget.spent.toLocaleString()}/${budget.cap.toLocaleString()} sats ${budget.pct}% │${bar}│`); + } + + lines.push(`║`); + lines.push(`╙─────────────────────────────────────────╜`); + + console.log(lines.join("\n")); + + } finally { + client.close(); + } +} + +main().catch(e => console.error(e.message)); diff --git a/scripts/validate.js b/scripts/validate.js new file mode 100644 index 0000000..c8942f8 --- /dev/null +++ b/scripts/validate.js @@ -0,0 +1,139 @@ +#!/usr/bin/env node +// Validate a Lightning payment by verifying the preimage against the invoice +// Ported from https://github.com/kingonly/validate-payment by @kingonly +// +// Supports BOLT11 and BOLT12 invoice formats +// +// Usage: node validate.js [invoice] [preimage] +// echo "invoice preimage" | node validate.js +// node validate.js --check lnbc... (checks if invoice was paid via NWC) + +const crypto = require('crypto'); +const { decode: decodeBolt11 } = require('light-bolt11-decoder'); +const BOLT12Decoder = require('bolt12-decoder'); +const { Invoice } = require('@getalby/lightning-tools'); + +const args = process.argv.slice(2); + +function toHexString(bytes) { + return bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), ''); +} + +function extractPaymentHash(invoice) { + const trimmed = invoice.trim(); + + // BOLT11 + if (trimmed.startsWith('lnbc') || trimmed.startsWith('lntb')) { + const decoded = decodeBolt11(invoice); + const paymentHash = decoded.sections?.find(s => s.name === 'payment_hash')?.value; + if (!paymentHash) throw new Error('BOLT11: payment_hash not found in invoice'); + return { paymentHash, type: 'BOLT11', decoded }; + } + + // BOLT12 + if (trimmed.startsWith('lni')) { + const decoded = BOLT12Decoder.decode(invoice); + const paymentHash = decoded.paymentHash; + if (!paymentHash) throw new Error('BOLT12: paymentHash not found in invoice'); + return { paymentHash, type: 'BOLT12', decoded }; + } + + throw new Error('Invalid invoice format. Must start with "lnbc"/"lntb" (BOLT11) or "lni" (BOLT12)'); +} + +function hashPreimage(preimage) { + // Convert hex preimage string to bytes + const preimageBytes = new Uint8Array( + preimage.match(/.{1,2}/g)?.map(byte => parseInt(byte, 16)) || [] + ); + const hashBuffer = crypto.createHash('sha256').update(preimageBytes).digest(); + return hashBuffer.toString('hex'); +} + +function decodeInvoicePretty(invoice, decoded) { + const trimmed = invoice.trim(); + let amount, description, timestamp, expiry; + + if (decoded && decoded.sections) { + // BOLT11 + amount = decoded.sections.find(s => s.name === 'amount')?.value; + if (amount) amount = parseInt(amount) / 1000; // millisats -> sats + description = decoded.sections.find(s => s.name === 'description')?.value || '(none)'; + timestamp = decoded.sections.find(s => s.name === 'timestamp')?.value; + expiry = decoded.sections.find(s => s.name === 'expiry')?.value; + } + + return { + amount: amount ? `${amount.toLocaleString()} sats` : 'unknown', + description, + timestamp: timestamp ? new Date(parseInt(timestamp) * 1000).toLocaleString() : 'unknown', + expiry: expiry ? `${expiry / 3600} hour(s)` : 'no expiry', + }; +} + +async function main() { + let invoice, preimage; + + if (args.length >= 2) { + invoice = args[0]; + preimage = args[1]; + } else if (process.stdin.isTTY) { + console.log('Lightning Payment Validator'); + console.log('Ported from https://github.com/kingonly/validate-payment'); + console.log(''); + console.log('Usage:'); + console.log(' node validate.js [bolt11_invoice] [preimage_hex]'); + console.log(' node validate.js [bolt12_invoice] [preimage_hex]'); + console.log(''); + console.log('Options:'); + console.log(' node validate.js --paid-check [bolt11_invoice] Check if invoice was paid via NWC'); + console.log(' node validate.js --lnurl-check [lnurl_invoice] Check if LNURL-Verify supports payment check'); + console.log(''); + console.log('Or pipe: echo "invoice preimage" | node validate.js'); + process.exit(0); + } else { + // Read from stdin + let data = ''; + for await (const chunk of process.stdin) data += chunk; + const parts = data.trim().split(/\s+/); + if (parts.length < 2) { + console.error('Error: expected "invoice preimage" on stdin'); + process.exit(1); + } + invoice = parts[0]; + preimage = parts[1]; + } + + // Extract payment hash + const { paymentHash, type, decoded } = extractPaymentHash(invoice); + + // Hash the preimage + const computedHash = hashPreimage(preimage); + + // Decode human-readable info + const info = decodeInvoicePretty(invoice, decoded); + + // Compare + const isValid = computedHash === paymentHash; + + console.log(`⚡ Payment Validation (${type})`); + console.log(`=`.repeat(40)); + console.log(`Amount: ${info.amount}`); + console.log(`Description: ${info.description}`); + console.log(`Created: ${info.timestamp}`); + console.log(`Expiry: ${info.expiry}`); + console.log(``); + console.log(`Payment hash (from invoice): ${paymentHash}`); + console.log(`SHA-256(preimage provided): ${computedHash}`); + console.log(``); + + if (isValid) { + console.log(`✅ VALID — The preimage cryptographically proves this invoice was paid.`); + console.log(`The payment is confirmed and complete.`); + } else { + console.log(`❌ INVALID — The preimage does NOT match the payment hash.`); + console.log(`This payment has NOT been confirmed.`); + } +} + +main().catch(e => { console.error(`Error: ${e.message}`); process.exit(1); }); diff --git a/scripts/wallets.js b/scripts/wallets.js new file mode 100644 index 0000000..b5849c1 --- /dev/null +++ b/scripts/wallets.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node +// Multi-wallet manager - switch between multiple NWC wallets +// Usage: node wallets.js [add|list|switch|remove|status] [name] [nwc_url] +// +// Strategic advantage: Diversification - never rely on a single point of failure. +// Biblical wisdom: "The rich rule over the poor, and the borrower is slave to the lender." - Pr 22:7 + +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const WALLETS_FILE = path.join(process.env.HOME, ".hermes", "ledgers", "wallets.json"); + +function loadWallets() { + if (fs.existsSync(WALLETS_FILE)) { + return JSON.parse(fs.readFileSync(WALLETS_FILE, "utf8")); + } + return { active: null, wallets: {} }; +} + +function saveWallets(data) { + fs.writeFileSync(WALLETS_FILE, JSON.stringify(data, null, 2)); +} + +async function main() { + const command = process.argv[2] || "list"; + const name = process.argv[3]; + const nwcUrl = process.argv.slice(4).join(" "); + + if (command === "add") { + if (!name || !nwcUrl) { + console.log("Usage: node wallets.js add "); + console.log(" name: Friendly name for this wallet"); + console.log(" nwc_url: nostr+walletconnect://... full URL"); + return; + } + + // Validate URL format + if (!nwcUrl.startsWith("nostr+walletconnect://")) { + console.error("Invalid NWC URL - must start with nostr+walletconnect://"); + return; + } + + const wallets = loadWallets(); + + // Parse pubkey for display + const url = new URL(nwcUrl); + const pubkey = url.hostname; + const short = pubkey.substring(0, 8) + "..." + pubkey.substring(pubkey.length - 4); + + wallets.wallets[name] = { + url: nwcUrl, + pubkey: pubkey, + shortPubkey: short, + addedAt: new Date().toISOString(), + active: false + }; + + if (!wallets.active) { + wallets.active = name; + wallets.wallets[name].active = true; + } + + saveWallets(wallets); + console.log(`✅ Wallet "${name}" added (${short})`); + if (wallets.active === name) { + console.log(` Set as active wallet`); + } + return; + } + + if (command === "list") { + const wallets = loadWallets(); + if (Object.keys(wallets.wallets).length === 0) { + console.log("No wallets registered. Add one with: node wallets.js add "); + return; + } + + console.log("══ Registered Wallets ══\n"); + for (const [key, wallet] of Object.entries(wallets.wallets)) { + const indicator = wallets.active === key ? " 👈 ACTIVE" : ""; + console.log(`${wallets.active === key ? "●" : "○"} ${key}${indicator}`); + console.log(` Pubkey: ${wallet.shortPubkey}`); + console.log(` Added: ${new Date(wallet.addedAt).toLocaleDateString()}`); + console.log(); + } + return; + } + + if (command === "switch") { + if (!name) { + console.log("Usage: node wallets.js switch "); + console.log("Available wallets:"); + const wallets = loadWallets(); + Object.keys(wallets.wallets).forEach(k => console.log(` ${k}`)); + return; + } + + const wallets = loadWallets(); + if (!wallets.wallets[name]) { + console.error(`Wallet "${name}" not found`); + return; + } + + wallets.active = name; + for (const [k, v] of Object.entries(wallets.wallets)) { + v.active = (k === name); + } + saveWallets(wallets); + console.log(`Switched to wallet: "${name}" (${wallets.wallets[name].shortPubkey})`); + + // Update config_local.json for other scripts + try { + const configPath = path.join(process.env.HOME, ".hermes", "config_local.json"); + if (fs.existsSync(configPath)) { + const config = JSON.parse(fs.readFileSync(configPath, "utf8")); + config.wallet.nwc_url = wallets.wallets[name].url; + config.wallet.nwc_wallet_pubkey = wallets.wallets[name].pubkey; + fs.writeFileSync(configPath, JSON.stringify(config, null, 2)); + console.log("Updated config_local.json"); + } + } catch (e) { + console.log("Note: Could not update config_local.json - " + e.message); + } + return; + } + + if (command === "remove") { + if (!name) { + console.log("Usage: node wallets.js remove "); + return; + } + + const wallets = loadWallets(); + if (!wallets.wallets[name]) { + console.error(`Wallet "${name}" not found`); + return; + } + + if (wallets.active === name) { + console.error("Cannot remove active wallet. Switch to another first."); + return; + } + + delete wallets.wallets[name]; + saveWallets(wallets); + console.log(`Removed wallet: "${name}"`); + return; + } + + if (command === "status") { + const wallets = loadWallets(); + if (!wallets.active) { + console.log("No active wallet set."); + return; + } + const active = wallets.wallets[wallets.active]; + console.log(`Active Wallet: "${wallets.active}"`); + console.log(`Pubkey: ${active.shortPubkey}`); + console.log(`Total wallets: ${Object.keys(wallets.wallets).length}`); + return; + } + + console.log("Usage: node wallets.js [add|list|switch|remove|status] [name] [nwc_url]"); +} + +main().catch(e => console.error(e.message)); From 86c229673b2ec96fede5bb3c53d0770d0aa3fe61 Mon Sep 17 00:00:00 2001 From: welliv Date: Sat, 4 Apr 2026 20:25:04 +0000 Subject: [PATCH 2/3] feat: add post-timeout reconciliation, rolling window budget, pending counter, and pay.js with double-spend protection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconciliation engine (reconcile.js): - Post-fallback reconciliation against the node's source of truth - Detects settled payments missing from ledger (backfills them) - Resolves pending payments: settled, in-flight, or stale_expired - Double-spend risk detection (same invoice paid multiple times) - Dry-run mode for auditing Budget Guardian enhancements: - Rolling window budget (default 24h, configurable via --window) - Pending counter — in-flight payments count as spent immediately - Node-verified spending (reads from node, not local state) - New commands: pending, clear-pending Payment flow (pay.js): - Pre-send: budget check with rolling window + pending counter - Pre-send: register pending (prevents double-spend if crash mid-flight) - Post-send: timeout triggers reconciliation after 5s delay - If node confirms → payment was delivered late - If node shows in-flight → keep pending, protect budget - If node has no record → safe to retry - Post-send: preimage verification + proof --- scripts/budget_guardian.js | 115 ++++++++++++++-- scripts/pay.js | 259 ++++++++++++++++++++++++++++++++++++ scripts/reconcile.js | 266 +++++++++++++++++++++++++++++++++++++ 3 files changed, 626 insertions(+), 14 deletions(-) create mode 100644 scripts/pay.js create mode 100644 scripts/reconcile.js diff --git a/scripts/budget_guardian.js b/scripts/budget_guardian.js index 0a199ba..c76b06e 100644 --- a/scripts/budget_guardian.js +++ b/scripts/budget_guardian.js @@ -1,9 +1,14 @@ #!/usr/bin/env node -// Budget Guardian - spending caps with alerts -// Usage: NWC_URL="..." node budget_guardian.js [setup|check|reset] [weekly_sats] -// +// Budget Guardian - spending caps with rolling window + pending counter +// Usage: NWC_URL="..." node budget_guardian.js [setup|status|reset|pending] [cap_sats] [--window=3600] +// +// Safety layers: +// 1. Rolling window — checks last N minutes (not just calendar day) +// 2. Pending counter — in-flight payments count as spent before they settle +// 3. Node-verified — reads from the node's transaction history, not local state +// // Game Theory: Creates commitment device - you set your own spending limit -// and the system enforces it, removing temptation from the equation. +// and the system enforces it, removing temptation from the equation. const { NWCClient } = require("@getalby/sdk/nwc"); const { getFiatValue } = require("@getalby/lightning-tools/fiat"); @@ -21,7 +26,29 @@ function loadBudget() { if (fs.existsSync(BUDGET_FILE)) { return JSON.parse(fs.readFileSync(BUDGET_FILE, "utf8")); } - return { weekly_cap: null, alerts_enabled: true, history: [] }; + return { + weekly_cap: null, + rolling_window_sec: 86400, // default: 24h rolling window + alerts_enabled: true, + history: [], + pending: [] // in-flight payments tracked here + }; +} + +function loadPending() { + const pendingFile = path.join(path.dirname(BUDGET_FILE), "pending_payments.json"); + if (fs.existsSync(pendingFile)) { + try { return JSON.parse(fs.readFileSync(pendingFile, "utf8")).payments || []; } + catch { return []; } + } + return []; +} + +function getPendingTotal(pending, windowSec) { + const now = Math.floor(Date.now() / 1000); + return pending + .filter(p => (now - p.timestamp) < windowSec) // only count recent pending + .reduce((sum, p) => sum + p.sats, 0); } function saveBudget(data) { @@ -37,13 +64,22 @@ async function getCurrentWeekStart() { return Math.floor(now.getTime() / 1000); } -async function getOutgoingForPeriod(from_ts, client) { +async function getOutgoingForPeriod(from_ts, client, state = "settled") { const txs = await client.listTransactions({ type: "outgoing", from: from_ts, limit: 500 }); return txs.transactions - .filter(t => t.state === "settled") + .filter(t => t.state === state) .reduce((sum, t) => sum + (t.amount / 1000), 0); } +// Calculate effective spent: settled in rolling window + in-flight pending +async function getEffectiveSpent(client, windowSec) { + const now = Math.floor(Date.now() / 1000); + const settled = await getOutgoingForPeriod(now - windowSec, client, "settled"); + const pending = loadPending(); + const pendingTotal = getPendingTotal(pending, windowSec); + return { settled, pending_total: pendingTotal, pending_count: pending.filter(p => (now - p.timestamp) < windowSec).length, effective: settled + pendingTotal }; +} + async function main() { if (!NWC_URL) { console.error("NWC_URL not set"); @@ -87,24 +123,32 @@ async function main() { const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); try { const weekStart = await getCurrentWeekStart(); + const windowSec = budget.rolling_window_sec || 86400; const spent = await getOutgoingForPeriod(weekStart, client); + const { settled, pending_total, pending_count, effective } = await getEffectiveSpent(client, windowSec); + const remaining = budget.weekly_cap - spent; + const remainingEffective = budget.weekly_cap - effective; const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); const spentUsd = (spent * rate).toFixed(2); + const effectiveUsd = (effective * rate).toFixed(2); const remainingUsd = (remaining * rate).toFixed(2); + const remainingEffectiveUsd = (remainingEffective * rate).toFixed(2); const pct = (spent / budget.weekly_cap * 100).toFixed(1); - const status = remaining > 0 ? "✅ Under budget" : "❌ OVER BUDGET"; + const effectivePct = (effective / budget.weekly_cap * 100).toFixed(1); + const status = remainingEffective > 0 ? "✅ Under budget" : "❌ OVER BUDGET"; console.log(`\n══ Budget Guardian ══\n`); console.log(`Weekly Cap: ${budget.weekly_cap.toLocaleString()} sats`); - console.log(`Spent: ${spent.toLocaleString()} sats ($${spentUsd}) — ${pct}%`); - console.log(`Remaining: ${remaining.toLocaleString()} sats ($${remainingUsd})`); + console.log(`Settled: ${spent.toLocaleString()} sats ($${spentUsd}) — calendar week`); + console.log(`Rolling ${Math.round(windowSec/3600)}h: ${settled.toLocaleString()} sats settled + ${pending_total.toLocaleString()} sats pending (${pending_count} in-flight)`); + console.log(`Effective: ${effective.toLocaleString()} sats ($${effectiveUsd}) — ${effectivePct}%`); + console.log(`Remaining: ${Math.max(remainingEffective, 0).toLocaleString()} sats ($${remainingEffectiveUsd})`); console.log(`Status: ${status}`); - console.log(`\nWeek started: ${new Date(weekStart * 1000).toLocaleDateString()}`); - if (pct > 90) { - console.log(`\n⚠️ WARNING: You're at ${pct}% of your weekly budget!`); + if (effectivePct > 90) { + console.log(`\n⚠️ WARNING: You're at ${effectivePct}% of your budget (${Math.round(windowSec/3600)}h window)!`); } // Show trend @@ -120,7 +164,50 @@ async function main() { return; } - console.log(`Usage: node budget_guardian.js [setup |check|reset|status]`); + // Show pending payments + if (command === "pending") { + const pending = loadPending(); + const now = Math.floor(Date.now() / 1000); + const windowSec = budget.rolling_window_sec || 86400; + const active = pending.filter(p => (now - p.timestamp) < windowSec); + const stale = pending.filter(p => (now - p.timestamp) >= windowSec); + + if (active.length === 0 && stale.length === 0) { + console.log("No pending payments"); + return; + } + + console.log(`\n══ Pending Payments ══\n`); + if (active.length > 0) { + console.log(`Active (in window):`); + for (const p of active) { + console.log(` ⏳ ${p.sats.toLocaleString()} sats — ${p.description || "no memo"} — ${Math.round(now - p.timestamp)}s ago`); + } + } + if (stale.length > 0) { + console.log(`\nStale (outside window):`); + for (const p of stale) { + console.log(` ❌ ${p.sats.toLocaleString()} sats — ${p.description || "no memo"} — ${Math.round((now - p.timestamp) / 60)}min ago`); + } + } + return; + } + + // Clear stale pending + if (command === "clear-pending") { + const pending = loadPending(); + const now = Math.floor(Date.now() / 1000); + const windowSec = budget.rolling_window_sec || 86400; + const active = pending.filter(p => (now - p.timestamp) < windowSec); + const cleared = pending.length - active.length; + + const pendingFile = path.join(path.dirname(BUDGET_FILE), "pending_payments.json"); + fs.writeFileSync(pendingFile, JSON.stringify({ payments: active }, null, 2)); + console.log(`Cleared ${cleared} stale pending payments. ${active.length} remain.`); + return; + } + + console.log(`Usage: node budget_guardian.js [setup |status|reset|pending|clear-pending] [--window=3600]`); } main().catch(e => { diff --git a/scripts/pay.js b/scripts/pay.js new file mode 100644 index 0000000..7440c4f --- /dev/null +++ b/scripts/pay.js @@ -0,0 +1,259 @@ +#!/usr/bin/env node +// Pay sats with safety, budget enforcement, pending tracking, and auto-reconciliation +// Usage: NWC_URL="..." node pay.js +// +// Safety features: +// 1. Budget check with rolling window + pending counter +// 2. Pre-decode & preview before sending +// 3. Pending register (treats in-flight as already-spent) +// 4. Post-send verification with preimage proof +// 5. Timeout reconciliation (resolves the double-spend gap) + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { LightningAddress } = require("@getalby/lightning-tools"); +const { decode: decodeBolt11 } = require("light-bolt11-decoder"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const crypto = require("crypto"); +const fs = require("fs"); +const path = require("path"); + +const NWC_URL = process.env.NWC_URL; + +// Paths — shared with reconcile.js and budget_guardian.js +const LEDGER_DIR = path.join(process.env.HOME, ".hermes", "ledgers"); +const LEDGER_FILE = path.join(LEDGER_DIR, "transactions_ledger.json"); +const PENDING_FILE = path.join(LEDGER_DIR, "pending_payments.json"); +const BUDGET_FILE = path.join(LEDGER_DIR, "budget_guardian.json"); + +if (!fs.existsSync(LEDGER_DIR)) fs.mkdirSync(LEDGER_DIR, { recursive: true }); + +function loadJson(filepath, fallback = {}) { + if (fs.existsSync(filepath)) { + try { return JSON.parse(fs.readFileSync(filepath, "utf8")); } + catch { return typeof fallback === "function" ? fallback() : fallback; } + } + return typeof fallback === "function" ? fallback() : fallback; +} + +function saveJson(filepath, data) { + fs.writeFileSync(filepath, JSON.stringify(data, null, 2)); +} + +async function getFiatUsd(sats) { + try { + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + return { rate, usd: (sats * rate).toFixed(4) }; + } catch { + return { rate: null, usd: "N/A" }; + } +} + +// ── Budget + Pending Counter Check ────────────────────────────────── +// Rolling window: checks last N minutes (default 60) instead of just calendar week +// Pending counter: treats in-flight payments as already-spent +async function checkBudget(client, amountSats) { + const budget = loadJson(BUDGET_FILE, {}); + if (!budget.weekly_cap) return { ok: true, reason: "No budget configured" }; + + const now = Math.floor(Date.now() / 1000); + + // Rolling window (default 1 hour, can be configured) + const rollingWindow = budget.rolling_window_sec || 3600; + const windowStart = now - rollingWindow; + + // Get actual settled spend from node + const windowTx = await client.listTransactions({ + type: "outgoing", + from: windowStart, + limit: 500, + }); + const settledInWindow = (windowTx.transactions || []) + .filter(t => t.state === "settled") + .reduce((sum, t) => sum + t.amount / 1000, 0); + + // Add in-flight pending payments + const pending = loadJson(PENDING_FILE, { payments: [] }).payments; + // Clean up stale pending (> rolling window old) + const freshPending = pending.filter(p => (now - p.timestamp) < rollingWindow); + const pendingTotal = freshPending.reduce((sum, p) => sum + p.sats, 0); + + const effectiveSpent = settledInWindow + pendingTotal; + const effectiveRemaining = budget.weekly_cap - effectiveSpent; + + return { + ok: (effectiveSpent + amountSats) <= budget.weekly_cap, + cap: budget.weekly_cap, + settled_in_window: settledInWindow, + pending_total: pendingTotal, + pending_count: freshPending.length, + effective_spent: effectiveSpent, + effective_remaining: effectiveRemaining, + rolling_window_min: Math.round(rollingWindow / 60), + reason: (effectiveSpent + amountSats) > budget.weekly_cap + ? `Budget exceeded: ${effectiveSpent} settled + ${pendingTotal} pending + ${amountSats} requested = ${effectiveSpent + pendingTotal + amountSats} > ${budget.weekly_cap} cap (${Math.round(rollingWindow/60)}min rolling window)` + : "Within budget", + }; +} + +// Register a pending payment (before sending) +function registerPending(paymentHash, sats, invoice, description) { + const pendingFile = loadJson(PENDING_FILE, { payments: [] }); + pendingFile.payments.push({ + payment_hash: paymentHash, + sats, + invoice: invoice ? invoice.substring(0, 80) : "", + description: description || "", + timestamp: Math.floor(Date.now() / 1000), + }); + saveJson(PENDING_FILE, pendingFile); +} + +// Clear pending after settlement +function clearPending(paymentHash) { + const pendingFile = loadJson(PENDING_FILE, { payments: [] }); + pendingFile.payments = pendingFile.payments.filter(p => p.payment_hash !== paymentHash); + saveJson(PENDING_FILE, pendingFile); +} + +// ── Payment Flow ──────────────────────────────────────────────────── +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL not set"); + process.exit(1); + } + + const amountArg = parseInt(process.argv[2]); + const recipient = process.argv[3] || ""; + + if (!amountArg || amountArg <= 0 || !recipient) { + console.log("Usage: node pay.js "); + console.log("\n Recipient can be:"); + console.log(" • Lightning address: user@domain.com"); + console.log(" • BOLT-11 invoice: lnbc1..."); + console.log("\n Safety: Budget is checked before sending. In-flight payments count against the cap."); + return; + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + + try { + // 1. Get invoice + let bolt11; + let description = ""; + let paymentHash; + + if (recipient.toLowerCase().includes("@")) { + // Lightning address + const ln = new LightningAddress(recipient); + await ln.fetch(); + const inv = await ln.requestInvoice({ satoshi: amountArg }); + bolt11 = inv.paymentRequest; + decoded = decodeBolt11(bolt11); + paymentHash = decoded.sections.find(s => s.name === "payment_hash")?.value; + description = decoded.sections.find(s => s.name === "description")?.value || ""; + } else { + // Raw BOLT-11 invoice + bolt11 = recipient; + const decoded = decodeBolt11(bolt11); + paymentHash = decoded.sections.find(s => s.name === "payment_hash")?.value; + description = decoded.sections.find(s => s.name === "description")?.value || ""; + // Override amount from invoice if needed + } + + const { usd } = await getFiatUsd(amountArg); + + // 2. Budget check + const budgetCheck = await checkBudget(client, amountArg); + console.log(`\n💰 Payment Preview`); + console.log(`─────────────────────────`); + console.log(` Amount: ${amountArg.toLocaleString()} sats (~$${usd})`); + console.log(` To: ${recipient}`); + console.log(` Memo: ${description || "—"}`); + console.log(` Payment: ${paymentHash?.substring(0, 16)}...`); + console.log(`\n🛡️ Budget Guardian`); + console.log(`─────────────────────────`); + if (budgetCheck.ok) { + console.log(` ✅ Budget OK: ${budgetCheck.effective_remaining} sats remaining`); + console.log(` (Rolling ${budgetCheck.rolling_window_min}min window, ${budgetCheck.pending_count} pending)`); + } else { + console.log(` ❌ ${budgetCheck.reason}`); + client.close(); + process.exit(1); + } + + // 3. Register pending (prevents double-spend if we crash mid-flight) + registerPending(paymentHash, amountArg, bolt11, description); + + // 4. Pay + console.log(`\n⚡ Sending...`); + let result; + try { + result = await client.payInvoice({ invoice: bolt11 }); + } catch (payErr) { + // TIMEOUT / NETWORK ERROR PATH → trigger reconciliation + console.log(`\n⚠️ Send error: ${payErr.message}`); + console.log(`🔍 Running post-failure reconciliation...`); + + // Brief wait for propagation + await new Promise(r => setTimeout(r, 5000)); + + try { + const txs = await client.listTransactions({ limit: 20 }); + const nodeTx = txs.transactions.find(t => t.payment_hash === paymentHash); + + if (nodeTx && nodeTx.state === "settled") { + result = { preimage: nodeTx.preimage, fees_paid: nodeTx.fees_paid }; + console.log(`✅ Payment was DELIVERED (confirmed via node reconciliation)`); + } else if (nodeTx && (nodeTx.state === "pending" || nodeTx.state === "in-flight")) { + console.log(`⏳ Payment is IN-FLIGHT on node. Will resolve on next reconciliation.`); + console.log(` Pending counter is already active — budget is protected.`); + client.close(); + process.exit(2); // special exit: in-flight + } else { + // Not on node → payment likely never went through, safe to retry + // Clear the pending entry + clearPending(paymentHash); + console.log(`❌ Payment NOT found on node. Safe to retry.`); + client.close(); + process.exit(3); // special exit: failed, safe to retry + } + } catch (reconErr) { + console.log(`⚠️ Reconciliation also failed: ${reconErr.message}`); + console.log(` Pending counter remains active. Check node manually.`); + client.close(); + process.exit(4); + } + } + + // 5. Clear pending & verify preimage + clearPending(paymentHash); + const preimageHex = result.preimage; + const preimageBytes = Buffer.from(preimageHex, "hex"); + const computedHash = crypto.createHash("sha256").update(preimageBytes).digest("hex"); + + const valid = computedHash === paymentHash; + const feesSats = result.fees_paid / 1000; + + // 6. Output for agent pipeline + console.log(`\n✅ Payment confirmed!`); + console.log(`─────────────────────────`); + console.log(` Preimage: ${preimageHex}`); + console.log(` Fees: ${feesSats} sats`); + console.log(` Verified: ${valid ? "CRYPTO PROOF ✅" : "⚠️ Preimage mismatch"}`); + + // Also output machine-readable format + console.log(`\nSATS=${amountArg}`); + console.log(`FEES=${feesSats}`); + console.log(`PREIMAGE=${preimageHex}`); + console.log(`VALID=${valid}`); + console.log(`PAYMENT_HASH=${paymentHash}`); + + } finally { + client.close(); + } +} + +main().catch(e => { + console.error(`💥 Fatal: ${e.message}`); + process.exit(1); +}); diff --git a/scripts/reconcile.js b/scripts/reconcile.js new file mode 100644 index 0000000..5c4291e --- /dev/null +++ b/scripts/reconcile.js @@ -0,0 +1,266 @@ +#!/usr/bin/env node +// Reconciliation Engine — resolves the offline/timeout edge case +// Usage: NWC_URL="..." node reconcile.js [--dry-run] [--window=300] +// +// PROBLEM: If payInvoice() throws after the payment was routed on Lightning, +// the agent doesn't know it succeeded. It may retry → double-spend risk, +// the ledger is stale, and the budget counter is wrong. +// +// SOLUTION: Post-failure reconciliation against the node's source of truth. +// 1. Scan node transaction history for the reconciliation window +// 2. Compare what the node says vs what the ledger says +// 3. Flag gaps, backfill missing entries, detect potential double-spends +// 4. Update pending counters for in-flight payments + +const { NWCClient } = require("@getalby/sdk/nwc"); +const { getFiatValue } = require("@getalby/lightning-tools/fiat"); +const fs = require("fs"); +const path = require("path"); +const crypto = require("crypto"); + +const NWC_URL = process.env.NWC_URL; +const LEDGER_DIR = path.join(process.env.HOME, ".hermes", "ledgers"); +const LEDGER_FILE = path.join(LEDGER_DIR, "transactions_ledger.json"); +const PENDING_FILE = path.join(LEDGER_DIR, "pending_payments.json"); +const RECON_LOG = path.join(LEDGER_DIR, "reconciliation_log.json"); + +// Ensure directories +if (!fs.existsSync(LEDGER_DIR)) fs.mkdirSync(LEDGER_DIR, { recursive: true }); + +function loadJson(filepath, fallback = []) { + if (fs.existsSync(filepath)) { + try { return JSON.parse(fs.readFileSync(filepath, "utf8")); } + catch { return typeof fallback === "function" ? fallback() : fallback; } + } + return typeof fallback === "function" ? fallback() : fallback; +} + +function saveJson(filepath, data) { + fs.writeFileSync(filepath, JSON.stringify(data, null, 2)); +} + +async function getFiatUsd(sats) { + try { + const rate = await getFiatValue({ satoshi: 1, currency: "USD" }); + return (sats * rate).toFixed(4); + } catch { return "N/A"; } +} + +function verifyPreimage(preimage, paymentHash) { + if (!preimage) return false; + const computed = crypto.createHash("sha256").update(Buffer.from(preimage, "hex")).digest("hex"); + return computed === paymentHash; +} + +// Parse CLI args +const args = process.argv.slice(2); +const dryRun = args.includes("--dry-run"); +const windowArg = args.find(a => a.startsWith("--window=")); +const RECON_WINDOW_SEC = windowArg ? parseInt(windowArg.split("=")[1]) : 600; // 10 min default + +async function main() { + if (!NWC_URL) { + console.error("Error: NWC_URL not set"); + process.exit(1); + } + + const client = new NWCClient({ nostrWalletConnectUrl: NWC_URL }); + const info = await client.getInfo(); + console.log(`🔗 Connected: ${info.alias || "NWC wallet"}`); + + // Load current state + const ledger = loadJson(LEDGER_FILE, []); + const pending = loadJson(PENDING_FILE, { payments: [] }).payments; + const reconciliationLog = loadJson(RECON_LOG, []); + + const now = Math.floor(Date.now() / 1000); + const windowStart = now - RECON_WINDOW_SEC; + + console.log(`\n🔍 Reconciliation window: last ${RECON_WINDOW_SEC}s`); + console.log(` Ledger entries: ${ledger.length}`); + console.log(` Pending payments: ${pending.length}`); + console.log(` Window start: ${new Date(windowStart * 1000).toISOString()}`); + console.log(` Window end: ${new Date(now * 1000).toISOString()}`); + console.log(""); + + // Step 1: Fetch node's transaction history for the window + console.log("📡 Fetching transaction history from node..."); + let nodeTxs = []; + try { + const txs = await client.listTransactions({ from: windowStart, limit: 500 }); + nodeTxs = txs.transactions || []; + console.log(` Found ${nodeTxs.length} transactions in window`); + } catch (err) { + console.error(` ❌ Failed to fetch transactions: ${err.message}`); + client.close(); + process.exit(1); + } + + // Build lookup sets + const nodeHashes = new Set(nodeTxs.map(t => t.payment_hash)); + const nodeHashmap = new Map(nodeTxs.map(t => [t.payment_hash, t])); + const ledgerHashes = new Set(ledger.map(l => l.payment_hash)); + const pendingHashes = new Set(pending.map(p => p.payment_hash)); + + // Step 2: Reconcile — what the node has that the ledger doesn't + console.log("\n📊 Reconciliation Results"); + console.log("═".repeat(60)); + + const results = { + timestamp: new Date().toISOString(), + window_start: windowStart, + window_end: now, + window_sec: RECON_WINDOW_SEC, + node_transactions: nodeTxs.length, + ledger_entries: ledger.length, + pending_count: pending.length, + backfilled: [], + resolved_pending: [], + stale_pending: [], + double_spend_risk: [], + discrepancies: [], + }; + + // 2a: Node settled txs missing from ledger → backfill + const missingFromLedger = nodeTxs.filter(t => + t.state === "settled" && !ledgerHashes.has(t.payment_hash) + ); + + if (missingFromLedger.length === 0) { + console.log("✅ Ledger is in sync with node (no missing settled txs)"); + } else { + console.log(`\n⚠️ ${missingFromLedger.length} settled transaction(s) on node but NOT in ledger:`); + for (const tx of missingFromLedger) { + const sats = tx.amount / 1000; + const usd = await getFiatUsd(sats); + const preimageValid = verifyPreimage(tx.preimage, tx.payment_hash); + const dir = tx.type === "incoming" ? "→ IN" : "← OUT"; + console.log(` ${dir} ${sats.toLocaleString()} sats (~$${usd}) — ${tx.description || "no memo"} — ${preimageValid ? "✅ preimage" : "⚠️ no preimage"}`); + + const record = { + type: tx.type, + state: "settled", + sats, + usd: usd || "N/A", + fees: (tx.fees_paid || 0) / 1000, + description: tx.description || "", + payment_hash: tx.payment_hash, + preimage: tx.preimage || null, + crypto_proof: tx.preimage + ? { verified: preimageValid, preimage: tx.preimage, sha256_match: preimageValid } + : { verified: false, reason: "No preimage available" }, + created_at: tx.created_at, + settled_at: tx.settled_at, + invoice: tx.invoice || "", + verified_at: Date.now(), + backfilled_by: "reconciliation", + backfilled_at: new Date().toISOString(), + }; + + if (!dryRun) { + ledger.push(record); + } + results.backfilled.push({ + payment_hash: tx.payment_hash, + sats, + type: tx.type, + description: tx.description || "", + }); + } + + // Save updated ledger + if (!dryRun && missingFromLedger.length > 0) { + saveJson(LEDGER_FILE, ledger); + console.log(`\n💾 Ledger updated: ${missingFromLedger.length} entries backfilled`); + } + } + + // 2b: Check pending payments against node + console.log("\n🔄 Checking pending payments..."); + const resolved = []; + const stillPending = []; + const stale = []; + + for (const p of pending) { + const nodeTx = nodeHashmap.get(p.payment_hash); + + if (nodeTx && nodeTx.state === "settled") { + // Payment settled on node — resolve pending + resolved.push({ ...p, resolved_as: "settled", node_settled_at: nodeTx.settled_at }); + console.log(` ✅ ${p.payment_hash.substring(0, 16)}... SETTLED on node at ${new Date(nodeTx.settled_at * 1000).toISOString()}`); + } else if (nodeTx && (nodeTx.state === "pending" || nodeTx.state === "in-flight")) { + // Still pending on node — keep in pending list + stillPending.push(p); + console.log(` ⏳ ${p.payment_hash.substring(0, 16)}... still IN-FLIGHT`); + } else { + // Not found on node — either failed, or outside lookup window + const ageSeconds = now - (p.timestamp || 0); + const maxPendingSec = Math.max(RECON_WINDOW_SEC, 3600); // 1 hour max + + if (ageSeconds > maxPendingSec) { + stale.push({ ...p, resolved_as: "stale_expired", age_sec: ageSeconds }); + console.log(` ❌ ${p.payment_hash.substring(0, 16)}... STALE (${Math.round(ageSeconds / 60)}min old, not on node)`); + } else { + stillPending.push(p); + console.log(` 🕐 ${p.payment_hash.substring(0, 16)}... young pending (${Math.round(ageSeconds)}s), keeping`); + } + } + } + + results.resolved_pending = resolved; + results.stale_pending = stale; + + // 2c: Double-spend risk detection + // If the node shows 2+ outgoing payments with the same invoice/payment_request + const outgoingByInvoice = new Map(); + for (const tx of nodeTxs.filter(t => t.type === "outgoing")) { + if (!tx.invoice) continue; + if (!outgoingByInvoice.has(tx.invoice)) outgoingByInvoice.set(tx.invoice, []); + outgoingByInvoice.get(tx.invoice).push(tx); + } + + for (const [invoice, txs] of outgoingByInvoice) { + if (txs.length > 1) { + const totalPaid = txs.reduce((sum, t) => sum + t.amount / 1000, 0); + results.double_spend_risk.push({ + invoice: invoice.substring(0, 50) + "...", + count: txs.length, + total_sats: totalPaid, + payment_hashes: txs.map(t => t.payment_hash), + }); + console.log(`\n🚨 DOUBLE-SPEND DETECTED: invoice paid ${txs.length}x (${totalPaid}sats total)`); + } + } + + // Summary + console.log("\n" + "═".repeat(60)); + console.log("📋 SUMMARY"); + console.log("─".repeat(60)); + console.log(` Ledger entries: ${ledger.length}`); + console.log(` Backfilled: ${results.backfilled.length}`); + console.log(` Pending resolved: ${resolved.length}`); + console.log(` Pending stale: ${stale.length}`); + console.log(` Still pending: ${stillPending.length}`); + console.log(` Double-spend risks: ${results.double_spend_risk.length}`); + console.log(` Dry run: ${dryRun ? "Yes (no changes written)" : "No"}`); + + // Save reconciliation log + if (!dryRun) { + reconciliationLog.push(results); + saveJson(RECON_LOG, reconciliationLog); + + // Update pending payments + const newPending = [...stillPending]; + saveJson(PENDING_FILE, { payments: newPending, last_reconciled: new Date().toISOString() }); + + console.log(`\n💾 Reconciliation log saved`); + console.log(`💾 Pending updated: ${newPending.length} remaining`); + } + + client.close(); +} + +main().catch(e => { + console.error(`💥 Fatal: ${e.message}`); + process.exit(1); +}); From 85e2efaa260f41a988816ac249c23ec697c448d9 Mon Sep 17 00:00:00 2001 From: welliv Date: Mon, 6 Apr 2026 12:03:45 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20sync=20SKILL.md=20v7.0.0=20?= =?UTF-8?q?=E2=80=94=20remove=20PIL=20cards,=20tighten=20NWC=20URL=20secur?= =?UTF-8?q?ity,=20add=20setup/run=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 56 ++++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/SKILL.md b/SKILL.md index 4a4e932..8aa9189 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: alby-bitcoin-payments -description: Bitcoin Lightning wallet via NWC. Say anything natural, get styled receipts. Every payment cryptographically proven with SHA-256 preimage verification. +description: Bitcoin Lightning wallet via NWC. Say anything natural, get confirmed transactions. Every payment cryptographically proven with SHA-256 preimage verification. version: "7.0.0" --- @@ -10,7 +10,7 @@ A complete Bitcoin Lightning wallet powered by Alby's Nostr Wallet Connect. The ## How It Works -User → says anything natural → agent does everything → styled receipt. +User → says anything natural → agent does everything → confirmation. ``` User: "send 500 to alice@getalby.com" @@ -27,21 +27,22 @@ User: "verify this payment" 2. If auto-ledger not running, start it in background 3. Show balance +**SECURITY: NWC URL MUST NEVER be displayed, echoed, or shared in any output, logs, or chat.** + **On every wallet operation:** - `cd /root/.hermes/skills/alby-bitcoin-payments` first - NWC returns millisats → always divide by 1000 - Always include fiat equivalent (USD default) -- NWC URL → never echo back +- NWC URL → never echo, never log, never display in any form +- Read URL only from `~/.hermes/config_local.json` (chmod 600) — never ask user for it again **Before sending:** 1. Decode destination → show amount, fiat, recipient 2. Confirm: `"Send X sats ($Y) to Z? Reply YES to confirm."` 3. After paying → verify in `listTransactions()` (NWC can report false success) -4. Generate styled PIL card → deliver as MEDIA **On invoice creation:** - BOLT-11 in code block (one-tap copy) -- QR code as MEDIA image - Include sats + fiat **On BOLT-11 or lightning address in any message:** @@ -49,9 +50,6 @@ User: "verify this payment" ## Response Style -**Telegram/messaging:** Always PIL card as MEDIA, emoji summary -**Terminal:** ASCII panel + clean text - ### Balance Card ``` Wallet: alias | mainnet @@ -108,16 +106,54 @@ node budget_guardian.js status # Current usage node budget_guardian.js reset # Reset for new week ``` +## Initial Setup + +1. `cd ~/.hermes/skills/alby-bitcoin-payments` +2. Run `npm install` (uses `package.json` in skill root) +3. Set NWC URL in `~/.hermes/config_local.json` under `wallet.nwc_url` + ```json + {"wallet": {"nwc_url": "nostr+walletconnect://..."}} + ``` +4. Set file permissions: `chmod 600 ~/.hermes/config_local.json` +5. Start auto-ledger: `export NWC_URL=$(python3 -c "import json; print(json.load(open('~/.hermes/config_local.json'))['wallet']['nwc_url'])") && node scripts/auto_ledger.js &` + +### Running Scripts + +Always export NWC URL first (scripts read from env, not config file): +```bash +cd ~/.hermes/skills/alby-bitcoin-payments +export NWC_URL=$(python3 -c "import json; print(json.load(open('~/.hermes/config_local.json'))['wallet']['nwc_url'])") +node scripts/balance.js +``` + ## Environment | Path | Purpose | |------|---------| | `/root/.hermes/skills/alby-bitcoin-payments/` | Skill root — always `cd` here first | -| `/usr/bin/python3` | PIL/Pillow card generation (NOT venv python) | | `~/.hermes/ledgers/` | Auto-ledger + proof files | | `~/.hermes/config_local.json` | NWC URL + user config | -**Requirements:** Node.js 22+, Python 3 with Pillow, `@getalby/sdk`, `@getalby/lightning-tools` +**Script Execution:** Always export the NWC URL before running scripts: +```bash +export NWC_URL=$(python3 -c "import json; print(json.load(open('/root/.hermes/config_local.json'))['wallet']['nwc_url'])") +``` + +**Important Script Argument Orders:** +- `pay.js` — takes `` then ``: `pay.js 100 user@domain.com` (amount first!) +- `qr_invoice.js` — takes `` then ``: `qr_invoice.js 100 "my invoice"` + +**Key Operational Notes:** +- The `@getalby/sdk` NWCClient must be imported from `@getalby/sdk/nwc` (not the main module) +- The `package.json` may not be present in the installed skill directory — it's in the clone root, needed for `npm install` +- The auto-ledger (`auto_ledger.js`) does NOT start automatically — run it manually in background after wallet setup: + ```bash + mkdir -p ~/.hermes/ledgers/proofs && node scripts/auto_ledger.js & + ``` +- Payment verification via NWC `listTransactions()` is the authoritative source — NWC `payInvoice()` can report false success +- SHA-256 preimage verification: `crypto.createHash('sha256').update(Buffer.from(preimage, 'hex')).digest('hex')` should match `payment_hash` + +**PIL cards:** Disabled. Do not generate styled PIL receipt cards. Just use text confirmations and ASCII panels. ## Troubleshooting