The Art of Waiting: Random Delays for Private Payments

Max

npub1klkk3vrzme455yh9rl2jshq7rc8dpegj3ndf82c3ks2sk40dxt7qulx3vt

hex

c74adbd681e278c28230534ac8e0c4aa07ba6d9ed627480b80715b59111d227f

nevent

nevent1qqsvwjkm66q7y7xzsgc9xjkgurz25pa6dk0dvf6gpwq8zk6ezywjylcprpmhxue69uhhyetvv9ujuem4d36kwatvw5hx6mm9qgst0mtgkp3du662ztj3l4fgts0purksu5fgek5n4vgmg9gt2hkn9lqjq5uss

naddr

naddr1qqgrxvpnx4jn2cn9v5crvvejxp3xgqgcwaehxw309aex2mrp0yhxwatvw4nh2mr49ekk7egzyzm7669svt0xkjsju50a22zurc0qa589z2xd4yatzx6p2z64a5e0cqcyqqq823cc0y4zl

Kind-30023 (Article)

2025-12-07T05:47:45Z

The problem with predictable timing

You queue up some payments in Wasabi and start coinjoin. 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 and cluster likely-related transactions, eroding the anonymity set you thought you had.

The fix is simple: don't be predictable.

Randomized round timing

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 an indefinite run you stop manually. 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 "$@"

Raw JSON

