The Art of Waiting: Random Delays for Private Payments

The Art of Waiting: Random Delays for Private Payments

The Problem With Predictable Timing

You queue up some payments in Wasabi, start coinjoin, and wait. The wallet joins round after round, back to back, until your payments clear. Fast and efficient.

But there's a problem: chain analysts don't just look at transaction graphs. They look at timing.

If someone coinjoins continuously for 6 hours, then stops, that's a fingerprint. If payments always clear within minutes of each other, that's a pattern. Timing analysis can correlate activity across rounds, cluster likely-related transactions, and narrow down the anonymity set you thought you had.

The fix is simple: don't be predictable.

Randomized Round Timing

Instead of continuous coinjoin, these scripts introduce random delays between rounds. You configure an average frequency - say, "2 rounds per day" - and the script uses an exponential distribution to generate natural-looking intervals.

Most waits cluster around your average, but occasionally you get shorter or longer gaps. This is the same statistical pattern that models when humans naturally do things: check email, make purchases, send messages. It looks organic because it is organic.

│
│█████████████████████
│██████████████
│█████████
│██████
│████
│██
│█
└─────────────────────
 2h    8h   16h   30h
   (most)    (tail)

The exponential distribution creates this long tail - mostly reasonable waits, occasionally longer pauses that break up any pattern.

Two Scripts, Two Use Cases

wrcj.sh - Standalone privacy mixing

Run coinjoin rounds with random delays until you hit a target: a specific number of rounds, a privacy percentage, or just keep going until you hit Ctrl+C. Use this when you're mixing coins without pending payments.

$ ./wrcj.sh

=== Wasabi Intermittent CoinJoin ===

Available wallets:
  [1] savings

Select wallet: 1
[14:32:01] Loading wallet savings...

Coinjoin frequency (average):

  [1] 1/day     [4] 1/week
  [2] 2/day     [5] 2/week
  [3] 4/day     [6] Custom

Select [1-6]: 2
[14:32:05] Avg 12h 0m between rounds

Stop condition:

  [1] 1 round       [4] 10 rounds
  [2] 3 rounds      [5] Until % private
  [3] 5 rounds      [6] Forever (Ctrl+C)
  [7] Custom rounds

Select [1-7]: 5
Current privacy: 23%
Target privacy %: 80
[14:32:12] Target: 80% private (currently 23%)

=== Starting ===

[14:32:12] Starting coinjoin...
[14:32:12] Waiting for round...
[14:51:23] Round complete: 7a3b9c4d2e1f8a5b...
[14:51:23] Privacy: 34% (target: 80%)
[14:51:23] Next round in 9h 23m...

wcj.sh - Payment runner with optional delays

Queue payments with wpay.sh, then run them through coinjoin. Choose continuous mode for speed, or intermittent mode for privacy. The script stops automatically when all payments clear.

$ ./wcj.sh

=== Wasabi CoinJoin ===

Wallets:
  [1] spending

Select wallet: 1
[14:32:01] Loading wallet spending...

=== Wallet: spending ===

Pending payments:
  100000 sats -> tb1qxxx...
  50000 sats -> tb1qyyy...

Coinjoin mode:

  [1] Continuous (fast, less private timing)
  [2] Intermittent (random delays, better privacy)

Select [1-2]: 2

Coinjoin frequency (average):
  [1] 1/day     [4] 1/week
  [2] 2/day     [5] 2/week
  [3] 4/day     [6] Custom

Select [1-6]: 3
[14:32:10] Avg 6h 0m between rounds

=== Starting ===

[14:32:10] Starting coinjoin...
[14:32:10] Waiting for round...
[14:51:23] Round complete: 7a3b9c4d2e1f8a5b...
[14:51:23] Sent: 50000 sats -> tb1qyyy...
[14:51:23] 1 payment(s) remaining
[14:51:23] Next round in 4h 17m...

The Tradeoff

