How to Safely Roll Dice for Your Bitcoin Seed
After the Coldcard entropy bug, dice are the one seed source you can verify yourself. How many to roll, how to check the result, and the one formatting mistake that silently gives you a different wallet.
A five year old firmware bug just cost people ~1,367 BTC. The one seed-generation method that came through untouched was the one where a human rolled dice on a table. Here’s how to do it properly, including a formatting trap that will hand you a completely different wallet and convince you you’ve been hacked.
This is the technical version. It gets into entropy maths, hash functions and source code. If you just want the steps (buy dice, roll them, write them down correctly), read Rolling Dice for Your Bitcoin Wallet: The Simple Guide instead. You won’t miss anything you need in order to do this safely.
🎲 Why everyone is suddenly asking about dice
On July 30, 2026, roughly 1,083 BTC left ~1,196 addresses in about 41 minutes. By August 2, Galaxy Research put the running total at ~1,367 BTC (~$88.6M) across 4,585 addresses. Nobody touched a single physical device.
The cause was a build flag. When Coinkite migrated Coldcard’s elliptic-curve work to libsecp256k1 in March 2021, seed generation moved from ckcc.rng_bytes() to ngu.random.bytes(). Both had identical signatures, so nothing complained. But the guard selecting the implementation looked like this:
1
#ifndef MICROPY_HW_ENABLE_RNG
#ifndef tests whether a macro is defined, not whether it’s nonzero. The macro was defined, with a value of 0. So the build quietly took the fallback branch and wired seed generation to MicroPython’s software PRNG instead of the STM32 hardware RNG.1
The result: ~40 bits of effective entropy on Mk2/Mk3, ~72 bits on Mk4/Mk5/Q, where 128 or 256 was intended. That’s enumerable offline. An attacker never needs your device. They grind candidate seeds, derive addresses, and check them against the chain.
Bitcoin was never broken here.
SHA256was never broken. A preprocessor directive was wrong, and five years of “air-gapped” seeds were computable the whole time.
Here’s the part that matters for this post. From Coinkite’s own advisory:
“If you added at least 50 fair, independent, private dice rolls when originally creating the seed… We do not consider that seed at risk from this RNG issue alone.”
Dice survived. Not because Coldcard’s dice code was better written, but because dice don’t go through the RNG at all.
🔑 What dice actually buy you
Every hardware wallet asks you to trust a number you cannot see, produced by a chip you cannot audit, running firmware you probably didn’t build from source. The entire security of your wallet rests on that number being unpredictable, and you have no way to check.
Dice change the question. You are not verifying that the device’s randomness was good. You are supplying the randomness yourself, and the device’s only job is arithmetic you can reproduce on any computer.
flowchart TD
subgraph BAD["Device-generated seed: what broke"]
A1[Hardware TRNG] -.->|"build flag bug<br/>routed around it"| A2[Software PRNG]
A2 --> A3["~72 bits<br/>enumerable"]
A3 --> A4[Seed words]
end
subgraph GOOD["Dice-generated seed: untouched"]
B1["🎲 Your dice"] --> B2["SHA-256(rolls)"]
B2 --> B3["256 bits<br/>reproducible"]
B3 --> B4[Seed words]
end
The whole dice pipeline is four steps, and every one of them is something you can redo yourself on a machine you control:
flowchart LR
D["🎲 rolls<br/>4261533…"] --> S["SHA-256"]
S --> H["256-bit hash"]
H --> B["BIP-39<br/>+ checksum"]
B --> W["24 words"]
That reproducibility is the entire point. If your device shows you different words than your own sha256sum does, you have caught it lying.
🧮 How many rolls?
Here is where nearly every guide goes wrong, including some of the official docs.
A six-sided die carries log2(6) = 2.585 bits. Fifty rolls is 129.2 bits, which clears the 128 bits a 12-word seed needs. A hundred rolls is 258.5 bits, clearing the 256 a 24-word seed needs. That’s the whole calculation, and it holds for every tool that hashes your rolls, which, as we’ll see, is all of the common ones by default.
Drag this:
Saturated.
Takes a roll count only. Never type actual dice values into a web page, including this one.
Switch to Raw entropy and the numbers collapse. That’s not a different tool. It’s a different mode, and knowing which one you’re in is the only genuinely subtle thing on this page.
The standard construction, and why the tools agree
The construction used by Coldcard, SeedSigner and Keystone alike: accumulate your rolls as an ASCII string and hash them. From Coinkite’s public-domain verification script, which is plain Python and needs no hardware at all:
1
2
3
r = input().strip()
h = sha256(r.encode()).digest() # 24 words
h = sha256(r.encode()).digest()[:16] # 12 words
iancoleman’s tool does the same thing, as long as you pick a word count rather than Raw:
1
2
3
4
5
if (mnemonicLength != "raw") {
var hash = sjcl.hash.sha256.hash(entropy.cleanStr);
var numberOfBits = 32 * mnemonicLength / 3; // 128 for 12w, 256 for 24w
bits = bits.substring(0, numberOfBits);
}
Same hash, same truncation. I checked this rather than assuming it. Feeding identical rolls to both implementations produces byte-identical mnemonics for 12 and 24 words, including degenerate inputs like fifty 1s and a hundred 6s. I also ran SeedSigner’s own published 99-roll test vector through rolls.py and got SeedSigner’s mnemonic back word for word.
If a guide tells you these tools produce different seeds from the same dice, it’s wrong. Coldcard, SeedSigner, Keystone and iancoleman (in word-count mode) are the same algorithm, and any of them can check any other.
So the roll count is simply:
12 words → roll 50. 24 words → roll 100.
2.585bits per roll, in both tools. There is no second number to remember.
Where 1.67 bits/roll comes from
iancoleman also has a Raw mnemonic length, which skips the hash and maps each roll straight to bits using an unbiased extractor: four outcomes give 2 bits, two give 1 bit:2
1
2
"base 6 (dice)": { "0": "00", "1": "01", "2": "10", "3": "11", "4": "0", "5": "1" }
// Average (4*2 + 2*1) / 6 = 1.66 bits per roll without bias
That’s deliberately conservative. It throws away bits rather than risk modulo bias. But it means raw mode needs 77 rolls for 128 bits and 154 for 256.
This same 1.67 figure drives iancoleman’s on-screen entropy meter, which is why the tool will show you a weak entropy warning at 50 rolls even though your seed is fine. Its warning fires when 32 × words / 3 exceeds the raw bit count, so it’s judging your dice by the standard of an extractor it isn’t currently using. Roll 100 and the warning goes away regardless.
The true entropy of N fair d6 rolls is
N × 2.585bits. That’s information theory, not a tool setting. When either tool hashes, that’s what you get.
And note the flip side, which the calculator makes obvious: past saturation, extra rolls do nothing. SHA256 outputs 256 bits and a 12-word seed truncates to 128. Rolling 200 dice for a 12-word seed is 150 rolls of wasted evening. More is not better; enough is enough.
Two footnotes while we’re here. Coldcard’s docs say 99 rolls for 256 bits, but 99 × 2.585 = 255.9, a hair under. Round to 100. And a 24-word seed is 256 bits of entropy plus an 8-bit checksum (264 ÷ 11 = 24 words); 12 words is 128 + 4 (132 ÷ 11 = 12). The checksum is computed for you. It is not something you roll.
🚨 The formatting trap
Here is the one that will actually cost someone their coins, and I haven’t seen it written down anywhere.
Coldcard hashes your input verbatim. iancoleman strips everything that isn’t a digit 1–6 first.
That single difference means the two tools agree perfectly on clean input and diverge completely the moment you type a space:
1
2
3
4
5
$ echo -n "123456" | python3 rolls12.py # Coldcard
mirror reject rookie talk pudding throw happy era myth already payment owner
$ echo -n "1 2 3 4 5 6" | python3 rolls12.py # Coldcard, spaces included in the hash
ask avoid magnet arctic panther nominee emerge pepper erosion travel snap parrot
iancoleman returns mirror reject rookie… for both, because it discards the spaces before hashing. Coldcard treats "1 2 3 4 5 6" as a completely different string, and gives you a completely different wallet.
Write your rolls in your notebook grouped as
31415 92653 58979for legibility, type them into the device without spaces, then later verify them with spaces, and you will get a different seed and conclude your hardware wallet has been tampered with. It hasn’t. You changed the input.
There’s a related quirk in Coldcard’s own verification script. Its low-entropy warning counts characters, not rolls:
1
2
if len(r) < 99:
ae = 2.585 * len(r)
Six rolls typed as 3 1 4 1 5 9 is 11 characters, so it cheerfully reports 28 bits of entropy when you actually have 15.5. Separators don’t just change your seed. They defeat the safety check meant to catch you.
The rule is boring and absolute:
Record your rolls as one unbroken run of digits 1–6. No spaces, no line breaks, no grouping, no commas. Enter them the same way everywhere, every time.
🎯 How to actually roll
The math is the easy half. The physical half is where people quietly ruin it.
Get dice that are actually fair. Cheap board-game dice have rounded corners and hollowed-out pips, which shifts the center of mass toward the 1. Casino-grade precision dice have flush pips and sharp edges and are machined to a thousandth of an inch. A set costs about $10. You are protecting a seed with it.
Roll on a hard flat surface with a wall. A table with a book at the far end. Dice that tumble and bounce off something randomize far better than dice dropped onto carpet.
Decide your reading order before you roll. Left to right, top to bottom. Write it down. If you throw 20 dice at once and then decide how to read them after seeing the result, you have introduced yourself into the process, and you are terrible at being random.
Never re-roll a result you don’t like. Not the die that fell off the table, not the “too many sixes in a row” run. 666666 is exactly as likely as any other six rolls. Discarding results you find unaesthetic is precisely how you leak entropy.
Don’t arrange the dice. Roll, read, record, repeat.
You are not a good source of entropy. Neither is your birthday, a song lyric, or keyboard mashing. The dice are doing the work. Your only job is to not interfere with them.
Twenty dice thrown five times gets you to 100 quickly. One die rolled 100 times works identically and is easier to record without losing your place. Both are fine.
Write the results down as one continuous string of digits, not in tidy groups. It reads worse, and per the section above it is the single most important formatting decision you’ll make here.
💻 Where to enter them
Three workable options, in the order I’d reach for them.
1. A dedicated signer with a verifiable dice mode. A SeedSigner is the cleanest option: dice are its only entropy source, it has no persistent storage so it forgets everything at power-off, its builds have been reproducible since v0.7.0, and it’s ~$50 of parts. Tools > New Seed (dice icon) > 24 words (99 rolls). A Keystone works too and publishes its own verification guide. Both use exactly the construction above, so everything in the verify section applies to them unchanged.
2. An offline copy of iancoleman’s BIP39 tool. Open iancoleman.io/bip39, File → Save As, move the file to an air-gapped machine, open it there. Or download the signed GitHub release and verify it first. Use the Entropy field, set the type to dice, and set Mnemonic Length to 12 or 24, not Raw. In that mode it matches the signers above exactly, and 50/100 rolls is the right target. Ignore the weak-entropy warning at 50 rolls; as covered above, it’s measuring raw-mode yield rather than what your dice actually carry.
3. A Coldcard you already own. If you have one and you’re done with the brand, it is still a perfectly good dice-to-words calculator, because the dice path was never affected by this bug: it never called the broken RNG. New Seed Words > Advanced > 12 Word Dice Roll (or 24 Word Dice Roll), press 1–6 as you roll, write down the words, wipe the device, and import the words into whatever you’re using instead.
Note a distinction that applies on any device offering both: a pure dice roll mode derives the seed only from your rolls, which is what makes it reproducible. An add dice to device entropy mode mixes your rolls into the chip’s randomness: safe, but you cannot independently verify the result, because half the input came from a chip.
What about Krux, BitBox02, Jade? Krux does not match the tools above: it joins your rolls with dashes before hashing, so identical dice give a different seed. BitBox02 and Jade use a different approach entirely, a printed diceware table where you look up each word by hand.
Trezor and Ledger have no dice input at all, and that does not rule you out. Generate the words on something else and import them. See How to Use Dice With a Trezor, a Ledger, or Any Other Wallet for the per-tool comparison and the import steps.
Do not use the live iancoleman.io page in a normal browser for a seed you intend to fund. Do not use any web-based generator. Every browser extension you have installed can read that page, and so can anything else on a networked machine.
You’ll notice this post doesn’t ship a “type your rolls here” widget. That’s deliberate. It would contradict every other paragraph. The calculator above takes a roll count and nothing else.
✅ How to verify
This is the part that makes dice worth the effort, and almost nobody does it.
The construction is SHA256 over your rolls as an ASCII string, whichever of the matching tools produced your words. That means you can recompute it yourself. The published test vector uses 123456:
1
2
$ echo -n 123456 | sha256sum
8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
Or with OpenSSL, if sha256sum isn’t on your system:
1
2
$ echo -n 123456 | openssl sha256
(stdin)= 8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92
The -n matters. Without it echo appends a newline and you hash a different string.
Here’s that exact computation running live, on the fixed published vector, with no input field and nothing to paste a secret into:
Input: "123456". Press Run.
Hashes a hard-coded constant using your browser's Web Crypto API. It cannot accept any other input by design.
For the full check including the words, Coinkite publishes two public-domain scripts. They are plain Python with no dependencies and no hardware involved, so they verify a seed from any of the matching tools, not just a Coldcard. Run them on an offline machine. Tails with no network and no hard drive is ideal:
1
$ echo 123456 | python3 rolls.py
1
$ echo 123456 | python3 rolls12.py
Grab them from coldcard.com/docs/rolls.py and rolls12.py. If you’d rather use a script from a different vendor, SeedSigner ships an equivalent CLI in its repo, tools/mnemonic.py, run as python3 mnemonic.py dice <your rolls>. Feed in your real rolls, and confirm the words match what your device displayed. If they don’t, stop and figure out why.
Never run your real dice rolls through a computer you use for anything else. That defeats the entire exercise. Offline machine, no network, no persistent storage.
Read the code before you run it
You should not run a script that generates your seed without reading it, and you shouldn’t have to download it to do that. Each file is ~14 KB, but roughly 170 of its 223 lines are just the BIP-39 wordlist. Here is everything else:
rolls12.py: the whole algorithm, wordlist removed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
def entropy_to_mnemonic12(entropy):
# Apply BIP39 to convert entropy into seed words
assert len(entropy) == 16
v = int.from_bytes(entropy, 'big') << 4
indexes = []
for i in range(12):
v, m = divmod(v, 2048)
indexes.insert(0, m)
# final 4 bits are a checksum
indexes[-1] += sha256(entropy).digest()[0] >> 4
return [wl[i] for i in indexes]
def main():
# Read input, remove whitespace around it
r = input().strip()
# Calc sha256
h = sha256(r.encode()).digest()[:16]
# Show the hash
print(h.hex())
# Sanity check for empty input
empty = "e3b0c44298fc1c149afbf4c8996fb924"
if h.hex() == empty:
print('WARNING: Input is empty. This is a known wallet\n')
# Warnings for short length
if len(r) < 50:
ae = 2.585 * len(r)
print('WARNING: Input is only %d bits of entropy\n' % ae)
mnemonic = entropy_to_mnemonic12(h)
print('\n'.join('%4d: %s' % (n + 1, w) for n, w in enumerate(mnemonic)))
That is the entire trust surface. sha256 comes from Python’s standard library; there are no other imports and no network access. Note .strip() on line 2 of main(). It removes leading and trailing whitespace only, which is why interior spaces still change your seed.
rolls.py (24 words): how it differs
The 24-word version is the same file with five changes. Everything else is byte-identical:
rolls12.py | rolls.py | |
|---|---|---|
| entropy taken | sha256(r).digest()[:16] | sha256(r).digest() |
| entropy length | 16 bytes (128 bits) | 32 bytes (256 bits) |
| shift for checksum | << 4 | << 8 |
| checksum bits | sha256(entropy)[0] >> 4 | sha256(entropy)[0] |
| low-entropy warning at | len(r) < 50 | len(r) < 99 |
The 12-word script is a truncation of the same hash, not a different derivation, which is why 50 rolls saturates one and 100 saturates the other.
Verify what you downloaded
Reading the code above only helps if the file you run is the file you read. Check it:
1
2
3
$ shasum -a 256 rolls.py rolls12.py
4348a520e57df665e0ab57baa369a95ace0f9b5fba355b3f22b0b9b2c2e6cd30 rolls.py
533daff58437cdc9a482d16cd181ba9b0fe6f86a6839b792343d39b496034c85 rolls12.py
Those are the SHA-256 hashes of the current files served from coldcard.com/docs/, confirmed against the copies used to write this post. If yours differ, you did not get the file I read.
iancoleman’s standalone build is signed, which is stronger. Verify the signature rather than trusting any checksum you read on a blog, including this one:
1
2
3
4
5
6
$ gpg --verify signature.txt.asc
gpg: Good signature from "Ian Coleman <ian@iancoleman.io>"
Primary key fingerprint: 5AD5 C880 8370 8E93 A296 6FF4 9FF1 B58C A7B9 E6A5
$ shasum -a 256 bip39-standalone.html
129b03505824879b8a4429576e3de6951c8599644c1afcaae80840f79237695a
The signed message is the checksum, so a good signature plus a matching hash covers the whole file. Both are on the 0.5.6 release page.
I deliberately do not host copies of these files. A mirror on this blog would make mblack.io a second thing you have to trust, and a compromise of it could hand you a backdoored seed generator. Get the files from the vendor, check them against the hashes above, and trust the signature over anything I tell you.
And the trap that will make you think you’ve been hacked when you haven’t:
If your cross-check disagrees, suspect your typing before you suspect your hardware. Ninety-nine times out of a hundred it’s a space, a line break, or
Rawleft selected in iancoleman’s Mnemonic Length dropdown, not a compromised device. Re-enter the rolls as one unbroken digit run in both tools before concluding anything.
One more sanity check that costs nothing: before you send real money, restore the seed into a watch-only wallet on a second machine and confirm it derives the same first address, making sure both sides use the same derivation path and script type (m/84'/0'/0'/0/0 native SegWit unless you chose otherwise) and the same passphrase. Two wallets defaulting to different script types will show different addresses for the same valid seed. A backup you have never tested is not a backup.
🚫 What not to do
- Any web-based generator on a networked machine. Including iancoleman’s live page. Download it, air-gap it.
Math.random(), anywhere, ever. It is not a CSPRNG. This entire incident is what happens when a software PRNG stands in for real entropy.- Storing the dice rolls instead of the mnemonic. iancoleman’s own docs put it plainly: “Do not store entropy.” Rolls are fragile, tool-dependent, and useless if you forget which conversion you used. Store the words.
- Photographing the dice. That photo has GPS metadata, syncs to a cloud, and is now your seed.
- Reusing rolls across wallets. One set of rolls, one seed.
- Re-rolling results you don’t like. Covered above, worth repeating: this is the most common self-inflicted wound.
- Assuming a firmware update fixes an existing seed. It does not. If your seed was generated by an affected device without dice, the only fix is a new seed on fixed firmware and moving the funds.
For reference, the fixed versions: Mk3 4.2.0+, Mk4/Mk5 5.6.0+, Q 1.5.0Q+, Edge 6.6.0X / 6.6.0QX+.
Conclusion
The lesson here isn’t “Coldcard bad.” It’s that a hardware wallet’s most security-critical operation happens exactly once, in a place you cannot observe, and for five years nobody noticed it was wrong, including the people who wrote it, who are genuinely good at this.
Dice fix that by removing the unobservable step. You generate the entropy in the open, on a table, and the device does arithmetic you can redo on any machine you control. It takes fifteen minutes, costs ten dollars, and it is the only part of your setup you can personally verify end to end.
Roll 100. Write them as one unbroken run of digits. Check the hash offline. Then go outside.
Sources
- Coinkite: Security Advisory and Technical Backgrounder
- Block Engineering: Predictable RNG Fallback and 32-Bit Reseed in COLDCARD Firmware
- Coldcard docs: Verifying Dice Roll Math
- iancoleman/bip39: source and issue #435, dice bias
The guard used
#ifndef MICROPY_HW_ENABLE_RNG, which tests for definition rather than value. The macro was set to0, so the build silently selected MicroPython’sYasmarangsoftware PRNG. Details in Coinkite’s technical backgrounder and Block’s writeup. ↩︎See
src/js/entropy.jsin iancoleman/bip39. Version 0.5.0 introduced the bias correction; 0.5.6 shipped August 1, 2026 with the release note “Add warning to not share info from the page.” ↩︎
