#!/usr/bin/env python3
"""
coldcard_fund_watch.py — watch the Coldcard stolen-fund addresses for MOVEMENT.

Self-contained. Python 3.7+, standard library only, no pip install, no keys.
Read-only public chain data. It cannot touch a coin; it has no signing code.

WHY THE ALERT RULE IS WHAT IT IS

  IT ONLY FIRES WHEN A TRACKED ADDRESS APPEARS AS A TRANSACTION *INPUT*.

Receiving is not a spend. While writing this, the largest tracked stash (562 BTC)
had an unconfirmed transaction against it — 546 sats of INBOUND DUST. Anything
alerting on "mempool activity" would have announced that 562 BTC was moving, and
that rumour travels faster than the correction. Check direction first.

USAGE
    python3 coldcard_fund_watch.py                 # one pass, print findings
    python3 coldcard_fund_watch.py --watch         # loop every 30 min
    python3 coldcard_fund_watch.py --interval 600  # custom seconds
    python3 coldcard_fund_watch.py --api http://localhost:3006/api   # your own node
    python3 coldcard_fund_watch.py --selftest      # offline, no network

Address list from coldcard-hack-tracker.vercel.app, verified against the chain
2026-08-03: 94 of 97 matched the dashboard exactly, 3 were empty while still shown
as holding. Baseline at that time: 1429.81009788 BTC held, 1999.12130393 ever
received — i.e. ~569 BTC had already passed through and left.

Venue labels are the TRACKER OPERATOR'S attributions, not independently confirmed.
The largest (bc1qdj58du...) shows 346,899 BTC lifetime over 1,929 txs, which is
consistent with a large custodian but does not by itself prove whose it is.
"""

import sys, json, time, argparse, urllib.request, urllib.error

DEFAULT_API = "https://mempool.space/api"
GAP = 1.5
STATE_FILE = "coldcard_fund_watch_state.json"

VENUE = {
    'bc1qp6yzmq5kjr8yvyw7453gxvq4z3tvkdyadqm794': 'THORChain BTC vault',
    '3KMmeqPeQcngyTehdfSwsGqvxfU7J7qtc8': 'P2SH service hub',
    '3CTpBmp8uWTcHJBjmyVe8VPPyCHTzj2hBH': 'Bullish.com deposit',
    'bc1qactqjuk4kghfgaqqt454hzzzs5lsaysunf80gh': 'P2WPKH service hub',
    'bc1qdj58duywm3ng0twrxk5kykup9q6jmmj72n60ms': 'Coinbase Prime Custody',
    'bc1qdt6cswq9pld5e96el8ljhk4zfqmv423atgsrqw': 'parked sibling',
    # Added 2026-08-03, each verified against the chain before the label was trusted:
    #   328Gxewq...EEYD  9.79192439 BTC over 37 txs, balance 0
    #   3JEQJdb1...kvCe  8.84257163 BTC over 26 txs, balance 0
    #   bc1qs86u5g...    1.54000000 BTC over  2 txs, balance 0
    # NOTE the zero balances mean the OPPOSITE of what they mean on the attacker's
    # own vaults. An exchange deposit address is swept into the exchange's wallets
    # within minutes, so empty = THE EXCHANGE HOLDS IT, which is the outcome worth
    # reporting to them. On a tracked vault above, empty = the funds are gone.
    '328GxewqTzMxLPvLemaKS7Q5Wi1io8EEYD': 'KuCoin deposit',
    '3JEQJdb1Cwbzvevzj1ECAoiMbvb2yckvCe': 'KuCoin deposit',
    'bc1qs86u5g39288nxpe59xxul92kvvps6j747k320w': 'cashout consolidation',
}