Random delays mean slower completion. A payment that would clear in an hour with continuous coinjoin might take a day or more with intermittent timing.

But privacy isn't free. The question is whether your threat model includes timing analysis. If you're just breaking transaction graph links, continuous coinjoin is fine. If you're concerned about sophisticated analysts correlating your activity patterns, random delays are worth the wait.

Like Dalí's melting clocks, time becomes fluid - and that fluidity is your cover.


Setup

  1. Enable RPC in Wasabi's Config.json:
"JsonRpcServerEnabled": true
  1. Make scripts executable:
chmod +x wrcj.sh wcj.sh
  1. Requirements: curl, jq, bc

Appendix: Full Scripts

wrcj.sh

#!/usr/bin/env bash
set -uo pipefail

#===============================================================================
# Wasabi Intermittent CoinJoin
#===============================================================================
#
# PURPOSE:
#   Automates Wasabi Wallet coinjoin with randomized delays between rounds.
#   This reduces timing fingerprinting compared to continuous coinjoin.
#
# HOW IT WORKS:
#   1. Starts coinjoin via RPC
#   2. Watches Wasabi's log file for "Coinjoin TxId" (indicates successful round)
#   3. Stops coinjoin
#   4. Sleeps for a random duration (exponential distribution around configured average)
#   5. Repeats until target is reached (round count or privacy percentage)
#
# ROUND DETECTION:
#   Monitors ~/.walletwasabi/client/Logs.txt for new "Coinjoin TxId" entries.
#   This is the definitive signal that a coinjoin transaction was broadcast.
#   More reliable than UTXO tracking (which would false-trigger on regular payments).
#
# PRIVACY CALCULATION:
#   Wasabi uses "anonymity score weighted amounts" for privacy progress.
#   Each UTXO contributes proportionally: min(score, target) / target * amount
#   Score <= 1 is treated as 0% (non-private). Score >= target is 100%.
#
# REQUIREMENTS:
#   - Wasabi Wallet running with RPC enabled (JsonRpcServerEnabled: true in Config.json)
#   - curl, jq, bc
#
# USAGE:
#   ./wasabi-intermittent-cj.sh
#   (Interactive prompts for wallet, frequency, and target)
#
#===============================================================================

RPC=" http://127.0.0.1:37128"
LOG_FILE="$HOME/.walletwasabi/client/Logs.txt"

#-------------------------------------------------------------------------------
# Utilities
#-------------------------------------------------------------------------------

die() { echo "Error: $1" >&2; exit 1; }
log() { echo "[$(date '+%H:%M:%S')] $1"; }
require() { command -v "$1" &>/dev/null || die "$1 is required"; }

# JSON-RPC call with error handling
rpc() {
    local endpoint=$1 method=$2; shift 2
    local params="" result
    [[ $# -gt 0 ]] && params="$*"
    
    if [[ -z "$params" ]]; then
        result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"'"$method"'"}' "$endpoint")
    else
        result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"'"$method"'","params":['"$params"']}' "$endpoint")
    fi
    
    local err
    err=$(echo "$result" | jq -r '.error.message // empty')
    [[ -n "$err" ]] && die "RPC error: $err"
    
    echo "$result"
}

# Convert seconds to human-readable format (e.g., "2d 5h" or "3h 30m")
format_duration() {
    local s=${1:-0} d h m
    d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60))
    if ((d > 0)); then echo "${d}d ${h}h"
    elif ((h > 0)); then echo "${h}h ${m}m"
    else echo "${m}m"
    fi
}

#-------------------------------------------------------------------------------
# Privacy calculation
#-------------------------------------------------------------------------------

