mirror of
https://github.com/LORDBABUINO/stealth.git
synced 2026-06-09 14:11:52 -07:00
Merge pull request #5 from LORDBABUINO/cleanup/remove-unused-code
Remove unused code, optimize critical path, fix datadir auth
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
backend/script/bitcoin-data/
|
||||
node_modules/
|
||||
dist/
|
||||
.env
|
||||
|
||||
@@ -5,7 +5,6 @@ Connection settings are read from config.ini in the same directory.
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import time
|
||||
import os
|
||||
import configparser
|
||||
|
||||
@@ -23,6 +22,13 @@ def _build_base_args(section):
|
||||
|
||||
args = [cli_bin]
|
||||
|
||||
# Datadir — resolve relative paths from this file's directory
|
||||
datadir = section.get("datadir", "").strip()
|
||||
if datadir:
|
||||
if not os.path.isabs(datadir):
|
||||
datadir = os.path.join(os.path.dirname(os.path.abspath(__file__)), datadir)
|
||||
args.append(f"-datadir={datadir}")
|
||||
|
||||
network_flags = {
|
||||
"regtest": "-regtest",
|
||||
"testnet": "-testnet",
|
||||
@@ -42,10 +48,6 @@ def _build_base_args(section):
|
||||
_cfg = _load_config()
|
||||
_BASE_ARGS = _build_base_args(_cfg)
|
||||
|
||||
# Keep these for any scripts that might reference them directly
|
||||
CLI = _cfg.get("cli", "bitcoin-cli")
|
||||
SIGNET_ARGS = _BASE_ARGS
|
||||
|
||||
def cli(*args, wallet=None):
|
||||
"""Call bitcoin-cli [network] [wallet] <args> and return parsed JSON or string."""
|
||||
cmd = list(_BASE_ARGS)
|
||||
@@ -73,23 +75,6 @@ def mine_blocks(n=1):
|
||||
return int(cli("getblockcount"))
|
||||
|
||||
|
||||
def fund_wallet(wallet_name, amount=1.0, from_wallet="miner"):
|
||||
"""Send `amount` BTC from `from_wallet` to a new address in `wallet_name`."""
|
||||
addr = cli("getnewaddress", "", "bech32", wallet=wallet_name)
|
||||
txid = cli("sendtoaddress", addr, f"{amount:.8f}", wallet=from_wallet)
|
||||
return txid, addr
|
||||
|
||||
|
||||
def wait_for_mempool_empty(timeout=60):
|
||||
"""Wait until mempool is empty (all txs mined)."""
|
||||
for _ in range(timeout * 2):
|
||||
info = cli("getmempoolinfo")
|
||||
if info["size"] == 0:
|
||||
return True
|
||||
time.sleep(0.5)
|
||||
return False
|
||||
|
||||
|
||||
def get_tx(txid):
|
||||
"""Get decoded transaction."""
|
||||
return cli("getrawtransaction", txid, "true")
|
||||
@@ -128,11 +113,6 @@ def finalize_psbt(psbt):
|
||||
return cli("finalizepsbt", psbt)
|
||||
|
||||
|
||||
def decode_psbt(psbt):
|
||||
"""Decode a PSBT."""
|
||||
return cli("decodepsbt", psbt)
|
||||
|
||||
|
||||
def create_raw_tx(inputs, outputs):
|
||||
"""Create a raw transaction."""
|
||||
return cli("createrawtransaction", json.dumps(inputs), json.dumps(outputs))
|
||||
@@ -143,11 +123,6 @@ def sign_raw_tx(wallet_name, hex_tx):
|
||||
return cli("signrawtransactionwithwallet", hex_tx, wallet=wallet_name)
|
||||
|
||||
|
||||
def decode_raw_tx(hex_tx):
|
||||
"""Decode a raw transaction."""
|
||||
return cli("decoderawtransaction", hex_tx)
|
||||
|
||||
|
||||
def get_block_count():
|
||||
"""Get current block height."""
|
||||
return int(cli("getblockcount"))
|
||||
@@ -163,15 +138,3 @@ def send_to_address(wallet_name, address, amount):
|
||||
return cli("sendtoaddress", address, f"{amount:.8f}", wallet=wallet_name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Testing RPC connection...")
|
||||
info = cli("getblockchaininfo")
|
||||
print(f" Chain: {info['chain']}")
|
||||
print(f" Blocks: {info['blocks']}")
|
||||
|
||||
for w in ["miner", "alice", "bob", "carol", "exchange", "risky"]:
|
||||
try:
|
||||
bal = get_balance(w)
|
||||
print(f" {w}: {bal} BTC")
|
||||
except Exception as e:
|
||||
print(f" {w}: ERROR - {e}")
|
||||
|
||||
@@ -5,8 +5,12 @@ network = regtest
|
||||
# Path to the bitcoin-cli binary (use full path if not on PATH)
|
||||
cli = bitcoin-cli
|
||||
|
||||
# Data directory for bitcoind (matches setup.sh).
|
||||
# Relative paths are resolved from the directory containing this file.
|
||||
datadir = bitcoin-data
|
||||
|
||||
# Optional: override RPC connection details.
|
||||
# Leave these blank to use the defaults from ~/.bitcoin/bitcoin.conf.
|
||||
# Leave these blank to use cookie auth from the datadir.
|
||||
rpchost =
|
||||
rpcport =
|
||||
rpcuser =
|
||||
|
||||
@@ -1,700 +0,0 @@
|
||||
#!/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()
|
||||
@@ -20,11 +20,8 @@ Usage:
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import hashlib
|
||||
import argparse
|
||||
from collections import defaultdict
|
||||
from math import log2
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from bitcoin_rpc import cli, get_tx
|
||||
@@ -172,6 +169,8 @@ class TxGraph:
|
||||
self.our_addrs = set(addr_map.keys())
|
||||
self.utxos = utxos # current UTXOs
|
||||
self.tx_cache = {} # txid -> decoded tx
|
||||
self._input_cache = {} # txid -> parsed input addresses
|
||||
self._output_cache = {} # txid -> parsed output addresses
|
||||
self.our_txids = set() # txids we participate in
|
||||
|
||||
# Index: address -> list of (txid, direction, value)
|
||||
@@ -205,9 +204,12 @@ class TxGraph:
|
||||
return self.tx_cache[txid]
|
||||
|
||||
def get_input_addresses(self, txid):
|
||||
"""Get all input addresses for a transaction."""
|
||||
"""Get all input addresses for a transaction (cached)."""
|
||||
if txid in self._input_cache:
|
||||
return self._input_cache[txid]
|
||||
tx = self.fetch_tx(txid)
|
||||
if not tx:
|
||||
self._input_cache[txid] = []
|
||||
return []
|
||||
addrs = []
|
||||
for vin in tx.get("vin", []):
|
||||
@@ -219,12 +221,16 @@ class TxGraph:
|
||||
addr = vout_data.get("scriptPubKey", {}).get("address", "")
|
||||
value = vout_data.get("value", 0)
|
||||
addrs.append({"address": addr, "value": value, "txid": vin["txid"], "vout": vin["vout"]})
|
||||
self._input_cache[txid] = addrs
|
||||
return addrs
|
||||
|
||||
def get_output_addresses(self, txid):
|
||||
"""Get all output addresses for a transaction."""
|
||||
"""Get all output addresses for a transaction (cached)."""
|
||||
if txid in self._output_cache:
|
||||
return self._output_cache[txid]
|
||||
tx = self.fetch_tx(txid)
|
||||
if not tx:
|
||||
self._output_cache[txid] = []
|
||||
return []
|
||||
addrs = []
|
||||
for vout in tx.get("vout", []):
|
||||
@@ -235,6 +241,7 @@ class TxGraph:
|
||||
"n": vout["n"],
|
||||
"type": vout.get("scriptPubKey", {}).get("type", "unknown"),
|
||||
})
|
||||
self._output_cache[txid] = addrs
|
||||
return addrs
|
||||
|
||||
def is_ours(self, address):
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# mine_blocks.sh — Mine N blocks on the custom Signet
|
||||
set -euo pipefail
|
||||
|
||||
N="${1:-1}"
|
||||
source "$HOME/.bitcoin/signet_keys.env"
|
||||
|
||||
MINER="/home/renato/Desktop/bitcoin/bitcoin/contrib/signet/miner"
|
||||
GRIND="bitcoin-util grind"
|
||||
CLI="bitcoin-cli -signet"
|
||||
|
||||
CURRENT=$($CLI getblockcount)
|
||||
TARGET=$((CURRENT + N))
|
||||
echo "Mining $N blocks (from $CURRENT to $TARGET)..."
|
||||
|
||||
BLOCK_TIME=$(date +%s)
|
||||
for i in $(seq 1 $N); do
|
||||
BLOCK_TIME=$((BLOCK_TIME + 1))
|
||||
$MINER \
|
||||
--cli="bitcoin-cli -rpcwallet=miner" \
|
||||
generate \
|
||||
--grind-cmd="$GRIND" \
|
||||
--address="$MINER_ADDR" \
|
||||
--min-nbits \
|
||||
--set-block-time="$BLOCK_TIME" \
|
||||
2>&1 >/dev/null
|
||||
done
|
||||
echo "Done. Block height: $($CLI getblockcount)"
|
||||
@@ -1 +0,0 @@
|
||||
code ~/.bitcoin/bitcoin.conf
|
||||
@@ -1,35 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# run_all.sh — Setup custom signet and run all 12 vulnerability tests
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||
echo "║ Bitcoin Privacy Vulnerability Suite — Full Run ║"
|
||||
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||
echo ""
|
||||
|
||||
# Step 1: Setup signet (if not already running)
|
||||
if bitcoin-cli -signet getblockchaininfo &>/dev/null; then
|
||||
HEIGHT=$(bitcoin-cli -signet getblockcount)
|
||||
echo "✓ Custom Signet already running at block $HEIGHT"
|
||||
|
||||
# Check wallets exist
|
||||
WALLETS=$(bitcoin-cli -signet listwallets 2>/dev/null)
|
||||
if echo "$WALLETS" | grep -q "alice"; then
|
||||
echo "✓ Wallets already created"
|
||||
else
|
||||
echo "⚠ Wallets not found. Running setup..."
|
||||
bash setup_signet.sh
|
||||
fi
|
||||
else
|
||||
echo "Starting custom Signet setup..."
|
||||
bash setup_signet.sh
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Running vulnerability tests..."
|
||||
echo ""
|
||||
|
||||
# Step 2: Run all tests
|
||||
python3 test_vulnerabilities.py "$@"
|
||||
+40
-68
@@ -3,10 +3,9 @@
|
||||
# setup.sh — Bootstrap Bitcoin Core regtest for privacy vulnerability testing
|
||||
# =============================================================================
|
||||
# Reproduces the full environment:
|
||||
# • Writes ~/.bitcoin/bitcoin.conf (regtest, txindex, dustrelayfee, etc.)
|
||||
# • Stops any running bitcoind (both regtest and signet)
|
||||
# • Optionally wipes the regtest data dir (pass --fresh to start from block 0)
|
||||
# • Starts: bitcoind -daemon -regtest
|
||||
# • Starts bitcoind with all config passed via CLI flags (no bitcoin.conf edits)
|
||||
# • Creates wallets: miner alice bob carol exchange risky
|
||||
# • Mines 110 blocks so coinbases mature and miner has spendable BTC
|
||||
#
|
||||
@@ -16,18 +15,19 @@
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
# ─── Colours ──────────────────────────────────────────────────────────────────
|
||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
DATADIR="${SCRIPT_DIR}/bitcoin-data"
|
||||
REGTEST_DIR="${DATADIR}/regtest"
|
||||
WALLETS=(miner alice bob carol exchange risky)
|
||||
INITIAL_BLOCKS=110 # must be >100 so coinbases mature
|
||||
|
||||
# ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
G="\033[92m"; Y="\033[93m"; R="\033[91m"; B="\033[1m"; C="\033[96m"; RST="\033[0m"
|
||||
ok() { echo -e " ${G}✓${RST} $*"; }
|
||||
info() { echo -e " ${Y}ℹ${RST} $*"; }
|
||||
err() { echo -e " ${R}✗${RST} $*"; exit 1; }
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||
BITCOIN_CONF="${HOME}/.bitcoin/bitcoin.conf"
|
||||
REGTEST_DIR="${HOME}/.bitcoin/regtest"
|
||||
WALLETS=(miner alice bob carol exchange risky)
|
||||
INITIAL_BLOCKS=110 # must be >100 so coinbases mature
|
||||
MINER_FUND_BTC=500 # approximate, depends on block subsidy
|
||||
bcli() { bitcoin-cli -datadir="$DATADIR" -regtest "$@"; }
|
||||
|
||||
# ─── Parse args ───────────────────────────────────────────────────────────────
|
||||
FRESH=0
|
||||
@@ -46,21 +46,13 @@ echo ""
|
||||
echo -e "${B}Step 1: Stop any running bitcoind${RST}"
|
||||
|
||||
# Try to stop regtest instance (port 18443)
|
||||
if bitcoin-cli -regtest stop 2>/dev/null; then
|
||||
if bcli stop 2>/dev/null; then
|
||||
ok "Stopped regtest bitcoind"
|
||||
sleep 2
|
||||
else
|
||||
info "No regtest bitcoind running (or already stopped)"
|
||||
fi
|
||||
|
||||
# Try to stop signet instance (port 38332) if one is running
|
||||
if bitcoin-cli -signet stop 2>/dev/null; then
|
||||
ok "Stopped signet bitcoind"
|
||||
sleep 2
|
||||
else
|
||||
info "No signet bitcoind running"
|
||||
fi
|
||||
|
||||
# Hard-kill any remaining bitcoind processes
|
||||
if pgrep -x bitcoind > /dev/null 2>&1; then
|
||||
info "Hard-killing remaining bitcoind processes …"
|
||||
@@ -68,48 +60,29 @@ if pgrep -x bitcoind > /dev/null 2>&1; then
|
||||
sleep 2
|
||||
fi
|
||||
|
||||
# ─── 2. Write bitcoin.conf ────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${B}Step 2: Write ${BITCOIN_CONF}${RST}"
|
||||
mkdir -p "$(dirname "$BITCOIN_CONF")"
|
||||
|
||||
cat > "$BITCOIN_CONF" << 'EOF'
|
||||
# Bitcoin Core configuration
|
||||
# Network: regtest (local testing only)
|
||||
regtest=1
|
||||
txindex=1
|
||||
|
||||
[regtest]
|
||||
# Fee policy — needed so wallets can broadcast without estimatefee data
|
||||
fallbackfee=0.00010
|
||||
|
||||
# Allow outputs as small as 1 sat (needed for dust-attack reproduction)
|
||||
dustrelayfee=0.00000001
|
||||
|
||||
# Accept non-standard transactions (needed for some test scenarios)
|
||||
acceptnonstdtxn=1
|
||||
|
||||
# Enable RPC server
|
||||
server=1
|
||||
EOF
|
||||
|
||||
ok "Wrote bitcoin.conf"
|
||||
|
||||
# ─── 3. Optionally wipe regtest chain ─────────────────────────────────────────
|
||||
# ─── 2. Optionally wipe regtest chain ────────────────────────────────────────
|
||||
if [[ $FRESH -eq 1 ]]; then
|
||||
echo ""
|
||||
echo -e "${B}Step 3: Wipe regtest data dir${RST}"
|
||||
echo -e "${B}Step 2: Wipe regtest data dir${RST}"
|
||||
rm -rf "$REGTEST_DIR"
|
||||
ok "Wiped ${REGTEST_DIR}"
|
||||
else
|
||||
echo ""
|
||||
info "Step 3: Keeping existing regtest chain (use --fresh to wipe)"
|
||||
info "Step 2: Keeping existing regtest chain (use --fresh to wipe)"
|
||||
fi
|
||||
|
||||
# ─── 4. Start bitcoind ────────────────────────────────────────────────────────
|
||||
# ─── 3. Start bitcoind ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${B}Step 4: Start bitcoind -daemon -regtest${RST}"
|
||||
bitcoind -daemon -regtest
|
||||
echo -e "${B}Step 3: Start bitcoind${RST}"
|
||||
mkdir -p "$DATADIR"
|
||||
bitcoind -daemon \
|
||||
-datadir="$DATADIR" \
|
||||
-regtest \
|
||||
-txindex=1 \
|
||||
-server=1 \
|
||||
-fallbackfee=0.00010 \
|
||||
-dustrelayfee=0.00000001 \
|
||||
-acceptnonstdtxn=1
|
||||
ok "bitcoind launched"
|
||||
|
||||
# Wait for RPC to become ready
|
||||
@@ -117,7 +90,7 @@ echo -n " … waiting for RPC"
|
||||
for i in $(seq 1 30); do
|
||||
sleep 1
|
||||
echo -n "."
|
||||
if bitcoin-cli -regtest getblockchaininfo > /dev/null 2>&1; then
|
||||
if bcli getblockchaininfo > /dev/null 2>&1; then
|
||||
echo ""
|
||||
ok "RPC ready after ${i}s"
|
||||
break
|
||||
@@ -128,18 +101,18 @@ for i in $(seq 1 30); do
|
||||
fi
|
||||
done
|
||||
|
||||
BLOCKS=$(bitcoin-cli -regtest getblockcount)
|
||||
BLOCKS=$(bcli getblockcount)
|
||||
info "Chain height: ${BLOCKS} blocks"
|
||||
|
||||
# ─── 5. Create / load wallets ─────────────────────────────────────────────────
|
||||
# ─── 4. Create / load wallets ─────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${B}Step 5: Create wallets${RST}"
|
||||
echo -e "${B}Step 4: Create wallets${RST}"
|
||||
for w in "${WALLETS[@]}"; do
|
||||
if bitcoin-cli -regtest createwallet "$w" 2>/dev/null | grep -q '"name"'; then
|
||||
if bcli createwallet "$w" 2>/dev/null | grep -q '"name"'; then
|
||||
ok "Created wallet: ${w}"
|
||||
else
|
||||
# Wallet DB already exists on disk — just load it
|
||||
if bitcoin-cli -regtest loadwallet "$w" 2>/dev/null | grep -q '"name"'; then
|
||||
if bcli loadwallet "$w" 2>/dev/null | grep -q '"name"'; then
|
||||
ok "Loaded existing wallet: ${w}"
|
||||
else
|
||||
# Already loaded (returned error -35)
|
||||
@@ -148,32 +121,32 @@ for w in "${WALLETS[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
# ─── 6. Mine initial blocks (only if fresh or chain has <110 blocks) ──────────
|
||||
# ─── 5. Mine initial blocks (only if fresh or chain has <110 blocks) ──────────
|
||||
echo ""
|
||||
echo -e "${B}Step 6: Mine initial blocks${RST}"
|
||||
BLOCKS=$(bitcoin-cli -regtest getblockcount)
|
||||
echo -e "${B}Step 5: Mine initial blocks${RST}"
|
||||
BLOCKS=$(bcli getblockcount)
|
||||
|
||||
if [[ $BLOCKS -lt $INITIAL_BLOCKS ]]; then
|
||||
NEED=$(( INITIAL_BLOCKS - BLOCKS ))
|
||||
info "At block ${BLOCKS}, need ${NEED} more to reach ${INITIAL_BLOCKS}"
|
||||
MINER_ADDR=$(bitcoin-cli -regtest -rpcwallet=miner getnewaddress "" bech32)
|
||||
bitcoin-cli -regtest generatetoaddress "$NEED" "$MINER_ADDR" > /dev/null
|
||||
BLOCKS=$(bitcoin-cli -regtest getblockcount)
|
||||
MINER_ADDR=$(bcli -rpcwallet=miner getnewaddress "" bech32)
|
||||
bcli generatetoaddress "$NEED" "$MINER_ADDR" > /dev/null
|
||||
BLOCKS=$(bcli getblockcount)
|
||||
ok "Mined to block ${BLOCKS}"
|
||||
else
|
||||
ok "Already at block ${BLOCKS} — no mining needed"
|
||||
fi
|
||||
|
||||
MINER_BAL=$(bitcoin-cli -regtest -rpcwallet=miner getbalance)
|
||||
MINER_BAL=$(bcli -rpcwallet=miner getbalance)
|
||||
ok "Miner balance: ${MINER_BAL} BTC"
|
||||
|
||||
# ─── 7. Summary ───────────────────────────────────────────────────────────────
|
||||
# ─── 6. Summary ───────────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}"
|
||||
echo -e "${B} Setup complete!${RST}"
|
||||
echo -e "${B}${C}══════════════════════════════════════════════════════════${RST}"
|
||||
echo -e " Chain: ${G}regtest${RST}"
|
||||
echo -e " Blocks: ${G}$(bitcoin-cli -regtest getblockcount)${RST}"
|
||||
echo -e " Blocks: ${G}$(bcli getblockcount)${RST}"
|
||||
echo -e " Wallets: ${G}${WALLETS[*]}${RST}"
|
||||
echo ""
|
||||
echo -e " Next steps:"
|
||||
@@ -181,5 +154,4 @@ echo -e " python3 reproduce.py # create 12 vulnerability scenarios"
|
||||
echo -e " python3 detect.py --wallet alice \\"
|
||||
echo -e " --known-risky-wallets risky \\"
|
||||
echo -e " --known-exchange-wallets exchange"
|
||||
echo -e " python3 verify.py --fresh # full automated proof"
|
||||
echo ""
|
||||
|
||||
@@ -1,294 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# =============================================================================
|
||||
# setup_signet.sh — Bootstrap a private custom Signet for vulnerability testing
|
||||
# =============================================================================
|
||||
set -euo pipefail
|
||||
|
||||
DATADIR="$HOME/.bitcoin"
|
||||
SIGNET_DIR="$DATADIR/signet"
|
||||
MINER="/home/renato/Desktop/bitcoin/bitcoin/contrib/signet/miner"
|
||||
GRIND="bitcoin-util grind"
|
||||
CLI="bitcoin-cli"
|
||||
|
||||
echo "============================================"
|
||||
echo " STEP 0: Cleanup previous state"
|
||||
echo "============================================"
|
||||
bitcoin-cli stop 2>/dev/null || true
|
||||
bitcoin-cli -signet stop 2>/dev/null || true
|
||||
sleep 3
|
||||
|
||||
# Remove old signet data but keep blocks/chainstate for mainnet untouched
|
||||
rm -rf "$SIGNET_DIR"
|
||||
rm -f "$DATADIR/bitcoin.conf"
|
||||
|
||||
echo "============================================"
|
||||
echo " STEP 1: Generate Signet challenge key"
|
||||
echo "============================================"
|
||||
rm -f "$DATADIR/bitcoin.conf"
|
||||
|
||||
# Generate key pair using Python + bitcoin-cli (no wallet needed)
|
||||
KEYPAIR=$(python3 -c "
|
||||
import hashlib, os, struct
|
||||
|
||||
# Generate a random 32-byte private key
|
||||
privkey_bytes = os.urandom(32)
|
||||
|
||||
# secp256k1 parameters
|
||||
P = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
|
||||
A = 0
|
||||
B = 7
|
||||
Gx = 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
|
||||
Gy = 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
|
||||
N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
|
||||
|
||||
def modinv(a, m):
|
||||
g, x, _ = extended_gcd(a % m, m)
|
||||
return x % m
|
||||
|
||||
def extended_gcd(a, b):
|
||||
if a == 0:
|
||||
return b, 0, 1
|
||||
g, x, y = extended_gcd(b % a, a)
|
||||
return g, y - (b // a) * x, x
|
||||
|
||||
def point_add(p1, p2):
|
||||
if p1 is None: return p2
|
||||
if p2 is None: return p1
|
||||
x1, y1 = p1
|
||||
x2, y2 = p2
|
||||
if x1 == x2 and y1 != y2:
|
||||
return None
|
||||
if x1 == x2:
|
||||
lam = (3 * x1 * x1 + A) * modinv(2 * y1, P) % P
|
||||
else:
|
||||
lam = (y2 - y1) * modinv(x2 - x1, P) % P
|
||||
x3 = (lam * lam - x1 - x2) % P
|
||||
y3 = (lam * (x1 - x3) - y1) % P
|
||||
return (x3, y3)
|
||||
|
||||
def scalar_mult(k, point):
|
||||
result = None
|
||||
addend = point
|
||||
while k:
|
||||
if k & 1:
|
||||
result = point_add(result, addend)
|
||||
addend = point_add(addend, addend)
|
||||
k >>= 1
|
||||
return result
|
||||
|
||||
privkey_int = int.from_bytes(privkey_bytes, 'big') % N
|
||||
if privkey_int == 0:
|
||||
privkey_int = 1
|
||||
privkey_bytes = privkey_int.to_bytes(32, 'big')
|
||||
|
||||
pub = scalar_mult(privkey_int, (Gx, Gy))
|
||||
pubkey_bytes = b'\x02' + pub[0].to_bytes(32, 'big') if pub[1] % 2 == 0 else b'\x03' + pub[0].to_bytes(32, 'big')
|
||||
|
||||
# WIF encode (testnet/signet = 0xEF prefix)
|
||||
wif_payload = b'\xef' + privkey_bytes + b'\x01' # compressed
|
||||
checksum = hashlib.sha256(hashlib.sha256(wif_payload).digest()).digest()[:4]
|
||||
import base64
|
||||
# base58 encoding
|
||||
ALPHABET = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
|
||||
num = int.from_bytes(wif_payload + checksum, 'big')
|
||||
b58 = ''
|
||||
while num > 0:
|
||||
num, rem = divmod(num, 58)
|
||||
b58 = ALPHABET[rem] + b58
|
||||
for byte in (wif_payload + checksum):
|
||||
if byte == 0:
|
||||
b58 = '1' + b58
|
||||
else:
|
||||
break
|
||||
|
||||
print(f'{b58} {pubkey_bytes.hex()}')
|
||||
")
|
||||
|
||||
PRIVKEY=$(echo "$KEYPAIR" | awk '{print $1}')
|
||||
PUBKEY=$(echo "$KEYPAIR" | awk '{print $2}')
|
||||
|
||||
# Build 1-of-1 multisig signet challenge: OP_1 <33-byte pubkey> OP_1 OP_CHECKMULTISIG
|
||||
SIGNETCHALLENGE="5121${PUBKEY}51ae"
|
||||
|
||||
echo ""
|
||||
echo ">>> Private key (WIF): $PRIVKEY"
|
||||
echo ">>> Public key: $PUBKEY"
|
||||
echo ">>> Signet challenge: $SIGNETCHALLENGE"
|
||||
echo ""
|
||||
|
||||
# Save keys for later use
|
||||
cat > "$DATADIR/signet_keys.env" <<EOF
|
||||
PRIVKEY=$PRIVKEY
|
||||
PUBKEY=$PUBKEY
|
||||
SIGNETCHALLENGE=$SIGNETCHALLENGE
|
||||
EOF
|
||||
|
||||
echo "============================================"
|
||||
echo " STEP 2: Write bitcoin.conf for custom Signet"
|
||||
echo "============================================"
|
||||
rm -rf "$DATADIR/regtest"
|
||||
rm -f "$DATADIR/bitcoin.conf"
|
||||
|
||||
cat > "$DATADIR/bitcoin.conf" <<EOF
|
||||
signet=1
|
||||
[signet]
|
||||
daemon=1
|
||||
server=1
|
||||
txindex=1
|
||||
signetchallenge=$SIGNETCHALLENGE
|
||||
acceptnonstdtxn=1
|
||||
fallbackfee=0.00010
|
||||
mintxfee=0.00001
|
||||
dustrelayfee=0.00000001
|
||||
minrelaytxfee=0.00001
|
||||
EOF
|
||||
|
||||
echo "Config written to $DATADIR/bitcoin.conf"
|
||||
cat "$DATADIR/bitcoin.conf"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " STEP 3: Start custom Signet node"
|
||||
echo "============================================"
|
||||
bitcoind
|
||||
sleep 3
|
||||
|
||||
# Wait for RPC
|
||||
for i in $(seq 1 30); do
|
||||
if $CLI -signet getblockchaininfo >/dev/null 2>&1; then
|
||||
echo " Signet node ready"
|
||||
break
|
||||
fi
|
||||
echo " Waiting for node to start... ($i)"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
echo "Node info:"
|
||||
$CLI -signet getblockchaininfo | python3 -c "
|
||||
import sys, json
|
||||
d = json.load(sys.stdin)
|
||||
print(f' Chain: {d[\"chain\"]}')
|
||||
print(f' Blocks: {d[\"blocks\"]}')
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " STEP 4: Create wallets"
|
||||
echo "============================================"
|
||||
WALLETS=("miner" "alice" "bob" "carol" "exchange" "risky")
|
||||
|
||||
for w in "${WALLETS[@]}"; do
|
||||
echo -n " Creating wallet '$w'... "
|
||||
$CLI -signet -named createwallet wallet_name="$w" descriptors=true 2>&1 | grep -o '"name": "[^"]*"' || echo "(exists or loaded)"
|
||||
done
|
||||
|
||||
echo " Loaded wallets:"
|
||||
$CLI -signet listwallets
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " STEP 5: Import Signet challenge key into miner wallet"
|
||||
echo "============================================"
|
||||
# For descriptor wallets, we need to import with the PRIVATE key in the descriptor
|
||||
# Import both combo (for general key use) and multi(1,...) (for signet challenge signing)
|
||||
COMBO_INFO=$($CLI -signet getdescriptorinfo "combo($PRIVKEY)")
|
||||
COMBO_CHECKSUM=$(echo "$COMBO_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['checksum'])")
|
||||
COMBO_DESC="combo($PRIVKEY)#$COMBO_CHECKSUM"
|
||||
|
||||
MULTI_INFO=$($CLI -signet getdescriptorinfo "multi(1,$PRIVKEY)")
|
||||
MULTI_CHECKSUM=$(echo "$MULTI_INFO" | python3 -c "import sys,json; print(json.load(sys.stdin)['checksum'])")
|
||||
MULTI_DESC="multi(1,$PRIVKEY)#$MULTI_CHECKSUM"
|
||||
|
||||
echo " Importing combo and multi(1,...) descriptors..."
|
||||
|
||||
$CLI -signet -rpcwallet=miner importdescriptors "[{\"desc\": \"$COMBO_DESC\", \"timestamp\": \"now\"}, {\"desc\": \"$MULTI_DESC\", \"timestamp\": \"now\"}]" | python3 -c "
|
||||
import sys, json
|
||||
r = json.load(sys.stdin)
|
||||
for i, item in enumerate(r):
|
||||
s = item.get('success', False)
|
||||
print(f' Import {i+1} success: {s}')
|
||||
if not s:
|
||||
print(f' Error: {item.get(\"error\", \"unknown\")}')
|
||||
"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " STEP 6: Mine initial blocks (110 blocks)"
|
||||
echo "============================================"
|
||||
|
||||
MINER_ADDR=$($CLI -signet -rpcwallet=miner getnewaddress "" bech32)
|
||||
echo " Mining to: $MINER_ADDR"
|
||||
|
||||
echo " Mining 110 blocks (this may take a minute)..."
|
||||
BLOCK_TIME=$(date +%s)
|
||||
for i in $(seq 1 110); do
|
||||
BLOCK_TIME=$((BLOCK_TIME + 1))
|
||||
$MINER \
|
||||
--cli="$CLI -rpcwallet=miner" \
|
||||
generate \
|
||||
--grind-cmd="$GRIND" \
|
||||
--address="$MINER_ADDR" \
|
||||
--min-nbits \
|
||||
--set-block-time="$BLOCK_TIME" \
|
||||
2>&1 >/dev/null
|
||||
if [ $((i % 10)) -eq 0 ]; then
|
||||
echo " Mined block $i / 110"
|
||||
fi
|
||||
done
|
||||
|
||||
HEIGHT=$($CLI -signet getblockcount)
|
||||
echo " Block height: $HEIGHT"
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " STEP 7: Fund wallets"
|
||||
echo "============================================"
|
||||
|
||||
MINER_BAL=$($CLI -signet -rpcwallet=miner getbalance)
|
||||
echo " Miner balance: $MINER_BAL BTC"
|
||||
|
||||
# Fund each wallet
|
||||
for w in alice bob carol exchange risky; do
|
||||
ADDR=$($CLI -signet -rpcwallet=$w getnewaddress "" bech32)
|
||||
TXID=$($CLI -signet -rpcwallet=miner sendtoaddress "$ADDR" 10.0)
|
||||
echo " Funded $w with 10 BTC (txid: ${TXID:0:16}...)"
|
||||
done
|
||||
|
||||
echo " Mining 6 more blocks to confirm funding..."
|
||||
BLOCK_TIME=$(($(date +%s) + 200))
|
||||
for i in $(seq 1 6); do
|
||||
BLOCK_TIME=$((BLOCK_TIME + 1))
|
||||
$MINER \
|
||||
--cli="$CLI -rpcwallet=miner" \
|
||||
generate \
|
||||
--grind-cmd="$GRIND" \
|
||||
--address="$MINER_ADDR" \
|
||||
--min-nbits \
|
||||
--set-block-time="$BLOCK_TIME" \
|
||||
2>&1 >/dev/null
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo " Final balances:"
|
||||
for w in miner alice bob carol exchange risky; do
|
||||
BAL=$($CLI -signet -rpcwallet=$w getbalance)
|
||||
echo " $w: $BAL BTC"
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "============================================"
|
||||
echo " SETUP COMPLETE"
|
||||
echo "============================================"
|
||||
echo ""
|
||||
echo " Custom Signet is running with:"
|
||||
echo " - 6 wallets (miner, alice, bob, carol, exchange, risky)"
|
||||
echo " - Each funded with 10 BTC"
|
||||
echo " - txindex=1 for historical lookups"
|
||||
echo " - acceptnonstdtxn=1 for dust experiments"
|
||||
echo ""
|
||||
echo " Signet keys saved to: $DATADIR/signet_keys.env"
|
||||
echo " To mine more blocks, use: ./mine_blocks.sh <N>"
|
||||
echo ""
|
||||
|
||||
# Save mining address for later mining
|
||||
echo "MINER_ADDR=$MINER_ADDR" >> "$DATADIR/signet_keys.env"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,258 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
verify.py
|
||||
=========
|
||||
End-to-end proof that detect.py catches every vulnerability that reproduce.py
|
||||
creates — on a REGTEST chain.
|
||||
|
||||
Steps:
|
||||
1. Wipe & restart regtest
|
||||
2. Create wallets, fund miner
|
||||
3. Run reproduce.py (create all 12 vulnerability scenarios)
|
||||
4. Run detect.py --wallet alice (capture output)
|
||||
5. Parse output and assert every detector (1–12) produced ≥1 finding
|
||||
6. Print a 12-row proof table
|
||||
|
||||
Usage:
|
||||
python3 verify.py
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
|
||||
DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
WALLETS = ["miner", "alice", "bob", "carol", "exchange", "risky"]
|
||||
|
||||
G = "\033[92m"
|
||||
R = "\033[91m"
|
||||
B = "\033[1m"
|
||||
C = "\033[96m"
|
||||
Y = "\033[93m"
|
||||
RST = "\033[0m"
|
||||
|
||||
|
||||
def run(cmd, check=True, timeout=300):
|
||||
"""Run a shell command, return stdout."""
|
||||
result = subprocess.run(cmd, shell=True, capture_output=True, text=True, timeout=timeout)
|
||||
if check and result.returncode != 0:
|
||||
print(f" {R}FAIL:{RST} {cmd}")
|
||||
print(f" stderr: {result.stderr.strip()}")
|
||||
sys.exit(1)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def btc(cmd):
|
||||
return run(f"bitcoin-cli -regtest {cmd}")
|
||||
|
||||
|
||||
def btcw(wallet, cmd):
|
||||
return run(f"bitcoin-cli -regtest -rpcwallet={wallet} {cmd}")
|
||||
|
||||
|
||||
def banner(msg):
|
||||
print(f"\n{B}{C}{'═' * 70}{RST}")
|
||||
print(f"{B}{C} {msg}{RST}")
|
||||
print(f"{B}{C}{'═' * 70}{RST}")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 1: Fresh regtest
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def setup_regtest():
|
||||
banner("Step 1: Fresh regtest chain")
|
||||
# Stop if running
|
||||
run("bitcoin-cli -regtest stop 2>/dev/null || true", check=False)
|
||||
time.sleep(2)
|
||||
|
||||
# Wipe
|
||||
run("rm -rf ~/.bitcoin/regtest")
|
||||
print(" ✓ Wiped regtest datadir")
|
||||
|
||||
# Ensure bitcoin.conf exists with regtest settings
|
||||
conf = os.path.expanduser("~/.bitcoin/bitcoin.conf")
|
||||
with open(conf, "w") as f:
|
||||
f.write("regtest=1\ntxindex=1\n\n[regtest]\n"
|
||||
"fallbackfee=0.00010\ndustrelayfee=0.00000001\n"
|
||||
"acceptnonstdtxn=1\nserver=1\n")
|
||||
print(" ✓ Wrote bitcoin.conf")
|
||||
|
||||
# Start
|
||||
run("bitcoind -regtest -daemon")
|
||||
# Wait for RPC to become ready
|
||||
print(" … waiting for bitcoind RPC …", end="", flush=True)
|
||||
for i in range(30):
|
||||
time.sleep(1)
|
||||
res = subprocess.run("bitcoin-cli -regtest getblockchaininfo",
|
||||
shell=True, capture_output=True, text=True, timeout=10)
|
||||
if res.returncode == 0:
|
||||
print(f" ready after {i+1}s")
|
||||
break
|
||||
else:
|
||||
print(f"\n {R}ERROR: bitcoind didn't start after 30s{RST}")
|
||||
sys.exit(1)
|
||||
print(" ✓ bitcoind started")
|
||||
|
||||
# Create wallets
|
||||
for w in WALLETS:
|
||||
btc(f'createwallet "{w}"')
|
||||
print(f" ✓ Created wallets: {', '.join(WALLETS)}")
|
||||
|
||||
# Mine 110 blocks to get mature coinbases
|
||||
addr = btcw("miner", 'getnewaddress "" bech32')
|
||||
btc(f"generatetoaddress 110 {addr}")
|
||||
balance = btcw("miner", "getbalance")
|
||||
print(f" ✓ Mined 110 blocks — miner balance: {balance} BTC")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 2: Reproduce
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def run_reproduce():
|
||||
banner("Step 2: Run reproduce.py (create 12 vulnerability scenarios)")
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(DIR, "reproduce.py")],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" {R}reproduce.py FAILED:{RST}")
|
||||
print(result.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Count successes
|
||||
successes = result.stdout.count("✓")
|
||||
print(f" ✓ reproduce.py completed — {successes} scenario(s) created")
|
||||
# Print abbreviated output
|
||||
for line in result.stdout.split("\n"):
|
||||
if "✓" in line or "REPRODUCE" in line:
|
||||
print(f" {line.strip()}")
|
||||
return result.stdout
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 3: Detect
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def run_detect():
|
||||
banner("Step 3: Run detect.py --wallet alice")
|
||||
result = subprocess.run(
|
||||
[sys.executable, os.path.join(DIR, "detect.py"),
|
||||
"--wallet", "alice",
|
||||
"--known-risky-wallets", "risky",
|
||||
"--known-exchange-wallets", "exchange"],
|
||||
capture_output=True, text=True, timeout=300,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f" {R}detect.py FAILED:{RST}")
|
||||
print(result.stderr)
|
||||
sys.exit(1)
|
||||
print(f" ✓ detect.py completed")
|
||||
return result.stdout
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Step 4: Parse & verify
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
DETECTORS = {
|
||||
1: ("Address Reuse", r"1 · Address Reuse"),
|
||||
2: ("CIOH", r"2 · Common Input Ownership"),
|
||||
3: ("Dust UTXO Detection", r"3 · Dust UTXO Detection"),
|
||||
4: ("Dust Spent with Normal", r"4 · Dust Spent with Normal"),
|
||||
5: ("Change Output Detection", r"5 · Probable Change Output"),
|
||||
6: ("Consolidation Origin", r"6 · UTXOs from Prior Consolidation"),
|
||||
7: ("Script Type Mixing", r"7 · Script Type Mixing"),
|
||||
8: ("Cluster Merge", r"8 · Cluster Merge"),
|
||||
9: ("UTXO Age / Lookback", r"9 · UTXO Age"),
|
||||
10: ("Exchange Origin", r"10 · Probable Exchange Origin"),
|
||||
11: ("Tainted UTXOs", r"11 · Tainted UTXOs"),
|
||||
12: ("Behavioral Fingerprint", r"12 · Behavioral Fingerprint"),
|
||||
}
|
||||
|
||||
|
||||
def parse_and_verify(detect_output):
|
||||
banner("Step 4: Verification — does detect catch every reproduced vulnerability?")
|
||||
|
||||
# Split output into sections per detector
|
||||
lines = detect_output.split("\n")
|
||||
results = {}
|
||||
current_id = None
|
||||
|
||||
for line in lines:
|
||||
# Check if this line starts a detector section
|
||||
for did, (name, pattern) in DETECTORS.items():
|
||||
if pattern in line:
|
||||
current_id = did
|
||||
results[did] = {"findings": 0, "warnings": 0, "lines": []}
|
||||
break
|
||||
# Count findings/warnings within current section
|
||||
if current_id is not None:
|
||||
if "FINDING" in line:
|
||||
results[current_id]["findings"] += 1
|
||||
if "WARNING" in line:
|
||||
results[current_id]["warnings"] += 1
|
||||
results[current_id]["lines"].append(line)
|
||||
|
||||
# Also parse the summary line
|
||||
total_findings = 0
|
||||
total_warnings = 0
|
||||
m = re.search(r"Findings:\s+(\d+)", detect_output)
|
||||
if m:
|
||||
total_findings = int(m.group(1))
|
||||
m = re.search(r"Warnings:\s+(\d+)", detect_output)
|
||||
if m:
|
||||
total_warnings = int(m.group(1))
|
||||
|
||||
# ── Print proof table ──
|
||||
print()
|
||||
print(f" {'#':>3} {'Detector':<30} {'Findings':>8} {'Warnings':>8} {'Status'}")
|
||||
print(f" {'─'*3} {'─'*30} {'─'*8} {'─'*8} {'─'*8}")
|
||||
|
||||
all_pass = True
|
||||
for did in sorted(DETECTORS.keys()):
|
||||
name = DETECTORS[did][0]
|
||||
r = results.get(did, {"findings": 0, "warnings": 0})
|
||||
f_count = r["findings"]
|
||||
w_count = r["warnings"]
|
||||
detected = f_count > 0 or w_count > 0
|
||||
status = f"{G}PASS ✓{RST}" if detected else f"{R}FAIL ✗{RST}"
|
||||
if not detected:
|
||||
all_pass = False
|
||||
print(f" {did:>3} {name:<30} {f_count:>8} {w_count:>8} {status}")
|
||||
|
||||
print(f" {'─'*3} {'─'*30} {'─'*8} {'─'*8} {'─'*8}")
|
||||
print(f" {'':>3} {'TOTAL':<30} {total_findings:>8} {total_warnings:>8}")
|
||||
print()
|
||||
|
||||
if all_pass:
|
||||
print(f" {G}{B}═══ ALL 12 DETECTORS FIRED — PROOF COMPLETE ═══{RST}")
|
||||
print(f" {G}Every reproduced vulnerability was caught by detect.py on regtest.{RST}")
|
||||
else:
|
||||
failed = [did for did in DETECTORS if results.get(did, {}).get("findings", 0) == 0
|
||||
and results.get(did, {}).get("warnings", 0) == 0]
|
||||
print(f" {R}{B}═══ FAILURE — {len(failed)} detector(s) did not fire ═══{RST}")
|
||||
for did in failed:
|
||||
print(f" {R} Detector {did}: {DETECTORS[did][0]}{RST}")
|
||||
|
||||
return all_pass
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# Main
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
def main():
|
||||
print(f"\n{B}{'═' * 70}{RST}")
|
||||
print(f"{B}{C} VERIFY: reproduce → detect end-to-end proof on REGTEST{RST}")
|
||||
print(f"{B}{'═' * 70}{RST}")
|
||||
|
||||
setup_regtest()
|
||||
run_reproduce()
|
||||
detect_output = run_detect()
|
||||
passed = parse_and_verify(detect_output)
|
||||
|
||||
print()
|
||||
sys.exit(0 if passed else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -66,22 +66,7 @@
|
||||
<artifactId>rest-assured</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.bitcoindevkit</groupId>
|
||||
<artifactId>bdk-jvm</artifactId>
|
||||
<version>0.30.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>net.java.dev.jna</groupId>
|
||||
<artifactId>jna</artifactId>
|
||||
<version>5.13.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.jetbrains.kotlin</groupId>
|
||||
<artifactId>kotlin-stdlib</artifactId>
|
||||
<version>2.1.0</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<dependency>
|
||||
<groupId>org.slf4j</groupId>
|
||||
<artifactId>slf4j-api</artifactId>
|
||||
</dependency>
|
||||
|
||||
-54
@@ -4,13 +4,9 @@ import jakarta.enterprise.context.ApplicationScoped;
|
||||
import jakarta.ws.rs.*;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import org.backend.stealth.mocks.WalletMockData;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@ApplicationScoped
|
||||
@Path("/api/wallet")
|
||||
@@ -21,56 +17,6 @@ public class WalletResource {
|
||||
@ConfigProperty(name = "stealth.detect.script", defaultValue = "../../script/detect.py")
|
||||
String detectScript;
|
||||
|
||||
private static final Map<String, String> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
// DTOs
|
||||
|
||||
public record AnalyzeRequest(String descriptor) {}
|
||||
|
||||
public record AnalyzeResponse(String analysisId) {}
|
||||
|
||||
public record VulnerabilityData(String type, String severity, String description) {}
|
||||
|
||||
public record UtxoData(
|
||||
String txid,
|
||||
int vout,
|
||||
String address,
|
||||
double amountBtc,
|
||||
int confirmations,
|
||||
List<VulnerabilityData> vulnerabilities
|
||||
) {}
|
||||
|
||||
public record SummaryData(int total, int clean, int vulnerable) {}
|
||||
|
||||
public record ReportResponse(String descriptor, SummaryData summary, List<UtxoData> utxos) {}
|
||||
|
||||
// Endpoints
|
||||
|
||||
@POST
|
||||
@Path("/analyze")
|
||||
public Response analyze(AnalyzeRequest req) {
|
||||
if (req == null || req.descriptor() == null || req.descriptor().isBlank()) {
|
||||
return Response.status(Response.Status.BAD_REQUEST)
|
||||
.entity(Map.of("error", "descriptor is required"))
|
||||
.build();
|
||||
}
|
||||
String analysisId = UUID.randomUUID().toString();
|
||||
sessions.put(analysisId, req.descriptor());
|
||||
return Response.ok(new AnalyzeResponse(analysisId)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{analysisId}/utxos")
|
||||
public Response getUtxos(@PathParam("analysisId") String analysisId) {
|
||||
String descriptor = sessions.get(analysisId);
|
||||
if (descriptor == null) {
|
||||
return Response.status(Response.Status.NOT_FOUND)
|
||||
.entity(Map.of("error", "analysisId not found"))
|
||||
.build();
|
||||
}
|
||||
return Response.ok(WalletMockData.buildReport(descriptor)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/scan")
|
||||
public Response scan(@QueryParam("descriptor") String descriptor) {
|
||||
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package org.backend.stealth.controller.response;
|
||||
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import org.backend.stealth.service.impl.WalletController;
|
||||
import org.bitcoindevkit.BdkException;
|
||||
|
||||
@Path("/hello")
|
||||
public class ExampleResponse {
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.TEXT_PLAIN)
|
||||
public String hello() throws BdkException {
|
||||
|
||||
WalletController controller = new WalletController();
|
||||
controller.ConnectWallet();
|
||||
|
||||
return "Hello from Quarkus REST";
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package org.backend.stealth.domain.entity;
|
||||
|
||||
public class UTXO {
|
||||
|
||||
private String value;
|
||||
private String scriptPubKey;
|
||||
private String txid;
|
||||
private Integer vout;
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package org.backend.stealth.domain.entity;
|
||||
|
||||
public class Wallet {
|
||||
|
||||
private Integer id;
|
||||
private String descriptor;
|
||||
|
||||
public Wallet() {}
|
||||
|
||||
public Wallet(Integer id, String descriptor) {
|
||||
this.id = id;
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
|
||||
public Integer getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
public void setId(Integer id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public String getDescriptor() {
|
||||
return descriptor;
|
||||
}
|
||||
|
||||
public void setDescriptor(String descriptor) {
|
||||
this.descriptor = descriptor;
|
||||
}
|
||||
}
|
||||
|
||||
-4
@@ -1,4 +0,0 @@
|
||||
package org.backend.stealth.domain.repository;
|
||||
|
||||
public class BitcoinRepository {
|
||||
}
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
package org.backend.stealth.mocks;
|
||||
|
||||
import org.backend.stealth.controller.WalletResource.ReportResponse;
|
||||
import org.backend.stealth.controller.WalletResource.SummaryData;
|
||||
import org.backend.stealth.controller.WalletResource.UtxoData;
|
||||
import org.backend.stealth.controller.WalletResource.VulnerabilityData;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public class WalletMockData {
|
||||
|
||||
public static ReportResponse buildReport(String descriptor) {
|
||||
List<UtxoData> utxos = List.of(
|
||||
new UtxoData(
|
||||
"3a7f2b8c1d4e9f0a6b5c2d7e8f3a1b4c9d2e5f0a7b8c1d4e9f2a5b6c3d7e8f1",
|
||||
0,
|
||||
"bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh",
|
||||
0.05234891,
|
||||
1842,
|
||||
List.of()
|
||||
),
|
||||
new UtxoData(
|
||||
"b4c8e2f6a1d5b9c3e7f1a5d9b3c7e1f5a9d3b7c1e5f9a3d7b1c5e9f3a7d1b5",
|
||||
1,
|
||||
"bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq",
|
||||
0.00023000,
|
||||
312,
|
||||
List.of(
|
||||
new VulnerabilityData("DUST_SPEND", "medium",
|
||||
"This UTXO is near the dust threshold. Spending it may cost more in fees than its value, and dust outputs are often used as tracking vectors by chain surveillance companies."),
|
||||
new VulnerabilityData("ADDRESS_REUSE", "high",
|
||||
"This address has received funds in 3 separate transactions. Address reuse breaks the one-time-address privacy model and allows observers to link all deposits to the same wallet.")
|
||||
)
|
||||
),
|
||||
new UtxoData(
|
||||
"f9e3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9",
|
||||
0,
|
||||
"bc1q9h7garjcdkl4h5khfz2yxkhsmhep5j7g4cjtch",
|
||||
0.12000000,
|
||||
4521,
|
||||
List.of(
|
||||
new VulnerabilityData("CONSOLIDATION", "medium",
|
||||
"This UTXO was created by consolidating 7 inputs in a single transaction. Consolidation reveals that all input addresses belong to the same wallet, reducing privacy significantly.")
|
||||
)
|
||||
),
|
||||
new UtxoData(
|
||||
"2c6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d",
|
||||
2,
|
||||
"bc1qm34mqf4vn8f5vhf0q3djg2zuzfm9aap6e3n4j",
|
||||
0.87654321,
|
||||
98,
|
||||
List.of(
|
||||
new VulnerabilityData("CIOH", "high",
|
||||
"Common Input Ownership Heuristic (CIOH): this UTXO was spent alongside UTXOs from different derivation paths in the same transaction, strongly suggesting to analysts that all inputs share a common owner."),
|
||||
new VulnerabilityData("ADDRESS_REUSE", "high",
|
||||
"This address appears in 5 transactions as both sender and receiver, a pattern that severely compromises wallet privacy and makes cluster analysis trivial.")
|
||||
)
|
||||
),
|
||||
new UtxoData(
|
||||
"7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d",
|
||||
0,
|
||||
"bc1qcr8te4kr609gcawutmrza0j4xv80jy8zeqchgx",
|
||||
0.00500000,
|
||||
2103,
|
||||
List.of(
|
||||
new VulnerabilityData("DUST_SPEND", "low",
|
||||
"A small dust amount was received at this address in a prior transaction. While the dust has not been spent, its presence could be used to track this UTXO if included in a future transaction.")
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
SummaryData summary = new SummaryData(5, 1, 4);
|
||||
return new ReportResponse(descriptor, summary, utxos);
|
||||
}
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
package org.backend.stealth.service.impl;
|
||||
|
||||
import org.bitcoindevkit.*;
|
||||
|
||||
public class WalletController {
|
||||
|
||||
public void ConnectWallet() throws BdkException {
|
||||
|
||||
Mnemonic mnemonic = new Mnemonic(WordCount.WORDS12);
|
||||
|
||||
DescriptorSecretKey masterKey = new DescriptorSecretKey(
|
||||
Network.REGTEST,
|
||||
mnemonic,
|
||||
""
|
||||
);
|
||||
|
||||
String externalDescStr = "wpkh(" + masterKey.asString() + "/84'/1'/0'/0/*)";
|
||||
|
||||
Descriptor externalDescriptor = new Descriptor(externalDescStr, Network.REGTEST);
|
||||
|
||||
Wallet wallet = new Wallet(
|
||||
externalDescriptor,
|
||||
null, // changeDescriptor (pode continuar null por enquanto)
|
||||
Network.REGTEST,
|
||||
DatabaseConfig.Memory.INSTANCE
|
||||
);
|
||||
|
||||
System.out.println("✅ Carteira criada com sucesso! Endereço: " +
|
||||
wallet.getAddress(AddressIndex.New.INSTANCE).getAddress());
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
package org.backend.stealth.utils;
|
||||
|
||||
public class WalletUtils {
|
||||
|
||||
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package org.backend.stealth;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusIntegrationTest;
|
||||
|
||||
@QuarkusIntegrationTest
|
||||
class ExampleResponseIT extends ExampleResponseTest {
|
||||
// Execute the same tests but in packaged mode.
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
package org.backend.stealth;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.hamcrest.CoreMatchers.is;
|
||||
|
||||
@QuarkusTest
|
||||
class ExampleResponseTest {
|
||||
@Test
|
||||
void testHelloEndpoint() {
|
||||
given()
|
||||
.when().get("/hello")
|
||||
.then()
|
||||
.statusCode(200)
|
||||
.body(is("Hello from Quarkus REST"));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import VulnerabilityBadge from './VulnerabilityBadge'
|
||||
import styles from './UtxoCard.module.css'
|
||||
|
||||
function truncateAddress(addr) {
|
||||
if (!addr || addr.length <= 20) return addr
|
||||
return `${addr.slice(0, 12)}…${addr.slice(-8)}`
|
||||
}
|
||||
|
||||
function truncateTxid(txid) {
|
||||
if (!txid || txid.length <= 24) return txid
|
||||
return `${txid.slice(0, 16)}…${txid.slice(-8)}`
|
||||
}
|
||||
|
||||
export default function UtxoCard({ utxo }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const isClean = utxo.vulnerabilities.length === 0
|
||||
|
||||
const highestSeverity = utxo.vulnerabilities.reduce((acc, v) => {
|
||||
const order = { high: 3, medium: 2, low: 1 }
|
||||
return (order[v.severity] ?? 0) > (order[acc] ?? 0) ? v.severity : acc
|
||||
}, null)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.card} ${isClean ? styles.clean : styles.hasVulnerabilities}`}
|
||||
>
|
||||
<div
|
||||
className={styles.header}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
role="button"
|
||||
aria-expanded={open}
|
||||
>
|
||||
<div className={styles.headerLeft}>
|
||||
<div className={styles.addressRow}>
|
||||
<span className={styles.address} title={utxo.address}>
|
||||
{truncateAddress(utxo.address)}
|
||||
</span>
|
||||
</div>
|
||||
<div className={styles.badges}>
|
||||
{isClean ? (
|
||||
<span className={styles.cleanLabel}>✓ Clean</span>
|
||||
) : (
|
||||
utxo.vulnerabilities.map((v, i) => (
|
||||
<VulnerabilityBadge key={i} type={v.type} severity={v.severity} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.headerRight}>
|
||||
<span className={styles.amount}>
|
||||
{utxo.amountBtc.toFixed(8)} BTC
|
||||
</span>
|
||||
<span className={styles.confirmations}>
|
||||
{utxo.confirmations.toLocaleString()} confs
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<span className={`${styles.chevron} ${open ? styles.open : ''}`}>▼</span>
|
||||
</div>
|
||||
|
||||
<div className={`${styles.detail} ${open ? styles.open : ''}`}>
|
||||
<span className={styles.txidLabel}>txid</span>
|
||||
<div className={styles.txid}>
|
||||
{utxo.txid}:{utxo.vout}
|
||||
</div>
|
||||
|
||||
{!isClean && (
|
||||
<div className={styles.vulnerabilityList}>
|
||||
{utxo.vulnerabilities.map((v, i) => (
|
||||
<div key={i} className={`${styles.vulnItem} ${styles[v.severity]}`}>
|
||||
<div className={styles.vulnHeader}>
|
||||
<VulnerabilityBadge type={v.type} severity={v.severity} />
|
||||
</div>
|
||||
<p className={styles.vulnDesc}>{v.description}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
.card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
overflow: hidden;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.card.hasVulnerabilities {
|
||||
border-left: 3px solid var(--danger);
|
||||
}
|
||||
|
||||
.card.clean {
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 20px;
|
||||
cursor: pointer;
|
||||
gap: 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.headerLeft {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.addressRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.address {
|
||||
font-family: var(--font-data);
|
||||
font-size: 14px;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cleanLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--accent);
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.headerRight {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.amount {
|
||||
font-family: var(--font-data);
|
||||
font-size: 16px;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.confirmations {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
transition: transform var(--transition);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chevron.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Detail panel */
|
||||
.detail {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
transition: max-height 0.3s ease, padding 0.3s ease;
|
||||
}
|
||||
|
||||
.detail.open {
|
||||
max-height: 600px;
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.txid {
|
||||
font-family: var(--font-data);
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
word-break: break-all;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.txidLabel {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.vulnerabilityList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vulnItem {
|
||||
border-radius: var(--radius);
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.vulnItem.high {
|
||||
background: var(--danger-dim);
|
||||
border: 1px solid rgba(255, 77, 109, 0.2);
|
||||
}
|
||||
|
||||
.vulnItem.medium {
|
||||
background: var(--warning-dim);
|
||||
border: 1px solid rgba(244, 162, 97, 0.2);
|
||||
}
|
||||
|
||||
.vulnItem.low {
|
||||
background: var(--safe-dim);
|
||||
border: 1px solid rgba(46, 196, 182, 0.2);
|
||||
}
|
||||
|
||||
.vulnHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vulnDesc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
export const mockReport = {
|
||||
descriptor: 'wpkh([a1b2c3d4/84h/0h/0h]xpub6CatWdiZynkCminahu8Gmr7FAVnQXBTSMaBxn6qmBNkdm9tDkFzWmjmDrLBCQSTa7BHgpEjCXzMTCyDsQLSmcGYJHBB7cTwpqLNRKGP47uw/0/*)#qwer1234',
|
||||
summary: {
|
||||
total: 5,
|
||||
clean: 1,
|
||||
vulnerable: 4,
|
||||
},
|
||||
utxos: [
|
||||
{
|
||||
txid: '3a7f2b8c1d4e9f0a6b5c2d7e8f3a1b4c9d2e5f0a7b8c1d4e9f2a5b6c3d7e8f1',
|
||||
vout: 0,
|
||||
address: 'bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh',
|
||||
amountBtc: 0.05234891,
|
||||
confirmations: 1842,
|
||||
vulnerabilities: [],
|
||||
},
|
||||
{
|
||||
txid: 'b4c8e2f6a1d5b9c3e7f1a5d9b3c7e1f5a9d3b7c1e5f9a3d7b1c5e9f3a7d1b5',
|
||||
vout: 1,
|
||||
address: 'bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq',
|
||||
amountBtc: 0.00023000,
|
||||
confirmations: 312,
|
||||
vulnerabilities: [
|
||||
{
|
||||
type: 'DUST_SPEND',
|
||||
severity: 'medium',
|
||||
description:
|
||||
'This UTXO is near the dust threshold. Spending it may cost more in fees than its value, and dust outputs are often used as tracking vectors by chain surveillance companies.',
|
||||
},
|
||||
{
|
||||
type: 'ADDRESS_REUSE',
|
||||
severity: 'high',
|
||||
description:
|
||||
'This address has received funds in 3 separate transactions. Address reuse breaks the one-time-address privacy model and allows observers to link all deposits to the same wallet.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
txid: 'f9e3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9f3d7c1b5a9',
|
||||
vout: 0,
|
||||
address: 'bc1q9h7garjcdkl4h5khfz2yxkhsmhep5j7g4cjtch',
|
||||
amountBtc: 0.12000000,
|
||||
confirmations: 4521,
|
||||
vulnerabilities: [
|
||||
{
|
||||
type: 'CONSOLIDATION',
|
||||
severity: 'medium',
|
||||
description:
|
||||
'This UTXO was created by consolidating 7 inputs in a single transaction. Consolidation reveals that all input addresses belong to the same wallet, reducing privacy significantly.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
txid: '2c6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d6e0a4f8b2d',
|
||||
vout: 2,
|
||||
address: 'bc1qm34mqf4vn8f5vhf0q3djg2zuzfm9aap6e3n4j',
|
||||
amountBtc: 0.87654321,
|
||||
confirmations: 98,
|
||||
vulnerabilities: [
|
||||
{
|
||||
type: 'CIOH',
|
||||
severity: 'high',
|
||||
description:
|
||||
'Common Input Ownership Heuristic (CIOH): this UTXO was spent alongside UTXOs from different derivation paths in the same transaction, strongly suggesting to analysts that all inputs share a common owner.',
|
||||
},
|
||||
{
|
||||
type: 'ADDRESS_REUSE',
|
||||
severity: 'high',
|
||||
description:
|
||||
'This address appears in 5 transactions as both sender and receiver, a pattern that severely compromises wallet privacy and makes cluster analysis trivial.',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
txid: '7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d1b5e9f3a7d',
|
||||
vout: 0,
|
||||
address: 'bc1qcr8te4kr609gcawutmrza0j4xv80jy8zeqchgx',
|
||||
amountBtc: 0.00500000,
|
||||
confirmations: 2103,
|
||||
vulnerabilities: [
|
||||
{
|
||||
type: 'DUST_SPEND',
|
||||
severity: 'low',
|
||||
description:
|
||||
'A small dust amount was received at this address in a prior transaction. While the dust has not been spent, its presence could be used to track this UTXO if included in a future transaction.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -2,10 +2,11 @@ import { useState, useEffect } from 'react'
|
||||
import styles from './LoadingScreen.module.css'
|
||||
|
||||
const MESSAGES = [
|
||||
'Parsing descriptor',
|
||||
'Fetching transactions',
|
||||
'Scanning UTXO set',
|
||||
'Running heuristics',
|
||||
'Resolving descriptors',
|
||||
'Deriving addresses',
|
||||
'Importing & scanning blockchain',
|
||||
'Loading transaction history',
|
||||
'Running vulnerability detectors',
|
||||
]
|
||||
|
||||
export default function LoadingScreen({ descriptor }) {
|
||||
|
||||
Reference in New Issue
Block a user