Add Bitcoin/LND/LNbits local node setup scripts and node diagnostics endpoint

- scripts/bitcoin-ln-node/setup.sh: one-shot installer for Bitcoin Core (pruned mainnet), LND, and LNbits on Apple Silicon Mac. Generates secrets, writes configs, installs launchd plists for auto-start.
- scripts/bitcoin-ln-node/start.sh: start all services via launchctl; waits for RPC readiness and auto-unlocks LND wallet.
- scripts/bitcoin-ln-node/stop.sh: graceful shutdown (lncli stop → bitcoin-cli stop).
- scripts/bitcoin-ln-node/status.sh: full health check (Bitcoin sync %, LND channels/balance, LNbits HTTP, bore tunnel). Supports --json mode for machine consumption.
- scripts/bitcoin-ln-node/expose.sh: opens bore tunnel from LNbits port 5000 to bore.pub for Replit access.
- scripts/bitcoin-ln-node/get-lnbits-key.sh: fetches LNbits admin API key and prints Replit secret values.
- artifacts/api-server/src/routes/node-diagnostics.ts: GET /api/admin/node-status (JSON) and /api/admin/node-status/html — Timmy self-diagnoses its LNbits/LND connectivity and reports issues.
This commit is contained in:
alexpaynex
2026-03-18 21:58:41 +00:00
parent 4dd3c7f692
commit ca94c0a9e5
8 changed files with 1041 additions and 0 deletions

View File

@@ -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);

View File

@@ -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<LNbitsStatus> {
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<string, unknown>;
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(`<!DOCTYPE html><html lang="en">
<head><meta charset="UTF-8"><title>Timmy — Node Status</title>
<style>
body { font-family: monospace; background:#0f172a; color:#e2e8f0; padding:2rem; }
h1 { color:#f8fafc; } .badge { padding:4px 12px; border-radius:4px; font-weight:bold; }
table { border-collapse:collapse; margin-top:1rem; }
td,th { padding:6px 16px; border-bottom:1px solid #1e293b; text-align:left; }
th { color:#94a3b8; } .ok{color:#22c55e} .warn{color:#f59e0b} .bad{color:#ef4444}
pre { background:#1e293b; padding:1rem; border-radius:6px; overflow:auto; }
</style></head><body>
<h1>⚡ Timmy Node Status</h1>
<p>Overall: <span class="badge" style="background:${color}20;color:${color}">${overall.toUpperCase()}</span></p>
<h2>LNbits</h2>
<table>
<tr><th>URL</th><td>${lnbits.url}</td></tr>
<tr><th>Reachable</th><td class="${lnbits.reachable ? "ok" : "bad"}">${lnbits.reachable ? "✓ yes" : "✗ no"}</td></tr>
${lnbits.walletBalance !== undefined ? `<tr><th>Wallet Balance</th><td>${lnbits.walletBalance} sats</td></tr>` : ""}
${lnbits.error ? `<tr><th>Error</th><td class="bad">${lnbits.error}</td></tr>` : ""}
</table>
${nodeInfo.lnd ? `<h2>LND</h2>
<table>
<tr><th>Synced</th><td class="${nodeInfo.lnd.synced ? "ok" : "warn"}">${nodeInfo.lnd.synced ? "✓ yes" : "⏳ syncing"}</td></tr>
<tr><th>Block Height</th><td>${nodeInfo.lnd.blockHeight.toLocaleString()}</td></tr>
<tr><th>Active Channels</th><td>${nodeInfo.lnd.activeChannels}</td></tr>
</table>` : ""}
${issues.length > 0 ? `<h2>Issues</h2><ul>${issues.map(i => `<li class="bad">${i}</li>`).join("")}</ul>` : ""}
<p style="color:#475569;margin-top:2rem;font-size:0.8rem">
Refreshed at ${new Date().toISOString()} &nbsp;·&nbsp;
<a href="/api/admin/node-status" style="color:#60a5fa">JSON</a> &nbsp;·&nbsp;
<a href="/api/ui" style="color:#60a5fa">Timmy UI</a>
</p>
</body></html>`);
});
export default router;

View File

@@ -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:<PORT> │"
echo " │ LNBITS_API_KEY = <your-lnbits-admin-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

View File

@@ -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:<PORT>"
echo " LNBITS_API_KEY = <admin-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:<PORT> ← from expose.sh"
echo " LNBITS_API_KEY = $ADMIN_KEY"
echo ""
echo " Invoice key (read-only): $INKEY"
echo ""
# Save to secrets file
cat >> "$SECRETS_FILE" <<EOF
LNBITS_WALLET_ID="$WALLET_ID"
LNBITS_ADMIN_KEY="$ADMIN_KEY"
LNBITS_INVOICE_KEY="$INKEY"
EOF
ok "Keys saved to $SECRETS_FILE"
return 0 2>/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 = <admin key>"
echo ""

333
scripts/bitcoin-ln-node/setup.sh Executable file
View File