# Returns the weighted privacy percentage of the wallet.
# 
# Wasabi uses "anonymity score weighted amounts" rather than a binary threshold.
# Each UTXO contributes proportionally based on how close its score is to target:
#   - Score >= target: contributes 100% of its value
#   - Score < target: contributes (score / target) of its value
#   - Score <= 1: contributes 0% (considered non-private)
#
# Example with target=5:
#   - 0.1 BTC at score 5 contributes 0.1 BTC (100%)
#   - 0.1 BTC at score 3 contributes 0.06 BTC (60%)
#   - 0.1 BTC at score 1 contributes 0 BTC (0%)
#
get_privacy_pct() {
    local coins target total_sum weighted_sum
    
    target=$(rpc "$RPC/$WALLET" "getwalletinfo" | jq '.result.anonScoreTarget')
    coins=$(rpc "$RPC/$WALLET" "listunspentcoins" | jq '.result')
    
    total_sum=$(echo "$coins" | jq '[.[].amount] | add // 0')
    ((total_sum == 0)) && { echo "0"; return; }
    
    # Calculate weighted sum: each coin contributes min(score, target) / target of its amount
    # Score <= 1 contributes 0 (non-private)
    weighted_sum=$(echo "$coins" | jq --argjson t "$target" '
        [.[] | 
            if .anonymityScore <= 1 then 0
            elif .anonymityScore >= $t then .amount
            else .amount * .anonymityScore / $t
            end
        ] | add // 0
    ')
    
    # Calculate percentage using bc for floating point
    echo "$weighted_sum * 100 / $total_sum" | bc -l | cut -d. -f1
}

#-------------------------------------------------------------------------------
# Interactive configuration
#-------------------------------------------------------------------------------

select_wallet() {
    local wallets wallet_count
    wallets=$(rpc "$RPC" "listwallets" | jq -r '.result')
    wallet_count=$(echo "$wallets" | jq 'length')
    ((wallet_count == 0)) && die "No wallets found"
    
    echo -e "\nAvailable wallets:"
    for ((i=0; i<wallet_count; i++)); do
        echo "  [$((i+1))] $(echo "$wallets" | jq -r ".[$i].walletName")"
    done
    
    read -rp $'\nSelect wallet: ' choice
    local idx=$((choice - 1))
    ((idx < 0 || idx >= wallet_count)) && die "Invalid selection"
    
    WALLET=$(echo "$wallets" | jq -r ".[$idx].walletName")
    
    log "Loading wallet $WALLET..."
    local result err
    result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"loadwallet","params":["'"$WALLET"'"]}' "$RPC") || true
    err=$(echo "$result" | jq -r '.error.message // empty')
    [[ -n "$err" && "$err" != *"already"* ]] && die "Failed to load wallet: $err"
}

select_frequency() {
    cat <<EOF

Coinjoin frequency (average):

  [1] 1/day     [4] 1/week
  [2] 2/day     [5] 2/week
  [3] 4/day     [6] Custom

EOF
    read -rp "Select [1-6]: " choice
    
    case $choice in
        1) AVG_WAIT=$((24*3600)) ;;
        2) AVG_WAIT=$((12*3600)) ;;
        3) AVG_WAIT=$((6*3600)) ;;
        4) AVG_WAIT=$((168*3600)) ;;
        5) AVG_WAIT=$((84*3600)) ;;
        6)
            read -rp "Frequency (e.g. 3/day, 5/week): " custom
            custom=${custom,,}
            if [[ "$custom" =~ ^([0-9]+)/day$ ]]; then
                ((BASH_REMATCH[1] > 0)) || die "Invalid frequency"
                AVG_WAIT=$((24*3600 / BASH_REMATCH[1]))
            elif [[ "$custom" =~ ^([0-9]+)/week$ ]]; then
                ((BASH_REMATCH[1] > 0)) || die "Invalid frequency"
                AVG_WAIT=$((168*3600 / BASH_REMATCH[1]))
            else
                die "Could not parse '$custom'"
            fi
            ;;
        *) die "Invalid selection" ;;
    esac
    
    # Minimum 2 hours (coinjoin rounds take time)
    ((AVG_WAIT < 7200)) && AVG_WAIT=7200
    log "Avg $(format_duration $AVG_WAIT) between rounds"
}

