---
title: "Puzzle records"
description: "One PuzzleSpec literal per puzzle and one factory for every chain. Builders for keys and transactions. Absent means absent and never null."
canonical_url: "https://puzzles.agntn.dev/guide/records"
---
# Puzzle records

> One PuzzleSpec literal per puzzle and one factory for every chain. Builders for keys and transactions. Absent means absent and never null.

## One file, one literal

```ts [src/collections/bits/71.ts]
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 script
- `p2wsh(value, witnessScript)` once a spend has shown the script
- `p2pkh(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 <dataset-count>



</dataset-count>

 records do.

<table>
<thead>
  <tr>
    <th>
      Field
    </th>
    
    <th>
      Required
    </th>
    
    <th>
      Built with
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      <code>
        id
      </code>
      
      , <code>
        sourceUrl
      </code>
      
      , <code>
        startedAt
      </code>
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      strings, <code>
        YYYY-MM-DD HH:MM:SS
      </code>
      
       in UTC
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        chain
      </code>
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      <code>
        "bitcoin"
      </code>
      
      , <code>
        "litecoin"
      </code>
      
      , <code>
        "ethereum"
      </code>
      
       and the other keys of <code>
        Chain
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        address
      </code>
    </td>
    
    <td>
      yes
    </td>
    
    <td>
      the encoded address as a string, or one of the builders above
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        status
      </code>
    </td>
    
    <td>
      no, <code>
        unsolved
      </code>
    </td>
    
    <td>
      <code>
        Status.Solved
      </code>
      
      , <code>
        Status.Claimed
      </code>
      
      , <code>
        Status.Swept
      </code>
      
      , <code>
        Status.Expired
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        prize
      </code>
      
      , <code>
        currency
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      a number in the chain's native token, or <code>
        currency: "DAI"
      </code>
      
       when it isn't
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        pubkey
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        compressed("02…")
      </code>
      
       or <code>
        uncompressed("04…")
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        key
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      the <code>
        Key
      </code>
      
       builder below
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        transactions
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        funding
      </code>
      
      , <code>
        increase
      </code>
      
      , <code>
        decrease
      </code>
      
      , <code>
        pubkeyReveal
      </code>
      
      , <code>
        claim
      </code>
      
      , <code>
        sweep
      </code>
      
      , <code>
        transfer
      </code>
      
      , each <code>
        (txid, date, amount)
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        solvedAt
      </code>
      
      , <code>
        solveTime
      </code>
      
      , <code>
        solver
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      when, seconds, and <code>
        party(name, { addresses, profiles })
      </code>
      
      ; an author adds <code>
        key
      </code>
      
      , <code>
        kind
      </code>
      
      , <code>
        aliases
      </code>
      
      , <code>
        about
      </code>
      
       and <code>
        facts
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        assets
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        assets({ puzzle, solution, hints, sourceUrl, digests })
      </code>
      
      , file names under <code>
        assets/<collection>/
      </code>
      
      , each with its <code>
        digest()
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        stages
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        stage(name, about, [artifact(name, url, file?)], answer?, techniques?)
      </code>
      
       in the author's order; <code>
        file
      </code>
      
       is the copy under <code>
        assets/<collection>/
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        hints
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        official
      </code>
      
       or <code>
        community
      </code>
      
      , each <code>
        (text, source, confirmation?, { date?, answer? })
      </code>
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        techniques
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      <code>
        technique(name, source)
      </code>
      
      , the name from the <code>
        Technique
      </code>
      
       vocabulary
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        preGenesis
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      the address predates the puzzle thread, so the funding date is the thread's
    </td>
  </tr>
  
  <tr>
    <td>
      <code>
        escrow
      </code>
    </td>
    
    <td>
      no
    </td>
    
    <td>
      the contract address when the prize waits in a contract that pays only the target, so <code>
        balance()
      </code>
      
       adds the two
    </td>
  </tr>
</tbody>
</table>

## 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](https://puzzles.agntn.dev/authors) 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](https://puzzles.agntn.dev/solvers) 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:

```ts
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.

```ts
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:

```ts
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:

```ts
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:

```ts
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.

```json
{
  "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`.


## Sitemap

See the full [sitemap](https://puzzles.agntn.dev/sitemap.md) for all pages.
