Guide

Balances

puzzle.balance() through the explorers provider for its chain. Base units as bigint. API keys never reach an error. And a watch for what the record missed.

The call

ts
import { bits } from "@agntn/puzzles/collections/bits";

const balance = await bits.require(71).balance();

balance.chain; // "bitcoin"
balance.confirmed; // 710195986n, base units
balance.unconfirmed; // 0n, a signed mempool delta when the provider reports one
balance.total(); // 710195986n
balance.totalUnits(); // 7.10195986, a double, so 18 decimal places would round
balance.totalAmount(); // "7.10195986", every digit, whatever the chain
balance.decimals; // 8, from @agntn/chains

Every puzzle answers balance(). Collections forward balance(query) and balanceById(id) to it. The providers come from @agntn/explorers and load on the first call, so importing the package carries no network code.

Some prizes don't sit where the key leads. The key opens a wallet, and the wallet has to pull the prize out of a contract. Such a record names the contract as escrow, and balance() adds both addresses up. That's what the key is worth. If either lookup fails, the whole call fails. Half a prize is a wrong number, not a smaller one.

ChainProviderNeeds
BitcoinMempool at mempool.space, then Blockstreamnothing
Litecoinlitecoinspace.org, then Blockchairnothing, until its Blockchair spare blacklists your IP. Then a key
Bitcoin CashBlockchairnothing, until Blockchair blacklists your IP. Then a key
eCashBlockchairthe same as Bitcoin Cash, and the same key
EthereumBlockscout, or Etherscan V2 with a keynothing. An Etherscan key moves it to Etherscan
BaseBlockscout at base.blockscout.comnothing, and a key changes nothing
DecredDcrdatanothing, and patience: dcrdata answers a fresh address in 6 to 11 seconds
Arweavethe arweave.net gatewaynothing
DogecoinBlockchairthe same as Bitcoin Cash, and the same key
Monerononerejects with UnsupportedChainError before any request

Bitcoin has a spare. mempool.space drops lookups now and then, and a long pass is where it happens most. A timeout, a refused connection, a rate limit or a 5xx sends the same lookup once to Blockstream at blockstream.info. A rejected address or a balance that doesn't parse goes nowhere else. Another host would say the same. If Blockstream fails too, the error names both. Each host gets the whole timeout, so two timeouts in a row take twice as long. On an unsolved puzzle watch reads the history and the balance, so two lookups at least. Waiting out a dead host before each read? Not a plan. So once Blockstream answers in its place, mempool.space sits on the bench for ten minutes and Blockstream goes first. One timeout every ten minutes, not one per lookup. If Blockstream stumbles in those ten minutes, mempool.space is back in front right away.

Litecoin got a spare too, and it earned it. litecoinspace.org has gone dark more than once, and the last time the block height still answered while every address read timed out. Blockchair reads Litecoin too, so the same failures send the lookup there, with the same ten minute bench. Blockchair blacklists keyless callers sooner or later, so it takes BLOCKCHAIR_API_KEY. What can't it do? Show the key a spend revealed. So watch asks litecoinspace.org for that or nobody. A spare answering "no key" for a host that never answered would be a lie with good manners.

Ethereum picks its provider by the key. Without one, Blockscout at eth.blockscout.com answers and nobody needs an account. Pass an Etherscan key and the lookup goes to Etherscan instead, and Blockscout never sees that key. Both read the same chain, so they agree to the wei. Neither is the other's spare, though. A failed Blockscout lookup fails, it doesn't go knocking at Etherscan.

Base doesn't pick. Etherscan V2 moved Base behind its paid plans, so a free key would turn a working lookup into a refusal. Base always asks Blockscout, and any key you pass stays home.

Options

ts
await puzzle.balance({
  apiKey: "your-etherscan-key",
  baseUrl: "https://mempool.example", // another instance of the same provider, and no fallback
  timeout: 30_000, // milliseconds, the providers default to 15 seconds
});

That's the whole options object. There's no fetch to inject. The providers read globalThis.fetch when they run, so a test stubs the global and a runtime brings its own. Leave apiKey out and Ethereum goes to Blockscout. A baseUrl goes to whichever provider the key picked, so a keyless one has to be a Blockscout instance.

Errors

