diff --git a/infrastructure/lnd-init.sh b/infrastructure/lnd-init.sh index 9ee4d47..27fc3eb 100755 --- a/infrastructure/lnd-init.sh +++ b/infrastructure/lnd-init.sh @@ -157,7 +157,9 @@ if [[ -n "$COLD_ADDRESS" ]]; then # Timmy Node — Auto-sweep configuration # Change any value then run: bash ops.sh configure-sweep -COLD_ADDRESS=$COLD_ADDRESS +SWEEP_MODE="static" +COLD_ADDRESS="$COLD_ADDRESS" +XPUB="" KEEP_SATS=300000 MIN_SWEEP=50000 SWEEP_CRON="0 3 * * *" diff --git a/infrastructure/ops.sh b/infrastructure/ops.sh index f6c585f..a551e06 100755 --- a/infrastructure/ops.sh +++ b/infrastructure/ops.sh @@ -113,12 +113,34 @@ case "${1:-help}" in SWEEP_CONF="$INFRA_DIR/sweep.conf" SWEEP_LOG="/var/log/timmy-sweep.log" + STATE_FILE="$INFRA_DIR/sweep-state" + ADDR_LIST_FILE="$INFRA_DIR/sweep-addresses.txt" + echo -e "\n${CYAN}── Sweep config ──────────────────────────────${NC}" if [[ -f "$SWEEP_CONF" ]]; then - COLD_ADDRESS=""; KEEP_SATS=300000; MIN_SWEEP=50000 + SWEEP_MODE="static"; COLD_ADDRESS=""; XPUB="" + KEEP_SATS=300000; MIN_SWEEP=50000 SWEEP_FREQ_LABEL="daily at 3am UTC"; SWEEP_CRON="0 3 * * *" source "$SWEEP_CONF" - echo " Cold address : ${COLD_ADDRESS:-(not set — sweep disabled)}" + echo " Mode : ${SWEEP_MODE}" + case "$SWEEP_MODE" in + static) + echo " Destination : ${COLD_ADDRESS:-(not set — sweep disabled)}" + ;; + list) + COUNT=$(grep -c '[^[:space:]]' "$ADDR_LIST_FILE" 2>/dev/null || echo 0) + NEXT_INDEX=0; [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" + REMAINING=$(( COUNT - NEXT_INDEX )) + echo " Addresses : ${COUNT} total, ${REMAINING} remaining (next: index ${NEXT_INDEX})" + NEXT_ADDR=$(sed -n "$((NEXT_INDEX + 1))p" "$ADDR_LIST_FILE" 2>/dev/null | tr -d '[:space:]') + [[ -n "$NEXT_ADDR" ]] && echo " Next address : ${NEXT_ADDR}" + ;; + xpub) + NEXT_INDEX=0; [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" + echo " xpub : ${XPUB:0:30}..." + echo " Next index : ${NEXT_INDEX}" + ;; + esac echo " Keep on-chain: ${KEEP_SATS} sats" echo " Min to sweep : ${MIN_SWEEP} sats" echo " Frequency : ${SWEEP_FREQ_LABEL}" @@ -143,9 +165,12 @@ case "${1:-help}" in configure-sweep) SWEEP_CONF="$INFRA_DIR/sweep.conf" + STATE_FILE="$INFRA_DIR/sweep-state" + ADDR_LIST_FILE="$INFRA_DIR/sweep-addresses.txt" # Load current values as defaults - COLD_ADDRESS=""; KEEP_SATS=300000; MIN_SWEEP=50000 + SWEEP_MODE="static"; COLD_ADDRESS=""; XPUB="" + KEEP_SATS=300000; MIN_SWEEP=50000 SWEEP_CRON="0 3 * * *"; SWEEP_FREQ_LABEL="daily at 3am UTC" [[ -f "$SWEEP_CONF" ]] && source "$SWEEP_CONF" @@ -153,22 +178,84 @@ case "${1:-help}" in echo -e "${CYAN} Configure Auto-Sweep (Enter = keep current)${NC}" echo -e "${CYAN}══════════════════════════════════════════${NC}\n" - # Cold address - echo -e " Current cold address: ${YELLOW}${COLD_ADDRESS:-(none)}${NC}" - read -rp " New cold address: " INPUT - [[ -n "$INPUT" ]] && COLD_ADDRESS="$INPUT" + # ── Destination mode ────────────────────────────────────── + echo -e " Current destination mode: ${YELLOW}${SWEEP_MODE}${NC}" + echo " Choose a destination mode:" + echo " 1) Single address — same address every sweep" + echo " 2) Address list — rotate through a list of addresses (no reuse)" + echo " 3) xpub — derive a fresh address each sweep (no reuse)" + read -rp " Choice [1-3, Enter to keep]: " MODE_CHOICE + case "$MODE_CHOICE" in + 1) SWEEP_MODE="static" ;; + 2) SWEEP_MODE="list" ;; + 3) SWEEP_MODE="xpub" ;; + esac - # Keep threshold + # ── Mode-specific inputs ────────────────────────────────── + case "$SWEEP_MODE" in + static) + echo -e "\n Current cold address: ${YELLOW}${COLD_ADDRESS:-(none)}${NC}" + read -rp " New cold address (bc1q... / 1... / 3...): " INPUT + [[ -n "$INPUT" ]] && COLD_ADDRESS="$INPUT" + XPUB="" + ;; + + list) + if [[ -f "$ADDR_LIST_FILE" ]]; then + COUNT=$(grep -c '[^[:space:]]' "$ADDR_LIST_FILE" || true) + echo -e "\n Current address list: ${YELLOW}${COUNT} address(es) in $ADDR_LIST_FILE${NC}" + else + echo -e "\n No address list found yet." + fi + NEXT_INDEX=0 + [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" + echo " Current position: index ${NEXT_INDEX}" + echo "" + echo " Paste cold addresses now (one per line, blank line when done)." + echo " These REPLACE the existing list. Leave blank to keep current list." + echo "" + NEW_ADDRS=() + while IFS= read -rp " > " ADDR_INPUT && [[ -n "$ADDR_INPUT" ]]; do + ADDR_INPUT=$(echo "$ADDR_INPUT" | tr -d '[:space:]') + [[ -n "$ADDR_INPUT" ]] && NEW_ADDRS+=("$ADDR_INPUT") + done + if (( ${#NEW_ADDRS[@]} > 0 )); then + printf '%s\n' "${NEW_ADDRS[@]}" > "$ADDR_LIST_FILE" + chmod 600 "$ADDR_LIST_FILE" + echo "NEXT_INDEX=0" > "$STATE_FILE" + chmod 600 "$STATE_FILE" + echo -e " ${GREEN}Saved ${#NEW_ADDRS[@]} addresses. Index reset to 0.${NC}" + else + echo " Kept existing address list." + fi + COLD_ADDRESS=""; XPUB="" + ;; + + xpub) + echo -e "\n Current xpub: ${YELLOW}${XPUB:-(none)}${NC}" + echo " Paste your account xpub (from Sparrow, Coldcard, etc. — account-level, not master)." + read -rp " xpub: " INPUT + if [[ -n "$INPUT" ]]; then + XPUB="$INPUT" + echo "NEXT_INDEX=0" > "$STATE_FILE" + chmod 600 "$STATE_FILE" + echo -e " ${GREEN}xpub saved. Derivation index reset to 0.${NC}" + fi + COLD_ADDRESS="" + ;; + esac + + # ── Keep threshold ──────────────────────────────────────── echo -e "\n Current keep-on-chain threshold: ${YELLOW}${KEEP_SATS} sats${NC}" read -rp " New keep threshold (sats): " INPUT [[ "$INPUT" =~ ^[0-9]+$ ]] && KEEP_SATS="$INPUT" - # Min sweep floor + # ── Min sweep floor ─────────────────────────────────────── echo -e "\n Current minimum sweep amount: ${YELLOW}${MIN_SWEEP} sats${NC}" read -rp " New minimum sweep (sats): " INPUT [[ "$INPUT" =~ ^[0-9]+$ ]] && MIN_SWEEP="$INPUT" - # Frequency + # ── Frequency ───────────────────────────────────────────── echo -e "\n Current frequency: ${YELLOW}${SWEEP_FREQ_LABEL}${NC}" echo " Choose a new frequency (Enter to keep current):" echo " 1) Hourly" @@ -183,12 +270,14 @@ case "${1:-help}" in 4) SWEEP_CRON="0 3 * * 0"; SWEEP_FREQ_LABEL="weekly (Sunday 3am UTC)" ;; esac - # Write sweep.conf — quote values that may contain spaces + # ── Write sweep.conf ────────────────────────────────────── cat > "$SWEEP_CONF" </dev/null | grep -v "timmy-node.*sweep" | crontab - || true (crontab -l 2>/dev/null; echo "# Timmy Node — auto-sweep ($SWEEP_FREQ_LABEL)") | crontab - (crontab -l 2>/dev/null; echo "$SWEEP_CRON bash $INFRA_DIR/sweep.sh > /dev/null 2>&1") | crontab - echo -e "\n${GREEN}Sweep configured:${NC}" - echo " Cold address : ${COLD_ADDRESS:-(not set — sweep disabled)}" + echo " Mode : ${SWEEP_MODE}" + case "$SWEEP_MODE" in + static) echo " Destination : ${COLD_ADDRESS:-(not set — sweep disabled)}" ;; + list) + COUNT=$(grep -c '[^[:space:]]' "$ADDR_LIST_FILE" 2>/dev/null || echo 0) + NEXT_INDEX=0; [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" + echo " Addresses : ${COUNT} total, starting at index ${NEXT_INDEX}" + ;; + xpub) + NEXT_INDEX=0; [[ -f "$STATE_FILE" ]] && source "$STATE_FILE" + echo " xpub : ${XPUB:0:20}..." + echo " Next index : ${NEXT_INDEX}" + ;; + esac echo " Keep on-chain: ${KEEP_SATS} sats" echo " Min to sweep : ${MIN_SWEEP} sats" echo " Frequency : ${SWEEP_FREQ_LABEL}" diff --git a/infrastructure/sweep.sh b/infrastructure/sweep.sh index 4dcfd92..0a8b039 100755 --- a/infrastructure/sweep.sh +++ b/infrastructure/sweep.sh @@ -3,17 +3,18 @@ # Timmy Node — Auto-sweep hot wallet to cold storage # # Run manually: bash /opt/timmy-node/sweep.sh -# Run by cron: 0 3 * * * bash /opt/timmy-node/sweep.sh +# Run by cron: configured by ops.sh configure-sweep # -# Config file: /opt/timmy-node/sweep.conf -# COLD_ADDRESS=bc1q... (required — your cold wallet address) -# KEEP_SATS=300000 (keep this much on-chain for channel ops) -# MIN_SWEEP=50000 (don't sweep if amount is below this) +# 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') @@ -21,20 +22,89 @@ log() { echo "[$TIMESTAMP] $*" | tee -a "$LOG_FILE"; } # ── Load config ────────────────────────────────────────────── if [[ ! -f "$CONF_FILE" ]]; then - log "SKIP — no sweep.conf found at $CONF_FILE (run lnd-init.sh to configure)" + 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}" -if [[ -z "$COLD_ADDRESS" ]]; then - log "SKIP — COLD_ADDRESS not set in $CONF_FILE" - exit 0 -fi +# ── 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 \ @@ -45,9 +115,9 @@ if [[ -z "$BALANCE" || "$BALANCE" == "null" ]]; then exit 1 fi -log "On-chain balance: ${BALANCE} sats | keep: ${KEEP_SATS} | cold: ${COLD_ADDRESS}" +log "Balance: ${BALANCE} sats | keep: ${KEEP_SATS} | destination: ${SWEEP_TO}" -# ── Calculate sweep amount ─────────────────────────────────── +# ── Calculate sweep amount ──────────────────────────────────── SWEEP_AMT=$(( BALANCE - KEEP_SATS )) if (( SWEEP_AMT < MIN_SWEEP )); then @@ -55,11 +125,11 @@ if (( SWEEP_AMT < MIN_SWEEP )); then exit 0 fi -# ── Send to cold address ───────────────────────────────────── -log "SWEEP — sending ${SWEEP_AMT} sats to ${COLD_ADDRESS}..." +# ── Send to cold address ────────────────────────────────────── +log "SWEEP — sending ${SWEEP_AMT} sats to ${SWEEP_TO}..." if ! SEND_RESULT=$(docker exec lnd lncli --network=mainnet sendcoins \ - --addr "$COLD_ADDRESS" \ + --addr "$SWEEP_TO" \ --amt "$SWEEP_AMT" \ 2>&1) then @@ -74,9 +144,19 @@ if [[ -z "$TXID" ]]; then exit 1 fi -log "SUCCESS — txid=${TXID} amount=${SWEEP_AMT} sats → ${COLD_ADDRESS}" +log "SUCCESS — txid=${TXID} amount=${SWEEP_AMT} sats → ${SWEEP_TO}" -# ── Trigger channel state backup ───────────────────────────── +# ── 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" || \