select_target() {
    local current_pct
    current_pct=$(get_privacy_pct)
    
    cat <<EOF

Stop condition:

  [1] 1 round       [4] 10 rounds
  [2] 3 rounds      [5] Until % private
  [3] 5 rounds      [6] Forever (Ctrl+C)
  [7] Custom rounds

EOF
    read -rp "Select [1-7]: " choice
    
    # MODE: "rounds" or "privacy"
    case $choice in
        1) MODE="rounds"; MAX_ROUNDS=1 ;;
        2) MODE="rounds"; MAX_ROUNDS=3 ;;
        3) MODE="rounds"; MAX_ROUNDS=5 ;;
        4) MODE="rounds"; MAX_ROUNDS=10 ;;
        5)
            MODE="privacy"
            echo "Current privacy: ${current_pct}%"
            read -rp "Target privacy %: " TARGET_PCT
            [[ "$TARGET_PCT" =~ ^[0-9]+$ ]] || die "Invalid percentage"
            ((TARGET_PCT > 100)) && TARGET_PCT=100
            ((TARGET_PCT <= current_pct)) && { log "Already at ${current_pct}%!"; exit 0; }
            ;;
        6) MODE="rounds"; MAX_ROUNDS=0 ;;
        7)
            MODE="rounds"
            read -rp "Number of rounds (0=forever): " MAX_ROUNDS
            [[ "$MAX_ROUNDS" =~ ^[0-9]+$ ]] || die "Invalid number"
            ;;
        *) die "Invalid selection" ;;
    esac
    
    if [[ "$MODE" == "privacy" ]]; then
        log "Target: ${TARGET_PCT}% private (currently ${current_pct}%)"
    elif ((MAX_ROUNDS == 0)); then
        log "Running until Ctrl+C"
    else
        log "Target: $MAX_ROUNDS round(s)"
    fi
}

#-------------------------------------------------------------------------------
# Core logic
#-------------------------------------------------------------------------------

# Generates random wait time using exponential distribution.
# This creates natural-looking intervals: mostly near the average,
# occasionally shorter or longer, mimicking human behavior.
random_wait() {
    local r wait max
    r=$(awk 'BEGIN{srand(); r=rand(); if(r<0.001)r=0.001; print r}')
    wait=$(echo "-$AVG_WAIT * l($r)" | bc -l)
    wait=${wait%%.*}  # Remove decimal part
    wait=${wait:-0}   # Default to 0 if empty
    
    # Clamp to [2 hours, min(2.5x average, 3 days)]
    max=$((AVG_WAIT * 5 / 2))
    ((max > 259200)) && max=259200
    ((wait > max)) && wait=$max
    ((wait < 7200)) && wait=7200
    
    echo "$wait"
}

# Watches log file for "Coinjoin TxId" indicating a successful round.
# Only reads new content appended since function start.
wait_for_round() {
    local start_pos poll=0
    start_pos=$(wc -c < "$LOG_FILE")
    
    log "Waiting for round..."
    
    while true; do
        sleep 30
        ((++poll))
        
        local current_pos
        current_pos=$(wc -c < "$LOG_FILE")
        
        if ((current_pos > start_pos)); then
            if tail -c +"$((start_pos + 1))" "$LOG_FILE" | grep -qi "Coinjoin TxId"; then
                local txid
                txid=$(tail -c +"$((start_pos + 1))" "$LOG_FILE" | grep -oi "Coinjoin TxId.*" | grep -oE '[a-f0-9]{64}' | tail -1 || true)
                [[ -n "$txid" ]] && log "Round complete: ${txid:0:16}..." || log "Round complete!"
                return
            fi
        fi
        
        # Progress update every 5 minutes
        ((poll % 10 == 0)) && log "Still waiting... (~$((poll/2))m)"
    done
}