# address, label, cluster, reportBtc (as published by the tracker)
TRACKED = [
    ('bc1qn3uy9j26m79vghed2uddr89l344xa5efnn4d0rxhz4q3xxlyxryqq595ld', 'Wave 3 vault 1', '', 20.49703196),
    ('bc1qu5dgcwgm0c6qazqhacskakt8qrqj96wfkyfzjsf2lje9unzjznasfmaygs', 'Wave 3 vault 2', '', 15.47824995),
    ('bc1qt7ktwfru52emkyyuw6nkju6lck98uxtc5r6gfwethgas50ufn22qpfsewn', 'Wave 3 vault 3', '', 12.26829462),
    ('bc1qh0kkrnegczjr39rw52fvutv4ur6kvtdnva3jf6tkwwy7hwwtl4uqm96ruj', 'Wave 3 vault 4', '', 9.74151747),
    ('bc1qajca7effwcxgpdf4aagh3h3fz634fnk93ar7c00hvghs3lrgk4tqd23qsa', 'Wave 3 vault 5', '', 6.88509195),
    ('bc1qysjc4jrltc4je2f9uek477xdczkrmkd0mwv2wgn47r0wt3qv4a7spkkx7h', 'Wave 3 vault 6', '', 5.39098601),
    ('bc1qtsp50w4uru6t2mhmctvapfdy23g75r08werm7qmgzuedm4jsaztspf73yt', 'Wave 3 vault 7', '', 4.53878769),
    ('bc1qmqc0vur7v0xcnvq2pzm2kxlmvw6twhlxtsvd8jgudjdwnud6juusx4nlwt', 'Wave 3 vault 8', '', 4.14175791),
    ('bc1qsev2de8syz3g9005q7ckkthak6jhz2y4gzjc8a7tfatdkx7y42sqdtwlwd', 'Wave 3 vault 9', '', 4.01383972),
    ('bc1qunepx2e5f65xa9cftcv6t6qcnwky77g9sl8k95zcty4jqwkxuurqs9ap78', 'Wave 3 vault 10', '', 3.98704957),
    ('bc1qcryst78hk4z75hsu5dyutlgnyw3g3grcwsenfvhpl3uvpf0m6paqqr0epc', 'Wave 3 vault 11', '', 3.80969146),
    ('bc1qfdps7y2239kh8vpm7c08ysgsx25a4e90ashpnyzsl9kq493ge4dqcgzcc4', 'Wave 3 vault 12', '', 3.63032005),
    ('bc1qvyzwwpf38jntuusr8x6nefqmafkdqe8h0m5k8zkh5nnte29h8sxqes4sp2', 'Wave 3 vault 13', '', 3.39235311),
    ('bc1qg4qf5dqsry4wrhdp3wcf9eml6m0gy53c6nfxkg3wnyj7j36nefds7kkxcf', 'Wave 3 vault 14', '', 3.31668005),
    ('bc1qrw2njsqq5hv77wx7dnrff6la86tefqndv5rnwkmzh2g8qplp7vjq7acn5e', 'Wave 3 vault 15', '', 3.2672638),
    ('bc1qhm7vwpzaj9ahhhcmcxmsgqlj6tct84qupwdqe77djznvqrc9ksxqsxflvy', 'Wave 3 vault 16', '', 3.00585628),
    ('bc1qunseds6wr6q6qux972jfrl7fckfehcp0ldanhryvmvtw93w0ekzq0rwsnx', 'Wave 3 vault 17', '', 2.99922036),
    ('bc1qyqjwt8gc5jcveknknvdjzjvk5u5zcy0hkaznwxt69x5kyxueqztsm5a848', 'Wave 3 vault 18', '', 2.92126799),
    ('bc1qhx7w9u2ahwmq2gtkepgtl9urlkfjn6zqdqr5sjzjvs7z8z8r4ytqgwadeu', 'Wave 3 vault 19', '', 2.8116249),
    ('bc1qe68n9fruk29e9kgps0rf55mjpts0u2sgzq7nxamd0hppnpky8fas7226a5', 'Wave 3 vault 20', '', 2.73894035),
    ('bc1qy76lxfn55t7xk5xurvyt6u564fug9g8j3q5mf8j4cddesdn27l5sasslyf', 'Wave 3 vault 21', '', 2.72482097),
    ('bc1q7qu3wyh6vz40j03x4s7vyeh29t08tfc3gm9mckuqq27yg7zlmsrq0qacp4', 'Wave 3 vault 22', '', 2.6494159),
    ('bc1qe7lwuc3vjzmf3rgvh9sc7y8sxmej7vr8pcsyae7me7vr7hu8qfeqvxafaj', 'Wave 3 vault 23', '', 2.52064635),
    ('bc1qw3mqlq88es7046e366hmledrydd5aqz5kpw57hq3fxutmctl7m3swxyhph', 'Wave 3 vault 24', '', 2.50784278),
    ('bc1q48yxfmeckk5r0nkzkjw83xyhtvhl7jhfmkzdpxuqy3yg7nd4k4qsu4r4zn', 'Wave 3 vault 25', '', 2.20411992),
    ('bc1q4jg9dq0twcm23ee7e4etghm5y67h6tc59t95eklg8remnryamqtq7pmdnh', 'Wave 3 vault 26', '', 2.18639261),
    ('bc1qvlaxcmgnqyej6g0j79fzgga6s4za54yqyxzdfgv2cvazzn83kp6s9kqr94', 'Wave 3 vault 27', '', 2.08421608),
    ('bc1qmfyggrvay30lfrn0743f0g4v20agl2gvkmcaycxd5tf6nh5nxfgqlq084x', 'Wave 3 vault 28', '', 2.05746782),
    ('bc1qr4jfp7f5cmrtrl2gxsrujyec60gq3x54p4u08n8xgslkvww787nq4ylgjt', 'Wave 3 vault 29', '', 2.02848692),
    ('bc1q4x7ydyupwz26ytwwykrnh0dd8my343cmlzwfhfcdy9c80qsdztzqurffxu', 'Wave 3 vault 30', '', 1.73373065),
    ('bc1qkz22xld4ayh0gcvsdc43v9yuylzczhhasqsts8swq3clck0mrgzqulkacd', 'Wave 3 vault 31', '', 1.69188357),
    ('bc1qmhhm9s0mwq53jskqfywgpza6pqqugzax4twpdnsdd0jxv56dh8rqm58kkh', 'Wave 3 vault 32', '', 1.68228991),
    ('bc1qkaqcswmepnauaxnczf5waxuer3zqce2tsjcl99dnyeywfpcxmq0q9j8lch', 'Wave 3 vault 33', '', 1.58669331),
    ('bc1qxkpyzkx5pewn8gjqwvw979hpc3ufsqugm5c540xeeq4ue0s6yuestkfqla', 'Wave 3 vault 34', '', 1.51069414),
    ('bc1qj3jd8yakc2h3kta302h0nfn959aeqdyd987atyaquk0z6ulje2rqj5v27w', 'Wave 3 vault 35', '', 1.42355164),
    ('bc1qxuva8whfr2x49puaq09lv20ucla7ar5zfpx4a7ujmw69hawvd0esuddrs5', 'Wave 3 vault 36', '', 1.2984018),
    ('bc1q3jnv88p33tq48z5dyctmau6cws7lzfrepehc7duezxt6j7n98jxs0yqva7', 'Wave 3 vault 37', '', 1.29341473),
    ('bc1q2q8epfccrc9aynv7uz7z7j93rnz5gwuz8le6zhd0t386d3etcpxs6h0lm2', 'Wave 3 vault 38', '', 1.24272696),
    ('bc1qtwx5m06dmxt5as7ulpkuq5kd345q3teauwu833mkf5s7jft4nl9qjfyxru', 'Wave 3 vault 39', '', 1.14238463),
    ('bc1qfgv6pyhs6y2cs0rzc5cuadsdzjju6ae0t7qwvdle3zy5vmf02ynqs4h9ae', 'Wave 3 vault 40', '', 1.10389012),
    ('bc1qwy642mr3u4gwl3zwzxd6p7l3m33a0mhp6xdj27y83dqsrw7xxvcq65xj4s', 'Wave 3 vault 41', '', 1.05327357),
    ('bc1qtu8suvhp2zp994fnawvrmuvh5k263waap3kmwluzacfsdv4x9grs4eg09u', 'Wave 3 vault 42', '', 1.0430298),
    ('bc1qcu3w05j0n7s3q7nlwn0apsfnyapxd7r3xj2rrny0ffnkfxmpuafqetphx4', 'Wave 3 vault 43', '', 1.01976357),
    ('bc1q3ehq9tz6zz9xur969exg2rnpsqge6uf9vqanz3v856knnz5w2e9sa5yh8j', 'Wave 3 vault 44', '', 1.01857988),
    ('bc1qqu0sp6d4wxnp33ghgrtexrczzzl85f3vnlueep2hlsw69t38xazqltw5ac', 'Wave 3 vault 45', '', 1.01266757),
    ('bc1qsjltn7mjlkl3fuxdexdnjjyzp9fxcvzxn92upxa0764nfxl7a6eqzc0szd', 'Wave 3 vault 46', '', 1.0069355),
    ('bc1qtl628t8mfce0sgfxpkt5csu2uw4jema7ekz5gj2atsvktyzwur2qwh9r2j', 'Wave 3 vault 47', '', 1.00093255),
    ('bc1qa3mfkwwdsa2xa6xsazp5yd2cr5e3tyf7j0mgax7xzumpj4gtz77sdzd64z', 'Wave 3 vault 48', '', 1.00040325),
    ('bc1q5c6m8qrqum6xhlyaw9yplggak4vlz6p9kj8szcphh2x0jvfa3heq5mgfa2', 'Wave 3 vault 49', '', 1.0001318),
    ('bc1qt2k74x6s8wdtvqwmdnhdzm4k7dvueprh3e0hvs9q68w0eq6xm49s3kf758', 'Wave 3 vault 50', '', 0.9949898),
    ('bc1q7fgsarhqap7kzhpy2efj5d5fha735jne2e225g9dzn244f3je84smsv9fj', 'Wave 3 vault 51', '', 0.9934878),
    ('bc1qrx4r4dgfhtfn3ekumz6f53m7s9y5wch985fa0zxznz6hnm6vt9sqkutf0n', 'Wave 3 vault 52', '', 0.85192202),
    ('bc1q464uswwg3sq3c00wc8wqhlhqpl0samckyggnzhlk3rjl38nhr6ssslhtk0', 'Wave 3 vault 53', '', 0.85074447),
    ('bc1qkl4vkxz7ax3zkwz6awq4pv2xuznjzqrs4ve4h957zrvpy8jfyvsshglgc2', 'Wave 3 vault 54', '', 0.83717676),
    ('bc1q893u2894gx3ctxdl4hfn78ya60zzqndfpsucm02q7d0wf7l6rdvq2p64rx', 'Wave 3 vault 55', '', 0.8353508),
    ('bc1q75t6kucn702r8fmg5wt7xq3pgvw0lkua2p5a6wv2f2nlrmhlu44que6rl2', 'Wave 3 vault 56', '', 0.81458411),
    ('bc1qc5j2jgp0mgrxsmq79gq5yv0gavyuxfd3z3plss4mcrvnq0239zeq822mln', 'Wave 3 vault 57', '', 0.80094372),
    ('bc1qnjpfhc4pdmxmp2tzjz45u22anttwtk4u0lty5x2k7qwcsjm0hn2sa8tqc5', 'Wave 3 vault 58', '', 0.77812263),
    ('bc1q64nmnz88qe0ee0e3a3ne4mnelvajz8dq5zcf8mqvslqcx72hdtzqsu2td0', 'Wave 3 vault 59', '', 0.77291101),
    ('bc1qassuukkcmngzuvg3crswanmsnr08pjnunvc24f7r2p0zjnj35sushh93zs', 'Wave 3 vault 60', '', 0.7596758),
    ('bc1qxhju5xspcy8vu9ey4fve548wycm6nhq3vrvp902e0xsm0cz7l8zqh2snjc', 'Wave 3 vault 61', '', 0.75536701),
    ('bc1qq2y3q0c4ulhe9c00pmxmhxkdcsuc0pf93krhcmqk3cy7nkjkc6vqgjpzkl', 'Wave 3 vault 62', '', 0.70431447),
    ('bc1q22envza50tlwrdegd9uf86k98jccjc4uxh6te7lyapxnkx05q6nq5knnwx', 'Wave 3 vault 63', '', 0.70394873),
    ('bc1q6vc45ulxcyra499strhwrrppcnqk6s40wrn4frl4dpx5wansl0cs9yklv7', 'Wave 3 vault 64', '', 0.69906337),
    ('bc1qrmp5a6xdve2k9kun5g22y09gdwnmd3e6npxt0s2t7nzxrnv9uufs7z8w8m', 'Wave 3 vault 65', '', 0.67899409),
    ('bc1qu673mdghv3e8sctx7vmjv5uzrgz9khzw4q7nefuu3uhmsehst5sqhz5ztq', 'Wave 3 vault 66', '', 0.66510239),
    ('bc1qhvrd93pj6zh5qt23g79v3c06j7weuyljsjkc8klr7sqgkncgpkksptvlfn', 'Wave 3 vault 67', '', 0.64598208),
    ('bc1q0pyh8m970h2gsv8l8l58alk02qt6q9hnzf6mh4r3rurqkhvdllns3m6nkf', 'Wave 3 vault 68', '', 0.61847292),
    ('bc1q5k77u9n2c5tp7mg3pwauyhcju0c2sytfem84rpqwj5par392qwvslddty4', 'Wave 3 vault 69', '', 0.5941699),
    ('bc1qmkqkq6c7nj9ahrldajdktwgnkw98yxcn6rvq0p4ude8a3lrc4p2ql4mgyu', 'Wave 3 vault 70', '', 0.58741437),
    ('bc1qwymjp76jw6m2qyctynwfpy37cdwcdryhamclg2vdvucgheql09as3u33zw', 'Wave 3 vault 71', '', 0.58633145),
    ('bc1qm2de6c09vajzztvxzv8tzn2a7rj68sx4zjcaczunh6s34yh0fyvqmnq34p', 'Wave 3 vault 72', '', 0.5808048),
    ('bc1qcwcxxpnpryvhe42z3dweecdwfyvxgklwus654ta6ks3tlqfrfdus5g6ygk', 'Wave 3 vault 73', '', 0.55392397),
    ('bc1q6vpkjwgv88w53v03axsd6k0jjr8zjv9mrt5swgda9k45qh75764s37udc6', 'Wave 3 vault 74', '', 0.54963327),
    ('bc1qtujm6ufvvnmemtjyulmhgq9cky7ww92yc2t0ye8t2kyd6eu563vqwpqs6r', 'Wave 3 vault 75', '', 0.54569989),
    ('bc1qfeg6uqsv7rt59yp3fs44x8wunre5vpgum7shkejwxz86hvn3c9uqa58my8', 'Wave 3 vault 76', '', 0.54132885),
    ('bc1qkwhtguuqpdlr2zjq7nf87etgs9f3y98qy6epvgk6kl7xe02msntst8n5zl', 'Wave 3 vault 77', '', 0.5069498),
    ('bc1qkyncy6suewl4gs7ptvwggvvdwtt70u68rey7yw2ymyuxcpwxagmssw293h', 'Wave 3 vault 78', '', 0.50163823),
    ('bc1qq85v2c926eg6pgxhwp6q7lf6cnsz80qs3fcu9r', 'Holding 1', 'galaxy-july30', 562.02021083),
    ('bc1qx76cae2706qd5q576feh7xq8rfcsjpf2htfhe3', 'Holding 2', 'galaxy-july30', 398.47575357),
    ('bc1q8jy96fe5lf8vfugydnte3cguk92gpev7kwtp3q', 'Holding 3', 'galaxy-july30', 89.6232789),
    ('bc1qnk4zh9qcnap2mycp56qjrgza3cc8ylrh8fecp0', 'Holding 4', 'galaxy-july30', 32.4505632),
    ('bc1qtfrwa4j6rmj9rsgspv6a0yjumkg39js2numu75', 'Wave 2 vault', 'galaxy-july31', 45.90251994),
    ('bc1qmd5m5ktv7m5ffujxv4248fxv36myvdx79n8jp6', 'Wave 2 collector', 'galaxy-july31', 30.18476329),
    ('bc1q7rmsw0ra7zrphe66wwa9960ffm69cp8dlrrcgf', 'Evening vault', 'evening-july31', 0.50980268),
    ('bc1qayw8nrec0vsa5vj4xee4dqhfgztx2gqq7w2u0s', 'Aug 1 hop vault', 'evening-july31', 0.69135523),
    ('bc1qzm5pauxyv7t7vqstzpumqcn066wfjsmev34mf3', 'Aug 1 vault', 'morning-aug1', 0.33203236),
    ('bc1q0rvn88w08j75k4h48lf9fvhan7unjp7vjf5q6m', 'Aug 2 vault', 'early-aug2', 64.90373764),
    ('bc1q05g20l8k0tqjks9sq73mqgqx48pmw2cknju0ml', 'Wave 4 park', 'wave4-aug3', 8.55600708),
    ('bc1qjenjhm6wgyz9m50svnvlyv6339z6jtmlmv4z23', 'Wave 4 park', 'wave4-aug3', 5.07348539),
    ('1N8knQCfjqUeJQwjkZZavbboXXL6WVqfDo', 'Wave 4 park', 'wave4-aug3', 5.61303754),
    ('342L6n3b61n1CGoh8wCzuzyXUvyZTSjZtz', 'Wave 4 park', 'wave4-aug3', 2.02374869),
    ('bc1q65e6cd2k5ee2326fwwxsng370vf9pz85yce9u8', 'Wave 4 park', 'wave4-aug3', 1.14852067),
    ('36XfMDAYuCn76DDJt5HV6kJxCukb1F1x3G', 'Wave 4 park', 'wave4-aug3', 1.09429601),
    ('bc1qcmnjt058q8hs4fvjr9wlu2kt974fyqnprfjvtl', 'Wave 4 park', 'wave4-aug3', 1.04998888),
    ('bc1pj7f35576cz0cznm75h6scp9eknqeqx4r666kdpcnk9slgh4sp5vs4sulc2', 'Wave 4 park', 'wave4-aug3', 1.03514173),
    ('34nHYNnc9DLxo3iCzvSHiyD2YsC3jP4qDQ', 'Wave 4 park', 'wave4-aug3', 0.96227191),
]