@@ -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" <<EOF
RPC_USER="$RPC_USER"
RPC_PASS="$RPC_PASS"
WALLET_PASS="$WALLET_PASS"
LND_MACAROON_PASS="$LND_MACAROON_PASS"
EOF
chmod 600 "$SECRETS_FILE"
ok "Secrets written to $SECRETS_FILE (keep this safe!)"
fi
# ─── Step 5: Bitcoin Core config ─────────────────────────────────────────────
info "Writing Bitcoin Core config…"
mkdir -p "$BITCOIN_DIR"
# Only write if doesn't exist (don't clobber a running node's config)
if [[ ! -f "$BITCOIN_DIR/bitcoin.conf" ]]; then
cat > "$BITCOIN_DIR/bitcoin.conf" <<EOF
# Timmy node — mainnet, pruned
mainnet=1
server=1
prune=20000
# RPC
rpcuser=$RPC_USER
rpcpassword=$RPC_PASS
rpcallowip=127.0.0.1
rpcbind=127.0.0.1
rpcport=8332
# ZMQ (required by LND)
zmqpubrawblock=tcp://127.0.0.1:28332
zmqpubrawtx=tcp://127.0.0.1:28333
# Performance
dbcache=4096
maxmempool=512
maxconnections=24
listen=1
# Logging (keep it manageable)
debug=0
shrinkdebugfile=1
EOF
ok "bitcoin.conf written."
else
ok "bitcoin.conf already exists — leaving untouched."
fi
# ─── Step 6: LND config ──────────────────────────────────────────────────────
info "Writing LND config…"
mkdir -p "$LND_DIR"
if [[ ! -f "$LND_DIR/lnd.conf" ]]; then
cat > "$LND_DIR/lnd.conf" <<EOF
[Application Options]
debuglevel=info
maxpendingchannels=10
# Expose REST API for LNbits
restlisten=0.0.0.0:8080
rpclisten=0.0.0.0:10009
# Wallet password auto-unlock file (written by start.sh)
wallet-unlock-password-file=$LND_DIR/.wallet-password
[Bitcoin]
bitcoin.active=1
bitcoin.mainnet=1
bitcoin.node=bitcoind
[Bitcoind]
bitcoind.rpchost=127.0.0.1:8332
bitcoind.rpcuser=$RPC_USER
bitcoind.rpcpass=$RPC_PASS
bitcoind.zmqpubrawblock=tcp://127.0.0.1:28332
bitcoind.zmqpubrawtx=tcp://127.0.0.1:28333
bitcoind.estimatemode=ECONOMICAL
[tor]
tor.active=0
EOF
ok "lnd.conf written."
else
ok "lnd.conf already exists — leaving untouched."
fi
# Write wallet unlock password file (used by LND auto-unlock)
echo -n "$WALLET_PASS" > "$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" <<EOF
# LNbits config — Timmy node
LNBITS_DATA_FOLDER=$LNBITS_DATA_DIR
LNBITS_BACKEND_WALLET_CLASS=LndRestWallet
LND_REST_ENDPOINT=https://127.0.0.1:8080
LND_REST_CERT=$LND_DIR/tls.cert
LND_REST_MACAROON=$LND_DIR/data/chain/bitcoin/mainnet/admin.macaroon
LNBITS_SITE_TITLE=Timmy Node
LNBITS_ALLOWED_IPS=127.0.0.1
HOST=127.0.0.1
PORT=5000
FORWARDED_ALLOW_IPS=*
# Uncomment to set a fixed super user (otherwise auto-generated)
# LNBITS_SUPER_USER=$LNBITS_SUPER_USER
EOF
# Append super user to secrets
echo "LNBITS_SUPER_USER=\"$LNBITS_SUPER_USER\"" >> "$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" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.timmy.bitcoind</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/bitcoind</string>
<string>-conf=$HOME/Library/Application Support/Bitcoin/bitcoin.conf</string>
<string>-datadir=$HOME/Library/Application Support/Bitcoin</string>
<string>-daemon=0</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>$LOG_DIR/bitcoind.log</string>
<key>StandardErrorPath</key> <string>$LOG_DIR/bitcoind.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>$HOME</string>
</dict>
</dict>
</plist>
EOF
# LND — starts after bitcoind (bitcoind takes ~30s to be RPC-ready)
cat > "$LAUNCHD_DIR/com.timmy.lnd.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.timmy.lnd</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/lnd</string>
<string>--configfile=$LND_DIR/lnd.conf</string>
<string>--lnddir=$LND_DIR</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>$LOG_DIR/lnd.log</string>
<key>StandardErrorPath</key> <string>$LOG_DIR/lnd.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>$HOME</string>
</dict>
</dict>
</plist>
EOF
# LNbits — depends on LND being up
cat > "$LAUNCHD_DIR/com.timmy.lnbits.plist" <<EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key> <string>com.timmy.lnbits</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>cd $LNBITS_DIR &amp;&amp; env \$(cat $LNBITS_DATA_DIR/.env | grep -v '^#' | xargs) poetry run lnbits</string>
</array>
<key>RunAtLoad</key> <true/>
<key>KeepAlive</key> <true/>
<key>StandardOutPath</key> <string>$LOG_DIR/lnbits.log</string>
<key>StandardErrorPath</key> <string>$LOG_DIR/lnbits.err</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key><string>$HOME</string>
<key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
</dict>
</dict>
</plist>
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 ""

101
scripts/bitcoin-ln-node/start.sh Executable file
View File

@@ -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 ""

189
scripts/bitcoin-ln-node/status.sh Executable file
View File

@@ -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 ""

44
scripts/bitcoin-ln-node/stop.sh Executable file
View File

@@ -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."