# Sleep with periodic progress updates
do_sleep() {
    local remaining=$1
    local interval=$((remaining > 7200 ? 3600 : 1800))
    
    while ((remaining > 0)); do
        local chunk=$((remaining > interval ? interval : remaining))
        sleep "$chunk"
        ((remaining -= chunk))
        ((remaining > 0)) && log "$(format_duration $remaining) remaining..."
    done
}

# Check if target is reached. Returns 0 if done, 1 if should continue.
check_target() {
    if [[ "$MODE" == "privacy" ]]; then
        local pct
        pct=$(get_privacy_pct)
        if ((pct >= TARGET_PCT)); then
            log "Privacy target reached: ${pct}%"
            return 0
        fi
        log "Privacy: ${pct}% (target: ${TARGET_PCT}%)"
    else
        if ((MAX_ROUNDS > 0)); then
            log "Completed $ROUND_COUNT/$MAX_ROUNDS"
            ((ROUND_COUNT >= MAX_ROUNDS)) && return 0
        else
            log "Completed round #$ROUND_COUNT"
        fi
    fi
    return 1
}

cleanup() {
    echo
    log "Stopping..."
    [[ -n "$WALLET" ]] && curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"stopcoinjoin"}' "$RPC/$WALLET" &>/dev/null || true
    ((ROUND_COUNT > 0)) && log "Completed $ROUND_COUNT round(s)"
    exit 0
}

#-------------------------------------------------------------------------------
# Main
#-------------------------------------------------------------------------------

main() {
    require curl
    require jq
    require bc
    
    # Initialize globals
    WALLET="" AVG_WAIT=0 MAX_ROUNDS=0 ROUND_COUNT=0 MODE="rounds" TARGET_PCT=0
    
    echo "=== Wasabi Intermittent CoinJoin ==="
    
    # Verify RPC is accessible
    curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"getstatus"}' "$RPC" &>/dev/null \
        || die "Cannot connect to Wasabi RPC at $RPC"
    
    # Verify log file exists
    [[ -f "$LOG_FILE" ]] || die "Log file not found: $LOG_FILE"
    
    select_wallet
    select_frequency
    select_target
    
    trap cleanup SIGINT SIGTERM
    
    echo -e "\n=== Starting ===\n"
    
    while true; do
        log "Starting coinjoin..."
        curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"startcoinjoin","params":["","false","true"]}' "$RPC/$WALLET" &>/dev/null \
            || die "Failed to start coinjoin"
        
        wait_for_round
        
        curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"stopcoinjoin"}' "$RPC/$WALLET" &>/dev/null || true
        ((++ROUND_COUNT))
        
        check_target && { log "Done!"; exit 0; }
        
        local wait
        wait=$(random_wait)
        log "Next round in $(format_duration "$wait")..."
        do_sleep "$wait"
        echo
    done
}

main "$@"

wcj.sh

#!/usr/bin/env bash
set -uo pipefail

#===============================================================================
# Wasabi CoinJoin Payment Runner
#===============================================================================
#
# PURPOSE:
#   Runs coinjoin until all queued payments complete. Supports two modes:
#   - Continuous: coinjoin runs non-stop (faster, but predictable timing)
#   - Intermittent: random delays between rounds (slower, better privacy)
#
# PAYMENT DETECTION:
#   Polls listpaymentsincoinjoin every 30 seconds to detect completed/added payments.
#
# ROUND DETECTION (intermittent mode):
#   Watches ~/.walletwasabi/client/Logs.txt for "Coinjoin TxId" entries.
#
# REQUIREMENTS:
#   - Wasabi Wallet with RPC enabled (JsonRpcServerEnabled: true)
#   - curl, jq, bc (bc only needed for intermittent mode)
#
#===============================================================================

RPC=" http://127.0.0.1:37128"
LOG_FILE="$HOME/.walletwasabi/client/Logs.txt"

