Puzzle records
One file, one literal
import { bits, funding, increase, puzzle } from "@agntn/puzzles";
export const bits71 = puzzle({
id: "bits/71",
chain: "bitcoin",
address: "1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU",
sourceUrl: "https://bitcointalk.org/index.php?topic=5218972",
startedAt: "2015-01-15 18:07:14",
prize: 7.10195986,
key: bits(71),
transactions: [
funding(
"08389f34c98c606322740c0be6a7125d9860bb8d5cb182c02f98461e5fa6cd15",
"2015-01-15 18:07:14",
0.071,
),
increase(
"5d45587cfd1d5b0fb826805541da7d94c61fe432259e68ee26f4a04544384164",
"2017-07-11 05:00:53",
0.639,
),
],
});
That's the whole record. No status, because unsolved is the default. No solver, no assets, no null anywhere. chain is required, so the record says where it lives right under its ID. The address is a plain string, and the factory reads p2pkh and the HASH160 off it. bits(71) says the key is somewhere in [2^70, 2^71 - 1] and nothing more. Typo in a field name? The type checker rejects it before any test runs. And the factory freezes the literal through, nested parts included, so puzzle.address().value = … is refused, a TypeError in strict mode, instead of rewriting the record for every other reader.
One factory
puzzle() takes the same PuzzleSpec on every chain. A string address is read on the record's chain: the kind comes from the prefix or the version byte, the HASH160 from the encoding when it carries one. Write "1PWo…" on Litecoin and the record fails at load instead of landing in the dataset with the wrong kind.
The builders stay for what a string can't say:
p2sh(value, hash160, redeemScript(...))for a published redeem scriptp2wsh(value, witnessScript)once a spend has shown the scriptp2pkh(value, hash160)for a CashAddr, which the factory doesn't decode
Behind every chain sits an abstract base, BitcoinPuzzle and so on. A puzzle that needs behavior of its own can extend one directly. None of the 558 records do.
| Field | Required | Built with |
|---|---|---|
id, sourceUrl, startedAt | yes | strings, YYYY-MM-DD HH:MM:SS in UTC |
chain | yes | "bitcoin", "litecoin", "ethereum" and the other keys of Chain |
address | yes | the encoded address as a string, or one of the builders above |
status | no, unsolved | Status.Solved, Status.Claimed, Status.Swept, Status.Expired |
prize, currency | no | a number in the chain's native token, or currency: "DAI" when it isn't |
pubkey | no | compressed("02…") or uncompressed("04…") |
key | no | the Key builder below |
transactions | no | funding, increase, decrease, pubkeyReveal, claim, sweep, transfer, each (txid, date, amount) |
solvedAt, solveTime, solver | no | when, seconds, and party(name, { addresses, profiles }); an author adds key, kind, aliases, about and facts |
assets | no | assets({ puzzle, solution, hints, sourceUrl, digests }), file names under assets/<collection>/, each with its digest() |
stages | no | stage(name, about, [artifact(name, url, file?)], answer?, techniques?) in the author's order; file is the copy under assets/<collection>/ |
hints | no | official or community, each (text, source, confirmation?, { date?, answer? }) |
techniques | no | technique(name, source), the name from the Technique vocabulary |
preGenesis | no | the address predates the puzzle thread, so the funding date is the thread's |
escrow | no | the contract address when the prize waits in a contract that pays only the target, so balance() adds the two |
Solver and solution
solver names the person or team, built with party(). assets.solution points to a file explaining the solution. A record can have either, both or neither.
A collection's author is the same party() with more on it: a key that names its page, a kind of person or organization, aliases, one line of about, and facts. A fact is fact(text, source, { date? }): one sentence a public page states, with that page as its source. It records who said what, not whether it's true, the same way a hint does. authors() lists every author with its collections and getAuthor(key) finds one. The authors section has a page per author.
A named solver gets the same treatment. Give its party() a key and every puzzle it solved repeats that key, so solvers() can join the records into one entry: the solves in dataset order, profiles and addresses once each, and the facts of all of them. That way a fact sits on the puzzle it is about, not on whichever solve came first. getSolver(key) finds one, and the solvers section has a page per solver. A solver known only by the address the prize went to stays keyless and gets no page.
Puzzles that run in stages
Some puzzles aren't one image. GSMG has a picture that leads to a page, which leads to a page with two ciphertexts on it. stages keeps that chain in the author's order. A stage is a name, one line about what it puts in front of you, and what the author published for it. An artifact is one of those things: what it is, the URL where it lives, and optionally the file the repository keeps as a copy. The URL is the source, the file is insurance for the day the domain lapses.
A page can hold two stages and a stage can span two pages, so the URL belongs to the artifact, not to the stage. The line describes the stage, it doesn't solve it. A stage that somebody solved in public takes the fourth argument, the same answer(text, source, { date? }) a hint uses, so the answer carries its own source and never pretends to be the author's. There's no status on a stage. puzzle.stages() hands the list back, assetLinks() adds each artifact file once as kind: "artifact", and puzzles_show prints a stages block: each stage with its description, then its artifacts and its answer indented under it. Past three stages the block shrinks to its count, and puzzles_stages prints the whole thing.
For consumers of 0.20.3 and earlier, rename assets({ solver: file }) to assets({ solution: file }) and read assets.solution in JSON. assetLinks() now uses kind: "solution", and CLI and agent output labels the link solution asset:. There is no alias for the old asset field. The person field and solver() stay unchanged, as do file paths, including Zden's existing solver.png URLs.
Files are pinned by their bytes
A file name says which file, not which bytes. A re-encoded PNG or a page edited after the fact can quietly break a solve, and nobody notices until the hash in their notes stops matching. So every file a record ships has a digest(file, sha256, bytes, { url?, archive? }) in its assets: the SHA-256 of the repository copy, its size, the author's URL that served those exact bytes, and a Wayback id_ capture of them. url and archive go in only when a fetch or the capture's own digest proves the bytes. A page that wraps the image in HTML doesn't count, and a frame cut from a video has no author URL at all. assetLinks() hands the digest back on each link, with the author's URL as origin, because the link's own url is the repository copy.
pnpm test hashes every copy in assets/ against its record, so a new file without a digest fails the build, and so does a copy someone re-saved. puzzles assets gsmg --check <dir> runs the same comparison on your own downloads, and --live asks the author's URLs whether they still serve what the record says.
Keys are a chain of calls
Key material comes in more shapes than any other field. So it's a builder, not a literal:
import {
bits,
cosigners,
derivation,
encryptedWif,
hex,
keyfile,
seed,
share,
} from "@agntn/puzzles";
hex("00…01", 1).wif("KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn"); // bits/1
hex("af9a…4a0d").wif("5K9d…U7g").passphrase("5784623964023 578462396402"); // a brainwallet
encryptedWif("6PnW…4cH", { passphrase: "335Y-K745-C8WT-4D2W-80WP" }); // a BIP38 Ballet wallet
derivation("m/84'/0'/0'/0/0")
.xpub("zpub6qd…JCqL")
.shares(3, 5, [share(1, "session cigar …")]); // Bitaps
derivation("m/84'/0'/0'/0/0").entropy(
"1808…32ed",
source("https://twitter.com/aantonop/status/603701870482300928"),
); // Bitimage
wif("L2Z1…iMQz").entropy("8d00…5a95", source(THREAD, "SHA-256 of …")).derived(); // quizchain/6
seed("path mad alien … ghost shine", "m/84'/0'/0'/0/0"); // movie-enigma
seed("since desk thrive … ivory luggage", "m/44'/0'/0'/0/0"); // ledger-donjon
seed("fortune all man … virtual moon", "m/84'/0'/0'/0/0", "supernova"); // Born to Be Wild, passphrase and all
seed("banquier usure … horizon jaune", "m/44'/0'/0'/0/0").language("french"); // a mural in Paris
seed("visit kingdom … umbrella spot", "m/0'/0/1").electrum(); // luckylurker/vault-1
cosigners(
seed("scan amateur … hurt ugly", "m/48'/0'/…/0/0", "Satoshi Nakamoto").rounds(181505),
seed("scan amateur … hurt ugly", "m/48'/0'/…/0/0", "Satoshi Nakamoto").rounds(200913),
); // genesis, one phrase and two seeds
keyfile("U2FsdGVkX1…", { passphrase: "1111:51Nfg368+96938=97006" }); // weave/2, sealed on the page
bits(71); // a search width and nothing else
BIP39 isn't English only. A phrase from another official list carries .language(), and verification loads that list from @agntn/keys before it derives anything. Without it, banquier is just an unknown English word and the check fails.
English words aren't always BIP39 either. Electrum has its own seed format on the same list, and BIP39 reads its phrases into another wallet. Same words, wrong salt, wrong wallet. .electrum() marks such a phrase, so verification salts it the way Electrum does. The path is still yours to get right. LuckyLurker's Vault #1 kept its prize on m/0'/0/1, the second receiving address.
And BIP39 isn't always 2048 rounds of PBKDF2. Nothing stops an author from running more. Genesis ran 181505 for one cosigner and 200913 for the other. Wallet apps never ask for that number, and eight weeks of brute force at 2048 didn't either. .rounds() keeps it, next to .language() too.
Two keys behind one address? cosigners() takes one key per cosigner. Verification rebuilds the multisig script from them and checks the address it hashes to. The threshold comes from the witness script, so a p2wsh() without one gets SKIP, not FAIL. The keys go in the script's order, or in any order when the script sorts them.
key() hands back the builder. keyData() gives its serialized record. Every builder call returns a new builder, so puzzle.key()?.wif(found) gives you a key record with the WIF and leaves the puzzle as it was.
secretOf(keyData) picks the one representation verification will use, in this order: a hex key, a decrypted WIF, an encrypted WIF, a seed phrase, a mini key, a sealed keyfile, then the cosigners.
Cosigners count only when every one of them has a secret. Half a multisig spends nothing. A record with only a derivation path or an xpub has no secret either. hasPrivateKey() says false for it, and verification agrees.
Sometimes nobody printed the key at all. The author published a recipe, or confirmed a solver's, and the record ran it. Most Quizchain blocks from block 6 on are like that, and so is the Satoshi birthday quiz. .derived() marks such a key. The recipe itself stays where it was, in the entropy source or in a hint. Then hasDerivedKey() says true and the site writes "key derived" instead of "key published". puzzles show adds derived from the published recipe to the private key line. The derivation is the only evidence for that key, so the data gate checks every derived key against its address.
Hints carry their provenance
A hint is a claim about the puzzle, and the record says who made it and where. official is the author's, community is anyone else's and may be wrong. The text and its publication URL are enough: source is the evidence for the hint. An optional confirmation links to an archive capture or another page where the author published the same hint, not general information about the puzzle or a solver's reconstruction. It doesn't need a different URL from source. The record never says whether the hint is right.
official("Start at the top.", "https://example.com/rules");
Pass undefined as the third argument to add a date or published answer without a confirmation. Existing calls with confirmation(url, description?) still work:
import { confirmation, official } from "@agntn/puzzles";
official(
"this passphrase is 2 random alphanumeric characters, such as 'X9'.",
"https://keybase.io/warp",
confirmation(
"https://web.archive.org/web/20131213023906/https://keybase.io/warp/warp_1.0.6_SHA256_e68d4587b0e2ec34a7b554fbd1ed2d0fedfaeacf3e47fbb6c5403e252348cbfc.html",
"Wayback capture of the challenge page",
),
); // warp/challenge-1
puzzle.hints() hands the list back in record order, empty when there is none, and puzzles_show prints one line per hint after the assets. Past three, a block shrinks to its count, and puzzles_hints has them all.
A hint that holds for every puzzle in a collection goes on the collection, once, as the fourth constructor argument next to the key, the author and the list: super(key, author, puzzles, hints). A puzzle inherits those and adds its own on top. collection.hintsFor(query) and collection.hintsById(id) hand back the joined list, the collection's first, and puzzles_show prints the inherited ones as collection hints above the puzzle's hints. The serialized dataset carries them once per collection too, and so does the collection's page on the site, in its dossier.
Published answers stay separate from hints
Keep the original clue in text and a published response in answer. Otherwise a solved hint stops being a hint at all. The answer has its own source and optional publication date:
import { answer, confirmation, official } from "@agntn/puzzles";
official(
"Word #1: Presence without permanence.",
"https://luckylurker.com/bitcoin-vault/",
confirmation(
"https://github.com/floflo777/open-crypto-puzzles/blob/main/4-solved/luckylurker-seed-riddles-80ksats/data/hints.csv",
),
{
date: "2026-03-17 17:30:00",
answer: answer("visit", "https://luckylurker.com/bitcoin-vault/"),
},
);
answer says what was published, not whether it solves the puzzle. It doesn't set key or change the status. CLI and tools label it separately; the site keeps it behind Published answer until opened. No answer given, no answer field in the exported record.
Techniques say how it was built
Authors repeat themselves. Quizchain hashed its answers into BIP39 entropy, block after block. RushWallet ran every passphrase through one SHA-256. So what will an author reach for next time? techniques keeps the answer in a form you can query.
A technique is one name from a short vocabulary, Technique in src/core/technique.ts. It comes with the page that says it was used:
import { technique } from "@agntn/puzzles";
techniques: [
technique("md5-to-bip39-entropy", THREAD),
technique("atbash", THREAD),
], // quizchain/58
The source is the author's recipe or a published solution. An unsolved puzzle gets a technique only where its author said so. The Genesis puzzle got its tag that way, weeks before the sweep, because its author named SHA-256 in an OP_RETURN. A hunch stays out. "Probably a brainwallet" isn't a source.
Three places hold them. A stage takes its own as the fifth argument of stage(), so Beaufort sits on GSMG's phase 3.2.1, not on the whole puzzle. A record takes techniques. One that every puzzle shares goes on the collection, after the hints: super(key, author, puzzles, hints, techniques). That's how all of bits says masked-key-range once. collection.techniquesFor(query) and collection.techniquesById(id) join the three, collection first, then the record, then the stages.
The data gate keeps the list honest. A name outside the vocabulary fails. So does a source that isn't a web URL, or one name twice on the same owner. Where the record holds the recipe, the gate runs it too. A 32 character entropy hash has to be tagged MD5, a 64 character one SHA-256. A SHA-256 brainwallet's passphrase has to hash to the key on the record. Copy a tag wrong and the build tells you.
Status is explicit
A claim transaction plus a published key still means solved. A sweep by a stranger after the public key leaked means swept. A prize the author took back means expired. Those are judgments, and the record writes them down. The library refuses to guess them from the transaction list. It would guess wrong on exactly the puzzles people care about.
No nulls
toJSON() omits what a puzzle never claimed to have. No solver, no assets, no prize? Then no keys for them either. The data gate fails the build if the string null shows up anywhere in the dataset. Consumers check === undefined once and move on.
{
"id": "bits/71",
"chain": "bitcoin",
"address": { "value": "1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU", "kind": "p2pkh", "hash160": "f6f5…a5b8" },
"status": "unsolved",
"key": { "bits": 71 },
"prize": 7.10195986,
"start_date": "2015-01-15 18:07:14",
"source_url": "https://bitcointalk.org/index.php?topic=5218972",
"transactions": [ ... ]
}
The serialized names are snake case, start_date and source_url. That's what the JSON dataset looked like before this was a TypeScript package. Nothing downstream should have to care about the rewrite.
The data gate
pnpm test re-checks every record. Identifiers are unique and owned by their collection. Every address and every txid fits its chain's format. A published private key derives the stored address. A decrypted WIF matches its hex. A BIP38 payload decrypts to the declared key when the passphrase is known. A claimed or swept puzzle carries a public key. Every asset path exists, stage artifact files included, and its bytes match the SHA-256 and size the record pins for it. Every stage has a one line name and description, every artifact a one line name and a web URL, and a stage answer passes the same checks as a hint's. Every hint stays on one line with a web URL for its source and, when present, its confirmation. Nothing serializes as null. A wrong hex digit doesn't reach main.