COLDCARD RNG incident the public record, collected and explained
Informational only, and this site never asks for your seed words. details

Informational only. This is an open source collection of what others have published about the incident, together with an explanation of it. It is not financial, security or legal advice, and not a substitute for professional advice about your own situation. It is not affiliated with, endorsed by, or speaking for Coinkite. Material is attributed and quoted as published; where sources disagree their scenarios are kept separate with their assumptions rather than reconciled into one answer. Everything is meant to be checked against the linked evidence rather than taken on trust. Act on your own judgement about a particular situation. Editorial standards and corrections.

Do not disclose recovery material to a website, form, message or support account. This site never asks for it, and contributions containing recovery words or private keys are not accepted.

Chain-derived COLDCARD RNG postmortem

kelbie-rng-postmortem

https://raw.githubusercontent.com/Kelbie/coldcard-rng-postmortem/main/README.md

Latest reviewed change

source content difference between and

Two figures were corrected (the same-four-blocks wave count moved from 291 to 286 addresses, and sweeps that paid more in fees than the coins were worth moved from 27 to 31) and the fee analysis was rewritten: the fixed size-table part is now given as 42 or 53 vbytes with two build variants, and the estimate now described as running from a vbyte under to 239 over.

seen +7 -6 full history below
 31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
 nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave entered the
 ledger because one victim screenshotted an address that was not even his own — everything else
-about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
+about it was reached by noticing that 286 addresses were emptied in the same four blocks the same
 way, and four claimants have since named addresses inside it. A tenth wave arrived later still,
 paying to Taproot in blocks past everything else here, and it is in the record only because one
 person filed an abuse report naming three transactions. Every figure here is a floor, not a total —

First lines only. The complete diff is in the timeline below.

Organisation
Kelbie
Evidence role
Independent primary analysis
Published
2026-07-31
Source changes
8
Detected differences
8
Unreviewed
0
Copies held
9

Rapidly updated independent postmortem built from frozen chain data and public reports. It documents its derivation and attribution rules, but its wave grouping, entity attribution and counterfactual conclusions remain the author's analysis rather than facts established by the chain alone.