# Globals (initialized in main)
WALLET=""
MODE=""
AVG_WAIT=0
EVER_HAD_PAYMENTS=false

#-------------------------------------------------------------------------------
# Utilities
#-------------------------------------------------------------------------------

die() { echo "Error: $1" >&2; exit 1; }
log() { echo "[$(date '+%H:%M:%S')] $1"; }

rpc() {
    local method=$1; shift
    local params="" result
    [[ $# -gt 0 ]] && params="$*"
    
    if [[ -z "$params" ]]; then
        result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"'"$method"'"}' "$RPC/$WALLET")
    else
        result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"'"$method"'","params":['"$params"']}' "$RPC/$WALLET")
    fi
    
    local err
    err=$(echo "$result" | jq -r '.error.message // empty')
    [[ -n "$err" ]] && die "RPC error: $err"
    
    echo "$result"
}

# Convert seconds to human-readable format
format_duration() {
    local s=${1:-0} d h m
    d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60))
    if ((d > 0)); then echo "${d}d ${h}h"
    elif ((h > 0)); then echo "${h}h ${m}m"
    else echo "${m}m"
    fi
}

#-------------------------------------------------------------------------------
# Payment tracking
#-------------------------------------------------------------------------------

get_pending() {
    curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"listpaymentsincoinjoin"}' "$RPC/$WALLET" \
        | jq '[.result[] | select(.state[0].status == "Pending")] | sort_by(.address)'
}

show_pending() {
    local payments="$1"
    local count
    count=$(echo "$payments" | jq 'length')
    if ((count == 0)); then
        echo "  (none)"
    else
        echo "$payments" | jq -r '.[] | "  \(.amount) sats -> \(.address)"'
    fi
}

# Compare payment lists, log completed and added payments
# Sets global PREV_ADDRS and PREV_PAYMENTS for next call
check_payments() {
    local curr curr_addrs curr_count
    curr=$(get_pending)
    curr_count=$(echo "$curr" | jq 'length')
    curr_addrs=$(echo "$curr" | jq -r '.[].address' | sort | tr '\n' ' ')
    
    ((curr_count > 0)) && EVER_HAD_PAYMENTS=true
    
    # Check for completed payments
    for addr in $PREV_ADDRS; do
        if ! echo "$curr_addrs" | grep -q "$addr"; then
            local amount
            amount=$(echo "$PREV_PAYMENTS" | jq -r ".[] | select(.address == \"$addr\") | .amount")
            log "Sent: $amount sats -> $addr"
        fi
    done
    
    # Check for new payments
    for addr in $curr_addrs; do
        if [[ -n "$addr" ]] && ! echo "$PREV_ADDRS" | grep -q "$addr"; then
            local amount
            amount=$(echo "$curr" | jq -r ".[] | select(.address == \"$addr\") | .amount")
            log "Added: $amount sats -> $addr"
            EVER_HAD_PAYMENTS=true
        fi
    done
    
    # Update state for next call
    PREV_PAYMENTS="$curr"
    PREV_ADDRS="$curr_addrs"
    
    # Return: 0 if done, 1 if payments remain
    if [[ "$EVER_HAD_PAYMENTS" == true ]] && ((curr_count == 0)); then
        return 0
    fi
    return 1
}

#-------------------------------------------------------------------------------
# Interactive configuration
#-------------------------------------------------------------------------------

