Give Bitcoin addresses an index, and the page a true sentence - #42
Conversation
The public site offered address search over a deployment that could not answer one. Every address returned 405 "Address lookups cannot be used with bitcoind as backend", the page rendered that beside the word OK because it pasted the status number to the proxy's reason phrase, and underneath it told the reader their address had too many transactions for the backend to handle. Three claims, none of them true, and every release gate green throughout, because no gate had an opinion about address lookup at all. A first-party mempool/electrs now answers the address family, and the gateway owns the split: /api/v1/ stays with the explorer backend and the overlay, /api/ goes to the index with the prefix stripped, and the index's internal routes are refused rather than proxied. That split is not optional with BACKEND=esplora, because the backend deliberately does not mount those routes in that mode. The rest is making the failure impossible to repeat rather than merely fixed. Address lookup is a capability now, with a readiness rule that lives in one place and is obeyed by the capability document, the release preflight, the cutover verification and the production check, so the four cannot drift. Readiness means a real address query and a real UTXO query answered and the index is within two blocks of Core; a listening port is not readiness. The reasons an address lookup can fail travel as names rather than as statuses, so a missing index, a timeout and an address that genuinely has too much history are three different sentences instead of one wrong one. The pipe no longer reads the status line's reason phrase at all, so "405 OK" cannot be rendered. BACKEND=none is a release blocker, a configured index has to answer genuine summary, history and UTXO queries before a cutover is allowed, every configured source including fallbacks must be loopback or a Unix socket, and the production smoke opens real address pages of every script type in a browser and fails on the sentences that shipped.
The address index is a separate process from the backend and the overlay, with its own reasons to be down: a rebuild, a compaction, the disk guard stopping it before it fills a shared volume. What must not happen then is the rest of the origin going with it, and what must never happen is an address answering 200 with an empty body, because a zero balance rendered from a missing index tells somebody their money is gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The account was locked for billing, and every hosted job began failing in two seconds with no steps and no log. One of them is a required check, so a gate that exists to prove Safari and Chrome on iOS still work stopped proving anything, and there was nothing to read about why. The Windows fleet was idle and needs no package to run these engines: Playwright ships what WebKit and Firefox need there, so the apt command the Linux fleet has no sudo for is not needed at all. The frontend ng script becomes portable in the same change, because it invoked a .js file as a program and only a POSIX shell will do that. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every other unit this deployment runs is in production/linux and is packed into the artifact, because taking a unit from whichever checkout happens to be on the host is how a service ends up running a configuration nobody can point at. The index was installed by hand and its units existed only on the machine. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A release may move which upstream owns a path, and this one does: the explorer backend stops mounting the address, transaction, block and mempool routes and the index starts serving them. The two directions are not symmetric. A new gateway in front of an old backend is fine. An old gateway in front of a new backend sends /api/ to a process that no longer answers it, so every transaction, block and address page 404s until the gateway catches up. Restarting the backend first put the origin through that half of the swap on purpose. Its socket unit holds the port across the gateway's own restart, so going first costs nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The progress line guarded on the height being non-null with the safe navigation operator, which answers undefined rather than null when the document itself is missing, so the guard passed and the interpolation below it read a property off nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The address capability published Core's own last-checked time as the feature's last successful update. Core is never behind itself, so that value stays current while the index it is being compared against goes stale, which is the one moment the field exists to expose. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The browser gate counted class names that do not exist in this build, so its check for an empty table over a non-empty index would have reported every working address page as showing nothing. Measured against a live transaction page: the rows carry data-cy="tx-<index>", and the per-row link is hidden when the list is inside a transaction page, so counting links measures which page it is on rather than whether it has content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
electrs finishes its initial index before it binds its HTTP port, so the first build reports as unavailable rather than syncing: there is nothing listening to ask how far it has got. Reading that as a fault would send an operator looking for a broken index that is working exactly as intended. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The account hit its Actions storage quota, and every browser job began reporting failure with "No mobile failures" printed directly above the error. The screenshots are a report about the gate, not the gate, and the step that decides the job has already run by then, so an upload that cannot store them can no longer invert the verdict. It cannot hide a red one either, for the same reason. The release artifact upload is deliberately left alone: there the upload is the product. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
#40 is merged, which should clear every check that was failing on this PR for reasons that had nothing to do with it:
Merge develop into this branch and the run should go green without any change on your side. Happy to do the develop merge for you if you have moved on. |
Two workflow files needed a decision rather than a merge. The WebKit and Firefox job: develop moved it to RunsOn Spot, which is what the workspace CI policy asks for and which landed first. That replaces the move to the Windows fleet made here for the same reason, so develop's runner, its dependency-install fallback and its comment are what remain, and the Windows-only shell default goes with them. The artifact uploads: develop added if-no-files-found, which handles a directory that is not there. This branch added continue-on-error, which handles a store that refuses to take one. They are different failures and both are kept. The address page steps are added beside the chain page steps they were written to sit next to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
develop's if: failure() keeps a green run from attempting an upload at all, which is most of the problem. The comment here was written before that landed and claimed the whole of it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four and a half hours of per-batch lines rotated the journal on the indexer host, taking every other service's history with it. This is the same failure this deployment has had before, where a noisy component buried every real error, and it is worth a cap rather than a habit of reading du instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Second correction on top of my earlier note, and this one was my fault. #40 split the deep visual pass into three shards that each built their own copy, which took a run from two concurrent Angular production builds to five, on a fleet that runs more than one runner per host. Everything that started a build in the same ninety second window was killed with exit code 137, out of memory, including on pull requests that touched nothing but scripts. That is what put #48 is merged and fixes it: the frontend job keeps its build under a commit-keyed cache and the shards take it, so a run is back to two concurrent builds. Verified on its own run, 33340340913: all eight jobs green, every shard's fallback build step skipped because the cache hit, whole run 48 minutes. Merge develop into this branch now and it should go green. Still expected to fail and still not required: Sorry for the churn. Say the word and I will do the develop merge for you. |
|
Your required checks are green, so this is ready as far as they go. One thing to know before it merges: this branch is from develop as it was at 23:26Z, and #48 landed at 23:43Z. So the run that went green here did not include #48, and #48 is the change that stops the visual shards each building their own copy. Your One more develop merge and a re-run would make the verdict match what gets merged. I did not push it myself because you were active on the branch twenty minutes ago and I did not want to land on top of you. Say the word if you would rather I did it. |
Status: green, and deliberately not merged yetEvery Universe CI check passes on
The Why this is not merged yetThis branch makes That block is the point of the change. It is also not something to leave sitting in front of other people's releases for hours. So the merge is sequenced with the cutover rather than run ahead of it: the index finishes, production moves to it, and this lands. Nothing is waiting on review, and an open PR blocks nobody. Where the index isThe tx-store pass is at 485 GB and compacting, writing 1 GB SST files. 945 GB free on the volume, against a guard that stops the index at 120 GB. The history pass follows, and electrs binds its HTTP port only after both are done, which is the readiness signal the cutover waits on. One operational fix went in along the way: four and a half hours of per-batch index logging rotated the shared journal on the indexer host and took every other service's history with it. The unit now rate-limits its own journal share. It is not being restarted to apply that, because the running pass has hours invested in it and a logging cap is not worth the risk. |
What was on the public site
An address page on
explorer.bitcoinuniverse.io:Three claims in four lines, none of them true.
MEMPOOL.BACKENDwasnone, so Bitcoin Core answered everything and Core cannot answer an address lookup. The header still invited a reader to search an address, the search box still recognised one, and every address landed here.GETeverywhere else. The word beside it came from the proxy chain, which is free to write anything in a reason phrase, and the page pasted the two together and presented the result as a diagnosis.Every release gate was green throughout. That is the part worth fixing: this was not a bug that slipped past the checks, it was a state no check was looking at.
The index
A first-party
mempool/electrsv3.3.0 (141215c) runs on the indexer host asuniverse-explorer-electrs, indexing the same Bitcoin Core the explorer reads. Loopback only, no public listener, no third-party API or fallback anywhere in the path. It runs in light mode, which is what makes the storage fit, and reads Core's block files through a POSIX default ACL on thebitcoingroup, documented inDEPLOYMENT.mdbecause it is invisible and its absence fails days later.MEMPOOL.BACKENDbecomesesplora.The routing
With
esplorathe explorer backend deliberately does not mount the address, script hash, transaction, block or mempool routes: it expects the edge to send that family to the index. The gateway now owns that split, the same way upstream does in nginx.Get this wrong and the site loads perfectly while every one of those paths 404s, so there is an exhaustive routing table test, including that the index never sees a
/api/v1/path and that a deployment with no index configured keeps the old behaviour exactly.The sentences
Failures travel as names now, not as statuses.
address-backend-unavailable,address-backend-syncing,address-history-too-large,address-query-timeout,invalid-address,upstream-unavailable. The page renders a different sentence for each, and a syncing index gets a progress report rather than an apology: "Indexed through block X of Y."The HTTP error pipe no longer reads
statusTextat all, so "405 OK" cannot be rendered by construction rather than by matching that one string.Search reads the same capability document and says, before a reader clicks, that address history is unavailable or still indexing. The address stays recognised and stays clickable, because it is still the right destination and it starts working on its own.
The gates
Address lookup is a capability with a readiness rule that lives in one file and is obeyed by the capability document, the release preflight, the cutover verification and the production synthetic check, so those four cannot drift apart.
Ready means: configured, answering, reporting an indexed height, within
MAX_BEHIND_TIPof Core, and a real address summary and a real UTXO query both returned usable documents. A listening port is not readiness.MEMPOOL.BACKEND=noneis a release blocker.esplorawithout a configured endpoint is a release blocker.verify_liveopens the public address contract through the gateway and asks the backend directly for an address, requiring a refusal: a backend that answers means the gateway is routing/api/to the wrong upstream.Proving the gates can fail
Every new safeguard has a negative test. The address page audit is fed the production page line for line and required to reject it; a failing status beside any success phrase is refused, not just "405 OK"; an empty table over a non-empty index is refused, because that one looks like a working page. The Esplora contract rules are the same functions the deployment's own readiness probe calls, so a fixture that is more generous than the real provider is a failing test rather than a passing one forever.
One thing found on the way
bc1qcx70rmarfudyct7lx0ptrat2c5kgstghx2j69, the address in the screenshot, is not a valid address. It is 41 characters where a P2WPKH is 42 and its bech32 checksum does not verify; Bitcoin Core rejects it. It is kept as a regression case for exactly that reason: it is what a reader gets when an address is mistyped, and what the site did with it was answer a contradiction and then blame their address. It must now be called invalid, and the checks hold the origin to that.Tests
🤖 Generated with Claude Code