Every check is recorded, including checks that found no text change. A detected edit is therefore bounded between two checks. The publisher's exact save time is not observable from this record. Last checked .

  1. source content difference between and Current source content +7 -6

    Two figures were corrected (the same-four-blocks wave count moved from 291 to 286 addresses, and sweeps that paid more in fees than the coins were worth moved from 27 to 31) and the fee analysis was rewritten: the fixed size-table part is now given as 42 or 53 vbytes with two build variants, and the estimate now described as running from a vbyte under to 239 over.

    seen · Captured here 16,589 chars
    What changed from the previous capture 13 lines
     31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
     nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave entered the
     ledger because one victim screenshotted an address that was not even his own — everything else
    -about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    +about it was reached by noticing that 286 addresses were emptied in the same four blocks the same
     way, and four claimants have since named addresses inside it. A tenth wave arrived later still,
     paying to Taproot in blocks past everything else here, and it is in the record only because one
     person filed an abuse report naming three transactions. Every figure here is a floor, not a total —
     nothing more — two people can run one program, one person can run two.
     **Wave colour** is read from exactly those two numbers: hue is the family, shade the variant. Hue is
     an identity rather than a distance, so refreshing cannot repaint a wave that has not changed.
    -**The fee** is priced from a size table and nothing else — `42 vbytes + 68 | 91 | 148 per input by
    -script type`, looked up before signing and never rechecked, which is why the estimate always runs
    -over what got signed. It is not a share of value: inside wave 960345 the fee spans 1.7 million to
    -one against the amount taken, and 27 sweeps across the record paid more to move the coins than the
    -coins were worth. Where a wave uses several rates the split is resolved rather than averaged,
    +**The fee** is priced from a size table and nothing else — `42 or 53 vbytes + 68 | 91 | 148 per
    +input by script type`, looked up before signing and never rechecked, so the estimate does not track
    +what got signed: it runs from a vbyte under to 239 over. The fixed part belongs to the build rather
    +than to the destination, which is why there are two of them. It is not a share of value: inside wave
    +960345 the fee spans 1.7 million to one against the amount taken, and 31 sweeps across the record
    +paid more to move the coins than the coins were worth. Where a wave uses several rates the split is resolved rather than averaged,
     because the cases differ — wave 960345 binds the rate to the code path (50 sat/vB on all 1,214
     single-address sweeps, 8 on both multi-address ones), wave 960352 has one stray, and wave 960518
     uses nine rates whose strongest correlation with value, size or order is r = −0.38.
    
    Extracted text as captured
    # coldcard.rip — an ongoing postmortem
    <!-- facts -->
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    wallet seeds, the 1,432.48 BTC swept from 5,477 addresses across ten waves since 30 July 2026, and the
    higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    the vendor and Block advisories, and the original posts as they were made.
    <!-- /facts -->
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave entered the
    ledger because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 286 addresses were emptied in the same four blocks the same
    way, and four claimants have since named addresses inside it. A tenth wave arrived later still,
    paying to Taproot in blocks past everything else here, and it is in the record only because one
    person filed an abuse report naming three transactions. Every figure here is a floor, not a total —
    rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    bun run facts                        # resync the marked prose blocks (README, meta description)
    ```

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  2. source content difference between and source content +46 -17

    The postmortem moved its headline figures to 1,432.48 BTC swept from 5,477 addresses across ten waves, retitled itself coldcard.rip, and documented a tenth wave paid to Taproot that entered via an abuse report. It also added methodology sections on what a report claims versus corroborates (reported_inputs, ATTESTATIONS, voice) and revised rebuild timing notes.

    seen · Captured here 16,451 chars
    What changed from the previous capture 63 lines
    -# COLDCARD RNG — an ongoing postmortem
    +# coldcard.rip — an ongoing postmortem
     <!-- facts -->
     A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    -wallet seeds, the 1,431.97 BTC swept from 5,415 addresses across nine waves since 30 July 2026, and the
    +wallet seeds, the 1,432.48 BTC swept from 5,477 addresses across ten waves since 30 July 2026, and the
     higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
     the vendor and Block advisories, and the original posts as they were made.
     <!-- /facts -->
     tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
     satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
     31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    -nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    -ledger only because one victim screenshotted an address that was not even his own — everything else
    +nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave entered the
    +ledger because one victim screenshotted an address that was not even his own — everything else
     about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    -way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
    +way, and four claimants have since named addresses inside it. A tenth wave arrived later still,
    +paying to Taproot in blocks past everything else here, and it is in the record only because one
    +person filed an abuse report naming three transactions. Every figure here is a floor, not a total —
    +rebuild from the block store to refresh it.
     Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
     with a source link. Nothing calls an API at runtime.
     ## Running it
     bun run commits   # GitHub commit metadata → src/data/commits.json
     bun run threads   # reconstructed X threads → src/data/threads.json
     ```
    -A full rebuild of `chain.json` is about ten seconds. It used to be twenty minutes against a public
    -explorer, and it once served a **stale balance** from its own cache — the page went on saying coins
    -had not moved after they had. Reading blocks from disk removes both problems: there is no cache to
    -go stale, and a question that costs nothing can be asked of every transaction rather than only of
    -the addresses already known.
    +A full rebuild of `chain.json` takes **five to twelve minutes** — budget for it, and do not assume
    +a long run has hung. Almost all of that is SQLite: the address index is around 4.5 GB, the walk asks
    +it for one address's transactions at a time, and there are now several hundred addresses to ask
    +about. It runs at roughly 15% CPU throughout, so a `ps` showing little CPU time is the normal
    +picture rather than a stuck process.
    +It used to be twenty minutes against a public explorer, and it once served a **stale balance** from
    +its own cache — the page went on saying coins had not moved after they had. Reading blocks from disk
    +removes both problems: there is no cache to go stale, and a question that costs nothing can be asked
    +of every transaction rather than only of the addresses already known.
     `blocks/` is gitignored. Re-index after every refill — `chain.py` reads the index, not the API.
     See **[analysis/README.md](analysis/README.md)** for the module map, the fingerprint primitives and
     how to hunt a new operator's pattern.
     the file gives wave 960188's collector plus 500 victims. `txids` is a list because a wallet emptied
     one UTXO at a time is one event; splitting it across reports would lose that they belong together,
     which is what stops an operator who never pools from deriving as one wave per address.
    -Where nobody has tied a named victim to an address, use `PUBLISHED_ADDRESSES` with the publisher's
    -own confidence — `attacker-controlled` where the source names it as part of the theft,
    +**What a report claims, and what it only corroborates.** `reported_inputs` decides, and the three
    +settings are different claims rather than degrees of one:
    +| setting | the claimant said | ledger |
    +|---|---|---|
    +| omitted | the sweep emptied their wallet, and listed no addresses | **Claimed by**, `whole wallet` |
    +| `[(addr, sats), …]` | these addresses were mine | **Claimed by**, `listed` |
    +| `[]` | that transaction took my coins; I am not saying which input was mine | **Sweep reported by** |
    +The third claims none of the addresses it names and still reaches every one of them, because on
    +wave 960668's 795-address co-spend it is the difference between a row nobody has spoken about and
    +a row three people reported. A published satoshi figure is checked against the chain, and the
    +build refuses a whole-wallet claim over more than 32 source addresses rather than handing one
    +reporter several hundred strangers' addresses.
    +Where nobody has tied a claimant to an address, use `PUBLISHED_ADDRESSES` with the publisher's own
    +confidence — `attacker-controlled` where the source names it as part of the theft,
     `pattern-match-unconfirmed` where they found it by fingerprint and said so. Block published waves
     960183 and 960185 as a fingerprint match while stating they "have not confirmed that these indeed
    -are related to drain"; that caveat survives into the UI rather than being flattened.
    +are related to drain"; that caveat is carried as the claim's `hedge` and printed beside the
    +publisher's name rather than flattened. `voice` records who was speaking: three of these entries
    +are victims naming where their own coins went and one is a bystander relaying an acquaintance, and
    +filing those as an analyst's finding was the record's oldest overstatement.
     `SCAN_MATCHES` is this project's own reading, on nobody's authority — named `RC.n`, outside the
     numbered sequence, so withdrawing one renames nothing.
     Where a report names a sweep already tracked here but does not identify the claimant's input, use
    -`CORROBORATING_REPORTS`. The build resolves its transaction to a wave and displays the source there,
    -but it does not create a victim attribution or alter the derived address set and totals.
    +`CORROBORATING_REPORTS`. The build resolves its transaction to a wave, shows it against every
    +swept address of that sweep as a **Sweep reported by** claim, and creates no ownership attribution
    +and no change to the derived address set or totals.
    +`ATTESTATIONS` are for somebody naming an address the walk had already reached. They add no
    +address and no satoshi; what they add is that two records built opposite ways round agree. Galaxy
    +listed five addresses and four were already here, which until this existed the record could not
    +say — the walk kept whichever claim arrived first. An attestation that does not resolve to a
    +tracked address fails the build, because that is a seed nobody has justified.
     Then re-run `analysis.chain`. Everything downstream rebuilds without touching a component.
     ### What is derived, not written
     No number on the page is typed by hand, and no claim about the current state of the coins is a fixed
     instead of paraphrasing press coverage into the record; nvk's deleted tweet is marked unrecoverable
     rather than reconstructed.
     ## Scope
    -Assembled from public record for postmortem purposes. Victim addresses were already public on the
    -blockchain and, in one case, published by the victim. The defect is patched — Mk3 4.2.0, Mk4/Mk5 5.6.0,
    +Assembled from public record for postmortem purposes. Swept addresses were already public on the
    +blockchain; a claimant has named 66 of them, and most of the record is addresses nobody has spoken
    +about, reached by following the money from the ones somebody did. The defect is patched — Mk3 4.2.0, Mk4/Mk5 5.6.0,
     Q 1.5.0Q — and the only useful action left for an affected user is migrating off the seed, since
     updating firmware does not repair one already generated.
    
    Extracted text as captured
    # coldcard.rip — an ongoing postmortem
    <!-- facts -->
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    wallet seeds, the 1,432.48 BTC swept from 5,477 addresses across ten waves since 30 July 2026, and the
    higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    the vendor and Block advisories, and the original posts as they were made.
    <!-- /facts -->
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave entered the
    ledger because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    way, and four claimants have since named addresses inside it. A tenth wave arrived later still,
    paying to Taproot in blocks past everything else here, and it is in the record only because one
    person filed an abuse report naming three transactions. Every figure here is a floor, not a total —
    rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    bun run facts                        # resync the marked prose blocks (README, meta description)
    ```

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  3. source content difference between and source content +5 -2

    The README added a CORROBORATING_REPORTS input for reports that name an already tracked sweep without identifying the claimant, and updated timeline path references from timeline.tsx to a timeline directory.

    seen · Captured here 14,015 chars
    What changed from the previous capture 7 lines
     how to hunt a new operator's pattern.
     ## Adding new evidence
     `analysis/evidence.py` is the only hand-maintained *chain* input in the project (the curated
    -narrative lives in `src/data/timeline.tsx`, and the exhibit sources in `src/data/*-sources.ts`).
    +narrative lives in `src/data/timeline/`, and the exhibit sources in `src/data/*-sources.ts`).
     It holds evidence, not conclusions — there is no list of attacker addresses anywhere. The addresses, waves, labels and
     justifications are all derived by walking the chain:
     ```
     are related to drain"; that caveat survives into the UI rather than being flattened.
     `SCAN_MATCHES` is this project's own reading, on nobody's authority — named `RC.n`, outside the
     numbered sequence, so withdrawing one renames nothing.
    +Where a report names a sweep already tracked here but does not identify the claimant's input, use
    +`CORROBORATING_REPORTS`. The build resolves its transaction to a wave and displays the source there,
    +but it does not create a victim attribution or alter the derived address set and totals.
     Then re-run `analysis.chain`. Everything downstream rebuilds without touching a component.
     ### What is derived, not written
     No number on the page is typed by hand, and no claim about the current state of the coins is a fixed
     src/data/chain.json      generated; the source of every chain figure on the page
     src/lib/facts.ts         phrases those figures for prose, the meta description and this README
                              (`bun run facts` rewrites the marked blocks after a data refresh)
    -src/data/timeline.tsx    curated events, verbatim quotes, sourcing notes
    +src/data/timeline/       curated events, verbatim quotes, sourcing notes
     src/components/          Social (X + Reddit chrome), SweepReplay, MoneyFlow, Attribution,
                              Fingerprint, VictimLedger, RootCause, Forensics, Timeline
     ```
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    <!-- facts -->
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    wallet seeds, the 1,431.97 BTC swept from 5,415 addresses across nine waves since 30 July 2026, and the
    higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    the vendor and Block advisories, and the original posts as they were made.
    <!-- /facts -->
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    ledger only because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    bun run facts                        # resync the marked prose blocks (README, meta description)
    ```
    Commit the result — deploys ship whatever chain.json is in git. There is no scheduled refresh:
    CI cannot hold the block store, so freshness is a local ritual and the page discloses its own
    "last pulled" time instead.

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  4. source content difference between and source content +1 -1

    The headline figures changed to 1,431.97 BTC swept from 5,415 addresses across nine waves, reflecting a newly derived wave.

    seen · Captured here 13,730 chars
    What changed from the previous capture 2 lines
     # COLDCARD RNG — an ongoing postmortem
     <!-- facts -->
     A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    -wallet seeds, the 1,367.07 BTC swept from 4,620 addresses across eight waves since 30 July 2026, and the
    +wallet seeds, the 1,431.97 BTC swept from 5,415 addresses across nine waves since 30 July 2026, and the
     higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
     the vendor and Block advisories, and the original posts as they were made.
     <!-- /facts -->
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    <!-- facts -->
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    wallet seeds, the 1,431.97 BTC swept from 5,415 addresses across nine waves since 30 July 2026, and the
    higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    the vendor and Block advisories, and the original posts as they were made.
    <!-- /facts -->
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    ledger only because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    bun run facts                        # resync the marked prose blocks (README, meta description)
    ```
    Commit the result — deploys ship whatever chain.json is in git. There is no scheduled refresh:
    CI cannot hold the block store, so freshness is a local ritual and the page discloses its own
    "last pulled" time instead.

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  5. source content difference between and source content +16 -8

    The headline was updated to 1,367.07 BTC swept from 4,620 addresses across eight waves, and the README added a bun run facts step that rewrites marked prose blocks after each data refresh.

    seen · Captured here 13,731 chars
    What changed from the previous capture 24 lines
     # COLDCARD RNG — an ongoing postmortem
    -A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable wallet
    -seeds, the 1,082 BTC swept across three waves on 30 July 2026, and the higher-entropy tiers still
    -exposed. Reconstructed
    -from primary sources — the Bitcoin blockchain, the vendor and Block advisories, and the original posts
    -as they were made.
    +<!-- facts -->
    +A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    +wallet seeds, the 1,367.07 BTC swept from 4,620 addresses across eight waves since 30 July 2026, and the
    +higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    +the vendor and Block advisories, and the original posts as they were made.
    +<!-- /facts -->
     **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
     defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
     tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
     python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
     python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
     python3 -m analysis.chain            # → src/data/chain.json
    +bun run facts                        # resync the marked prose blocks (README, meta description)
     ```
    +Commit the result — deploys ship whatever chain.json is in git. There is no scheduled refresh:
    +CI cannot hold the block store, so freshness is a local ritual and the page discloses its own
    +"last pulled" time instead.
     ```bash
     bun run media     # avatars + screenshots → src/data/media.json
     bun run commits   # GitHub commit metadata → src/data/commits.json
     See **[analysis/README.md](analysis/README.md)** for the module map, the fingerprint primitives and
     how to hunt a new operator's pattern.
     ## Adding new evidence
    -`analysis/evidence.py` is the only hand-maintained data in the project. It holds evidence, not
    -conclusions — there is no list of attacker addresses anywhere. The addresses, waves, labels and
    +`analysis/evidence.py` is the only hand-maintained *chain* input in the project (the curated
    +narrative lives in `src/data/timeline.tsx`, and the exhibit sources in `src/data/*-sources.ts`).
    +It holds evidence, not conclusions — there is no list of attacker addresses anywhere. The addresses, waves, labels and
     justifications are all derived by walking the chain:
     ```
     victim report  →  the transaction they name  →  its destination is a collector
     blocks/                  local block store, gitignored, rebuildable
     scripts/                 tweet / media / commit archivers — TypeScript, they talk to web APIs
     src/data/schema.ts       types for the frozen dataset
    -src/data/chain.json      generated; the only source of numbers on the page
    +src/data/chain.json      generated; the source of every chain figure on the page
    +src/lib/facts.ts         phrases those figures for prose, the meta description and this README
    +                         (`bun run facts` rewrites the marked blocks after a data refresh)
     src/data/timeline.tsx    curated events, verbatim quotes, sourcing notes
     src/components/          Social (X + Reddit chrome), SweepReplay, MoneyFlow, Attribution,
                              Fingerprint, VictimLedger, RootCause, Forensics, Timeline
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    <!-- facts -->
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable
    wallet seeds, the 1,367.07 BTC swept from 4,620 addresses across eight waves since 30 July 2026, and the
    higher-entropy tiers still exposed. Reconstructed from primary sources — the Bitcoin blockchain,
    the vendor and Block advisories, and the original posts as they were made.
    <!-- /facts -->
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    ledger only because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    bun run facts                        # resync the marked prose blocks (README, meta description)
    ```
    Commit the result — deploys ship whatever chain.json is in git. There is no scheduled refresh:
    CI cannot hold the block store, so freshness is a local ritual and the page discloses its own
    "last pulled" time instead.

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  6. source content difference between and source content +120 -158

    The postmortem rewrote its data pipeline from a mempool.space API fetcher to a local Python block store, documented the resumed 31 July sweeping as 207.73 BTC from 1,918 addresses, and reworked its wave-grouping and fee-analysis sections.

    seen · Captured here 13,049 chars
    What changed from the previous capture 278 lines
     defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
     tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
     satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    -31 July, hours after the advisories, with different tooling and no pooling, and is in the ledger here
    -only because one victim photographed his wallet history. Every figure here is a floor, not a total — re-run the fetch scripts to refresh it.
    +31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    +nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    +ledger only because one victim screenshotted an address that was not even his own — everything else
    +about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    +way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
     Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
     with a source link. Nothing calls an API at runtime.
     ## Running it
     bun run typecheck
     ```
     ## Refreshing the data
    -Two generators, both writing into `src/data/`. Run them after any change to the address registry or
    -the media list:
    +Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    +archivers stay TypeScript because they talk to web APIs, not to blocks.
     ```bash
    -bun run fetch     # transactions → src/data/chain.json
    +python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    +python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    +python3 -m analysis.chain            # → src/data/chain.json
    +```
    +```bash
     bun run media     # avatars + screenshots → src/data/media.json
     bun run commits   # GitHub commit metadata → src/data/commits.json
    +bun run threads   # reconstructed X threads → src/data/threads.json
     ```
    -`fetch` caches raw API responses under `.cache/`, so re-running after a change to
    -classification or rollup logic costs nothing. Set `NO_CACHE=1` to force a fresh pull, or
    -`PAGE_DELAY_MS` to back off harder against the public explorer's rate limit.
    -### Chain data
    -`src/data/chain.json` is generated, not hand-written. Point the fetcher at your own node instead of
    -the public API:
    -```bash
    -MEMPOOL_API=https://your.node/api bun run fetch
    -```
    -The USD price behind every fiat figure is also captured at fetch time (`btcUsd` in chain.json,
    -always from the public mempool.space instance) rather than typed into the code; if the price
    -endpoint is unreachable, fiat figures render as "—".
    -### Images
    -`bun run media` downloads real avatars and post screenshots and freezes them into
    -`src/data/media.json` as data URIs, so no image is loaded from a third party at runtime. Add a tweet ID
    -to `src/data/media-sources.ts` and the script takes both the author's avatar and any attached images.
    -Every public Nitter mirror is dead or behind an antibot, so this uses Twitter's own syndication
    -endpoint (`cdn.syndication.twimg.com/tweet-result`), which needs no auth and reports deletions.
    -Set `avatarOnly: true` on an entry to take the avatar but skip its images. That flag exists for one
    -real case: the researcher who proved the flaw attached a screenshot containing the mnemonic and xprv of
    -his own disposable test device. The page describes what that image asserts rather than republishing key
    -material.
    +A full rebuild of `chain.json` is about ten seconds. It used to be twenty minutes against a public
    +explorer, and it once served a **stale balance** from its own cache — the page went on saying coins
    +had not moved after they had. Reading blocks from disk removes both problems: there is no cache to
    +go stale, and a question that costs nothing can be asked of every transaction rather than only of
    +the addresses already known.
    +`blocks/` is gitignored. Re-index after every refill — `chain.py` reads the index, not the API.
    +See **[analysis/README.md](analysis/README.md)** for the module map, the fingerprint primitives and
    +how to hunt a new operator's pattern.
     ## Adding new evidence
    -`src/data/evidence.ts` is the only hand-maintained file. It holds evidence, not conclusions —
    -there is no list of attacker addresses anywhere in the project. The addresses, the waves, their
    -labels and their justifications are all derived by walking the chain.
    -The walk runs in the direction the evidence actually flows:
    +`analysis/evidence.py` is the only hand-maintained data in the project. It holds evidence, not
    +conclusions — there is no list of attacker addresses anywhere. The addresses, waves, labels and
    +justifications are all derived by walking the chain:
     ```
     victim report  →  the transaction they name  →  its destination is a collector
     collector      →  everything paying into it  →  those payers are more victims
     collector      →  where it spends onward     →  that destination is a vault
     ```
    -So the strongest thing you can add is a **victim report** — someone saying publicly that coins left
    +The strongest thing you can add is a **victim report** — someone saying publicly that coins left
     without their say-so, naming the transaction:
    -```ts
    -{
    -  who: "u/example",
    -  txids: ["2fe075cf…"],                 // the destinations are derived from these
    -  source: { label: "r/Bitcoin", url: "https://…" },
    -  note: "What they published, and how it can be checked.",
    -}
    +```python
    +VictimReport(
    +    who="u/example",
    +    txids=["2fe075cf…"],          # destinations are derived from these
    +    source=Source(label="r/Bitcoin", url="https://…"),
    +    note="What they published, and how it can be checked.",
    +)
     ```
    -One report is enough to derive a collector, its vault, and every other victim that paid into it.
    -The first report in the file yields wave 960188's collector, its vault, and 500 victims.
    -`txids` is a list because a wallet emptied one UTXO at a time is one event with one transaction per
    -UTXO, and splitting that across several reports would lose the fact that they belong together. The
    -grouping matters: it is what stops an operator who never pools from being derived as one wave per
    -address.
    -Where nobody has tied a named victim to an address, add it to `PUBLISHED_ADDRESSES` instead, with
    -the publisher's own confidence:
    -| `claim` | meaning |
    -|---|---|
    -| `attacker-controlled` | the source names it as part of the theft |
    -| `pattern-match-unconfirmed` | the source found it by fingerprint and said it was unconfirmed |
    -Block published waves 960183 and 960185 as a fingerprint match and said in the same thread that they "have not
    -confirmed that these indeed are related to drain". That caveat survives into the UI rather than being
    -flattened into "attacker address".
    -Then `bun run fetch`. Everything downstream rebuilds: the flow diagram, the replay, the ledger, the
    -findings, the attribution tests and every headline number, without touching a component.
    +One report yields a collector, its vault, and every other victim that paid into it — the first in
    +the file gives wave 960188's collector plus 500 victims. `txids` is a list because a wallet emptied
    +one UTXO at a time is one event; splitting it across reports would lose that they belong together,
    +which is what stops an operator who never pools from deriving as one wave per address.
    +Where nobody has tied a named victim to an address, use `PUBLISHED_ADDRESSES` with the publisher's
    +own confidence — `attacker-controlled` where the source names it as part of the theft,
    +`pattern-match-unconfirmed` where they found it by fingerprint and said so. Block published waves
    +960183 and 960185 as a fingerprint match while stating they "have not confirmed that these indeed
    +are related to drain"; that caveat survives into the UI rather than being flattened.
    +`SCAN_MATCHES` is this project's own reading, on nobody's authority — named `RC.n`, outside the
    +numbered sequence, so withdrawing one renames nothing.
    +Then re-run `analysis.chain`. Everything downstream rebuilds without touching a component.
     ### What is derived, not written
    -Nothing on the page is a number typed by hand, and no claim about the current state of the coins is
    -a fixed sentence. In particular:
    +No number on the page is typed by hand, and no claim about the current state of the coins is a fixed
    +sentence.
     - **Which addresses are the attacker's** — from the walk above, never from a list.
    -- **Waves** — a wave is a connected component of the grouping graph. Collectors that pool into the
    -  same vault are one operation, numbered by when their first sweep confirmed. Pooling is not the
    -  only edge: an operator who sends every stolen UTXO to a fresh address pools nothing, and what
    -  holds those addresses together is that one victim reported the whole set as one drain.
    -- **Episodes** — a run of waves with no more than an hour of quiet between them. Waves say who
    -  pooled with whom; episodes say what happened at the same time as what, which is what the headline
    -  "X BTC in Y minutes" is a claim about. The threshold is not tuned: the longest quiet inside the
    -  30 July burst is 22 minutes and the next sweeping is more than a day later, so anything between
    -  those gives the same answer. The headline figures read the first episode; everything else carries
    -  all of it.
    -The two graphs both had to grow something to show a wave that arrived a day late and never pooled,
    -rather than being scoped away:
    -- The **clock replay** runs on plain linear time: one second of the axis is one second of 30 and 31
    -  July, everywhere along it. Nothing is cut and nothing is compressed, so the dead air between the
    -  sittings takes up as much of the picture as it took of the two days — which is 86% of it, and the
    -  first sitting is a spike two percent wide at the left, because that is what forty-one minutes is
    -  next to thirty-one hours. Runs of activity are still detected, but only for the broadcast model:
    -  sweeps are spread through the gap before their block only where the operator was working through
    -  that gap, so six transactions are not smeared across two hours of a wallet sitting untouched.
    -  Blocks too close together to label separately get a tick each and share one label naming the
    -  range, so nine blocks never render as three.
    -- The **flow diagram** pools a wave's collectors into one lane when they are all too small to draw,
    -  the same way its victim fan already pools its tail. Seven addresses each worth a ten-thousandth of
    -  the total would otherwise be seven identical hairlines under a column of overlapping labels. The
    -  lane ends in "not consolidated" instead of a vault, which is the third column telling the truth
    -  rather than drawing a vault that holds nothing.
    -- **Labels** — "Wave 960183 collector A" is generated from the wave and role, with a letter only where
    -  there is more than one.
    -- **Justifications** — the sentence explaining why an address is in the set is emitted by the same
    -  code that found it, so the two cannot drift apart.
    -- **Where a finding appears** — findings are not a separate list to maintain. Each one carries an
    -  `anchor` and is rendered inside the timeline entry it is about, so it reads as what the chain
    -  showed at that moment. Chain anchors resolve through block times, so a finding follows its block
    -  rather than repeating a timestamp; the handful pinned to a post use the `ANCHOR` constants in
    -  `timeline.tsx`, written once and shared with the entry itself. A finding with no anchor is about
    -  the present, and renders under the money flow instead.
    -- **Which waves were built by the same program** — `fingerprints[]` splits what a transaction
    -  reveals into two kinds of evidence and groups the waves on the first kind only. A **build** trait
    -  is something the program does regardless of who runs it: the order it writes inputs in, where it
    -  gets a size estimate from, what it puts in nLockTime, whether it grinds its signatures. A
    -  **config** trait is a dial — fee rate, replace-by-fee, whether coins are pooled into a collector
    -  or scattered one address per sweep. Waves form a **family** when no build trait *either could
    -  show* disagrees, and split into **variants** inside it by their dials. Traits a wave cannot
    -  exhibit — input ordering, when every sweep spends one input — come back null and are skipped, so
    -  a small wave joins on the evidence it has rather than being split off for evidence it could never
    -  have produced. Where that leaves it fitting more than one family, `alsoFits` says so and the page
    -  prints it as an open question rather than a placement.
    -  A family is a claim about software and nothing more. Two people can run one program; one person
    -  can run two. Nothing here converts a shared build into a shared operator.
    -- **The fee** — priced from a size table, and from nothing else. It is not a share of what was
    -  taken: inside wave 960345 alone the fee runs from a ninety-thousandth of the value to nineteen
    -  times it, a span of 1.7 million to one, and across the record 27 sweeps paid more to move the
    -  coins than the coins were worth. Nor does it track size or position in the run. Where a wave uses more than one rate, the split is resolved rather than
    -  reported as a range, because the three cases are different findings: wave 960345 pays 50 sat/vB on
    -  all 1,214 of its single-address sweeps and 8 on both of its two multi-address sweeps, so there the
    -  rate is bound to the code path; wave 960352 has one stray sweep; wave 960518 uses nine rates
    -  across sixteen sweeps, whose strongest correlation with the value taken, the size signed or the
    -  order it went out in is r = −0.38 — a number the derivation measures rather than a "tracks
    -  nothing" this file asserts.
    -- **Wave colour** — read from exactly those two numbers: hue is the family, shade is the variant.
    -  Colour used to be the running order, which made the palette a second time axis the page already
    -  had three of, and left the one thing colour could have said unsaid. Hue is an identity rather than
    -  a distance — families take bands in the order they open, so refreshing the data cannot repaint a
    -  wave that has not changed, and a new program appends rather than shuffling. How *close* two
    -  families are is a question the trait table answers and a colour wheel cannot.
    -- **Block's published fingerprint, re-applied** — Block scanned 888,661 transactions for seven
    -  shared properties: version 2, locktime 0, final sequence on every input, one output and inputs
    -  from one source address, a P2WPKH destination, one homogeneous supported input type and a
    -  30 sat/vB pre-signing estimate. Those seven are re-run here against every wave. They match the
    -  three waves they were derived from and miss half the record, because three of the seven — final
    -  sequence, one source address, and the 30 sat/vB constant — are dials rather than build traits. An
    -  operator who turns the fee down or enables RBF drops out of that scan while running the same
    -  program, which is what happened the next morning. Their published caveat was that the matches
    -  might not be related to the drain at all; this is the opposite problem, and it is the argument
    -  that every total here is a floor.
    -- **The signing fingerprint** — `signing[]` in `chain.json` records, per wave, what the software
    -  chose rather than what the chain forced: the locktimes, the input sequences, how many signatures
    -  carry a 33-byte R, and the fee rate *solved for* rather than measured. The last one is the useful
    -  one. Every sweep's fee is an exact whole number of satoshis per vbyte of an estimated size —
    -  42 vbytes plus 68, 91 or 148 per input by script type — and that estimate is always a little
    -  over what actually got signed, so the fee was priced from a table before the transaction existed
    -  and never recomputed. It fits 1,202 sweeps out of 1,202, across both sittings, at 30 sat/vB and
    -  then at 2. If a future wave breaks that, the attribution tests will say so on their own.
    -- **Dormancy** — "untouched for 1d 16h" comes from `attribution.dormantSeconds`, measured from the
    -  last time the operator *spent* (inbound dust from onlookers does not reset the clock).
    -- **Whether the coins are still there** — `exfiltratedSats` is zero today; the moment a vault spends
    -  onward, every "nothing has moved" phrase on the page rewrites itself and the fetch warns loudly.
    -Classification is automatic:
    +- **Waves** — a connected component of the grouping graph: collectors pooling into the same vault
    +  are one operation. Named for the block their first sweep landed in, so finding a wave *between*
    +  two known ones renames neither. Pooling is not the only edge, and two others carry real weight.
    +  One victim reporting several destinations joins them, because one drain is one event. And where
    +  an operator pools nothing at all — a fresh address per wallet, a fresh vault per address — what
    +  joins them is that they were **emptied together**: same few blocks, same distinctive fee
    +  arithmetic. That rule only fires from an address already in the set on someone's word, and only
    +  after the signature is run over control windows elsewhere in the chain and found to match
    +  nothing; if it matches, the rule declines and records why. It is what makes wave 960395's 294
    +  addresses one wave instead of 294.
    +- **Episodes** — a run of waves with under an hour of quiet between them. Waves say who pooled with
    +  whom; episodes say what happened at the same time as what, which is what "X BTC in Y minutes"
    +  claims. Not tuned: the longest quiet inside the 30 July burst is 22 minutes and the next sweeping
    +  is a day later, so anything between gives this grouping.
    +- **Labels and justifications** — "Wave 960183 collector A" is generated from wave and role; the
    +  sentence explaining why an address is in the set is emitted by the code that found it, so the two
    +  cannot drift apart.
    +- **Where a finding appears** — each carries an `anchor` and renders inside the timeline entry it is
    +  about. Chain anchors resolve through block times, so a finding follows its block rather than
    +  repeating a timestamp.
    +**Which waves were built by the same program.** `fingerprints[]` splits what a transaction reveals
    +into two kinds and groups on the first only. A **build** trait is what the program does regardless
    +of who runs it — input order, where it gets a size estimate, what it writes in nLockTime, whether it
    +grinds signatures. A **config** trait is a dial: fee rate, RBF, whether coins pool or scatter. Waves
    +form a **family** when no build trait *either could show* disagrees, and split into **variants** by
    +their dials. Traits a wave cannot exhibit come back null and are skipped, so a small wave joins on
    +the evidence it has rather than being split off for evidence it could never have produced; where
    +that leaves more than one family open, `alsoFits` says so. A family is a claim about software and
    +nothing more — two people can run one program, one person can run two.
    +**Wave colour** is read from exactly those two numbers: hue is the family, shade the variant. Hue is
    +an identity rather than a distance, so refreshing cannot repaint a wave that has not changed.
    +**The fee** is priced from a size table and nothing else — `42 vbytes + 68 | 91 | 148 per input by
    +script type`, looked up before signing and never rechecked, which is why the estimate always runs
    +over what got signed. It is not a share of value: inside wave 960345 the fee spans 1.7 million to
    +one against the amount taken, and 27 sweeps across the record paid more to move the coins than the
    +coins were worth. Where a wave uses several rates the split is resolved rather than averaged,
    +because the cases differ — wave 960345 binds the rate to the code path (50 sat/vB on all 1,214
    +single-address sweeps, 8 on both multi-address ones), wave 960352 has one stray, and wave 960518
    +uses nine rates whose strongest correlation with value, size or order is r = −0.38.
    +There are **two size tables**. The second adds ¼ vbyte per input — the witness stack-item count byte
    +— and rounds down on a tie; it cannot show itself below three inputs. Not every hop uses either: the
    +P2WSH vault hops of 31 July pay exactly 10 sat/vB of the *real* vsize, so a size-table scan misses
    +them entirely, which is how one scan of that wave undercounted it by a quarter.
    +**Block's published fingerprint, re-applied.** Block scanned 888,661 transactions for seven shared
    +properties: version 2, locktime 0, final sequence on every input, one output and inputs from one
    +source address, a P2WPKH destination, one homogeneous supported input type, a 30 sat/vB pre-signing
    +estimate. Re-run here against every wave, they match the three they were derived from and miss half
    +the record — because three of the seven are dials, not build traits. An operator who turns the fee
    +down or enables RBF drops out of that scan while running the same program, which is what happened
    +the next morning. Their caveat was that matches might not relate to the drain at all; this is the
    +opposite problem, and it is the argument that every total here is a floor.
    +**Input ordering is not measured.** BIP-69 needs each input's previous txid, and the bulk block
    +format carries only an internal index that does not sort like one. Rather than infer an order from
    +it, the trait reports itself unobservable. Family grouping is unaffected — the waves it would have
    +separated are already separated by nLockTime and fee arithmetic.
    +**Dormancy** is measured from the last time the operator *spent*; inbound dust from onlookers does
    +not reset the clock. **`exfiltratedSats`** is zero today, and the moment a vault spends onward every
    +"nothing has moved" phrase rewrites itself and the build warns loudly.
    +Movement classification is automatic:
     | kind | meaning |
     |---|---|
     | `sweep` | external inputs paying a tracked address — stolen funds |
    -| `consolidation` | inputs come only from tracked addresses — the operator pooling their own outputs |
    -| `graffiti` | inbound value arriving *after* that wave consolidated — onlookers sending dust to a now-public address |
    -The last one is decided by **time, not amount**. Wave 960183 swept outputs worth 1,200 satoshis — smaller
    -than the dust strangers later sent for fun — so any size threshold misfiles real thefts.
    -Totals are reconciled against the figures other people published, at the precision they published
    -them. If they ever stop agreeing, the table says `DRIFT` and the fetch warns, rather than quietly
    -disagreeing.
    +| `consolidation` | inputs only from tracked addresses — the operator pooling their own outputs |
    +| `graffiti` | value arriving *after* that wave consolidated — onlookers dusting a now-public address |
    +Decided by **time, not amount**: wave 960183 swept outputs worth 1,200 satoshis, smaller than the
    +dust strangers later sent for fun, so any size threshold misfiles real thefts.
    +Totals reconcile against figures other people published, at the precision they published them; if
    +they stop agreeing the build prints `DRIFT` rather than quietly disagreeing. One mapping worth
    +knowing: Galaxy Research's "BTC taken" is the victim **debit** — value plus miner fee — where
    +`sweptSats` here is what arrived, and their "coins predating the flaw: 0 of N" row is the **UTXO**
    +count, not addresses.
     ## Layout
     ```
    -scripts/fetch-chain.ts   pulls mempool.space → src/data/chain.json
    -scripts/lib/discover.ts  derives the attacker set from the evidence
    -scripts/lib/fingerprint.ts  groups the waves by what built them
    -scripts/lib/attribution.ts  builds the attribution case from the result
    -src/data/evidence.ts     victim reports + published addresses — the only input
    +analysis/                blockchain analysis — Python, reads blocks/ (see its README)
    +  evidence.py            victim reports + published addresses — the only hand-written input
    +  chain.py               assembles src/data/chain.json
    +  patterns.py            predicates + scan + profile, for hunting a new fingerprint
    +blocks/                  local block store, gitignored, rebuildable
    +scripts/                 tweet / media / commit archivers — TypeScript, they talk to web APIs
     src/data/schema.ts       types for the frozen dataset
     src/data/chain.json      generated; the only source of numbers on the page
     src/data/timeline.tsx    curated events, verbatim quotes, sourcing notes
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable wallet
    seeds, the 1,082 BTC swept across three waves on 30 July 2026, and the higher-entropy tiers still
    exposed. Reconstructed
    from primary sources — the Bitcoin blockchain, the vendor and Block advisories, and the original posts
    as they were made.
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories: another 207.73 BTC from 1,918 addresses that afternoon, pooled
    nowhere, a fresh holding address and a fresh vault for every wallet drained. That wave is in the
    ledger only because one victim screenshotted an address that was not even his own — everything else
    about it was reached by noticing that 291 addresses were emptied in the same four blocks the same
    way. Every figure here is a floor, not a total — rebuild from the block store to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Chain analysis is Python and reads a local copy of the blockchain; the tweet, media and commit
    archivers stay TypeScript because they talk to web APIs, not to blocks.
    ```bash
    python3 -m analysis.fetch_blocks     # blocks/<height>.json.gz — ~1-2GB, resumable, reorg-aware
    python3 -m analysis.index --rebuild  # address → transactions, as SQLite (~50s)
    python3 -m analysis.chain            # → src/data/chain.json
    ```
    ```bash
    bun run media     # avatars + screenshots → src/data/media.json
    bun run commits   # GitHub commit metadata → src/data/commits.json
    bun run threads   # reconstructed X threads → src/data/threads.json
    ```

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  7. source content difference between and source content +43 -3

    The README added derivation sections on grouping waves into families by build traits rather than configuration dials, on fee pricing and its lack of correlation with value, on wave colour, and on re-applying Block's published seven-property fingerprint across the whole record, plus a new fingerprint script in the file map.

    seen · Captured here 15,900 chars
    What changed from the previous capture 46 lines
       pooled with whom; episodes say what happened at the same time as what, which is what the headline
       "X BTC in Y minutes" is a claim about. The threshold is not tuned: the longest quiet inside the
       30 July burst is 22 minutes and the next sweeping is more than a day later, so anything between
    -  those gives the same answer. The headline figures and the block cascade read the first episode;
    -  everything else carries all of it.
    +  those gives the same answer. The headline figures read the first episode; everything else carries
    +  all of it.
     The two graphs both had to grow something to show a wave that arrived a day late and never pooled,
     rather than being scoped away:
     - The **clock replay** runs on plain linear time: one second of the axis is one second of 30 and 31
       rather than repeating a timestamp; the handful pinned to a post use the `ANCHOR` constants in
       `timeline.tsx`, written once and shared with the entry itself. A finding with no anchor is about
       the present, and renders under the money flow instead.
    +- **Which waves were built by the same program** — `fingerprints[]` splits what a transaction
    +  reveals into two kinds of evidence and groups the waves on the first kind only. A **build** trait
    +  is something the program does regardless of who runs it: the order it writes inputs in, where it
    +  gets a size estimate from, what it puts in nLockTime, whether it grinds its signatures. A
    +  **config** trait is a dial — fee rate, replace-by-fee, whether coins are pooled into a collector
    +  or scattered one address per sweep. Waves form a **family** when no build trait *either could
    +  show* disagrees, and split into **variants** inside it by their dials. Traits a wave cannot
    +  exhibit — input ordering, when every sweep spends one input — come back null and are skipped, so
    +  a small wave joins on the evidence it has rather than being split off for evidence it could never
    +  have produced. Where that leaves it fitting more than one family, `alsoFits` says so and the page
    +  prints it as an open question rather than a placement.
    +  A family is a claim about software and nothing more. Two people can run one program; one person
    +  can run two. Nothing here converts a shared build into a shared operator.
    +- **The fee** — priced from a size table, and from nothing else. It is not a share of what was
    +  taken: inside wave 960345 alone the fee runs from a ninety-thousandth of the value to nineteen
    +  times it, a span of 1.7 million to one, and across the record 27 sweeps paid more to move the
    +  coins than the coins were worth. Nor does it track size or position in the run. Where a wave uses more than one rate, the split is resolved rather than
    +  reported as a range, because the three cases are different findings: wave 960345 pays 50 sat/vB on
    +  all 1,214 of its single-address sweeps and 8 on both of its two multi-address sweeps, so there the
    +  rate is bound to the code path; wave 960352 has one stray sweep; wave 960518 uses nine rates
    +  across sixteen sweeps, whose strongest correlation with the value taken, the size signed or the
    +  order it went out in is r = −0.38 — a number the derivation measures rather than a "tracks
    +  nothing" this file asserts.
    +- **Wave colour** — read from exactly those two numbers: hue is the family, shade is the variant.
    +  Colour used to be the running order, which made the palette a second time axis the page already
    +  had three of, and left the one thing colour could have said unsaid. Hue is an identity rather than
    +  a distance — families take bands in the order they open, so refreshing the data cannot repaint a
    +  wave that has not changed, and a new program appends rather than shuffling. How *close* two
    +  families are is a question the trait table answers and a colour wheel cannot.
    +- **Block's published fingerprint, re-applied** — Block scanned 888,661 transactions for seven
    +  shared properties: version 2, locktime 0, final sequence on every input, one output and inputs
    +  from one source address, a P2WPKH destination, one homogeneous supported input type and a
    +  30 sat/vB pre-signing estimate. Those seven are re-run here against every wave. They match the
    +  three waves they were derived from and miss half the record, because three of the seven — final
    +  sequence, one source address, and the 30 sat/vB constant — are dials rather than build traits. An
    +  operator who turns the fee down or enables RBF drops out of that scan while running the same
    +  program, which is what happened the next morning. Their published caveat was that the matches
    +  might not be related to the drain at all; this is the opposite problem, and it is the argument
    +  that every total here is a floor.
     - **The signing fingerprint** — `signing[]` in `chain.json` records, per wave, what the software
       chose rather than what the chain forced: the locktimes, the input sequences, how many signatures
       carry a 33-byte R, and the fee rate *solved for* rather than measured. The last one is the useful
     ```
     scripts/fetch-chain.ts   pulls mempool.space → src/data/chain.json
     scripts/lib/discover.ts  derives the attacker set from the evidence
    +scripts/lib/fingerprint.ts  groups the waves by what built them
     scripts/lib/attribution.ts  builds the attribution case from the result
     src/data/evidence.ts     victim reports + published addresses — the only input
     src/data/schema.ts       types for the frozen dataset
     src/data/chain.json      generated; the only source of numbers on the page
     src/data/timeline.tsx    curated events, verbatim quotes, sourcing notes
     src/components/          Social (X + Reddit chrome), SweepReplay, MoneyFlow, Attribution,
    -                         VictimLedger, RootCause, Forensics, Timeline
    +                         Fingerprint, VictimLedger, RootCause, Forensics, Timeline
     ```
     ## Sourcing notes
     Tweet timestamps are decoded from the snowflake ID — `ts_ms = (id >> 22) + 1288834974657` — which is
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable wallet
    seeds, the 1,082 BTC swept across three waves on 30 July 2026, and the higher-entropy tiers still
    exposed. Reconstructed
    from primary sources — the Bitcoin blockchain, the vendor and Block advisories, and the original posts
    as they were made.
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories, with different tooling and no pooling, and is in the ledger here
    only because one victim photographed his wallet history. Every figure here is a floor, not a total — re-run the fetch scripts to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Two generators, both writing into `src/data/`. Run them after any change to the address registry or
    the media list:
    ```bash
    bun run fetch     # transactions → src/data/chain.json
    bun run media     # avatars + screenshots → src/data/media.json
    bun run commits   # GitHub commit metadata → src/data/commits.json
    ```
    `fetch` caches raw API responses under `.cache/`, so re-running after a change to
    classification or rollup logic costs nothing. Set `NO_CACHE=1` to force a fresh pull, or
    `PAGE_DELAY_MS` to back off harder against the public explorer's rate limit.
    ### Chain data
    `src/data/chain.json` is generated, not hand-written. Point the fetcher at your own node instead of
    the public API:
    ```bash
    MEMPOOL_API=https://your.node/api bun run fetch

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  8. source content difference between and source content +4 -4

    The README renamed waves from ordinal numbers to block heights throughout, so 'wave 3' became 'wave 960188' and Block's 'waves 1 and 2' became 'waves 960183 and 960185'.

    seen · Captured here 12,208 chars
    What changed from the previous capture 8 lines
     }
     ```
     One report is enough to derive a collector, its vault, and every other victim that paid into it.
    -The first report in the file yields wave 3's collector, wave 3's vault, and 500 victims.
    +The first report in the file yields wave 960188's collector, its vault, and 500 victims.
     `txids` is a list because a wallet emptied one UTXO at a time is one event with one transaction per
     UTXO, and splitting that across several reports would lose the fact that they belong together. The
     grouping matters: it is what stops an operator who never pools from being derived as one wave per
     |---|---|
     | `attacker-controlled` | the source names it as part of the theft |
     | `pattern-match-unconfirmed` | the source found it by fingerprint and said it was unconfirmed |
    -Block published waves 1 and 2 as a fingerprint match and said in the same thread that they "have not
    +Block published waves 960183 and 960185 as a fingerprint match and said in the same thread that they "have not
     confirmed that these indeed are related to drain". That caveat survives into the UI rather than being
     flattened into "attacker address".
     Then `bun run fetch`. Everything downstream rebuilds: the flow diagram, the replay, the ledger, the
       the total would otherwise be seven identical hairlines under a column of overlapping labels. The
       lane ends in "not consolidated" instead of a vault, which is the third column telling the truth
       rather than drawing a vault that holds nothing.
    -- **Labels** — "Wave 1 collector A" is generated from the wave and role, with a letter only where
    +- **Labels** — "Wave 960183 collector A" is generated from the wave and role, with a letter only where
       there is more than one.
     - **Justifications** — the sentence explaining why an address is in the set is emitted by the same
       code that found it, so the two cannot drift apart.
     | `sweep` | external inputs paying a tracked address — stolen funds |
     | `consolidation` | inputs come only from tracked addresses — the operator pooling their own outputs |
     | `graffiti` | inbound value arriving *after* that wave consolidated — onlookers sending dust to a now-public address |
    -The last one is decided by **time, not amount**. Wave 1 swept outputs worth 1,200 satoshis — smaller
    +The last one is decided by **time, not amount**. Wave 960183 swept outputs worth 1,200 satoshis — smaller
     than the dust strangers later sent for fun — so any size threshold misfiles real thefts.
     Totals are reconciled against the figures other people published, at the precision they published
     them. If they ever stop agreeing, the table says `DRIFT` and the fetch warns, rather than quietly
    
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable wallet
    seeds, the 1,082 BTC swept across three waves on 30 July 2026, and the higher-entropy tiers still
    exposed. Reconstructed
    from primary sources — the Bitcoin blockchain, the vendor and Block advisories, and the original posts
    as they were made.
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories, with different tooling and no pooling, and is in the ledger here
    only because one victim photographed his wallet history. Every figure here is a floor, not a total — re-run the fetch scripts to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Two generators, both writing into `src/data/`. Run them after any change to the address registry or
    the media list:
    ```bash
    bun run fetch     # transactions → src/data/chain.json
    bun run media     # avatars + screenshots → src/data/media.json
    bun run commits   # GitHub commit metadata → src/data/commits.json
    ```
    `fetch` caches raw API responses under `.cache/`, so re-running after a change to
    classification or rollup logic costs nothing. Set `NO_CACHE=1` to force a fresh pull, or
    `PAGE_DELAY_MS` to back off harder against the public explorer's rate limit.
    ### Chain data
    `src/data/chain.json` is generated, not hand-written. Point the fetcher at your own node instead of
    the public API:
    ```bash
    MEMPOOL_API=https://your.node/api bun run fetch

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

  9. Earliest copy held
    seen · Captured here 12,188 chars
    Extracted text as captured
    # COLDCARD RNG — an ongoing postmortem
    A postmortem of the COLDCARD (Coinkite) random-number-generator flaw: five years of predictable wallet
    seeds, the 1,082 BTC swept across three waves on 30 July 2026, and the higher-entropy tiers still
    exposed. Reconstructed
    from primary sources — the Bitcoin blockchain, the vendor and Block advisories, and the original posts
    as they were made.
    **This documents a live situation.** The 30 July waves took the cheapest-to-crack tier. The same
    defect leaves ~72-bit Mk4/Q/Mk5 seeds reachable with more compute, Block has reported a second earlier
    tranche of ~488 BTC it declined to attribute — traced here to two earlier waves that reconcile to the
    satoshi — and no firmware update can repair a seed that already exists. The sweeping also resumed on
    31 July, hours after the advisories, with different tooling and no pooling, and is in the ledger here
    only because one victim photographed his wallet history. Every figure here is a floor, not a total — re-run the fetch scripts to refresh it.
    Everything on the page is either derived from chain data frozen into the repository or quoted verbatim
    with a source link. Nothing calls an API at runtime.
    ## Running it
    ```bash
    bun install
    bun dev          # http://localhost:3000
    ```
    ```bash
    bun run build      # static bundle to dist/
    bun start          # production server
    bun run typecheck
    ```
    ## Refreshing the data
    Two generators, both writing into `src/data/`. Run them after any change to the address registry or
    the media list:
    ```bash
    bun run fetch     # transactions → src/data/chain.json
    bun run media     # avatars + screenshots → src/data/media.json
    bun run commits   # GitHub commit metadata → src/data/commits.json
    ```
    `fetch` caches raw API responses under `.cache/`, so re-running after a change to
    classification or rollup logic costs nothing. Set `NO_CACHE=1` to force a fresh pull, or
    `PAGE_DELAY_MS` to back off harder against the public explorer's rate limit.
    ### Chain data
    `src/data/chain.json` is generated, not hand-written. Point the fetcher at your own node instead of
    the public API:
    ```bash
    MEMPOOL_API=https://your.node/api bun run fetch

    Excerpt only. The complete copy is held offline and backs quotations on this site. The original publication remains the canonical public source.

How to check this yourself

The excerpts and plain unified diffs above show the text this project held and how it changed. To verify a quotation, compare it against the page itself or against the Internet Archive's copies, which are independent of this project.

Complete captures are held offline rather than mirrored here, so this page shows diffs and excerpts. If a quotation is ever disputed, the full copy can be produced. Ask.