diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5378851..b821226 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -7,6 +7,7 @@ import demoRouter from "./demo.js"; import devRouter from "./dev.js"; import testkitRouter from "./testkit.js"; import uiRouter from "./ui.js"; +import nodeDiagnosticsRouter from "./node-diagnostics.js"; const router: IRouter = Router(); @@ -17,6 +18,7 @@ router.use(sessionsRouter); router.use(demoRouter); router.use(testkitRouter); router.use(uiRouter); +router.use(nodeDiagnosticsRouter); if (process.env.NODE_ENV !== "production") { router.use(devRouter); diff --git a/artifacts/api-server/src/routes/node-diagnostics.ts b/artifacts/api-server/src/routes/node-diagnostics.ts new file mode 100644 index 0000000..b895d62 --- /dev/null +++ b/artifacts/api-server/src/routes/node-diagnostics.ts @@ -0,0 +1,169 @@ +import { Router, type IRouter } from "express"; +import { execFile } from "child_process"; +import { promisify } from "util"; + +const execFileAsync = promisify(execFile); +const router: IRouter = Router(); + +interface NodeStatus { + timestamp: string; + lnbits: LNbitsStatus; + lnd?: LNDStatus; + bitcoin?: BitcoinStatus; + overall: "ok" | "degraded" | "down"; + issues: string[]; +} + +interface LNbitsStatus { + reachable: boolean; + url: string; + walletBalance?: number; + error?: string; +} + +interface LNDStatus { + synced: boolean; + blockHeight: number; + activeChannels: number; + walletBalance: number; + channelBalance: number; +} + +interface BitcoinStatus { + blocks: number; + headers: number; + syncPct: number; + pruned: boolean; + peers: number; +} + +async function probeLNbits(): Promise { + const url = process.env.LNBITS_URL ?? ""; + const key = process.env.LNBITS_API_KEY ?? ""; + + if (!url || !key) { + return { reachable: false, url: url || "(not configured)", error: "LNBITS_URL or LNBITS_API_KEY not set" }; + } + + try { + const res = await fetch(`${url}/api/v1/wallet`, { + headers: { "X-Api-Key": key }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) { + return { reachable: false, url, error: `HTTP ${res.status}` }; + } + const data = (await res.json()) as { balance: number }; + return { reachable: true, url, walletBalance: Math.round((data.balance ?? 0) / 1000) }; + } catch (e) { + return { reachable: false, url, error: String(e) }; + } +} + +async function probeLNbitsNodeInfo(): Promise<{ lnd?: LNDStatus; bitcoin?: BitcoinStatus }> { + const url = process.env.LNBITS_URL ?? ""; + const key = process.env.LNBITS_API_KEY ?? ""; + if (!url || !key) return {}; + + try { + const res = await fetch(`${url}/api/v1/extension/lndhub/api/v1/getinfo`, { + headers: { "X-Api-Key": key }, + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return {}; + const data = (await res.json()) as Record; + + const lnd: LNDStatus = { + synced: Boolean(data["synced_to_chain"]), + blockHeight: Number(data["block_height"] ?? 0), + activeChannels: Number(data["num_active_channels"] ?? 0), + walletBalance: 0, + channelBalance: 0, + }; + return { lnd }; + } catch { + return {}; + } +} + +router.get("/api/admin/node-status", async (_req, res) => { + const issues: string[] = []; + + const [lnbits, nodeInfo] = await Promise.all([ + probeLNbits(), + probeLNbitsNodeInfo(), + ]); + + if (!lnbits.reachable) { + issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`); + } + if (nodeInfo.lnd && !nodeInfo.lnd.synced) { + issues.push("LND not yet synced to chain tip"); + } + + const overall: NodeStatus["overall"] = + !lnbits.reachable ? "down" : + issues.length > 0 ? "degraded" : + "ok"; + + const status: NodeStatus = { + timestamp: new Date().toISOString(), + lnbits, + ...nodeInfo, + overall, + issues, + }; + + res.json(status); +}); + +router.get("/api/admin/node-status/html", async (_req, res) => { + const issues: string[] = []; + const [lnbits, nodeInfo] = await Promise.all([probeLNbits(), probeLNbitsNodeInfo()]); + + if (!lnbits.reachable) issues.push(`LNbits unreachable: ${lnbits.error ?? "unknown"}`); + if (nodeInfo.lnd && !nodeInfo.lnd.synced) issues.push("LND not yet synced"); + + const overall = !lnbits.reachable ? "down" : issues.length > 0 ? "degraded" : "ok"; + const color = overall === "ok" ? "#22c55e" : overall === "degraded" ? "#f59e0b" : "#ef4444"; + + res.setHeader("Content-Type", "text/html"); + res.send(` +Timmy — Node Status + +

⚡ Timmy Node Status

+

Overall: ${overall.toUpperCase()}

+ +

LNbits

+ + + + ${lnbits.walletBalance !== undefined ? `` : ""} + ${lnbits.error ? `` : ""} +
URL${lnbits.url}
Reachable${lnbits.reachable ? "✓ yes" : "✗ no"}
Wallet Balance${lnbits.walletBalance} sats
Error${lnbits.error}
+ +${nodeInfo.lnd ? `

LND

+ + + + +
Synced${nodeInfo.lnd.synced ? "✓ yes" : "⏳ syncing"}
Block Height${nodeInfo.lnd.blockHeight.toLocaleString()}
Active Channels${nodeInfo.lnd.activeChannels}
` : ""} + +${issues.length > 0 ? `

Issues

` : ""} + +

+ Refreshed at ${new Date().toISOString()}  ·  + JSON  ·  + Timmy UI +

+`); +}); + +export default router; diff --git a/scripts/bitcoin-ln-node/expose.sh b/scripts/bitcoin-ln-node/expose.sh new file mode 100755 index 0000000..35bd2e8 --- /dev/null +++ b/scripts/bitcoin-ln-node/expose.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy node — expose LNbits to Replit via bore tunnel +# Run this after LNbits is up. Keep this terminal open. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LNBITS_LOCAL="http://127.0.0.1:5000" + +GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${CYAN}[expose]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } + +# ─── Ensure LNbits is up ────────────────────────────────────────────────────── +info "Checking LNbits is reachable locally…" +if ! curl -sf "$LNBITS_LOCAL/api/v1/health" &>/dev/null; then + warn "LNbits not responding at $LNBITS_LOCAL" + warn "Waiting up to 30s…" + for i in $(seq 1 6); do + sleep 5 + curl -sf "$LNBITS_LOCAL/api/v1/health" &>/dev/null && break + [[ $i -eq 6 ]] && die "LNbits still not up. Run 'bash $SCRIPT_DIR/start.sh' first." + done +fi +ok "LNbits is running locally." + +# ─── Ensure bore is installed ───────────────────────────────────────────────── +if ! command -v bore &>/dev/null; then + info "Installing bore (tunnel tool)…" + if command -v cargo &>/dev/null; then + cargo install bore-cli + elif command -v brew &>/dev/null; then + brew install bore-cli 2>/dev/null || { + warn "bore not in Homebrew — installing via cargo…" + brew install rust 2>/dev/null && cargo install bore-cli + } + else + die "Neither cargo nor brew found. Install bore manually: https://github.com/ekzhang/bore" + fi +fi + +# Kill any existing bore tunnel on port 5000 +if EXISTING=$(pgrep -f "bore local.*5000" 2>/dev/null); then + info "Killing existing bore tunnel (PID $EXISTING)…" + kill "$EXISTING" 2>/dev/null || true + sleep 1 +fi + +# ─── Start bore ────────────────────────────────────────────────────────────── +info "Opening bore tunnel: local:5000 → bore.pub…" +echo "" + +# bore prints the public address on startup — capture it +BORE_OUT=$(mktemp) +bore local 5000 --to bore.pub 2>&1 & +BORE_PID=$! + +sleep 3 + +# Extract the assigned port from bore output +if BORE_LINE=$(bore local 5000 --to bore.pub --help 2>&1 | head -1); then true; fi +# Check if bore is still running +if ! kill -0 $BORE_PID 2>/dev/null; then + die "bore exited immediately. Check your internet connection." +fi + +# bore prints: "listening at bore.pub:PORT" +# We need to find what port was assigned +BORE_PORT=$(ps aux | grep "bore local 5000" | grep -v grep | grep -oE '\-\-port [0-9]+' | awk '{print $2}' || true) +if [[ -z "$BORE_PORT" ]]; then + # Fallback: tail the bore process log + sleep 2 + BORE_PORT=$(bore local 5000 --to bore.pub 2>&1 & sleep 3; pkill -f "bore local" 2>/dev/null || true) +fi + +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN} Bore tunnel is LIVE (PID: $BORE_PID)${NC}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +echo " LNbits is now reachable from Replit." +echo "" +echo " Set these secrets in Replit:" +echo " ┌──────────────────────────────────────────────────┐" +echo " │ LNBITS_URL = http://bore.pub: │" +echo " │ LNBITS_API_KEY = │" +echo " └──────────────────────────────────────────────────┘" +echo "" +echo " Get your API key: bash $SCRIPT_DIR/get-lnbits-key.sh" +echo "" +echo " Keep this terminal open. Ctrl+C to stop the tunnel." +echo "" + +# Wait for bore to exit +wait $BORE_PID diff --git a/scripts/bitcoin-ln-node/get-lnbits-key.sh b/scripts/bitcoin-ln-node/get-lnbits-key.sh new file mode 100755 index 0000000..2eb2b42 --- /dev/null +++ b/scripts/bitcoin-ln-node/get-lnbits-key.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy node — fetch LNbits admin API key +# Run this after LNbits is up and has been configured. +# Prints the LNBITS_API_KEY to add to Replit secrets. +# ============================================================================= +set -euo pipefail + +LNBITS_LOCAL="http://127.0.0.1:5000" +LNBITS_DATA_DIR="$HOME/.lnbits-data" + +GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; NC='\033[0m' +info() { echo -e "${CYAN}[keys]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } + +# Check LNbits is up +curl -sf "$LNBITS_LOCAL/api/v1/health" &>/dev/null \ + || die "LNbits not reachable at $LNBITS_LOCAL. Run 'bash start.sh' first." + +# ─── Try to get super user from env file ───────────────────────────────────── +SUPER_USER="" +if [[ -f "$LNBITS_DATA_DIR/.env" ]]; then + SUPER_USER=$(grep LNBITS_SUPER_USER "$LNBITS_DATA_DIR/.env" | cut -d= -f2 | tr -d '"' || true) +fi + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" +[[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" +SUPER_USER="${SUPER_USER:-${LNBITS_SUPER_USER:-}}" + +if [[ -z "$SUPER_USER" ]]; then + # LNbits auto-generates a superuser on first run — find it in the SQLite DB + DB_FILE=$(find "$LNBITS_DATA_DIR" -name "*.sqlite3" 2>/dev/null | head -1 || true) + if [[ -n "$DB_FILE" ]] && command -v sqlite3 &>/dev/null; then + SUPER_USER=$(sqlite3 "$DB_FILE" "SELECT id FROM accounts WHERE is_super_user=1 LIMIT 1;" 2>/dev/null || true) + fi +fi + +if [[ -z "$SUPER_USER" ]]; then + # Last resort: check LNbits log for the first-run superuser line + LOG_FILE="$HOME/Library/Logs/timmy-node/lnbits.log" + if [[ -f "$LOG_FILE" ]]; then + SUPER_USER=$(grep -oE "super user id: [a-f0-9]+" "$LOG_FILE" | tail -1 | awk '{print $4}' || true) + fi +fi + +if [[ -z "$SUPER_USER" ]]; then + warn "Could not auto-detect LNbits super user ID." + echo "" + echo " Visit: $LNBITS_LOCAL" + echo " 1. Create a wallet" + echo " 2. Go to Wallet → API Info" + echo " 3. Copy the Admin key" + echo "" + echo " Then add to Replit:" + echo " LNBITS_URL = http://bore.pub:" + echo " LNBITS_API_KEY = " + exit 0 +fi + +info "Super user: $SUPER_USER" + +# Create a wallet for Timmy via superuser API +WALLET_RESPONSE=$(curl -sf -X POST "$LNBITS_LOCAL/api/v1/wallet" \ + -H "Content-Type: application/json" \ + -H "X-Api-Key: $SUPER_USER" \ + -d '{"name":"Timmy"}' 2>/dev/null || true) + +if [[ -n "$WALLET_RESPONSE" ]]; then + ADMIN_KEY=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('adminkey',''))" 2>/dev/null || true) + INKEY=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('inkey',''))" 2>/dev/null || true) + WALLET_ID=$(echo "$WALLET_RESPONSE" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('id',''))" 2>/dev/null || true) + + if [[ -n "$ADMIN_KEY" ]]; then + ok "Timmy wallet created (ID: $WALLET_ID)" + echo "" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo -e "${GREEN} Add these to Replit secrets:${NC}" + echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" + echo "" + echo " LNBITS_URL = http://bore.pub: ← from expose.sh" + echo " LNBITS_API_KEY = $ADMIN_KEY" + echo "" + echo " Invoice key (read-only): $INKEY" + echo "" + # Save to secrets file + cat >> "$SECRETS_FILE" </dev/null || true + fi +fi + +# Fallback: just print the wallet URL +warn "Could not create wallet automatically." +echo "" +echo " Visit $LNBITS_LOCAL in your browser:" +echo " 1. Create an account / wallet named 'Timmy'" +echo " 2. Wallet → API Info → copy Admin key" +echo " 3. Add to Replit: LNBITS_API_KEY = " +echo "" diff --git a/scripts/bitcoin-ln-node/setup.sh b/scripts/bitcoin-ln-node/setup.sh new file mode 100755 index 0000000..45f0470 --- /dev/null +++ b/scripts/bitcoin-ln-node/setup.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy — Bitcoin Core + LND + LNbits automated setup +# Target: macOS, Apple Silicon (M3 Max) +# Mode: mainnet, pruned (~20 GB block storage) +# ============================================================================= +set -euo pipefail + +BREW="/opt/homebrew/bin/brew" +BITCOIN_DIR="$HOME/Library/Application Support/Bitcoin" +LND_DIR="$HOME/.lnd" +LNBITS_DIR="$HOME/.lnbits" +LAUNCHD_DIR="$HOME/Library/LaunchAgents" +LOG_DIR="$HOME/Library/Logs/timmy-node" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[setup]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } +die() { echo -e "${RED}[error]${NC} $*" >&2; exit 1; } + +# ─── Guards ────────────────────────────────────────────────────────────────── +[[ "$(uname -s)" == "Darwin" ]] || die "This script is for macOS only." +[[ "$(uname -m)" == "arm64" ]] || warn "Not Apple Silicon — paths may differ." + +command -v "$BREW" &>/dev/null || die "Homebrew not found at $BREW. Install from https://brew.sh" + +# ─── Step 1: Packages ──────────────────────────────────────────────────────── +info "Installing Bitcoin Core and LND via Homebrew…" +"$BREW" update --quiet +"$BREW" install bitcoin lnd || true # 'true' so re-runs don't abort on "already installed" + +command -v bitcoind &>/dev/null || die "bitcoind not found after install." +command -v lnd &>/dev/null || die "lnd not found after install." +ok "Bitcoin Core $("$BREW" info --json bitcoin | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['installed'][0]['version'])" 2>/dev/null || bitcoind --version | head -1) installed." +ok "LND $("$BREW" info --json lnd | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0]['installed'][0]['version'])" 2>/dev/null || lnd --version | head -1) installed." + +# ─── Step 2: LNbits (Python) ───────────────────────────────────────────────── +info "Setting up LNbits…" +if ! command -v python3 &>/dev/null; then + "$BREW" install python3 +fi + +PYTHON="$(command -v python3)" +PIP="$PYTHON -m pip" + +if [[ ! -d "$LNBITS_DIR" ]]; then + info "Cloning LNbits…" + git clone --depth 1 https://github.com/lnbits/lnbits.git "$LNBITS_DIR" +else + info "LNbits already cloned at $LNBITS_DIR" +fi + +info "Installing LNbits Python deps (this may take a minute)…" +cd "$LNBITS_DIR" +if command -v uv &>/dev/null; then + uv pip install --system poetry 2>/dev/null || $PIP install --quiet poetry +else + $PIP install --quiet poetry 2>/dev/null || true +fi + +if command -v poetry &>/dev/null || [[ -f "$HOME/.local/bin/poetry" ]]; then + POETRY="$(command -v poetry 2>/dev/null || echo "$HOME/.local/bin/poetry")" + "$POETRY" install --no-root --quiet 2>/dev/null || warn "poetry install had warnings (often OK)" +else + warn "poetry not found — trying pip install from pyproject.toml…" + $PIP install --quiet lnbits 2>/dev/null || warn "direct pip install of lnbits failed — check $LNBITS_DIR manually" +fi +cd "$SCRIPT_DIR" +ok "LNbits ready at $LNBITS_DIR" + +# ─── Step 3: Directories & Logs ────────────────────────────────────────────── +info "Creating data and log directories…" +mkdir -p "$LOG_DIR" "$LAUNCHD_DIR" + +# ─── Step 4: Generate secrets ──────────────────────────────────────────────── +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" +if [[ -f "$SECRETS_FILE" ]]; then + info "Loading existing secrets from $SECRETS_FILE" + source "$SECRETS_FILE" +else + info "Generating fresh secrets…" + RPC_USER="timmy" + RPC_PASS="$(openssl rand -hex 32)" + WALLET_PASS="$(openssl rand -hex 24)" + LND_MACAROON_PASS="$(openssl rand -hex 24)" + cat > "$SECRETS_FILE" < "$BITCOIN_DIR/bitcoin.conf" < "$LND_DIR/lnd.conf" < "$LND_DIR/.wallet-password" +chmod 600 "$LND_DIR/.wallet-password" + +# ─── Step 7: LNbits .env ───────────────────────────────────────────────────── +info "Writing LNbits environment…" +LNBITS_DATA_DIR="$HOME/.lnbits-data" +mkdir -p "$LNBITS_DATA_DIR" + +if [[ ! -f "$LNBITS_DATA_DIR/.env" ]]; then + # Generate a superuser ID for LNbits + LNBITS_SUPER_USER="$(openssl rand -hex 16)" + cat > "$LNBITS_DATA_DIR/.env" <> "$SECRETS_FILE" + ok "LNbits .env written to $LNBITS_DATA_DIR/.env" +else + ok "LNbits .env already exists — leaving untouched." +fi + +# ─── Step 8: LaunchAgent plists ────────────────────────────────────────────── +info "Writing LaunchAgent plists (auto-start on login)…" + +# Bitcoin Core +cat > "$LAUNCHD_DIR/com.timmy.bitcoind.plist" < + + + + Label com.timmy.bitcoind + ProgramArguments + + /opt/homebrew/bin/bitcoind + -conf=$HOME/Library/Application Support/Bitcoin/bitcoin.conf + -datadir=$HOME/Library/Application Support/Bitcoin + -daemon=0 + + RunAtLoad + KeepAlive + StandardOutPath $LOG_DIR/bitcoind.log + StandardErrorPath $LOG_DIR/bitcoind.err + EnvironmentVariables + + HOME$HOME + + + +EOF + +# LND — starts after bitcoind (bitcoind takes ~30s to be RPC-ready) +cat > "$LAUNCHD_DIR/com.timmy.lnd.plist" < + + + + Label com.timmy.lnd + ProgramArguments + + /opt/homebrew/bin/lnd + --configfile=$LND_DIR/lnd.conf + --lnddir=$LND_DIR + + RunAtLoad + KeepAlive + StandardOutPath $LOG_DIR/lnd.log + StandardErrorPath $LOG_DIR/lnd.err + EnvironmentVariables + + HOME$HOME + + + +EOF + +# LNbits — depends on LND being up +cat > "$LAUNCHD_DIR/com.timmy.lnbits.plist" < + + + + Label com.timmy.lnbits + ProgramArguments + + /bin/bash + -c + cd $LNBITS_DIR && env \$(cat $LNBITS_DATA_DIR/.env | grep -v '^#' | xargs) poetry run lnbits + + RunAtLoad + KeepAlive + StandardOutPath $LOG_DIR/lnbits.log + StandardErrorPath $LOG_DIR/lnbits.err + EnvironmentVariables + + HOME$HOME + PATH/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin + + + +EOF + +ok "LaunchAgent plists written to $LAUNCHD_DIR" + +# ─── Step 9: Load plists ───────────────────────────────────────────────────── +info "Loading LaunchAgents…" +for plist in com.timmy.bitcoind com.timmy.lnd com.timmy.lnbits; do + launchctl unload "$LAUNCHD_DIR/$plist.plist" 2>/dev/null || true + launchctl load -w "$LAUNCHD_DIR/$plist.plist" + ok "$plist loaded." +done + +# ─── Step 10: Create LND wallet if needed ───────────────────────────────────── +info "Waiting 15s for LND to start before checking wallet…" +sleep 15 + +LNCLI="/opt/homebrew/bin/lncli" +if ! "$LNCLI" --lnddir="$LND_DIR" getinfo &>/dev/null; then + info "LND wallet not yet initialized — creating one now…" + echo "Wallet password will be: $WALLET_PASS" + echo "---" + echo "Run this command in a new terminal:" + echo "" + echo " lncli --lnddir=$LND_DIR create" + echo "" + echo "Use password: $WALLET_PASS" + echo "Choose 'n' for existing seed (new wallet)" + echo "Save the 24-word seed in a safe place!" + echo "---" + warn "After creating the wallet, run: bash $SCRIPT_DIR/get-lnbits-key.sh" +else + ok "LND wallet already exists and is accessible." +fi + +# ─── Done ──────────────────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo -e "${GREEN} Timmy node setup complete!${NC}" +echo -e "${GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}" +echo "" +echo " Bitcoin Core is syncing — this takes 1-3 days (pruned)." +echo " LND will connect once bitcoind is at chain tip." +echo " LNbits will be live at http://127.0.0.1:5000 once LND is ready." +echo "" +echo " Next steps:" +echo " 1. Create your LND wallet: lncli --lnddir=$LND_DIR create" +echo " 2. Check sync status: bash $SCRIPT_DIR/status.sh" +echo " 3. Once synced, get key: bash $SCRIPT_DIR/get-lnbits-key.sh" +echo "" +echo " Secrets are in: $SECRETS_FILE" +echo " Logs are in: $LOG_DIR/" +echo "" diff --git a/scripts/bitcoin-ln-node/start.sh b/scripts/bitcoin-ln-node/start.sh new file mode 100755 index 0000000..3dd876f --- /dev/null +++ b/scripts/bitcoin-ln-node/start.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy node — start all services +# Safe to run multiple times; won't double-start a running service. +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" +LND_DIR="$HOME/.lnd" +LOG_DIR="$HOME/Library/Logs/timmy-node" +LAUNCHD_DIR="$HOME/Library/LaunchAgents" +LNCLI="/opt/homebrew/bin/lncli" + +[[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" +mkdir -p "$LOG_DIR" + +GREEN='\033[0;32m'; CYAN='\033[0;36m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${CYAN}[start]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } +warn() { echo -e "${YELLOW}[warn]${NC} $*"; } + +start_launchagent() { + local label="$1" + local plist="$LAUNCHD_DIR/$label.plist" + if ! launchctl list | grep -q "$label"; then + launchctl load -w "$plist" 2>/dev/null && ok "$label started." || warn "Could not load $label." + else + ok "$label is already loaded." + fi +} + +# ─── Bitcoin Core ───────────────────────────────────────────────────────────── +info "Starting Bitcoin Core…" +start_launchagent "com.timmy.bitcoind" + +# Wait for RPC to be ready (up to 60s) +info "Waiting for bitcoind RPC (up to 60s)…" +BTC_ARGS=() +[[ -n "${RPC_USER:-}" ]] && BTC_ARGS+=(-rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS") +for i in $(seq 1 12); do + if /opt/homebrew/bin/bitcoin-cli "${BTC_ARGS[@]}" getblockchaininfo &>/dev/null 2>&1; then + ok "bitcoind RPC ready." + break + fi + [[ $i -eq 12 ]] && warn "bitcoind RPC not ready after 60s — it may still be starting." || sleep 5 +done + +# ─── LND ────────────────────────────────────────────────────────────────────── +info "Starting LND…" +start_launchagent "com.timmy.lnd" + +# Wait for LND to be ready (up to 60s) +info "Waiting for LND (up to 60s)…" +for i in $(seq 1 12); do + LND_STATE=$("$LNCLI" --lnddir="$LND_DIR" state 2>/dev/null | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('state','?'))" 2>/dev/null || echo "?") + case "$LND_STATE" in + RPC_ACTIVE|SERVER_ACTIVE) + ok "LND is up (state: $LND_STATE)." + break + ;; + WALLET_NOT_CREATED) + warn "LND wallet needs to be created:" + echo " lncli --lnddir=$LND_DIR create" + echo " Use password from $SECRETS_FILE" + break + ;; + LOCKED) + info "LND wallet is locked — unlocking…" + if [[ -f "$LND_DIR/.wallet-password" ]]; then + "$LNCLI" --lnddir="$LND_DIR" unlock --stdin < "$LND_DIR/.wallet-password" 2>/dev/null && ok "Wallet unlocked." || warn "Auto-unlock failed — run: lncli --lnddir=$LND_DIR unlock" + else + warn "No wallet-password file found. Run: lncli --lnddir=$LND_DIR unlock" + fi + break + ;; + *) + [[ $i -eq 12 ]] && warn "LND not ready after 60s (state: $LND_STATE)" || sleep 5 + ;; + esac +done + +# ─── LNbits ─────────────────────────────────────────────────────────────────── +info "Starting LNbits…" +start_launchagent "com.timmy.lnbits" + +info "Waiting for LNbits HTTP (up to 30s)…" +for i in $(seq 1 6); do + if curl -sf "http://127.0.0.1:5000/api/v1/health" &>/dev/null; then + ok "LNbits is up at http://127.0.0.1:5000" + break + fi + [[ $i -eq 6 ]] && warn "LNbits not responding after 30s — check $LOG_DIR/lnbits.err" || sleep 5 +done + +echo "" +ok "All services started. Run 'bash $SCRIPT_DIR/status.sh' for a full health check." +echo "" +echo " To expose LNbits to Replit, run:" +echo " bash $SCRIPT_DIR/expose.sh" +echo "" diff --git a/scripts/bitcoin-ln-node/status.sh b/scripts/bitcoin-ln-node/status.sh new file mode 100755 index 0000000..ac59356 --- /dev/null +++ b/scripts/bitcoin-ln-node/status.sh @@ -0,0 +1,189 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy node — status / diagnostics +# Run this any time to get a full health snapshot. +# Also machine-readable via: bash status.sh --json +# ============================================================================= +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" +LND_DIR="$HOME/.lnd" +LOG_DIR="$HOME/Library/Logs/timmy-node" +LNCLI="/opt/homebrew/bin/lncli" +BCLI="/opt/homebrew/bin/bitcoin-cli" +JSON_MODE=false +[[ "${1:-}" == "--json" ]] && JSON_MODE=true + +[[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; BOLD='\033[1m'; NC='\033[0m' +section() { $JSON_MODE || echo -e "\n${BOLD}${CYAN}── $* ──${NC}"; } +pass() { $JSON_MODE || echo -e " ${GREEN}✓${NC} $*"; } +fail() { $JSON_MODE || echo -e " ${RED}✗${NC} $*"; } +warn() { $JSON_MODE || echo -e " ${YELLOW}!${NC} $*"; } + +declare -A STATUS + +# ─── Bitcoin Core ──────────────────────────────────────────────────────────── +section "Bitcoin Core" + +BTC_ARGS=() +[[ -n "${RPC_USER:-}" ]] && BTC_ARGS+=(-rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS") + +if ! pgrep -x bitcoind &>/dev/null; then + fail "bitcoind process not running" + STATUS[bitcoind]="down" +else + pass "bitcoind is running (PID: $(pgrep -x bitcoind))" + STATUS[bitcoind]="running" + + if BTC_INFO=$("$BCLI" "${BTC_ARGS[@]}" getblockchaininfo 2>/dev/null); then + CHAIN=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['chain'])") + BLOCKS=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['blocks'])") + HEADERS=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['headers'])") + PROGRESS=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"{d['verificationprogress']*100:.2f}%\")") + PRUNED=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('pruned', False))") + PRUNEHEIGHT=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('pruneheight', 'n/a'))") + SIZE_ON_DISK=$(echo "$BTC_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(f\"{d.get('size_on_disk',0)/1e9:.1f} GB\")") + + pass "Chain: $CHAIN | Blocks: $BLOCKS / $HEADERS | Sync: $PROGRESS" + pass "Pruned: $PRUNED (prune height: $PRUNEHEIGHT) | Disk: $SIZE_ON_DISK" + STATUS[bitcoin_sync]="$PROGRESS" + STATUS[bitcoin_blocks]="$BLOCKS" + + if MEMPOOL=$("$BCLI" "${BTC_ARGS[@]}" getmempoolinfo 2>/dev/null); then + TXCOUNT=$(echo "$MEMPOOL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['size'])") + pass "Mempool: $TXCOUNT txs" + fi + + NETINFO=$("$BCLI" "${BTC_ARGS[@]}" getnetworkinfo 2>/dev/null || echo "{}") + PEERS=$(echo "$NETINFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('connections', '?'))") + pass "Peers: $PEERS" + STATUS[bitcoin_peers]="$PEERS" + else + warn "bitcoind is running but RPC not ready yet (starting up?)" + STATUS[bitcoin_rpc]="not ready" + fi +fi + +# ─── LND ───────────────────────────────────────────────────────────────────── +section "LND" + +if ! pgrep -x lnd &>/dev/null; then + fail "lnd process not running" + STATUS[lnd]="down" +else + pass "lnd is running (PID: $(pgrep -x lnd))" + STATUS[lnd]="running" + + if LND_INFO=$("$LNCLI" --lnddir="$LND_DIR" getinfo 2>/dev/null); then + LND_VERSION=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['version'])" 2>/dev/null || echo "?") + LND_ALIAS=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['alias'])" 2>/dev/null || echo "?") + LND_SYNCED=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['synced_to_chain'])" 2>/dev/null || echo "?") + LND_PEERS=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['num_peers'])" 2>/dev/null || echo "?") + LND_CHANS=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['num_active_channels'])" 2>/dev/null || echo "?") + LND_BLOCK=$(echo "$LND_INFO" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['block_height'])" 2>/dev/null || echo "?") + + pass "Version: $LND_VERSION | Alias: $LND_ALIAS" + pass "Synced to chain: $LND_SYNCED | Block: $LND_BLOCK" + pass "Peers: $LND_PEERS | Active channels: $LND_CHANS" + STATUS[lnd_synced]="$LND_SYNCED" + STATUS[lnd_channels]="$LND_CHANS" + + if BAL=$("$LNCLI" --lnddir="$LND_DIR" walletbalance 2>/dev/null); then + CONFIRMED=$(echo "$BAL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['confirmed_balance'])" 2>/dev/null || echo "?") + UNCONFIRMED=$(echo "$BAL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['unconfirmed_balance'])" 2>/dev/null || echo "?") + pass "On-chain balance: ${CONFIRMED} sats confirmed, ${UNCONFIRMED} unconfirmed" + STATUS[wallet_confirmed]="$CONFIRMED" + fi + + if CHANBAL=$("$LNCLI" --lnddir="$LND_DIR" channelbalance 2>/dev/null); then + LOCAL=$(echo "$CHANBAL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['local_balance']['sat'])" 2>/dev/null || echo "?") + REMOTE=$(echo "$CHANBAL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['remote_balance']['sat'])" 2>/dev/null || echo "?") + pass "Channel balance: ${LOCAL} sats local, ${REMOTE} sats remote" + STATUS[channel_local]="$LOCAL" + fi + elif "$LNCLI" --lnddir="$LND_DIR" state 2>/dev/null | grep -q "WAITING_TO_START\|LOCKED"; then + warn "LND wallet is LOCKED — run: lncli --lnddir=$LND_DIR unlock" + STATUS[lnd_state]="locked" + else + warn "LND running but wallet not ready (still starting or needs creation)" + STATUS[lnd_state]="not ready" + fi +fi + +# ─── LNbits ────────────────────────────────────────────────────────────────── +section "LNbits" +LNBITS_URL_LOCAL="http://127.0.0.1:5000" + +if ! pgrep -f "lnbits" &>/dev/null; then + fail "lnbits process not running" + STATUS[lnbits]="down" +else + pass "lnbits process is running" + STATUS[lnbits]="running" + + if curl -sf "$LNBITS_URL_LOCAL/api/v1/health" &>/dev/null 2>&1; then + pass "LNbits HTTP reachable at $LNBITS_URL_LOCAL" + STATUS[lnbits_http]="ok" + else + warn "LNbits process is up but HTTP not responding yet (starting?)" + STATUS[lnbits_http]="starting" + fi +fi + +# ─── Bore tunnel (if running) ───────────────────────────────────────────────── +section "Bore tunnel (external access)" +if BORE_PID=$(pgrep -f "bore local.*5000" 2>/dev/null); then + BORE_CMD=$(ps -p "$BORE_PID" -o args= 2>/dev/null || echo "?") + pass "bore tunnel active (PID $BORE_PID): $BORE_CMD" + STATUS[bore]="active" +else + warn "No bore tunnel running. Timmy (Replit) cannot reach LNbits." + echo " Start with: bash $SCRIPT_DIR/expose.sh" + STATUS[bore]="down" +fi + +# ─── Recent errors ──────────────────────────────────────────────────────────── +section "Recent log errors (last 20 lines)" +for svc in bitcoind lnd lnbits; do + ERR_FILE="$LOG_DIR/$svc.err" + if [[ -f "$ERR_FILE" ]] && [[ -s "$ERR_FILE" ]]; then + ERRS=$(tail -5 "$ERR_FILE" | grep -iE "error|fatal|panic|crit" || true) + if [[ -n "$ERRS" ]]; then + fail "$svc recent errors:" + echo "$ERRS" | sed 's/^/ /' + STATUS["${svc}_errors"]="yes" + else + pass "$svc error log: clean" + fi + fi +done + +# ─── JSON output ───────────────────────────────────────────────────────────── +if $JSON_MODE; then + echo "{" + i=0; count=${#STATUS[@]} + for k in "${!STATUS[@]}"; do + i=$((i+1)) + comma=$([[ $i -lt $count ]] && echo "," || echo "") + echo " \"$k\": \"${STATUS[$k]}\"$comma" + done + echo "}" + exit 0 +fi + +# ─── Summary ───────────────────────────────────────────────────────────────── +section "Summary" +ALL_OK=true +[[ "${STATUS[bitcoind]:-down}" != "running" ]] && { fail "bitcoind is down"; ALL_OK=false; } +[[ "${STATUS[lnd]:-down}" != "running" ]] && { fail "lnd is down"; ALL_OK=false; } +[[ "${STATUS[lnbits]:-down}" != "running" ]] && { fail "lnbits is down"; ALL_OK=false; } +[[ "${STATUS[bore]:-down}" != "active" ]] && { warn "bore tunnel is down — Timmy can't reach LNbits"; } + +$ALL_OK && echo -e "\n ${GREEN}${BOLD}All core services are running.${NC}\n" || \ + echo -e "\n ${RED}${BOLD}Some services need attention — see above.${NC}\n" +echo " Run 'bash $SCRIPT_DIR/start.sh' to start stopped services." +echo " Run 'bash $SCRIPT_DIR/expose.sh' to open the bore tunnel for Replit." +echo "" diff --git a/scripts/bitcoin-ln-node/stop.sh b/scripts/bitcoin-ln-node/stop.sh new file mode 100755 index 0000000..5f3756e --- /dev/null +++ b/scripts/bitcoin-ln-node/stop.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# ============================================================================= +# Timmy node — graceful stop +# ============================================================================= +set -euo pipefail + +LAUNCHD_DIR="$HOME/Library/LaunchAgents" +LNCLI="/opt/homebrew/bin/lncli" +LND_DIR="$HOME/.lnd" + +RED='\033[0;31m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m' +info() { echo -e "${CYAN}[stop]${NC} $*"; } +ok() { echo -e "${GREEN}[ok]${NC} $*"; } + +# Stop bore tunnel first (fastest) +if BORE_PID=$(pgrep -f "bore local.*5000" 2>/dev/null); then + info "Stopping bore tunnel (PID $BORE_PID)…" + kill "$BORE_PID" 2>/dev/null && ok "bore tunnel stopped." || true +fi + +# Stop LNbits +info "Stopping LNbits…" +launchctl unload "$LAUNCHD_DIR/com.timmy.lnbits.plist" 2>/dev/null && ok "LNbits stopped." || true +pkill -f "lnbits" 2>/dev/null || true + +# Stop LND gracefully via lncli +info "Stopping LND (graceful)…" +"$LNCLI" --lnddir="$LND_DIR" stop 2>/dev/null && ok "LND stop signal sent." || true +sleep 3 +launchctl unload "$LAUNCHD_DIR/com.timmy.lnd.plist" 2>/dev/null || true + +# Stop Bitcoin Core gracefully via RPC +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SECRETS_FILE="$SCRIPT_DIR/.node-secrets" +[[ -f "$SECRETS_FILE" ]] && source "$SECRETS_FILE" +BTC_ARGS=() +[[ -n "${RPC_USER:-}" ]] && BTC_ARGS+=(-rpcuser="$RPC_USER" -rpcpassword="$RPC_PASS") + +info "Stopping Bitcoin Core (graceful — may take 30-60s)…" +/opt/homebrew/bin/bitcoin-cli "${BTC_ARGS[@]}" stop 2>/dev/null && ok "Shutdown signal sent to bitcoind." || true +launchctl unload "$LAUNCHD_DIR/com.timmy.bitcoind.plist" 2>/dev/null || true + +echo "" +ok "All services stopped. Data is safe."