#!/usr/bin/env bash # ============================================================ # Timmy Node — Auto-sweep hot wallet to cold storage # # Run manually: bash /opt/timmy-node/sweep.sh # Run by cron: configured by ops.sh configure-sweep # # Config: /opt/timmy-node/sweep.conf # State: /opt/timmy-node/sweep-state (address index, auto-managed) # Addresses: /opt/timmy-node/sweep-addresses.txt (for list mode) # ============================================================ set -euo pipefail INFRA_DIR="/opt/timmy-node" CONF_FILE="$INFRA_DIR/sweep.conf" STATE_FILE="$INFRA_DIR/sweep-state" ADDR_LIST_FILE="$INFRA_DIR/sweep-addresses.txt" LOG_FILE="/var/log/timmy-sweep.log" TIMESTAMP=$(date -u '+%Y-%m-%d %H:%M:%S UTC') log() { echo "[$TIMESTAMP] $*" | tee -a "$LOG_FILE"; } # ── Load config ────────────────────────────────────────────── if [[ ! -f "$CONF_FILE" ]]; then log "SKIP — no sweep.conf found at $CONF_FILE (run: bash ops.sh configure-sweep)" exit 0 fi source "$CONF_FILE" SWEEP_MODE="${SWEEP_MODE:-static}" COLD_ADDRESS="${COLD_ADDRESS:-}" XPUB="${XPUB:-}" KEEP_SATS="${KEEP_SATS:-300000}" MIN_SWEEP="${MIN_SWEEP:-50000}" # ── Resolve destination address ────────────────────────────── # Sets SWEEP_TO or exits. Logs the resolved address and remaining capacity. resolve_address() { case "$SWEEP_MODE" in xpub) if [[ -z "$XPUB" ]]; then log "ERROR — SWEEP_MODE=xpub but XPUB not set in $CONF_FILE" exit 1 fi NEXT_INDEX=0 [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" DESC="wpkh(${XPUB}/0/${NEXT_INDEX})" CHECKSUM_DESC=$(docker exec bitcoin bitcoin-cli \ -datadir=/home/bitcoin/.bitcoin \ getdescriptorinfo "$DESC" 2>&1 | jq -r '.descriptor // empty') if [[ -z "$CHECKSUM_DESC" ]]; then log "ERROR — could not get descriptor info for index $NEXT_INDEX (is Bitcoin Core synced?)" exit 1 fi DERIVED=$(docker exec bitcoin bitcoin-cli \ -datadir=/home/bitcoin/.bitcoin \ deriveaddresses "$CHECKSUM_DESC" 2>&1 | jq -r '.[0] // empty') if [[ -z "$DERIVED" || "$DERIVED" == "null" ]]; then log "ERROR — could not derive address at xpub index $NEXT_INDEX" exit 1 fi SWEEP_TO="$DERIVED" log "ADDRESS — xpub index ${NEXT_INDEX} → ${SWEEP_TO}" ;; list) if [[ ! -f "$ADDR_LIST_FILE" ]]; then log "ERROR — SWEEP_MODE=list but $ADDR_LIST_FILE not found (run: bash ops.sh configure-sweep)" exit 1 fi NEXT_INDEX=0 [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" ADDR=$(sed -n "$((NEXT_INDEX + 1))p" "$ADDR_LIST_FILE" | tr -d '[:space:]') if [[ -z "$ADDR" ]]; then log "ERROR — address list exhausted at index ${NEXT_INDEX} — add more addresses via ops.sh configure-sweep" exit 1 fi TOTAL=$(grep -c '[^[:space:]]' "$ADDR_LIST_FILE" || true) REMAINING=$(( TOTAL - NEXT_INDEX )) if (( REMAINING <= 5 )); then log "WARNING — only ${REMAINING} address(es) remaining in list, add more soon" fi SWEEP_TO="$ADDR" log "ADDRESS — list index ${NEXT_INDEX} (${REMAINING} remaining) → ${SWEEP_TO}" ;; static|*) if [[ -z "$COLD_ADDRESS" ]]; then log "SKIP — COLD_ADDRESS not set in $CONF_FILE (run: bash ops.sh configure-sweep)" exit 0 fi SWEEP_TO="$COLD_ADDRESS" ;; esac } resolve_address # ── Get confirmed on-chain balance ─────────────────────────── BALANCE=$(docker exec lnd lncli --network=mainnet walletbalance 2>/dev/null \ | jq -r '.confirmed_balance // "0"') if [[ -z "$BALANCE" || "$BALANCE" == "null" ]]; then log "ERROR — could not read LND wallet balance (is LND running?)" exit 1 fi log "Balance: ${BALANCE} sats | keep: ${KEEP_SATS} | destination: ${SWEEP_TO}" # ── Calculate sweep amount ──────────────────────────────────── SWEEP_AMT=$(( BALANCE - KEEP_SATS )) if (( SWEEP_AMT < MIN_SWEEP )); then log "SKIP — sweep amount ${SWEEP_AMT} sats is below MIN_SWEEP ${MIN_SWEEP} sats (nothing sent)" exit 0 fi # ── Send to cold address ────────────────────────────────────── log "SWEEP — sending ${SWEEP_AMT} sats to ${SWEEP_TO}..." if ! SEND_RESULT=$(docker exec lnd lncli --network=mainnet sendcoins \ --addr "$SWEEP_TO" \ --amt "$SWEEP_AMT" \ 2>&1) then log "ERROR — sendcoins failed: $SEND_RESULT" exit 1 fi TXID=$(echo "$SEND_RESULT" | jq -r '.txid // empty' 2>/dev/null || echo "") if [[ -z "$TXID" ]]; then log "ERROR — sendcoins returned no txid: $SEND_RESULT" exit 1 fi log "SUCCESS — txid=${TXID} amount=${SWEEP_AMT} sats → ${SWEEP_TO}" # ── Advance address index (xpub / list modes) ───────────────── if [[ "$SWEEP_MODE" == "xpub" || "$SWEEP_MODE" == "list" ]]; then NEXT_INDEX=0 [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" NEW_INDEX=$(( NEXT_INDEX + 1 )) echo "NEXT_INDEX=$NEW_INDEX" > "$STATE_FILE" chmod 600 "$STATE_FILE" log "INDEX — advanced to ${NEW_INDEX} for next sweep" fi # ── Trigger channel state backup ────────────────────────────── log "BACKUP — triggering channel state backup after sweep..." bash "$INFRA_DIR/ops.sh" backup >> "$LOG_FILE" 2>&1 && \ log "BACKUP — complete" || \ log "BACKUP — WARNING: backup failed, check ops.sh backup manually" exit 0