Verification
The call
import { verify, requirePuzzle } from "@agntn/puzzles";
const result = await verify(await requirePuzzle("bits/1"));
result.verified; // true
result.derivedAddress; // "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH"
result.expectedAddress; // the same
result.privateKey; // "00…01", the hex the derivation started from
verify(puzzle) returns a promise. It reads secretOf(puzzle.keyData()), turns the secret into a raw key, derives the address through the @agntn/keys wallet for the chain, and compares. addressesEqual lowercases Ethereum and compares everything else byte for byte.
The first call imports keys, and every later call reuses it. That keeps the curves out of import "@agntn/puzzles" and out of the CLI's start: puzzles collections doesn't need them, so it doesn't pay for them. Collections wrap it in their own lookups: bits.verify(1) and bits.verifyById("bits/1").
Failures are values
const result = await verify(await requirePuzzle("bits/71"));
result.verified; // false
result.unavailable; // true
result.error; // "Puzzle has no private key"
Three answers, one shape. verified: true with the derived address. verified: false, unavailable: true when there was nothing to derive: no key, an encrypted key without its passphrase, a mini key. And verified: false, unavailable: false with an error when a key exists and derives the wrong address. That last one is what the data gate exists to catch. Nothing throws for a bad record. Why not? Because puzzles verify --all would crash on the first unsolved puzzle.
| Secret | Verified how |
|---|---|
hex | The key as written. Format comes from the declared public key, then a matching decrypted WIF, then defaults to compressed |
wif | Decoded first. The WIF says whether the key is compressed, so the record doesn't have to |
seed with a phrase | BIP39 to BIP32 along the record's path, at its .rounds() when it has them, or Electrum's own salt after .electrum(). No path, or a Required passphrase, means unavailable |
cosigners | Every cosigner's key, then the multisig rebuilt and checked against the P2WSH address. Bitcoin only. No witness script means unavailable |
encrypted | Unavailable here. The data gate decrypts BIP38 payloads with the passphrase when the record has one, so an encrypted key with a known passphrase is still checked, just not at runtime |
keyfile | Opened with the recorded answer, like its Puzzle Weave page does. No answer, unavailable |
mini | Unavailable. Nobody has asked yet |
A WIF contributes its format only if it decodes on the record's chain to the same hex key. An invalid WIF or one for a different key doesn't override hex verification.
Bitcoin and Litecoin derive P2PKH and P2WPKH. Dogecoin never took SegWit, so it's P2PKH only. Bitcoin Cash and eCash derive their P2PKH in CashAddr, Ethereum and Base the same Keccak address, Decred its BLAKE-256 P2PKH. Monero has no key to address derivation here. Arweave gets its address out of a keyfile, nothing else. Any other key there stays unavailable too, and the data gate treats "nothing to contradict" as a pass, not a failure.
An Arweave keyfile is RSA, so no curve helps. So verify reruns the page's own decodewallet. SHA-512 over the answer 11,513 times. CryptoJS AES with a 1024-bit key. Then SHA-256 of the modulus. About 60 ms a puzzle. privateKey is then the keyfile as JSON, not hex. Every wallet that opens this way was claimed years ago.
P2SH, P2WSH and standard addresses on UTXO chains need more than a private key. With otherwise valid key material, these return unavailable: true, not a mismatch. Missing derivation support isn't evidence that the key is wrong.
Unless the record brings the rest. A Bitcoin P2WSH with its witness script and a key for every cosigner gets its multisig rebuilt.
The threshold comes from the script. The keys go in sorted, or in the script's order. Then the script hashes to an address like any other. privateKey is every cosigner's key, joined by spaces. Genesis was the first record to need it.
A seed phrase derives as written. The BIP39 checksum isn't enforced, because the seed is PBKDF2 over the words and a puzzle can publish a phrase whose checksum fails: movie-enigma did, and its phrase spent the prize. A word outside the list or a wrong word count still fails, and a wrong phrase of listed words fails on the address comparison.
Odd PBKDF2 rounds don't get a pass. The standard 2048 still runs first, so a phrase keys refuses fails the same way at 181505 rounds.
Recipes get checked too
A published key proves itself. But a lot of records also say where the key came from. An entropy hash. A WarpWallet passphrase and its salt. A brainwallet phrase. Copy one of those wrong and the key still verifies. Who'd notice?
verify would. It reruns the recipe and reports it apart, under result.recipe:
const result = await verify(await requirePuzzle("quizchain/74"));
result.verified; // true, the WIF derives the address
result.recipe; // { recipe: "bip39-entropy", path: "m/44'/0'/0'/0/0", verified: true, … }
| Recipe | Runs when | Rebuilds |
|---|---|---|
bip39-entropy | The key holds an entropy hash | Entropy to mnemonic to the record's path. No path means the BIP39 tool's default, m/44'/0'/0'/0/0 on Bitcoin |
warpwallet | A passphrase, tagged warpwallet | Nothing here. It's unavailable, and the data gate runs it instead |
sha256-brainwallet | A passphrase, tagged sha256-brainwallet | SHA-256 of the passphrase |
triple-sha256-brainwallet | A passphrase, tagged triple-sha256-brainwallet | Three rounds of it |
The tag picks the recipe, not the passphrase. A BIP38 passphrase looks exactly like a brainwallet one. Only the technique tells them apart. Scrypt brainwallets stay out, because no record holds their costs.
Same three answers as the key. verified. unavailable, say an entropy seed behind an unknown passphrase. Or a miss with the address it got instead. A miss is a data bug, even when the key verifies.
It works the other way too. bitimage/kitten has no printed key, just the SHA-256 of a kitten photo. The key says unavailable. The recipe lands on the address. First time that puzzle verifies at all.
Why skip WarpWallet? Its scrypt eats 256 MiB and about a second, which is the whole point of WarpWallet. A Worker gets 128 MB. So it goes the way of BIP38: the data gate rebuilds every WarpWallet key, verify says unavailable. The rest isn't free either. verify --all went from under a second to about two, mostly PBKDF2 behind every entropy seed.
Then Genesis showed up with two seeds and 382,418 rounds between them. That's about 1.4 seconds on its own. One record. Worth it? It's the only way to check that one at all.
What this isn't
A verified puzzle is one whose published key derives its stored address. It says the record is internally consistent. It doesn't say the address holds anything, that the key was the solver's, or that the puzzle is worth your CPU. bits/1 verifies and holds nothing. It was solved in 2013.
CLI and tools
puzzles verify bits/1 # OK bits/1
puzzles verify quizchain/74 # OK quizchain/74 recipe bip39-entropy OK
puzzles verify --all --quiet # exit 1 when any key or recipe fails, nothing printed
puzzles verify --technique md5-to-bip39-entropy # every MD5 seed, rerun from scratch
The list filters work here too: --collection, --technique, --status, --chain, --address, --with-pubkey. Why bother? Because a solved puzzle is a free regression test. Changed how @agntn/keys turns entropy into a seed? Run the line above. A slip shows up as a FAIL with the address it got instead.
puzzles verify --all prints one line per puzzle, OK, SKIP or FAIL, and the recipe's own label after it. It exits 1 only on a FAIL, the key's or the recipe's. The CI gate runs it with --quiet. puzzles_verify is the same answer for a model: bits/1: verified, derives 1BgG…, or unverifiable (Puzzle has no private key). The recipe gets a second line. isError stays unset either way.
The tool takes the filters too. puzzles_verify with technique: "md5-to-bip39-entropy" replays every MD5 seed in one call, where a model used to make one call per puzzle. A miss comes back whole, key line and recipe line. Everything else folds into ids: the unverifiable ones by reason, then one Verified: line. Why fold? Because a wall of identical paragraphs saying "still works" costs a lot of tokens for good news. The answer shrinks to about a tenth. On your machine, that is. The public server at puzzles.agntn.dev/mcp takes one id per call, so nobody's replay runs on the site's CPU.