The Art of Waiting: Random Delays for Private Payments

npub1klkk3vrzme455yh9rl2jshq7rc8dpegj3ndf82c3ks2sk40dxt7qulx3vt
hex
c74adbd681e278c28230534ac8e0c4aa07ba6d9ed627480b80715b59111d227fnevent
nevent1qqsvwjkm66q7y7xzsgc9xjkgurz25pa6dk0dvf6gpwq8zk6ezywjylcprpmhxue69uhhyetvv9ujuem4d36kwatvw5hx6mm9qgst0mtgkp3du662ztj3l4fgts0purksu5fgek5n4vgmg9gt2hkn9lqjq5ussnaddr
naddr1qqgrxvpnx4jn2cn9v5crvvejxp3xgqgcwaehxw309aex2mrp0yhxwatvw4nh2mr49ekk7egzyzm7669svt0xkjsju50a22zurc0qa589z2xd4yatzx6p2z64a5e0cqcyqqq823cc0y4zlKind-30023 (Article)
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
- Enable RPC in Wasabi's
Config.json:
"JsonRpcServerEnabled": true
- Make scripts executable:
chmod +x wrcj.sh wcj.sh
- 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 "$@"
原始 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"
}