mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-04-27 08:00:00 -07:00
feat: add create random transactions script
This commit is contained in:
700
backend/script/create_random_transactions.py
Normal file
700
backend/script/create_random_transactions.py
Normal file
@@ -0,0 +1,700 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
create_random_transactions.py
|
||||
==============================
|
||||
Creates n varied, realistic-looking Bitcoin transactions involving Alice's wallet
|
||||
on regtest. Each run is seeded with fresh entropy (block height + wall clock) so
|
||||
the on-chain history grows organically and never looks the same twice.
|
||||
|
||||
Address types used:
|
||||
• bech32 (P2WPKH) — bcrt1q…
|
||||
• bech32m (P2TR) — bcrt1p…
|
||||
• p2sh-segwit (P2SH-P2WPKH) — 2… (regtest)
|
||||
• legacy (P2PKH) — m… (regtest)
|
||||
|
||||
Transaction archetypes (weighted random selection):
|
||||
01. simple_payment Alice pays a peer, natural change
|
||||
02. multi_output Alice batch-pays multiple recipients in one TX
|
||||
03. consolidation Alice sweeps many small UTXOs → one
|
||||
04. self_transfer Alice rotates to her own fresh address
|
||||
05. utxo_split Alice fans one large UTXO out into several
|
||||
06. receive_from_peer Peer spontaneously sends Alice funds
|
||||
07. exchange_withdrawal Exchange batch-withdraws to Alice + others
|
||||
08. chain_hop Alice→Bob, then Bob→Carol (multi-hop chain)
|
||||
09. mixed_type_spend Spend P2WPKH + P2TR inputs in one TX
|
||||
10. round_amount_payment Deliberately round consumer-style payment
|
||||
11. psbt_coinjoin Alice+Bob cooperate via PSBT (PayJoin-like)
|
||||
12. cold_to_hot Taproot "cold" → P2WPKH "hot" sweep
|
||||
13. lightning_channel_like Exact-msat-aligned channel-open sizing
|
||||
14. high_freq_small Burst of rapid tiny payments (merchant pattern)
|
||||
15. receive_multiple_senders Several wallets simultaneously send Alice funds
|
||||
|
||||
Usage:
|
||||
python3 create_random_transactions.py 20
|
||||
python3 create_random_transactions.py 50 --no-mine-final
|
||||
python3 create_random_transactions.py 10 --seed 42
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import random
|
||||
import argparse
|
||||
import hashlib
|
||||
from collections import Counter
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from bitcoin_rpc import (
|
||||
cli, mine_blocks, get_utxos, get_balance,
|
||||
get_new_address, send_to_address, create_raw_tx, sign_raw_tx,
|
||||
send_raw, get_block_count, create_funded_psbt,
|
||||
process_psbt, finalize_psbt,
|
||||
)
|
||||
|
||||
# ─── Colours ──────────────────────────────────────────────────────────────────
|
||||
G = "\033[92m"
|
||||
Y = "\033[93m"
|
||||
R = "\033[91m"
|
||||
B = "\033[1m"
|
||||
C = "\033[96m"
|
||||
DIM = "\033[2m"
|
||||
RST = "\033[0m"
|
||||
|
||||
def ok(msg): print(f" {G}✓{RST} {msg}")
|
||||
def info(msg): print(f" {Y}ℹ{RST} {msg}")
|
||||
def warn(msg): print(f" {R}⚠{RST} {msg}")
|
||||
def hdr(msg): print(f"\n {B}{C}▸ {msg}{RST}")
|
||||
|
||||
# ─── Constants ────────────────────────────────────────────────────────────────
|
||||
ALICE = "alice"
|
||||
SIDE_WALLETS = ["bob", "carol", "exchange", "miner"]
|
||||
ALL_WALLETS = [ALICE] + SIDE_WALLETS + ["risky"]
|
||||
|
||||
# Every address-type label that Bitcoin Core's getnewaddress accepts
|
||||
ADDR_TYPES = ["bech32", "bech32m", "p2sh-segwit", "legacy"]
|
||||
|
||||
FEE_RESERVE = 0.00025 # BTC per input/output to leave for fees
|
||||
DUST_LIMIT = 0.00000546
|
||||
|
||||
# ─── Entropy / RNG ───────────────────────────────────────────────────────────
|
||||
def reseed() -> int:
|
||||
"""Seed random from chain height + nanosecond wall clock. Returns seed."""
|
||||
h = get_block_count()
|
||||
raw = f"{h}{time.time_ns()}{os.getpid()}"
|
||||
seed = int(hashlib.sha256(raw.encode()).hexdigest(), 16) % (2**32)
|
||||
random.seed(seed)
|
||||
info(f"RNG seeded from block {h} + wall-clock (seed={seed})")
|
||||
return seed
|
||||
|
||||
|
||||
# ─── Amount helpers ───────────────────────────────────────────────────────────
|
||||
def rand_btc(lo: float = 0.0005, hi: float = 0.05) -> float:
|
||||
"""Random BTC amount; occasionally semi-rounded to mimic human behaviour."""
|
||||
v = random.uniform(lo, hi)
|
||||
r = random.random()
|
||||
if r < 0.15:
|
||||
v = round(v, 2) # e.g. 0.03
|
||||
elif r < 0.30:
|
||||
v = round(v, 4) # e.g. 0.0312
|
||||
else:
|
||||
v = round(v, 8)
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
def round_btc() -> float:
|
||||
"""A consumer-style round amount."""
|
||||
return random.choice([
|
||||
0.001, 0.002, 0.005, 0.01, 0.02, 0.025, 0.05, 0.1,
|
||||
0.0025, 0.0075, 0.015,
|
||||
])
|
||||
|
||||
|
||||
def rand_addr_type() -> str:
|
||||
return random.choice(ADDR_TYPES)
|
||||
|
||||
|
||||
def rand_peer(exclude=None) -> str:
|
||||
pool = [w for w in SIDE_WALLETS if w != exclude]
|
||||
return random.choice(pool)
|
||||
|
||||
|
||||
# ─── Funding / block helpers ──────────────────────────────────────────────────
|
||||
def ensure_funded(wallet: str, min_btc: float = 0.5) -> None:
|
||||
bal = get_balance(wallet)
|
||||
if bal < min_btc:
|
||||
addr = get_new_address(wallet, "bech32")
|
||||
top_up = min_btc + random.uniform(0.5, 2.0)
|
||||
send_to_address("miner", addr, round(top_up, 8))
|
||||
info(f"Topped up {wallet} with {top_up:.4f} BTC from miner")
|
||||
|
||||
|
||||
def maybe_mine(force: bool = False) -> None:
|
||||
"""Mine 1-3 blocks with 40 % probability (or always when forced)."""
|
||||
if force or random.random() < 0.40:
|
||||
n = random.randint(1, 3)
|
||||
maddr = get_new_address("miner", "bech32")
|
||||
cli("generatetoaddress", n, maddr)
|
||||
ok(f"Mined {n} block(s) (height={get_block_count()})")
|
||||
time.sleep(0.15)
|
||||
|
||||
|
||||
def mine_confirm(n: int = 1) -> None:
|
||||
maddr = get_new_address("miner", "bech32")
|
||||
cli("generatetoaddress", n, maddr)
|
||||
time.sleep(0.15)
|
||||
|
||||
|
||||
# ─── Transaction archetypes ───────────────────────────────────────────────────
|
||||
|
||||
def tx_simple_payment() -> str:
|
||||
"""Alice pays a random peer a random amount — wallet produces change."""
|
||||
hdr("Simple Payment")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
peer = rand_peer()
|
||||
addr_type = rand_addr_type()
|
||||
dest = get_new_address(peer, addr_type)
|
||||
amt = rand_btc(0.001, 0.08)
|
||||
if get_balance(ALICE) < amt + FEE_RESERVE * 2:
|
||||
ensure_funded(ALICE, 1.0)
|
||||
txid = send_to_address(ALICE, dest, amt)
|
||||
maybe_mine()
|
||||
ok(f"Alice → {peer} ({addr_type}) {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_multi_output() -> str:
|
||||
"""Alice batch-pays 2-5 recipients in one sendmany transaction."""
|
||||
hdr("Multi-Output Batch Payment")
|
||||
ensure_funded(ALICE, 1.5)
|
||||
n_recv = random.randint(2, 5)
|
||||
batch = {}
|
||||
total = 0.0
|
||||
for _ in range(n_recv):
|
||||
addr = get_new_address(rand_peer(), rand_addr_type())
|
||||
amt = rand_btc(0.001, 0.025)
|
||||
batch[addr] = amt
|
||||
total += amt
|
||||
if get_balance(ALICE) < total + FEE_RESERVE * 4:
|
||||
ensure_funded(ALICE, total + 1.5)
|
||||
txid = cli("sendmany", "", json.dumps(batch), wallet=ALICE)
|
||||
maybe_mine()
|
||||
ok(f"Alice batch → {n_recv} recipients TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_consolidation() -> str | None:
|
||||
"""Alice sweeps several small UTXOs into one output (wallet hygiene)."""
|
||||
hdr("UTXO Consolidation")
|
||||
# First scatter several small UTXOs to Alice via different sender wallets
|
||||
n_scatter = random.randint(3, 7)
|
||||
for _ in range(n_scatter):
|
||||
sender = rand_peer()
|
||||
ensure_funded(sender, 0.3)
|
||||
addr = get_new_address(ALICE, random.choice(["bech32", "bech32m"]))
|
||||
send_to_address(sender, addr, rand_btc(0.003, 0.015))
|
||||
mine_confirm(1)
|
||||
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
small = [u for u in utxos if 0.001 < u["amount"] < 0.02]
|
||||
if len(small) < 2:
|
||||
info("Not enough small UTXOs for consolidation, skipping")
|
||||
return None
|
||||
|
||||
to_merge = small[: random.randint(2, min(len(small), 7))]
|
||||
dest = get_new_address(ALICE, "bech32")
|
||||
total = sum(u["amount"] for u in to_merge)
|
||||
fee = FEE_RESERVE * len(to_merge)
|
||||
net = round(total - fee, 8)
|
||||
if net <= DUST_LIMIT:
|
||||
info("Net after fee too small, skipping")
|
||||
return None
|
||||
|
||||
inputs = [{"txid": u["txid"], "vout": u["vout"]} for u in to_merge]
|
||||
raw = create_raw_tx(inputs, [{dest: net}])
|
||||
signed = sign_raw_tx(ALICE, raw)
|
||||
txid = send_raw(signed["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Consolidated {len(to_merge)} UTXOs → 1 TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_self_transfer() -> str:
|
||||
"""Alice rotates coins to her own fresh address (key rotation / cold→warm)."""
|
||||
hdr("Self-Transfer")
|
||||
ensure_funded(ALICE, 0.3)
|
||||
addr_type = rand_addr_type()
|
||||
dest = get_new_address(ALICE, addr_type)
|
||||
amt = rand_btc(0.01, 0.2)
|
||||
if get_balance(ALICE) < amt + FEE_RESERVE * 2:
|
||||
ensure_funded(ALICE, amt + 0.5)
|
||||
txid = send_to_address(ALICE, dest, amt)
|
||||
maybe_mine()
|
||||
ok(f"Alice self → {addr_type} {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_utxo_split() -> str | None:
|
||||
"""Alice fans one large UTXO out into 2-5 smaller outputs (own addresses)."""
|
||||
hdr("UTXO Split / Fan-out")
|
||||
ensure_funded(ALICE, 1.0)
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
big = [u for u in utxos if u["amount"] > 0.25]
|
||||
if not big:
|
||||
send_to_address("miner", get_new_address(ALICE, "bech32"), 1.5)
|
||||
mine_confirm(1)
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
big = [u for u in utxos if u["amount"] > 0.25]
|
||||
if not big:
|
||||
info("No large UTXO available for split, skipping")
|
||||
return None
|
||||
|
||||
source = random.choice(big)
|
||||
n_out = random.randint(2, 5)
|
||||
budget = source["amount"] - FEE_RESERVE * (n_out + 1)
|
||||
if budget <= 0:
|
||||
info("Budget after fee too small, skipping")
|
||||
return None
|
||||
|
||||
# Give each output a random share of the budget
|
||||
shares = [random.random() for _ in range(n_out)]
|
||||
total_s = sum(shares)
|
||||
outputs = []
|
||||
for share in shares:
|
||||
amt = round(budget * share / total_s, 8)
|
||||
amt = max(0.0001, amt)
|
||||
atype = random.choice(["bech32", "bech32m"])
|
||||
addr = get_new_address(ALICE, atype)
|
||||
outputs.append({addr: amt})
|
||||
|
||||
raw = create_raw_tx(
|
||||
[{"txid": source["txid"], "vout": source["vout"]}],
|
||||
outputs
|
||||
)
|
||||
signed = sign_raw_tx(ALICE, raw)
|
||||
txid = send_raw(signed["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Split 1 UTXO → {n_out} outputs TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_receive_from_peer() -> str:
|
||||
"""A peer spontaneously sends Alice funds — she just receives."""
|
||||
hdr("Receive from Peer")
|
||||
peer = rand_peer()
|
||||
ensure_funded(peer, 0.3)
|
||||
addr_type = rand_addr_type()
|
||||
alice_addr = get_new_address(ALICE, addr_type)
|
||||
amt = rand_btc(0.005, 0.12)
|
||||
txid = send_to_address(peer, alice_addr, amt)
|
||||
maybe_mine()
|
||||
ok(f"{peer} → Alice ({addr_type}) {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_exchange_withdrawal() -> str:
|
||||
"""Exchange batch-withdraws to Alice and several other wallets at once."""
|
||||
hdr("Exchange Batch Withdrawal")
|
||||
ensure_funded("exchange", 3.0)
|
||||
recipients = [ALICE] + random.sample([w for w in SIDE_WALLETS if w != "exchange"],
|
||||
random.randint(2, 3))
|
||||
batch = {}
|
||||
for w in recipients:
|
||||
addr = get_new_address(w, "bech32") # exchanges use bech32
|
||||
batch[addr] = rand_btc(0.005, 0.06)
|
||||
txid = cli("sendmany", "", json.dumps(batch), wallet="exchange")
|
||||
maybe_mine()
|
||||
ok(f"Exchange batch → {len(recipients)} wallets incl. Alice TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_chain_hop() -> tuple[str, str]:
|
||||
"""Alice pays Bob; Bob immediately forwards part to Carol (multi-hop)."""
|
||||
hdr("Chain Hop Alice → Bob → Carol")
|
||||
ensure_funded(ALICE, 0.3)
|
||||
ensure_funded("bob", 0.2)
|
||||
hop_amt = rand_btc(0.008, 0.06)
|
||||
bob_addr = get_new_address("bob", rand_addr_type())
|
||||
txid1 = send_to_address(ALICE, bob_addr, hop_amt)
|
||||
mine_confirm(1) # Bob needs confirmed UTXO to spend
|
||||
|
||||
fwd_amt = round(hop_amt * random.uniform(0.4, 0.85), 8)
|
||||
carol_addr = get_new_address("carol", rand_addr_type())
|
||||
txid2 = send_to_address("bob", carol_addr, fwd_amt)
|
||||
maybe_mine()
|
||||
ok(f"Alice→Bob TX={txid1[:16]}… Bob→Carol TX={txid2[:16]}…")
|
||||
return txid1, txid2
|
||||
|
||||
|
||||
def tx_mixed_type_spend() -> str | None:
|
||||
"""Spend a P2WPKH UTXO and a P2TR UTXO together in one transaction."""
|
||||
hdr("Mixed Script-Type Spend (P2WPKH + P2TR)")
|
||||
wpkh_addr = get_new_address(ALICE, "bech32")
|
||||
tr_addr = get_new_address(ALICE, "bech32m")
|
||||
fund_amt = rand_btc(0.06, 0.2)
|
||||
send_to_address("miner", wpkh_addr, fund_amt)
|
||||
send_to_address("miner", tr_addr, fund_amt)
|
||||
mine_confirm(1)
|
||||
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
wu = next((u for u in utxos if u.get("address") == wpkh_addr), None)
|
||||
tu = next((u for u in utxos if u.get("address") == tr_addr), None)
|
||||
if not wu or not tu:
|
||||
info("Could not locate both script-type UTXOs, skipping")
|
||||
return None
|
||||
|
||||
dest = get_new_address(rand_peer(), rand_addr_type())
|
||||
total = wu["amount"] + tu["amount"] - FEE_RESERVE * 2
|
||||
raw = create_raw_tx(
|
||||
[{"txid": wu["txid"], "vout": wu["vout"]},
|
||||
{"txid": tu["txid"], "vout": tu["vout"]}],
|
||||
[{dest: round(total, 8)}]
|
||||
)
|
||||
signed = sign_raw_tx(ALICE, raw)
|
||||
txid = send_raw(signed["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Mixed P2WPKH+P2TR spend TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_round_amount_payment() -> str:
|
||||
"""Alice makes a suspiciously round-amount payment — normal consumer habit."""
|
||||
hdr("Round-Amount Payment")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
peer = rand_peer()
|
||||
amt = round_btc()
|
||||
if get_balance(ALICE) < amt + FEE_RESERVE * 2:
|
||||
ensure_funded(ALICE, amt + 0.5)
|
||||
dest = get_new_address(peer, rand_addr_type())
|
||||
txid = send_to_address(ALICE, dest, amt)
|
||||
maybe_mine()
|
||||
ok(f"Alice round {amt} BTC → {peer} TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_psbt_coinjoin() -> str | None:
|
||||
"""Alice + Bob cooperate via PSBT (PayJoin / collaborative TX)."""
|
||||
hdr("PSBT Cooperative TX (PayJoin-like)")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
ensure_funded("bob", 0.5)
|
||||
|
||||
carol_dest = get_new_address("carol", rand_addr_type())
|
||||
alice_chg = get_new_address(ALICE, rand_addr_type())
|
||||
bob_chg = get_new_address("bob", rand_addr_type())
|
||||
|
||||
alice_pay = rand_btc(0.01, 0.08)
|
||||
alice_ret = rand_btc(0.005, 0.02)
|
||||
bob_ret = rand_btc(0.005, 0.02)
|
||||
|
||||
outputs = [
|
||||
{carol_dest: alice_pay},
|
||||
{alice_chg: alice_ret},
|
||||
{bob_chg: bob_ret},
|
||||
]
|
||||
try:
|
||||
psbt_res = create_funded_psbt(ALICE, [], outputs, {"fee_rate": 2})
|
||||
signed_a = process_psbt(ALICE, psbt_res["psbt"])
|
||||
signed_b = process_psbt("bob", signed_a["psbt"])
|
||||
final = finalize_psbt(signed_b["psbt"])
|
||||
if not final.get("complete"):
|
||||
info("PSBT incomplete, falling back to simple payment")
|
||||
return tx_simple_payment()
|
||||
txid = send_raw(final["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Cooperative PSBT Alice+Bob TX={txid[:16]}…")
|
||||
return txid
|
||||
except Exception as e:
|
||||
info(f"PSBT failed ({e}), falling back to simple payment")
|
||||
return tx_simple_payment()
|
||||
|
||||
|
||||
def tx_cold_to_hot() -> str | None:
|
||||
"""Sweep Taproot 'cold' address → P2WPKH 'hot' address (cold storage move)."""
|
||||
hdr("Cold→Hot Taproot → P2WPKH")
|
||||
cold_addr = get_new_address(ALICE, "bech32m")
|
||||
fund_amt = rand_btc(0.15, 0.6)
|
||||
send_to_address("miner", cold_addr, fund_amt)
|
||||
mine_confirm(1)
|
||||
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
cold_utxo = next((u for u in utxos if u.get("address") == cold_addr), None)
|
||||
if not cold_utxo:
|
||||
info("Cold UTXO not found, skipping")
|
||||
return None
|
||||
|
||||
hot_addr = get_new_address(ALICE, "bech32")
|
||||
net = round(cold_utxo["amount"] - FEE_RESERVE, 8)
|
||||
raw = create_raw_tx(
|
||||
[{"txid": cold_utxo["txid"], "vout": cold_utxo["vout"]}],
|
||||
[{hot_addr: net}]
|
||||
)
|
||||
signed = sign_raw_tx(ALICE, raw)
|
||||
txid = send_raw(signed["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Cold(P2TR)→Hot(P2WPKH) {fund_amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_lightning_channel_like() -> str:
|
||||
"""Fund a precise msat-aligned amount (simulates LN channel-open output)."""
|
||||
hdr("Lightning Channel-Open-Like")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
# Real LN channel capacities are multiples of 1 000 sats
|
||||
cap_sats = random.choice([
|
||||
50_000, 100_000, 200_000, 250_000, 500_000,
|
||||
1_000_000, 2_000_000, 3_000_000, 5_000_000,
|
||||
])
|
||||
cap_btc = round(cap_sats / 1e8, 8)
|
||||
peer = rand_peer()
|
||||
dest = get_new_address(peer, "bech32") # LN always opens P2WPKH/P2WSH
|
||||
if get_balance(ALICE) < cap_btc + FEE_RESERVE * 2:
|
||||
ensure_funded(ALICE, cap_btc + 0.5)
|
||||
txid = send_to_address(ALICE, dest, cap_btc)
|
||||
maybe_mine()
|
||||
ok(f"Channel-open-like {cap_sats:,} sats → {peer} TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_high_freq_small() -> list[str]:
|
||||
"""Burst of rapid tiny payments — simulates a micro-payment merchant."""
|
||||
hdr("High-Frequency Small Payments")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
n = random.randint(3, 9)
|
||||
txids = []
|
||||
for _ in range(n):
|
||||
if get_balance(ALICE) < 0.001 + FEE_RESERVE:
|
||||
ensure_funded(ALICE, 0.5)
|
||||
peer = rand_peer()
|
||||
dest = get_new_address(peer, "bech32")
|
||||
amt = rand_btc(0.0001, 0.003)
|
||||
txid = send_to_address(ALICE, dest, amt)
|
||||
txids.append(txid)
|
||||
time.sleep(random.uniform(0.03, 0.12)) # mimic real timing jitter
|
||||
maybe_mine()
|
||||
ok(f"Alice fired {n} small payments last={txids[-1][:16]}…")
|
||||
return txids
|
||||
|
||||
|
||||
def tx_receive_multiple_senders() -> None:
|
||||
"""Multiple wallets independently send Alice funds within the same block."""
|
||||
hdr("Receive from Multiple Senders")
|
||||
senders = random.sample(SIDE_WALLETS, random.randint(2, len(SIDE_WALLETS)))
|
||||
for sender in senders:
|
||||
ensure_funded(sender, 0.2)
|
||||
alice_addr = get_new_address(ALICE, rand_addr_type())
|
||||
amt = rand_btc(0.005, 0.05)
|
||||
txid = send_to_address(sender, alice_addr, amt)
|
||||
ok(f" {sender} → Alice {amt:.8f} BTC TX={txid[:16]}…")
|
||||
maybe_mine()
|
||||
|
||||
|
||||
def tx_legacy_address_receive() -> str:
|
||||
"""A peer sends Alice funds via a legacy P2PKH address (old-school wallet)."""
|
||||
hdr("Legacy P2PKH Receive")
|
||||
peer = rand_peer()
|
||||
ensure_funded(peer, 0.3)
|
||||
legacy_addr = get_new_address(ALICE, "legacy")
|
||||
amt = rand_btc(0.002, 0.05)
|
||||
txid = send_to_address(peer, legacy_addr, amt)
|
||||
maybe_mine()
|
||||
ok(f"{peer} → Alice (legacy) {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_p2sh_wrapped_receive() -> str:
|
||||
"""Receive into a P2SH-wrapped segwit address (older mobile wallets)."""
|
||||
hdr("P2SH-Wrapped Segwit Receive")
|
||||
peer = rand_peer()
|
||||
ensure_funded(peer, 0.3)
|
||||
p2sh_addr = get_new_address(ALICE, "p2sh-segwit")
|
||||
amt = rand_btc(0.002, 0.06)
|
||||
txid = send_to_address(peer, p2sh_addr, amt)
|
||||
maybe_mine()
|
||||
ok(f"{peer} → Alice (p2sh-segwit) {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_change_avoidance() -> str | None:
|
||||
"""Alice finds an exact-match UTXO to pay without producing change output."""
|
||||
hdr("Change-Avoidance Payment (exact UTXO match)")
|
||||
ensure_funded(ALICE, 0.5)
|
||||
utxos = get_utxos(ALICE, 1)
|
||||
if not utxos:
|
||||
info("No UTXOs, skipping")
|
||||
return None
|
||||
utxo = random.choice(utxos)
|
||||
fee = FEE_RESERVE
|
||||
net = round(utxo["amount"] - fee, 8)
|
||||
if net <= DUST_LIMIT:
|
||||
info("UTXO too small, skipping")
|
||||
return None
|
||||
peer = rand_peer()
|
||||
dest = get_new_address(peer, rand_addr_type())
|
||||
raw = create_raw_tx(
|
||||
[{"txid": utxo["txid"], "vout": utxo["vout"]}],
|
||||
[{dest: net}]
|
||||
)
|
||||
signed = sign_raw_tx(ALICE, raw)
|
||||
txid = send_raw(signed["hex"])
|
||||
maybe_mine()
|
||||
ok(f"Change-avoidance {net:.8f} BTC → {peer} TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_risky_origin_receive() -> str:
|
||||
"""Simulate receiving funds from the 'risky' wallet (taint scenario)."""
|
||||
hdr("Receive from Risky Wallet")
|
||||
ensure_funded("risky", 0.3)
|
||||
alice_addr = get_new_address(ALICE, rand_addr_type())
|
||||
amt = rand_btc(0.003, 0.04)
|
||||
txid = send_to_address("risky", alice_addr, amt)
|
||||
maybe_mine()
|
||||
ok(f"risky → Alice {amt:.8f} BTC TX={txid[:16]}…")
|
||||
return txid
|
||||
|
||||
|
||||
def tx_address_reuse_receive() -> tuple[str, str]:
|
||||
"""Two different peers send to the same Alice address (natural address-reuse)."""
|
||||
hdr("Natural Address Reuse (two inbound)")
|
||||
reused_addr = get_new_address(ALICE, random.choice(["bech32", "bech32m"]))
|
||||
peer_a, peer_b = random.sample(SIDE_WALLETS, 2)
|
||||
ensure_funded(peer_a, 0.2)
|
||||
ensure_funded(peer_b, 0.2)
|
||||
txid1 = send_to_address(peer_a, reused_addr, rand_btc(0.003, 0.03))
|
||||
txid2 = send_to_address(peer_b, reused_addr, rand_btc(0.003, 0.03))
|
||||
maybe_mine()
|
||||
ok(f"Two peers sent to same Alice addr TX1={txid1[:16]}… TX2={txid2[:16]}…")
|
||||
return txid1, txid2
|
||||
|
||||
|
||||
# ─── Archetype registry (name, function, weight) ─────────────────────────────
|
||||
ARCHETYPES: list[tuple[str, callable, float]] = [
|
||||
("simple_payment", tx_simple_payment, 3.5),
|
||||
("receive_from_peer", tx_receive_from_peer, 3.0),
|
||||
("round_amount_payment", tx_round_amount_payment, 2.5),
|
||||
("self_transfer", tx_self_transfer, 2.0),
|
||||
("multi_output", tx_multi_output, 2.0),
|
||||
("high_freq_small", tx_high_freq_small, 1.5),
|
||||
("receive_multiple_senders", tx_receive_multiple_senders, 1.5),
|
||||
("exchange_withdrawal", tx_exchange_withdrawal, 1.5),
|
||||
("legacy_address_receive", tx_legacy_address_receive, 1.5),
|
||||
("p2sh_wrapped_receive", tx_p2sh_wrapped_receive, 1.5),
|
||||
("change_avoidance", tx_change_avoidance, 1.5),
|
||||
("consolidation", tx_consolidation, 1.0),
|
||||
("utxo_split", tx_utxo_split, 1.0),
|
||||
("chain_hop", tx_chain_hop, 1.0),
|
||||
("mixed_type_spend", tx_mixed_type_spend, 1.0),
|
||||
("cold_to_hot", tx_cold_to_hot, 1.0),
|
||||
("lightning_channel_like", tx_lightning_channel_like, 1.0),
|
||||
("address_reuse_receive", tx_address_reuse_receive, 1.0),
|
||||
("risky_origin_receive", tx_risky_origin_receive, 0.5),
|
||||
("psbt_coinjoin", tx_psbt_coinjoin, 0.5),
|
||||
]
|
||||
|
||||
_NAMES, _FNS, _WEIGHTS = zip(*ARCHETYPES)
|
||||
_TOTAL_W = sum(_WEIGHTS)
|
||||
|
||||
|
||||
def weighted_choice() -> tuple[str, callable]:
|
||||
r = random.uniform(0, _TOTAL_W)
|
||||
cum = 0.0
|
||||
for name, fn, w in zip(_NAMES, _FNS, _WEIGHTS):
|
||||
cum += w
|
||||
if r <= cum:
|
||||
return name, fn
|
||||
return _NAMES[-1], _FNS[-1]
|
||||
|
||||
|
||||
# ─── Main ─────────────────────────────────────────────────────────────────────
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Create n realistic varied Bitcoin transactions for Alice's wallet on regtest",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog=__doc__,
|
||||
)
|
||||
parser.add_argument("n", type=int,
|
||||
help="Number of transaction events to generate")
|
||||
parser.add_argument("--seed", type=int, default=None,
|
||||
help="Fix RNG seed (for reproducible runs)")
|
||||
parser.add_argument("--mine-final", dest="mine_final",
|
||||
action="store_true", default=True,
|
||||
help="Mine a final confirming block after all TXs (default: on)")
|
||||
parser.add_argument("--no-mine-final", dest="mine_final",
|
||||
action="store_false",
|
||||
help="Skip the final confirming block")
|
||||
args = parser.parse_args()
|
||||
|
||||
print(f"\n{B}{C}{'═'*70}{RST}")
|
||||
print(f"{B}{C} create_random_transactions.py{RST}")
|
||||
print(f"{B} Generating {args.n} realistic transaction events for Alice{RST}")
|
||||
print(f"{B}{C}{'═'*70}{RST}")
|
||||
|
||||
if args.seed is not None:
|
||||
random.seed(args.seed)
|
||||
info(f"RNG seeded manually: {args.seed}")
|
||||
else:
|
||||
reseed()
|
||||
|
||||
# ── Bootstrap: make sure every wallet has funds ──────────────────────────
|
||||
info("Bootstrapping wallet balances…")
|
||||
for w in ALL_WALLETS:
|
||||
ensure_funded(w, 0.3)
|
||||
mine_confirm(1)
|
||||
time.sleep(0.3)
|
||||
|
||||
# ── Main loop ─────────────────────────────────────────────────────────────
|
||||
completed = 0
|
||||
failed = 0
|
||||
used_types: list[str] = []
|
||||
next_mine = random.randint(3, 6) # mine after this many events
|
||||
|
||||
for i in range(args.n):
|
||||
name, fn = weighted_choice()
|
||||
print(f"\n{B}[{i+1}/{args.n}]{RST} {DIM}{name}{RST}")
|
||||
try:
|
||||
fn()
|
||||
completed += 1
|
||||
used_types.append(name)
|
||||
except Exception as exc:
|
||||
warn(f"'{name}' raised: {exc}")
|
||||
failed += 1
|
||||
# Fallback: guaranteed-safe simple payment
|
||||
try:
|
||||
tx_simple_payment()
|
||||
completed += 1
|
||||
used_types.append("simple_payment(fallback)")
|
||||
except Exception as exc2:
|
||||
warn(f"Fallback also failed: {exc2}")
|
||||
|
||||
# Periodic mining to keep mempool manageable
|
||||
if (i + 1) >= next_mine:
|
||||
info("Periodic block mine to clear mempool…")
|
||||
mine_blocks(random.randint(1, 2))
|
||||
time.sleep(0.2)
|
||||
next_mine += random.randint(3, 6)
|
||||
|
||||
# ── Final block ───────────────────────────────────────────────────────────
|
||||
if args.mine_final:
|
||||
info("Mining final confirming block…")
|
||||
mine_confirm(1)
|
||||
|
||||
# ── Summary ───────────────────────────────────────────────────────────────
|
||||
type_counts = Counter(used_types)
|
||||
print(f"\n{B}{C}{'═'*70}{RST}")
|
||||
print(f"{B} Summary{RST}")
|
||||
print(f"{B}{C}{'─'*70}{RST}")
|
||||
print(f" Requested : {args.n}")
|
||||
print(f" Completed : {G}{completed}{RST}")
|
||||
print(f" Failed : {R if failed else G}{failed}{RST}")
|
||||
print(f" Chain height: {get_block_count()}")
|
||||
alice_bal = get_balance(ALICE)
|
||||
print(f" Alice balance: {G}{alice_bal:.8f}{RST} BTC")
|
||||
print(f"\n Transaction-type breakdown:")
|
||||
for t, cnt in type_counts.most_common():
|
||||
bar = "█" * cnt
|
||||
print(f" {G}{cnt:3d}{RST} {bar[:30]:<30} {t}")
|
||||
print(f"{B}{C}{'═'*70}{RST}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user