ts
import {
  BalanceError,
  BalanceProviderError,
  InvalidAddressError,
  UnsupportedChainError,
} from "@agntn/puzzles";

All three descend from BalanceError, which descends from PuzzlesError. InvalidAddressError is a provider or @agntn/chains saying the address doesn't fit. UnsupportedChainError is Monero, for now. BalanceProviderError is everything else: a transport failure (on Bitcoin, from both hosts), a not numeric balance, a rejected key. An API key never appears in an error message. It's replaced with REDACTED, and the original error isn't attached as cause, so it can't leak through a logger either.

CLI and tools

shell
puzzles balance bits/71                       # bits/71: 7.10195986 BTC
puzzles balance weave/11                       # Ethereum through Blockscout, no key
puzzles balance weave/11 --api-key …           # through Etherscan, or set ETHERSCAN_API_KEY
puzzles balance bits/71 --json                # { chain, confirmed, unconfirmed, decimals }
puzzles balance --status unsolved              # one row per open prize
puzzles balance --collection bits --json      # [{ id, chain, confirmed, … }, { id, chain, error }]

Without an id, balance takes the filters list takes: --collection, --address, --chain, --status, --technique and --with-pubkey. It asks one explorer after another, a quarter of a second apart, because a burst of 90 requests is how mempool.space starts dropping them. Each puzzle gets its own row, OK bits/71 7.10195986 BTC or FAIL bits/72 Balance lookup failed: …, printed as soon as the answer comes back. A failed lookup doesn't end the pass. The command exits 1 at the end, so a script knows to run the FAIL rows again. With neither an id nor a filter it refuses instead of asking about every puzzle in the dataset.

Each chain reads its own key: ETHERSCAN_API_KEY for Ethereum, BLOCKCHAIR_API_KEY for Bitcoin Cash, Dogecoin, eCash and Litecoin's spare. --api-key beats both and goes to every chain in the pass, so for a mixed set leave it out and set the two variables. Your Etherscan key never lands at Blockchair that way.

puzzles_balance is the same call for a model, and it answers with the same line, bits/71: 7.10195986 BTC. The symbol is the chain's own coin. weave/9 pays 100 DAI, and its balance is the ETH at that address, not the DAI. puzzles_balance is annotated as open world, because it reaches beyond the bundled dataset. So is puzzles_watch, below.

What changed since the record?

A balance is the present. A record is the past someone wrote down. They drift apart quietly. Somebody tops up a prize. Somebody spends from it. The author edits the page. Who notices? Usually nobody, for months.

watch notices. It reads the chain and lays it over the record:

shell
puzzles watch bitaps                          # OK  bitaps
puzzles watch bits/71                        # OK, until somebody tops it up again
puzzles watch --status unsolved               # every open prize, a quarter second apart
puzzles watch --collection genesis --json     # [{ id, chain, findings, errors, truncated }]
puzzles watch bits/71 --since 2026-09-01     # the source page too

Each finding is one row. On 2 October 2026, bits/71 printed these, among others:

text
DEPOSIT bits/71 572001d88e5c3030a89fe119e62cf80892876b59dcb9efe91348dd38745147e1 0.00000666 BTC 2026-09-29T22:18:51.000Z at 1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU
BALANCE bits/71 7.1019168 BTC held, 7.1002257 BTC recorded as the prize

Every one of those deposits is on the record now. So today bits/71 reads OK. For how long? Until the next stranger sends it a few hundred sats.

A DEPOSIT or a SPEND is a transaction the record doesn't list. TRANSACTION is one where the explorer won't say which way it went. BALANCE is an unsolved prize the addresses don't hold anymore, compared at the precision the record keeps. Only an unsolved puzzle gets that check. Any other prize is history, not a promise.

PUBKEY looks small and isn't. It's a public key the chain gave away that the record doesn't hold, or holds spelled differently. Why the fuss? On an unsolved Bitcoin puzzle the key decides whether Kangaroo can even start. So watch asks the explorer which key a spend from the target showed. It asks only when the target spent, since an address that never spent has no key to show. No spend, no extra request. Mempool and Blockstream can tell, so that's Bitcoin and Litecoin for now. A taproot address is its own key, which isn't a reveal. On a mismatch the row prints both keys. One of them is wrong, and it's probably not the chain's.