def get(url, timeout=25, tries=3):
    for i in range(tries):
        try:
            req = urllib.request.Request(url, headers={
                "Accept": "application/json",
                "User-Agent": "coldcard-fund-watch/1.0 (read-only)"})
            with urllib.request.urlopen(req, timeout=timeout) as r:
                return json.loads(r.read().decode())
        except urllib.error.HTTPError as e:
            if e.code == 429:
                time.sleep(20); continue
            if i == tries - 1: raise
            time.sleep(3)
        except Exception:
            if i == tries - 1: raise
            time.sleep(3)


def spends_from(tx, addr):
    """Sats this tx spends FROM addr. Receiving returns 0 — that is the point."""
    total = 0
    for vin in tx.get("vin", []):
        prev = vin.get("prevout") or {}
        if prev.get("scriptpubkey_address") == addr:
            total += int(prev.get("value") or 0)
    return total


def venue(a):
    return "  [%s]" % VENUE[a] if a in VENUE else ""


def load_state():
    try:
        with open(STATE_FILE) as fh: return json.load(fh)
    except Exception:
        return {}


def save_state(s):
    try:
        with open(STATE_FILE, "w") as fh: json.dump(s, fh)
    except Exception as e:
        print("  (could not write state: %s)" % e, file=sys.stderr)