{
  "kind": 30023,
  "id": "c74adbd681e278c28230534ac8e0c4aa07ba6d9ed627480b80715b59111d227f",
  "pubkey": "b7ed68b062de6b4a12e51fd5285c1e1e0ed0e5128cda93ab11b4150b55ed32fc",
  "created_at": 1777543606,
  "tags": [
    [
      "d",
      "3035e5bee06320bd"
    ],
    [
      "image",
      "https://image.nostr.build/f1cd59111470fe23f2965238032d2021555ddabaf05a4875bdc82f6effc716b0.jpg"
    ],
    [
      "title",
      "The Art of Waiting: Random Delays for Private Payments"
    ],
    [
      "summary",
      "Two scripts that randomize timing between Wasabi coinjoin rounds, making your payment patterns indistinguishable from organic human behavior."
    ],
    [
      "published_at",
      "1765086465"
    ],
    [
      "t",
      "austrian-economics"
    ],
    [
      "t",
      "freedom-tech"
    ],
    [
      "t",
      "bitcoin"
    ],
    [
      "t",
      "wasabi"
    ],
    [
      "t",
      "coinjoin"
    ],
    [
      "t",
      "privacy"
    ],
    [
      "t",
      "opsec"
    ]
  ],
  "content": "## The problem with predictable timing\n\nYou queue up some payments in Wasabi and start coinjoin. The wallet joins round after round, back to back, until your payments clear. Fast and efficient.\n\nBut there's a problem: chain analysts don't just look at transaction graphs. They look at timing.\n\nIf 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 and cluster likely-related transactions, eroding the anonymity set you thought you had.\n\nThe fix is simple: don't be predictable.\n\n## Randomized round timing\n\nThese 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.\n\nMost 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.\n\n```\n│\n│█████████████████████\n│██████████████\n│█████████\n│██████\n│████\n│██\n│█\n└─────────────────────\n 2h    8h   16h   30h\n   (most)    (tail)\n```\n\nThe exponential distribution creates this long tail - mostly reasonable waits, occasionally longer pauses that break up any pattern.\n\n## Two scripts, two use cases\n\n**wrcj.sh** - Standalone privacy mixing\n\nRun coinjoin rounds with random delays until you hit a target: a specific number of rounds, a privacy percentage, or an indefinite run you stop manually. Use this when you're mixing coins without pending payments.\n\n```\n$ ./wrcj.sh\n\n=== Wasabi Intermittent CoinJoin ===\n\nAvailable wallets:\n  [1] savings\n\nSelect wallet: 1\n[14:32:01] Loading wallet savings...\n\nCoinjoin frequency (average):\n\n  [1] 1/day     [4] 1/week\n  [2] 2/day     [5] 2/week\n  [3] 4/day     [6] Custom\n\nSelect [1-6]: 2\n[14:32:05] Avg 12h 0m between rounds\n\nStop condition:\n\n  [1] 1 round       [4] 10 rounds\n  [2] 3 rounds      [5] Until % private\n  [3] 5 rounds      [6] Forever (Ctrl+C)\n  [7] Custom rounds\n\nSelect [1-7]: 5\nCurrent privacy: 23%\nTarget privacy %: 80\n[14:32:12] Target: 80% private (currently 23%)\n\n=== Starting ===\n\n[14:32:12] Starting coinjoin...\n[14:32:12] Waiting for round...\n[14:51:23] Round complete: 7a3b9c4d2e1f8a5b...\n[14:51:23] Privacy: 34% (target: 80%)\n[14:51:23] Next round in 9h 23m...\n```\n\n**wcj.sh** - Payment runner with optional delays\n\nQueue 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.\n\n```\n$ ./wcj.sh\n\n=== Wasabi CoinJoin ===\n\nWallets:\n  [1] spending\n\nSelect wallet: 1\n[14:32:01] Loading wallet spending...\n\n=== Wallet: spending ===\n\nPending payments:\n  100000 sats -\u003e tb1qxxx...\n  50000 sats -\u003e tb1qyyy...\n\nCoinjoin mode:\n\n  [1] Continuous (fast, less private timing)\n  [2] Intermittent (random delays, better privacy)\n\nSelect [1-2]: 2\n\nCoinjoin frequency (average):\n  [1] 1/day     [4] 1/week\n  [2] 2/day     [5] 2/week\n  [3] 4/day     [6] Custom\n\nSelect [1-6]: 3\n[14:32:10] Avg 6h 0m between rounds\n\n=== Starting ===\n\n[14:32:10] Starting coinjoin...\n[14:32:10] Waiting for round...\n[14:51:23] Round complete: 7a3b9c4d2e1f8a5b...\n[14:51:23] Sent: 50000 sats -\u003e tb1qyyy...\n[14:51:23] 1 payment(s) remaining\n[14:51:23] Next round in 4h 17m...\n```\n\n## The Tradeoff\n\nRandom delays mean slower completion. A payment that would clear in an hour with continuous coinjoin might take a day or more with intermittent timing.\n\nBut 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.\n\nLike Dalí's melting clocks, time becomes fluid - and that fluidity is your cover.\n\n---\n\n## Setup\n\n1. Enable RPC in Wasabi's `Config.json`:\n```json\n\"JsonRpcServerEnabled\": true\n```\n\n2. Make scripts executable:\n```bash\nchmod +x wrcj.sh wcj.sh\n```\n\n3. Requirements: `curl`, `jq`, `bc`\n\n---\n\n## Appendix: full scripts\n\n### wrcj.sh\n\n```bash\n#!/usr/bin/env bash\nset -uo pipefail\n\n#===============================================================================\n# Wasabi Intermittent CoinJoin\n#===============================================================================\n#\n# PURPOSE:\n#   Automates Wasabi Wallet coinjoin with randomized delays between rounds.\n#   This reduces timing fingerprinting compared to continuous coinjoin.\n#\n# HOW IT WORKS:\n#   1. Starts coinjoin via RPC\n#   2. Watches Wasabi's log file for \"Coinjoin TxId\" (indicates successful round)\n#   3. Stops coinjoin\n#   4. Sleeps for a random duration (exponential distribution around configured average)\n#   5. Repeats until target is reached (round count or privacy percentage)\n#\n# ROUND DETECTION:\n#   Monitors ~/.walletwasabi/client/Logs.txt for new \"Coinjoin TxId\" entries.\n#   This is the definitive signal that a coinjoin transaction was broadcast.\n#   More reliable than UTXO tracking (which would false-trigger on regular payments).\n#\n# PRIVACY CALCULATION:\n#   Wasabi uses \"anonymity score weighted amounts\" for privacy progress.\n#   Each UTXO contributes proportionally: min(score, target) / target * amount\n#   Score \u003c= 1 is treated as 0% (non-private). Score \u003e= target is 100%.\n#\n# REQUIREMENTS:\n#   - Wasabi Wallet running with RPC enabled (JsonRpcServerEnabled: true in Config.json)\n#   - curl, jq, bc\n#\n# USAGE:\n#   ./wasabi-intermittent-cj.sh\n#   (Interactive prompts for wallet, frequency, and target)\n#\n#===============================================================================\n\nRPC=\" http://127.0.0.1:37128\"\nLOG_FILE=\"$HOME/.walletwasabi/client/Logs.txt\"\n\n#-------------------------------------------------------------------------------\n# Utilities\n#-------------------------------------------------------------------------------\n\ndie() { echo \"Error: $1\" \u003e\u00262; exit 1; }\nlog() { echo \"[$(date '+%H:%M:%S')] $1\"; }\nrequire() { command -v \"$1\" \u0026\u003e/dev/null || die \"$1 is required\"; }\n\n# JSON-RPC call with error handling\nrpc() {\n    local endpoint=$1 method=$2; shift 2\n    local params=\"\" result\n    [[ $# -gt 0 ]] \u0026\u0026 params=\"$*\"\n    \n    if [[ -z \"$params\" ]]; then\n        result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"'\"$method\"'\"}' \"$endpoint\")\n    else\n        result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"'\"$method\"'\",\"params\":['\"$params\"']}' \"$endpoint\")\n    fi\n    \n    local err\n    err=$(echo \"$result\" | jq -r '.error.message // empty')\n    [[ -n \"$err\" ]] \u0026\u0026 die \"RPC error: $err\"\n    \n    echo \"$result\"\n}\n\n# Convert seconds to human-readable format (e.g., \"2d 5h\" or \"3h 30m\")\nformat_duration() {\n    local s=${1:-0} d h m\n    d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60))\n    if ((d \u003e 0)); then echo \"${d}d ${h}h\"\n    elif ((h \u003e 0)); then echo \"${h}h ${m}m\"\n    else echo \"${m}m\"\n    fi\n}\n\n#-------------------------------------------------------------------------------\n# Privacy calculation\n#-------------------------------------------------------------------------------\n\n# Returns the weighted privacy percentage of the wallet.\n# \n# Wasabi uses \"anonymity score weighted amounts\" rather than a binary threshold.\n# Each UTXO contributes proportionally based on how close its score is to target:\n#   - Score \u003e= target: contributes 100% of its value\n#   - Score \u003c target: contributes (score / target) of its value\n#   - Score \u003c= 1: contributes 0% (considered non-private)\n#\n# Example with target=5:\n#   - 0.1 BTC at score 5 contributes 0.1 BTC (100%)\n#   - 0.1 BTC at score 3 contributes 0.06 BTC (60%)\n#   - 0.1 BTC at score 1 contributes 0 BTC (0%)\n#\nget_privacy_pct() {\n    local coins target total_sum weighted_sum\n    \n    target=$(rpc \"$RPC/$WALLET\" \"getwalletinfo\" | jq '.result.anonScoreTarget')\n    coins=$(rpc \"$RPC/$WALLET\" \"listunspentcoins\" | jq '.result')\n    \n    total_sum=$(echo \"$coins\" | jq '[.[].amount] | add // 0')\n    ((total_sum == 0)) \u0026\u0026 { echo \"0\"; return; }\n    \n    # Calculate weighted sum: each coin contributes min(score, target) / target of its amount\n    # Score \u003c= 1 contributes 0 (non-private)\n    weighted_sum=$(echo \"$coins\" | jq --argjson t \"$target\" '\n        [.[] | \n            if .anonymityScore \u003c= 1 then 0\n            elif .anonymityScore \u003e= $t then .amount\n            else .amount * .anonymityScore / $t\n            end\n        ] | add // 0\n    ')\n    \n    # Calculate percentage using bc for floating point\n    echo \"$weighted_sum * 100 / $total_sum\" | bc -l | cut -d. -f1\n}\n\n#-------------------------------------------------------------------------------\n# Interactive configuration\n#-------------------------------------------------------------------------------\n\nselect_wallet() {\n    local wallets wallet_count\n    wallets=$(rpc \"$RPC\" \"listwallets\" | jq -r '.result')\n    wallet_count=$(echo \"$wallets\" | jq 'length')\n    ((wallet_count == 0)) \u0026\u0026 die \"No wallets found\"\n    \n    echo -e \"\\nAvailable wallets:\"\n    for ((i=0; i\u003cwallet_count; i++)); do\n        echo \"  [$((i+1))] $(echo \"$wallets\" | jq -r \".[$i].walletName\")\"\n    done\n    \n    read -rp $'\\nSelect wallet: ' choice\n    local idx=$((choice - 1))\n    ((idx \u003c 0 || idx \u003e= wallet_count)) \u0026\u0026 die \"Invalid selection\"\n    \n    WALLET=$(echo \"$wallets\" | jq -r \".[$idx].walletName\")\n    \n    log \"Loading wallet $WALLET...\"\n    local result err\n    result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"loadwallet\",\"params\":[\"'\"$WALLET\"'\"]}' \"$RPC\") || true\n    err=$(echo \"$result\" | jq -r '.error.message // empty')\n    [[ -n \"$err\" \u0026\u0026 \"$err\" != *\"already\"* ]] \u0026\u0026 die \"Failed to load wallet: $err\"\n}\n\nselect_frequency() {\n    cat \u003c\u003cEOF\n\nCoinjoin frequency (average):\n\n  [1] 1/day     [4] 1/week\n  [2] 2/day     [5] 2/week\n  [3] 4/day     [6] Custom\n\nEOF\n    read -rp \"Select [1-6]: \" choice\n    \n    case $choice in\n        1) AVG_WAIT=$((24*3600)) ;;\n        2) AVG_WAIT=$((12*3600)) ;;\n        3) AVG_WAIT=$((6*3600)) ;;\n        4) AVG_WAIT=$((168*3600)) ;;\n        5) AVG_WAIT=$((84*3600)) ;;\n        6)\n            read -rp \"Frequency (e.g. 3/day, 5/week): \" custom\n            custom=${custom,,}\n            if [[ \"$custom\" =~ ^([0-9]+)/day$ ]]; then\n                ((BASH_REMATCH[1] \u003e 0)) || die \"Invalid frequency\"\n                AVG_WAIT=$((24*3600 / BASH_REMATCH[1]))\n            elif [[ \"$custom\" =~ ^([0-9]+)/week$ ]]; then\n                ((BASH_REMATCH[1] \u003e 0)) || die \"Invalid frequency\"\n                AVG_WAIT=$((168*3600 / BASH_REMATCH[1]))\n            else\n                die \"Could not parse '$custom'\"\n            fi\n            ;;\n        *) die \"Invalid selection\" ;;\n    esac\n    \n    # Minimum 2 hours (coinjoin rounds take time)\n    ((AVG_WAIT \u003c 7200)) \u0026\u0026 AVG_WAIT=7200\n    log \"Avg $(format_duration $AVG_WAIT) between rounds\"\n}\n\nselect_target() {\n    local current_pct\n    current_pct=$(get_privacy_pct)\n    \n    cat \u003c\u003cEOF\n\nStop condition:\n\n  [1] 1 round       [4] 10 rounds\n  [2] 3 rounds      [5] Until % private\n  [3] 5 rounds      [6] Forever (Ctrl+C)\n  [7] Custom rounds\n\nEOF\n    read -rp \"Select [1-7]: \" choice\n    \n    # MODE: \"rounds\" or \"privacy\"\n    case $choice in\n        1) MODE=\"rounds\"; MAX_ROUNDS=1 ;;\n        2) MODE=\"rounds\"; MAX_ROUNDS=3 ;;\n        3) MODE=\"rounds\"; MAX_ROUNDS=5 ;;\n        4) MODE=\"rounds\"; MAX_ROUNDS=10 ;;\n        5)\n            MODE=\"privacy\"\n            echo \"Current privacy: ${current_pct}%\"\n            read -rp \"Target privacy %: \" TARGET_PCT\n            [[ \"$TARGET_PCT\" =~ ^[0-9]+$ ]] || die \"Invalid percentage\"\n            ((TARGET_PCT \u003e 100)) \u0026\u0026 TARGET_PCT=100\n            ((TARGET_PCT \u003c= current_pct)) \u0026\u0026 { log \"Already at ${current_pct}%!\"; exit 0; }\n            ;;\n        6) MODE=\"rounds\"; MAX_ROUNDS=0 ;;\n        7)\n            MODE=\"rounds\"\n            read -rp \"Number of rounds (0=forever): \" MAX_ROUNDS\n            [[ \"$MAX_ROUNDS\" =~ ^[0-9]+$ ]] || die \"Invalid number\"\n            ;;\n        *) die \"Invalid selection\" ;;\n    esac\n    \n    if [[ \"$MODE\" == \"privacy\" ]]; then\n        log \"Target: ${TARGET_PCT}% private (currently ${current_pct}%)\"\n    elif ((MAX_ROUNDS == 0)); then\n        log \"Running until Ctrl+C\"\n    else\n        log \"Target: $MAX_ROUNDS round(s)\"\n    fi\n}\n\n#-------------------------------------------------------------------------------\n# Core logic\n#-------------------------------------------------------------------------------\n\n# Generates random wait time using exponential distribution.\n# This creates natural-looking intervals: mostly near the average,\n# occasionally shorter or longer, mimicking human behavior.\nrandom_wait() {\n    local r wait max\n    r=$(awk 'BEGIN{srand(); r=rand(); if(r\u003c0.001)r=0.001; print r}')\n    wait=$(echo \"-$AVG_WAIT * l($r)\" | bc -l)\n    wait=${wait%%.*}  # Remove decimal part\n    wait=${wait:-0}   # Default to 0 if empty\n    \n    # Clamp to [2 hours, min(2.5x average, 3 days)]\n    max=$((AVG_WAIT * 5 / 2))\n    ((max \u003e 259200)) \u0026\u0026 max=259200\n    ((wait \u003e max)) \u0026\u0026 wait=$max\n    ((wait \u003c 7200)) \u0026\u0026 wait=7200\n    \n    echo \"$wait\"\n}\n\n# Watches log file for \"Coinjoin TxId\" indicating a successful round.\n# Only reads new content appended since function start.\nwait_for_round() {\n    local start_pos poll=0\n    start_pos=$(wc -c \u003c \"$LOG_FILE\")\n    \n    log \"Waiting for round...\"\n    \n    while true; do\n        sleep 30\n        ((++poll))\n        \n        local current_pos\n        current_pos=$(wc -c \u003c \"$LOG_FILE\")\n        \n        if ((current_pos \u003e start_pos)); then\n            if tail -c +\"$((start_pos + 1))\" \"$LOG_FILE\" | grep -qi \"Coinjoin TxId\"; then\n                local txid\n                txid=$(tail -c +\"$((start_pos + 1))\" \"$LOG_FILE\" | grep -oi \"Coinjoin TxId.*\" | grep -oE '[a-f0-9]{64}' | tail -1 || true)\n                [[ -n \"$txid\" ]] \u0026\u0026 log \"Round complete: ${txid:0:16}...\" || log \"Round complete!\"\n                return\n            fi\n        fi\n        \n        # Progress update every 5 minutes\n        ((poll % 10 == 0)) \u0026\u0026 log \"Still waiting... (~$((poll/2))m)\"\n    done\n}\n\n# Sleep with periodic progress updates\ndo_sleep() {\n    local remaining=$1\n    local interval=$((remaining \u003e 7200 ? 3600 : 1800))\n    \n    while ((remaining \u003e 0)); do\n        local chunk=$((remaining \u003e interval ? interval : remaining))\n        sleep \"$chunk\"\n        ((remaining -= chunk))\n        ((remaining \u003e 0)) \u0026\u0026 log \"$(format_duration $remaining) remaining...\"\n    done\n}\n\n# Check if target is reached. Returns 0 if done, 1 if should continue.\ncheck_target() {\n    if [[ \"$MODE\" == \"privacy\" ]]; then\n        local pct\n        pct=$(get_privacy_pct)\n        if ((pct \u003e= TARGET_PCT)); then\n            log \"Privacy target reached: ${pct}%\"\n            return 0\n        fi\n        log \"Privacy: ${pct}% (target: ${TARGET_PCT}%)\"\n    else\n        if ((MAX_ROUNDS \u003e 0)); then\n            log \"Completed $ROUND_COUNT/$MAX_ROUNDS\"\n            ((ROUND_COUNT \u003e= MAX_ROUNDS)) \u0026\u0026 return 0\n        else\n            log \"Completed round #$ROUND_COUNT\"\n        fi\n    fi\n    return 1\n}\n\ncleanup() {\n    echo\n    log \"Stopping...\"\n    [[ -n \"$WALLET\" ]] \u0026\u0026 curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"stopcoinjoin\"}' \"$RPC/$WALLET\" \u0026\u003e/dev/null || true\n    ((ROUND_COUNT \u003e 0)) \u0026\u0026 log \"Completed $ROUND_COUNT round(s)\"\n    exit 0\n}\n\n#-------------------------------------------------------------------------------\n# Main\n#-------------------------------------------------------------------------------\n\nmain() {\n    require curl\n    require jq\n    require bc\n    \n    # Initialize globals\n    WALLET=\"\" AVG_WAIT=0 MAX_ROUNDS=0 ROUND_COUNT=0 MODE=\"rounds\" TARGET_PCT=0\n    \n    echo \"=== Wasabi Intermittent CoinJoin ===\"\n    \n    # Verify RPC is accessible\n    curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"getstatus\"}' \"$RPC\" \u0026\u003e/dev/null \\\n        || die \"Cannot connect to Wasabi RPC at $RPC\"\n    \n    # Verify log file exists\n    [[ -f \"$LOG_FILE\" ]] || die \"Log file not found: $LOG_FILE\"\n    \n    select_wallet\n    select_frequency\n    select_target\n    \n    trap cleanup SIGINT SIGTERM\n    \n    echo -e \"\\n=== Starting ===\\n\"\n    \n    while true; do\n        log \"Starting coinjoin...\"\n        curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"startcoinjoin\",\"params\":[\"\",\"false\",\"true\"]}' \"$RPC/$WALLET\" \u0026\u003e/dev/null \\\n            || die \"Failed to start coinjoin\"\n        \n        wait_for_round\n        \n        curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"stopcoinjoin\"}' \"$RPC/$WALLET\" \u0026\u003e/dev/null || true\n        ((++ROUND_COUNT))\n        \n        check_target \u0026\u0026 { log \"Done!\"; exit 0; }\n        \n        local wait\n        wait=$(random_wait)\n        log \"Next round in $(format_duration \"$wait\")...\"\n        do_sleep \"$wait\"\n        echo\n    done\n}\n\nmain \"$@\"\n```\n\n### wcj.sh\n\n```bash\n#!/usr/bin/env bash\nset -uo pipefail\n\n#===============================================================================\n# Wasabi CoinJoin Payment Runner\n#===============================================================================\n#\n# PURPOSE:\n#   Runs coinjoin until all queued payments complete. Supports two modes:\n#   - Continuous: coinjoin runs non-stop (faster, but predictable timing)\n#   - Intermittent: random delays between rounds (slower, better privacy)\n#\n# PAYMENT DETECTION:\n#   Polls listpaymentsincoinjoin every 30 seconds to detect completed/added payments.\n#\n# ROUND DETECTION (intermittent mode):\n#   Watches ~/.walletwasabi/client/Logs.txt for \"Coinjoin TxId\" entries.\n#\n# REQUIREMENTS:\n#   - Wasabi Wallet with RPC enabled (JsonRpcServerEnabled: true)\n#   - curl, jq, bc (bc only needed for intermittent mode)\n#\n#===============================================================================\n\nRPC=\" http://127.0.0.1:37128\"\nLOG_FILE=\"$HOME/.walletwasabi/client/Logs.txt\"\n\n# Globals (initialized in main)\nWALLET=\"\"\nMODE=\"\"\nAVG_WAIT=0\nEVER_HAD_PAYMENTS=false\n\n#-------------------------------------------------------------------------------\n# Utilities\n#-------------------------------------------------------------------------------\n\ndie() { echo \"Error: $1\" \u003e\u00262; exit 1; }\nlog() { echo \"[$(date '+%H:%M:%S')] $1\"; }\n\nrpc() {\n    local method=$1; shift\n    local params=\"\" result\n    [[ $# -gt 0 ]] \u0026\u0026 params=\"$*\"\n    \n    if [[ -z \"$params\" ]]; then\n        result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"'\"$method\"'\"}' \"$RPC/$WALLET\")\n    else\n        result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"'\"$method\"'\",\"params\":['\"$params\"']}' \"$RPC/$WALLET\")\n    fi\n    \n    local err\n    err=$(echo \"$result\" | jq -r '.error.message // empty')\n    [[ -n \"$err\" ]] \u0026\u0026 die \"RPC error: $err\"\n    \n    echo \"$result\"\n}\n\n# Convert seconds to human-readable format\nformat_duration() {\n    local s=${1:-0} d h m\n    d=$((s/86400)) h=$(((s%86400)/3600)) m=$(((s%3600)/60))\n    if ((d \u003e 0)); then echo \"${d}d ${h}h\"\n    elif ((h \u003e 0)); then echo \"${h}h ${m}m\"\n    else echo \"${m}m\"\n    fi\n}\n\n#-------------------------------------------------------------------------------\n# Payment tracking\n#-------------------------------------------------------------------------------\n\nget_pending() {\n    curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"listpaymentsincoinjoin\"}' \"$RPC/$WALLET\" \\\n        | jq '[.result[] | select(.state[0].status == \"Pending\")] | sort_by(.address)'\n}\n\nshow_pending() {\n    local payments=\"$1\"\n    local count\n    count=$(echo \"$payments\" | jq 'length')\n    if ((count == 0)); then\n        echo \"  (none)\"\n    else\n        echo \"$payments\" | jq -r '.[] | \"  \\(.amount) sats -\u003e \\(.address)\"'\n    fi\n}\n\n# Compare payment lists, log completed and added payments\n# Sets global PREV_ADDRS and PREV_PAYMENTS for next call\ncheck_payments() {\n    local curr curr_addrs curr_count\n    curr=$(get_pending)\n    curr_count=$(echo \"$curr\" | jq 'length')\n    curr_addrs=$(echo \"$curr\" | jq -r '.[].address' | sort | tr '\\n' ' ')\n    \n    ((curr_count \u003e 0)) \u0026\u0026 EVER_HAD_PAYMENTS=true\n    \n    # Check for completed payments\n    for addr in $PREV_ADDRS; do\n        if ! echo \"$curr_addrs\" | grep -q \"$addr\"; then\n            local amount\n            amount=$(echo \"$PREV_PAYMENTS\" | jq -r \".[] | select(.address == \\\"$addr\\\") | .amount\")\n            log \"Sent: $amount sats -\u003e $addr\"\n        fi\n    done\n    \n    # Check for new payments\n    for addr in $curr_addrs; do\n        if [[ -n \"$addr\" ]] \u0026\u0026 ! echo \"$PREV_ADDRS\" | grep -q \"$addr\"; then\n            local amount\n            amount=$(echo \"$curr\" | jq -r \".[] | select(.address == \\\"$addr\\\") | .amount\")\n            log \"Added: $amount sats -\u003e $addr\"\n            EVER_HAD_PAYMENTS=true\n        fi\n    done\n    \n    # Update state for next call\n    PREV_PAYMENTS=\"$curr\"\n    PREV_ADDRS=\"$curr_addrs\"\n    \n    # Return: 0 if done, 1 if payments remain\n    if [[ \"$EVER_HAD_PAYMENTS\" == true ]] \u0026\u0026 ((curr_count == 0)); then\n        return 0\n    fi\n    return 1\n}\n\n#-------------------------------------------------------------------------------\n# Interactive configuration\n#-------------------------------------------------------------------------------\n\nselect_wallet() {\n    local wallets wallet_count\n    wallets=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"listwallets\"}' \"$RPC\" | jq -r '.result')\n    wallet_count=$(echo \"$wallets\" | jq 'length')\n    ((wallet_count == 0)) \u0026\u0026 die \"No wallets found\"\n    \n    echo \"Wallets:\"\n    echo \"\"\n    for ((i=0; i\u003cwallet_count; i++)); do\n        echo \"  [$((i+1))] $(echo \"$wallets\" | jq -r \".[$i].walletName\")\"\n    done\n    \n    echo \"\"\n    read -rp \"Select wallet: \" choice\n    local idx=$((choice - 1))\n    ((idx \u003c 0 || idx \u003e= wallet_count)) \u0026\u0026 die \"Invalid selection\"\n    \n    WALLET=$(echo \"$wallets\" | jq -r \".[$idx].walletName\")\n    \n    echo \"\"\n    log \"Loading wallet $WALLET...\"\n    local result err\n    result=$(curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"loadwallet\",\"params\":[\"'\"$WALLET\"'\"]}' \"$RPC\") || true\n    err=$(echo \"$result\" | jq -r '.error.message // empty')\n    [[ -n \"$err\" \u0026\u0026 \"$err\" != *\"already\"* ]] \u0026\u0026 die \"Failed to load wallet: $err\"\n}\n\nselect_mode() {\n    cat \u003c\u003cEOF\n\nCoinjoin mode:\n\n  [1] Continuous (fast, less private timing)\n  [2] Intermittent (random delays, better privacy)\n\nEOF\n    read -rp \"Select [1-2]: \" choice\n    \n    case $choice in\n        1) MODE=\"continuous\" ;;\n        2) MODE=\"intermittent\" ;;\n        *) die \"Invalid selection\" ;;\n    esac\n}\n\nselect_frequency() {\n    cat \u003c\u003cEOF\n\nCoinjoin frequency (average):\n\n  [1] 1/day     [4] 1/week\n  [2] 2/day     [5] 2/week\n  [3] 4/day     [6] Custom\n\nEOF\n    read -rp \"Select [1-6]: \" choice\n    \n    case $choice in\n        1) AVG_WAIT=$((24*3600)) ;;\n        2) AVG_WAIT=$((12*3600)) ;;\n        3) AVG_WAIT=$((6*3600)) ;;\n        4) AVG_WAIT=$((168*3600)) ;;\n        5) AVG_WAIT=$((84*3600)) ;;\n        6)\n            read -rp \"Frequency (e.g. 3/day, 5/week): \" custom\n            custom=${custom,,}\n            if [[ \"$custom\" =~ ^([0-9]+)/day$ ]]; then\n                ((BASH_REMATCH[1] \u003e 0)) || die \"Invalid frequency\"\n                AVG_WAIT=$((24*3600 / BASH_REMATCH[1]))\n            elif [[ \"$custom\" =~ ^([0-9]+)/week$ ]]; then\n                ((BASH_REMATCH[1] \u003e 0)) || die \"Invalid frequency\"\n                AVG_WAIT=$((168*3600 / BASH_REMATCH[1]))\n            else\n                die \"Could not parse '$custom'\"\n            fi\n            ;;\n        *) die \"Invalid selection\" ;;\n    esac\n    \n    # Minimum 2 hours\n    ((AVG_WAIT \u003c 7200)) \u0026\u0026 AVG_WAIT=7200\n    log \"Avg $(format_duration $AVG_WAIT) between rounds\"\n}\n\n#-------------------------------------------------------------------------------\n# Core logic\n#-------------------------------------------------------------------------------\n\n# Exponential distribution for natural-looking random intervals\nrandom_wait() {\n    local r wait max\n    r=$(awk 'BEGIN{srand(); r=rand(); if(r\u003c0.001)r=0.001; print r}')\n    wait=$(echo \"-$AVG_WAIT * l($r)\" | bc -l)\n    wait=${wait%%.*}  # Remove decimal part\n    wait=${wait:-0}   # Default to 0 if empty\n    \n    # Clamp to [2 hours, min(2.5x average, 3 days)]\n    max=$((AVG_WAIT * 5 / 2))\n    ((max \u003e 259200)) \u0026\u0026 max=259200\n    ((wait \u003e max)) \u0026\u0026 wait=$max\n    ((wait \u003c 7200)) \u0026\u0026 wait=7200\n    \n    echo \"$wait\"\n}\n\n# Watches log file for \"Coinjoin TxId\" indicating a successful round\nwait_for_round() {\n    local start_pos poll=0\n    start_pos=$(wc -c \u003c \"$LOG_FILE\")\n    \n    log \"Waiting for round...\"\n    \n    while true; do\n        sleep 30\n        ((++poll))\n        \n        local current_pos\n        current_pos=$(wc -c \u003c \"$LOG_FILE\")\n        \n        if ((current_pos \u003e start_pos)); then\n            if tail -c +\"$((start_pos + 1))\" \"$LOG_FILE\" | grep -qi \"Coinjoin TxId\"; then\n                local txid\n                txid=$(tail -c +\"$((start_pos + 1))\" \"$LOG_FILE\" | grep -oi \"Coinjoin TxId.*\" | grep -oE '[a-f0-9]{64}' | tail -1 || true)\n                [[ -n \"$txid\" ]] \u0026\u0026 log \"Round complete: ${txid:0:16}...\" || log \"Round complete!\"\n                return\n            fi\n        fi\n        \n        ((poll % 10 == 0)) \u0026\u0026 log \"Still waiting... (~$((poll/2))m)\"\n    done\n}\n\n# Sleep with periodic progress updates, checking payments periodically\ndo_sleep() {\n    local remaining=$1\n    local interval=$((remaining \u003e 7200 ? 3600 : 1800))\n    \n    while ((remaining \u003e 0)); do\n        local chunk=$((remaining \u003e interval ? interval : remaining))\n        sleep \"$chunk\"\n        ((remaining -= chunk))\n        ((remaining \u003e 0)) \u0026\u0026 log \"$(format_duration $remaining) remaining...\"\n    done\n}\n\nstart_coinjoin() {\n    curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"startcoinjoin\",\"params\":[\"\",\"false\",\"true\"]}' \"$RPC/$WALLET\" \u0026\u003e/dev/null \\\n        || die \"Failed to start coinjoin\"\n}\n\nstop_coinjoin() {\n    curl -sf -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"stopcoinjoin\"}' \"$RPC/$WALLET\" \u0026\u003e/dev/null || true\n}\n\ncleanup() {\n    echo \"\"\n    log \"Stopping...\"\n    stop_coinjoin\n    log \"CoinJoin stopped\"\n    exit 0\n}\n\n#-------------------------------------------------------------------------------\n# Main loops\n#-------------------------------------------------------------------------------\n\nrun_continuous() {\n    log \"Starting coinjoin...\"\n    start_coinjoin\n    \n    while true; do\n        sleep 30\n        check_payments \u0026\u0026 break\n    done\n}\n\nrun_intermittent() {\n    [[ -f \"$LOG_FILE\" ]] || die \"Log file not found: $LOG_FILE\"\n    command -v bc \u0026\u003e/dev/null || die \"bc is required for intermittent mode\"\n    \n    local first_round=true\n    \n    while true; do\n        log \"Starting coinjoin...\"\n        start_coinjoin\n        \n        wait_for_round\n        \n        stop_coinjoin\n        \n        # Check payments after each round\n        check_payments \u0026\u0026 break\n        \n        local remaining\n        remaining=$(echo \"$PREV_PAYMENTS\" | jq 'length')\n        log \"$remaining payment(s) remaining\"\n        \n        local wait\n        wait=$(random_wait)\n        log \"Next round in $(format_duration \"$wait\")...\"\n        do_sleep \"$wait\"\n        echo \"\"\n    done\n}\n\nmain() {\n    # Check dependencies\n    command -v curl \u0026\u003e/dev/null || die \"curl is required\"\n    command -v jq \u0026\u003e/dev/null || die \"jq is required\"\n    \n    echo \"=== Wasabi CoinJoin ===\"\n    \n    # Check RPC\n    curl -sf --connect-timeout 3 -d '{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"getstatus\"}' \"$RPC\" \u0026\u003e/dev/null \\\n        || die \"Cannot connect to Wasabi RPC at $RPC\"\n    \n    select_wallet\n    \n    # Show pending payments\n    PREV_PAYMENTS=$(get_pending)\n    PREV_ADDRS=$(echo \"$PREV_PAYMENTS\" | jq -r '.[].address' | sort | tr '\\n' ' ')\n    local count\n    count=$(echo \"$PREV_PAYMENTS\" | jq 'length')\n    \n    echo \"\"\n    echo \"=== Wallet: $WALLET ===\"\n    echo \"\"\n    echo \"Pending payments:\"\n    show_pending \"$PREV_PAYMENTS\"\n    \n    ((count \u003e 0)) \u0026\u0026 EVER_HAD_PAYMENTS=true\n    \n    if ((count == 0)); then\n        echo \"\"\n        read -rp \"No pending payments. Start coinjoin anyway? [y/N]: \" confirm\n        [[ \"${confirm^^}\" != \"Y\" ]] \u0026\u0026 exit 0\n    fi\n    \n    select_mode\n    \n    [[ \"$MODE\" == \"intermittent\" ]] \u0026\u0026 select_frequency\n    \n    trap cleanup SIGINT SIGTERM\n    \n    echo \"\"\n    echo \"=== Starting ===\"\n    echo \"\"\n    \n    if [[ \"$MODE\" == \"continuous\" ]]; then\n        run_continuous\n    else\n        run_intermittent\n    fi\n    \n    echo \"\"\n    echo \"=== All payments done ===\"\n    stop_coinjoin\n    log \"CoinJoin stopped\"\n}\n\nmain \"$@\"\n```\n",
  "sig": "f7abf24b69e4fac604162bdd644c1ef0443251a5ddd7ecae9c254e84abd5ba677a3f5e38c8701abef2c7d841c94ff9b4b291760b8e12e97727d98949d16eb7ce"
}