select_wallet() {
    local wallets wallet_count
    wallets=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"listwallets"}' "$RPC" | jq -r '.result')
    wallet_count=$(echo "$wallets" | jq 'length')
    ((wallet_count == 0)) && die "No wallets found"
    
    echo "Wallets:"
    echo ""
    for ((i=0; i<wallet_count; i++)); do
        echo "  [$((i+1))] $(echo "$wallets" | jq -r ".[$i].walletName")"
    done
    
    echo ""
    read -rp "Select wallet: " choice
    local idx=$((choice - 1))
    ((idx < 0 || idx >= wallet_count)) && die "Invalid selection"
    
    WALLET=$(echo "$wallets" | jq -r ".[$idx].walletName")
    
    echo ""
    log "Loading wallet $WALLET..."
    local result err
    result=$(curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"loadwallet","params":["'"$WALLET"'"]}' "$RPC") || true
    err=$(echo "$result" | jq -r '.error.message // empty')
    [[ -n "$err" && "$err" != *"already"* ]] && die "Failed to load wallet: $err"
}

select_mode() {
    cat <<EOF

Coinjoin mode:

  [1] Continuous (fast, less private timing)
  [2] Intermittent (random delays, better privacy)

EOF
    read -rp "Select [1-2]: " choice
    
    case $choice in
        1) MODE="continuous" ;;
        2) MODE="intermittent" ;;
        *) die "Invalid selection" ;;
    esac
}

select_frequency() {
    cat <<EOF

Coinjoin frequency (average):

  [1] 1/day     [4] 1/week
  [2] 2/day     [5] 2/week
  [3] 4/day     [6] Custom

EOF
    read -rp "Select [1-6]: " choice
    
    case $choice in
        1) AVG_WAIT=$((24*3600)) ;;
        2) AVG_WAIT=$((12*3600)) ;;
        3) AVG_WAIT=$((6*3600)) ;;
        4) AVG_WAIT=$((168*3600)) ;;
        5) AVG_WAIT=$((84*3600)) ;;
        6)
            read -rp "Frequency (e.g. 3/day, 5/week): " custom
            custom=${custom,,}
            if [[ "$custom" =~ ^([0-9]+)/day$ ]]; then
                ((BASH_REMATCH[1] > 0)) || die "Invalid frequency"
                AVG_WAIT=$((24*3600 / BASH_REMATCH[1]))
            elif [[ "$custom" =~ ^([0-9]+)/week$ ]]; then
                ((BASH_REMATCH[1] > 0)) || die "Invalid frequency"
                AVG_WAIT=$((168*3600 / BASH_REMATCH[1]))
            else
                die "Could not parse '$custom'"
            fi
            ;;
        *) die "Invalid selection" ;;
    esac
    
    # Minimum 2 hours
    ((AVG_WAIT < 7200)) && AVG_WAIT=7200
    log "Avg $(format_duration $AVG_WAIT) between rounds"
}

#-------------------------------------------------------------------------------
# Core logic
#-------------------------------------------------------------------------------

# Exponential distribution for natural-looking random intervals
random_wait() {
    local r wait max
    r=$(awk 'BEGIN{srand(); r=rand(); if(r<0.001)r=0.001; print r}')
    wait=$(echo "-$AVG_WAIT * l($r)" | bc -l)
    wait=${wait%%.*}  # Remove decimal part
    wait=${wait:-0}   # Default to 0 if empty
    
    # Clamp to [2 hours, min(2.5x average, 3 days)]
    max=$((AVG_WAIT * 5 / 2))
    ((max > 259200)) && max=259200
    ((wait > max)) && wait=$max
    ((wait < 7200)) && wait=7200
    
    echo "$wait"
}

# Watches log file for "Coinjoin TxId" indicating a successful round
wait_for_round() {
    local start_pos poll=0
    start_pos=$(wc -c < "$LOG_FILE")
    
    log "Waiting for round..."
    
    while true; do
        sleep 30
        ((++poll))
        
        local current_pos
        current_pos=$(wc -c < "$LOG_FILE")
        
        if ((current_pos > start_pos)); then
            if tail -c +"$((start_pos + 1))" "$LOG_FILE" | grep -qi "Coinjoin TxId"; then
                local txid
                txid=$(tail -c +"$((start_pos + 1))" "$LOG_FILE" | grep -oi "Coinjoin TxId.*" | grep -oE '[a-f0-9]{64}' | tail -1 || true)
                [[ -n "$txid" ]] && log "Round complete: ${txid:0:16}..." || log "Round complete!"
                return
            fi
        fi
        
        ((poll % 10 == 0)) && log "Still waiting... (~$((poll/2))m)"
    done
}