def one_pass(api, state, quiet=False):
    events, held, ever, errs = [], 0.0, 0.0, 0
    for addr, label, cluster, report in TRACKED:
        try:
            d = get("%s/address/%s" % (api, addr))
            cs = d.get("chain_stats", {}) or {}
            ms = d.get("mempool_stats", {}) or {}
            recv = (cs.get("funded_txo_sum") or 0) / 1e8
            bal = recv - (cs.get("spent_txo_sum") or 0) / 1e8
            held += bal; ever += recv

            before = state.get(addr)
            if before is not None and bal < before - 1e-8:
                events.append(("CONFIRMED_OUT", addr, label, before - bal, before, bal, None, []))
            state[addr] = bal

            if (ms.get("tx_count") or 0) > 0:
                for tx in (get("%s/address/%s/txs/mempool" % (api, addr)) or []):
                    out = spends_from(tx, addr)
                    if out > 0:
                        dests = sorted(
                            [(o.get("scriptpubkey_address"), (o.get("value") or 0) / 1e8)
                             for o in tx.get("vout", [])],
                            key=lambda t: -t[1])[:3]
                        events.append(("MEMPOOL_SPEND", addr, label, out / 1e8,
                                       None, None, tx.get("txid"), dests))
                time.sleep(GAP)
        except Exception as e:
            errs += 1
            print("  ERR %s %s" % (addr[:20], e), file=sys.stderr)
        time.sleep(GAP)

    if not quiet:
        print("pass: %d addresses, %d errors | held %.8f BTC | ever received %.8f BTC | events %d"
              % (len(TRACKED), errs, held, ever, len(events)))
    for kind, addr, label, amt, f, t, txid, dests in events:
        print("\n" + "!" * 66)
        if kind == "MEMPOOL_SPEND":
            print("  UNCONFIRMED SPEND — %.8f BTC leaving" % amt)
            print("  %s  (%s)" % (addr, label or "tracked"))
            print("  txid %s" % txid)
            for d, v in dests:
                print("      -> %s %.8f%s" % (d, v, venue(d)))
        else:
            print("  CONFIRMED OUT — %.8f BTC left" % amt)
            print("  %s  (%s)" % (addr, label or "tracked"))
            print("  balance %.8f -> %.8f" % (f, t))
        print("!" * 66)
    return events