watch reads an address page by page, 100 transactions at a time, until a page comes back short. So the busy gsmg address gets read back to its very first transaction. On Bitcoin each page rereads the history from the top, like a forum thread that always opens on page one. A few extra requests. Worth it. Reading stops at 1000, since Mempool, Blockscout and Arweave won't serve a page past that. Anything busier gets a PARTIAL row where OK would be. Its older transactions went unchecked. A call that moves no coin in stays out, and so does one that reverted. Teikhos would otherwise list every failed authenticate as news, plus a 0.1 ETH funding from 2018 that failed and stayed with the author. A spend always counts, even an empty one. It can leak the public key.

--since adds the source page. watch takes its newest Wayback capture and the last one up to that date. If they read differently, you get a SOURCE row with both links and a line count. Wayback is slow, often half a minute per page. So a page that every puzzle of a collection links gets read once per pass. Pass the date of your previous run and the same old edit won't come back every night.

A check that can't run prints FAIL. Monero has no explorer, and an archive times out sometimes. The exit code tells a cron job what happened. It's 0 when everything it read matches, PARTIAL included, 1 for any finding, 2 when only lookups failed. Nothing gets written, though. A finding is for you to read. You decide if the record changes.

puzzles_watch is the same check for a model, one puzzle per call, with optional since and apiKey.

Is the prize still worth a weekend?

Before you sink days into a puzzle, run the checklist. Who made it? Where's the page? Which address, which script type? Does it still hold anything? What counts as the answer? Every session used to build that by hand from show, balance and an explorer tab. Now it's one call:

shell
puzzles eligibility gsmg                                # complete, exit 0
puzzles eligibility teikhos/0                           # three missing rows, exit 1
puzzles eligibility 1PWo3JeB9jrGwfHDNpdGK54CRas7fsVzXU  # the address finds bits/71
puzzles eligibility 0x000000000000000000000000000000000000dEaD --chain base
puzzles eligibility gsmg --json                         # { id, …, live, evidence, conflicts, carriers, verifier, missing }

The live row is the explorer's own lifetime totals. Received and spent, the whole history, no pages to walk. An address past the 1000 transactions watch reads still gets its totals here. Mempool, Blockstream, Blockchair and dcrdata keep those totals. Etherscan, Blockscout and Arweave don't. There you get the balance and a missing row for each total.

The status comes from the record, never from the chain. unsolved reads as prize open, anything else as prize closed. Under it sit the evidence rows: what the record says, the claim transaction if there is one, what the addresses hold right now. A conflict row is the record and the chain disagreeing. On 2 October 2026 bits/71 had one:

text
conflict    addresses hold 7.1019168 BTC, the record says the prize is 7.1002257 BTC

Small deposits add up. watch lists them one by one.

carrier rows are the files and pages the clues hide in, or the key range of a range puzzle. verifier says what a solution is. For gsmg that's the private key of its uncompressed public key. For a Teikhos contract it's a missing row, because nothing on the record says whether an Ethereum address is a contract or a key. Guessing is easy. Guessing wrong about a contract costs you the weekend.

That's the rule for every field. Nobody fills it, it's a missing row with the reason. The exit code is 0 for a complete record and 1 for anything missing. An address no record holds still gets the live part and its verifier, and six missing rows for the rest. A bare Ethereum address also reads on Base, so it needs --chain. An address with a typo fails as an address: "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMX" is not an address on any supported chain, exit 1. Not a puzzle lookup that lists every collection key. A wall of keys for one wrong character never helped anyone.

puzzles_eligibility is the same record for a model, with query, an optional chain and apiKey. One row reads differently. A shipped carrier is a path, assets/gsmg/puzzle.png, and the last line points at puzzles_assets, which only hands over bytes that match the pin. Why not the link? A model with a shell and a URL downloads first and checks never. The author's pages stay links. A live page has no pin to check against.

On this site

Every puzzle page and the playground show a live balance. The browser doesn't call the explorers itself. The docs worker does, through /api/balance/<id>, with the same puzzle.balance() call. Ethereum goes to Etherscan when the worker holds a key and to Blockscout when it doesn't. Each answer is cached for five minutes, so a busy page is one request to mempool.space, not a thousand. A failure comes back as the library's own message, Balance lookup failed: …, under the same status code the tool would set isError for.