# Sleep with periodic progress updates, checking payments periodically
do_sleep() {
    local remaining=$1
    local interval=$((remaining > 7200 ? 3600 : 1800))
    
    while ((remaining > 0)); do
        local chunk=$((remaining > interval ? interval : remaining))
        sleep "$chunk"
        ((remaining -= chunk))
        ((remaining > 0)) && log "$(format_duration $remaining) remaining..."
    done
}

start_coinjoin() {
    curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"startcoinjoin","params":["","false","true"]}' "$RPC/$WALLET" &>/dev/null \
        || die "Failed to start coinjoin"
}

stop_coinjoin() {
    curl -sf -d '{"jsonrpc":"2.0","id":"1","method":"stopcoinjoin"}' "$RPC/$WALLET" &>/dev/null || true
}

cleanup() {
    echo ""
    log "Stopping..."
    stop_coinjoin
    log "CoinJoin stopped"
    exit 0
}

#-------------------------------------------------------------------------------
# Main loops
#-------------------------------------------------------------------------------

run_continuous() {
    log "Starting coinjoin..."
    start_coinjoin
    
    while true; do
        sleep 30
        check_payments && break
    done
}

run_intermittent() {
    [[ -f "$LOG_FILE" ]] || die "Log file not found: $LOG_FILE"
    command -v bc &>/dev/null || die "bc is required for intermittent mode"
    
    local first_round=true
    
    while true; do
        log "Starting coinjoin..."
        start_coinjoin
        
        wait_for_round
        
        stop_coinjoin
        
        # Check payments after each round
        check_payments && break
        
        local remaining
        remaining=$(echo "$PREV_PAYMENTS" | jq 'length')
        log "$remaining payment(s) remaining"
        
        local wait
        wait=$(random_wait)
        log "Next round in $(format_duration "$wait")..."
        do_sleep "$wait"
        echo ""
    done
}

main() {
    # Check dependencies
    command -v curl &>/dev/null || die "curl is required"
    command -v jq &>/dev/null || die "jq is required"
    
    echo "=== Wasabi CoinJoin ==="
    
    # Check RPC
    curl -sf --connect-timeout 3 -d '{"jsonrpc":"2.0","id":"1","method":"getstatus"}' "$RPC" &>/dev/null \
        || die "Cannot connect to Wasabi RPC at $RPC"
    
    select_wallet
    
    # Show pending payments
    PREV_PAYMENTS=$(get_pending)
    PREV_ADDRS=$(echo "$PREV_PAYMENTS" | jq -r '.[].address' | sort | tr '\n' ' ')
    local count
    count=$(echo "$PREV_PAYMENTS" | jq 'length')
    
    echo ""
    echo "=== Wallet: $WALLET ==="
    echo ""
    echo "Pending payments:"
    show_pending "$PREV_PAYMENTS"
    
    ((count > 0)) && EVER_HAD_PAYMENTS=true
    
    if ((count == 0)); then
        echo ""
        read -rp "No pending payments. Start coinjoin anyway? [y/N]: " confirm
        [[ "${confirm^^}" != "Y" ]] && exit 0
    fi
    
    select_mode
    
    [[ "$MODE" == "intermittent" ]] && select_frequency
    
    trap cleanup SIGINT SIGTERM
    
    echo ""
    echo "=== Starting ==="
    echo ""
    
    if [[ "$MODE" == "continuous" ]]; then
        run_continuous
    else
        run_intermittent
    fi
    
    echo ""
    echo "=== All payments done ==="
    stop_coinjoin
    log "CoinJoin stopped"
}

main "$@"