def selftest():
    ok = True
    def chk(n, got, want):
        nonlocal ok
        good = got == want; ok = ok and good
        print("  %-44s %s" % (n, "PASS" if good else "FAIL got=%r want=%r" % (got, want)))
    A = "bc1qexample"
    spend = {"vin": [{"prevout": {"scriptpubkey_address": A, "value": 500000}}]}
    deposit = {"vin": [{"prevout": {"scriptpubkey_address": "other", "value": 900000}}],
               "vout": [{"scriptpubkey_address": A, "value": 899000}]}
    print("known-answer tests (no network):")
    chk("spend from A is detected", spends_from(spend, A), 500000)
    chk("DEPOSIT to A does NOT count", spends_from(deposit, A), 0)
    chk("tx with no vin is silent", spends_from({}, A), 0)
    chk("venue label resolves", venue("3CTpBmp8uWTcHJBjmyVe8VPPyCHTzj2hBH").strip(),
        "[Bullish.com deposit]")
    chk("newly added KuCoin label resolves",
        venue("328GxewqTzMxLPvLemaKS7Q5Wi1io8EEYD").strip(), "[KuCoin deposit]")
    chk("unknown address has no label", venue("bc1qnothing"), "")
    chk("address list loaded", len(TRACKED), 97)
    total = sum(r for _, _, _, r in TRACKED)
    chk("reportBtc total matches published", round(total, 8), 1436.62417755)
    print("\n%s" % ("ALL PASS" if ok else "FAILURES ABOVE"))
    return 0 if ok else 1


def main():
    p = argparse.ArgumentParser(description="Watch Coldcard stolen-fund addresses for movement.")
    p.add_argument("--api", default=DEFAULT_API)
    p.add_argument("--watch", action="store_true", help="loop instead of a single pass")
    p.add_argument("--interval", type=int, default=1800, help="seconds between passes")
    p.add_argument("--selftest", action="store_true")
    a = p.parse_args()
    if a.selftest:
        return selftest()
    api = a.api.rstrip("/")
    state = load_state()
    first = not state
    if first:
        print("No prior state — this pass establishes the baseline. "
              "Movement is reported from the NEXT pass onward.")
    try:
        while True:
            one_pass(api, state)
            save_state(state)
            if not a.watch:
                return 0
            time.sleep(a.interval)
    except KeyboardInterrupt:
        save_state(state)
        print("\nstopped.")
        return 0


if __name__ == "__main__":
    sys.exit(main())
