---
title: "Balances"
description: "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."
canonical_url: "https://puzzles.agntn.dev/guide/balances"
---
# 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`](https://explorers.agntn.dev) 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.

<table>
<thead>
  <tr>
    <th>
      Chain
    </th>
    
    <th>
      Provider
    </th>
    
    <th>
      Needs
    </th>
  </tr>
</thead>

<tbody>
  <tr>
    <td>
      Bitcoin
    </td>
    
    <td>
      Mempool at mempool.space, then Blockstream
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      Litecoin
    </td>
    
    <td>
      litecoinspace.org, then Blockchair
    </td>
    
    <td>
      nothing, until its Blockchair spare blacklists your IP. Then a key
    </td>
  </tr>
  
  <tr>
    <td>
      Bitcoin Cash
    </td>
    
    <td>
      Blockchair
    </td>
    
    <td>
      nothing, until Blockchair blacklists your IP. Then a key
    </td>
  </tr>
  
  <tr>
    <td>
      eCash
    </td>
    
    <td>
      Blockchair
    </td>
    
    <td>
      the same as Bitcoin Cash, and the same key
    </td>
  </tr>
  
  <tr>
    <td>
      Ethereum
    </td>
    
    <td>
      Blockscout, or Etherscan V2 with a key
    </td>
    
    <td>
      nothing. An Etherscan key moves it to Etherscan
    </td>
  </tr>
  
  <tr>
    <td>
      Base
    </td>
    
    <td>
      Blockscout at base.blockscout.com
    </td>
    
    <td>
      nothing, and a key changes nothing
    </td>
  </tr>
  
  <tr>
    <td>
      Decred
    </td>
    
    <td>
      Dcrdata
    </td>
    
    <td>
      nothing, and patience: dcrdata answers a fresh address in 6 to 11 seconds
    </td>
  </tr>
  
  <tr>
    <td>
      Arweave
    </td>
    
    <td>
      the arweave.net gateway
    </td>
    
    <td>
      nothing
    </td>
  </tr>
  
  <tr>
    <td>
      Dogecoin
    </td>
    
    <td>
      Blockchair
    </td>
    
    <td>
      the same as Bitcoin Cash, and the same key
    </td>
  </tr>
  
  <tr>
    <td>
      Monero
    </td>
    
    <td>
      none
    </td>
    
    <td>
      rejects with <code>
        UnsupportedChainError
      </code>
      
       before any request
    </td>
  </tr>
</tbody>
</table>

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

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

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

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


## Sitemap

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