With no P-Chain to read, the overview draws its L1 cards from KNOWN_L1_CHAINS,
which still linked SPC and Pars to explorer names that do not resolve.
Co-authored-by: Hanzo Dev <[email protected]>
github.com/zoolabs and x.com/zoolabs belong to someone else. The links now
match @zooai/brand: github.com/zooai, x.com/zoo_labs and the Zoo Discord.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
On a Zoo, Hanzo, Pars, SPC or Osage host the validators page listed every L1
in the registry, and /dex rendered the Lux D-Chain markets. The network total
now sums every L1 only on the Lux primary-network explorer, and /dex answers
404 anywhere else. isPrimaryNetworkHost() is the same predicate as
isPrimaryNetworkExplorer(), taking the host from the request.
Co-authored-by: Hanzo Dev <[email protected]>
SPC, Pars and Osage have no explorer and no public node, the devnet has no
explorer, and the Hanzo testnet explorer has no DNS name. Their registry entries
put links to names that do not resolve on every page (the "SPC L1" and "Pars L1"
cards, the chain switcher, the network selector), and the Pars entry sent a
node request to api.pars.network on every page load that failed with 502.
The P-Chain still lists SPC and Pars; their cards render without a link.
Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
typescript 7.0.2 is the native compiler and exports no JavaScript API, so
typescript-eslint and @typescript-eslint/eslint-plugin cannot read it —
typescript-eslint refuses TS 7 outright. It comes in aliased as tsgo and does
the typechecking; typescript stays on the 6 line for the tools. lint:tsc
reports the same 239 diagnostics under both compilers.
Co-authored-by: Hanzo Dev <[email protected]>
One label per runner, spelled <os>-<arch>. The aliases these jobs named
were removed, and a job naming a label no runner carries waits instead of
failing.
Co-authored-by: Hanzo Dev <[email protected]>
Ranges drift once nothing moves them: a caret that has not been rewritten
since it was written pins whatever was current that day, and an exact pin
cannot even take a patch. Both are updated here.
One line per package, so a surface reads the same components, the same
tokens and the same face as every other one.
One family. Font stacks name Zen and fall back to the native faces, tokens
name the role rather than a face, and the packages that used to supply a
face now point at @hanzo/font.
Self-hosted @font-face blocks are removed rather than renamed: rewriting the
family in one would leave a src pointing at a file this repo no longer ships.
@hanzo/font carries the binaries and declares the faces, so the local copies
go with the declaration that used them.
Comments naming a face we no longer use are deleted, not reworded.
luxd serves chain routes under /v1/chain, and the node, cli and sdk releases
already ship it. The deployed gateways still answer the old prefix, so remote
reads break until they are redeployed; the estate moves together rather than
sitting half converted.
Lines that record an observation - a probe, a status code, a past release -
keep the prefix they were written against, as do URLs on hosts that are not
ours. Avalanche endpoints move to /ext/bc, which is the path that host serves.
git.hanzo.ai resolves .hanzo/workflows and stops there, so these ran on
no push and reported green on every one. CI is native and lives in
.hanzo/workflows.
The rename to /v1/chain is real in luxd source, but the deployed fleet has not
taken it yet: probed just now, api.lux.network, api.lux-test.network,
api.zoo.network and api.hanzo.network each answer 200 on /v1/bc/C/rpc and 404 on
/v1/chain/C/rpc. Composing /v1/chain against those hosts reaches nothing, which
is the failure the rename set out to prevent.
The move belongs with the node redeploy, not ahead of it.
luxd serves chain routes under /v1/chain; the /v1/bc prefix it replaced is
gone, so anything composing it reaches nothing. Lines that record an
observation - a probe, a status code, a past release - keep the prefix they
were written against, as do URLs on hosts that are not ours.
luxd serves chain APIs under one segment from constants.ChainAliasPrefix
(constants v1.6.4, node v1.36.181):
/v1/bc/<alias>/... -> /v1/chain/<alias>/...
/ext/bc/<alias>/... -> /v1/chain/<alias>/...
Observations keep their original addresses: a dated note or a recorded status
code is a fact about when it was written, not an instruction to follow.
The appearance layer, measured rather than assumed: which package owns
colour, which owns the ramps, why design's token sheets cannot go in
while Tailwind owns the same variable names, and that @hanzo/tokens'
published CSS export points at a file its tarball does not contain.
The explorer had no way for a person to change how it reads. Colour mode was
the only control, and it is one bit.
@hanzo/appearance stores the preference and puts it on <html>; what the numbers
mean is bound in styles/tokens.css. Type rides the root font size, so every
Tailwind rung — all of them in rem — moves from one declaration and nothing is
restated. Density rides --spacing, the single base every p-*, m-* and gap-*
computes from. Both default to 1, so an install that never opens the panel
renders exactly what it rendered before; measured in a browser against the
production build: 16px root, p-4 at 16px, unchanged.
@hanzo/design's token sheets stay out. They declare --text-*, --radius-* and
--breakpoint-* — the names Tailwind v4 publishes — outside any layer, and v4
compiles utilities to var() references rather than baked values, so importing
them silently reanchors the app: --text-base 16px to 14px, --radius-lg 8px to
12px. Two sets under one set of names is a fork, not a migration. They become
the ramps when Tailwind leaves and there is one ramp to own.
The boot script is hashed for CSP from the same call that renders it, the way
the adbutler scripts are. A pasted literal goes stale on the next release of
the package and fails silently — policy parses, browser drops the script, page
paints at the defaults.
The addresses list endpoint returns a projection of an address — no tags, no
name, no implementations. AddressesListItem guarded with `public_tags !== null`,
which is true of an absent field, so every visit to /accounts read `.length` off
undefined and the whole page fell to the error boundary. The type said the field
was always present, so nothing caught it.
Partial<AddressParam> states what the endpoint sends, and both read sites reach
for the tags the same optional way.
The error screen it fell to had its own defect: the status glyph is an
inline-block div and gui's H1 renders inline, so the two shared a line box and
"500" ran into "Oops! Something went wrong". The screen is a stack; it now says
so. The heading's own top margin was being outranked by gui's atomic reset.
The version link was a module constant naming github.com/luxfi/explore, so
every brand's footer sent readers to Lux's repo — explore.hanzo.ai showed
"Frontend: v1.1.30" pointing at the Lux org. Brand identity in this image is
resolved per host, and the source link now resolves with it: ChainBranding
gains sourceUrl (NEXT_PUBLIC_BRAND_SOURCE_URL for external operators, like
every other branding field), so Lux keeps its own repo and Hanzo, Zoo, Pars,
SPC and Osage each name theirs.
Also sets Hanzo's copyright to Hanzo AI, Inc., the entity its terms, privacy,
DPA and brand.json all name; Hanzo Industries Inc. predates that consolidation.
chainRegistry.spec.ts asserts a brand host surfaces no other org's name or URL
in what the footer renders.
github.com held commits this host lacked, which refuses the mirror push
("fetch first") and stops github receiving anything at all. Merging rather
than overwriting, so the mirror is a fast-forward again and neither side's
work is dropped.
Adds .hanzo/workflows/cicd.yml calling the canonical pipeline and drops the
GitHub-hosted workflows it replaces. Lands here because this is where the repo
is written — git.hanzo.ai is upstream and github.com is its push mirror, so the
same change made on github.com never reaches the runner fleet.
LLM.md is the source and stays the source; CLAUDE.md is a symlink to it, so there is
one file and it cannot drift from itself. What changes is that the link is committed
rather than left for each checkout to recreate.
Left untracked, the doc was conditional on a step outside the repo: a fresh clone had
no CLAUDE.md, nothing loaded LLM.md, and an agent worked here having never read what
this repo already explains. Most of the estate already tracks theirs; this is the rest
agreeing with them.
explore.lux.network had no security.txt, so a scanner looking for one found the
app answering and a researcher who found something had to guess an address.
There is a file now.
The policy it names is lux.network/security rather than a page added here. This
is a Blockscout fork whose route type and three exhaustive metadata maps are all
generated, so a page would cost six files of divergence to say what the network
page already says, and the field exists to point at a policy wherever it lives.
explore.lux.network measured nothing. It carried a Google Analytics component
that no deployment had ever given a property id, so every visit to the explorer
went unrecorded and there was no way to tell whether a change to the site
helped anyone. This is one of seven Lux surfaces moving onto first-party
analytics.
Pageviews now go to Hanzo Insights through @hanzo/event, the client the rest of
the fleet already reports with. It mounts once in _app, in the slot the Google
tag used to occupy, so every route is covered: the provider records the first
view and the route tracker records each one after it. The ingest key is
publishable and write-only, so it belongs in the bundle, and a deploy can point
the surface at another project with NEXT_PUBLIC_PUBLISHABLE_KEY. The new CSP
entry allows api.hanzo.ai so the beacon survives wherever that policy is
enforced.
Google Analytics is removed rather than left running alongside, together with
its feature config, CSP entry and environment variable, because two stacks
measuring the same site is how you end up trusting neither. Error reporting
stays with Rollbar, which this app already runs.
Zoo and Hanzo run as L2s on the primary network, Pars and Osage as their own
L1s, so "how many validators secure Lux" is a question about all of them. The
Validators page answered with this chain's 6 while the landing page said 21 —
same word, two quantities, one site, and no way for a reader to tell which
question either was answering.
Both numbers were right. The page now shows every network with its own count
beside the total, and says how many answered so a partial sum is never read as
the whole. A network that cannot be read is still listed: dropping it would make
it look like a network with no validators.
The stake beside the landing-page total names its network now. Stake stays
primary-only — each network bonds its own currency and the sum of two currencies
is not a quantity — so leaving it unlabelled next to an all-network validator
count invited reading the two as one ratio.
While the split is off every figure in this panel is a zero, and it spent the
top of the explorer's landing page explaining why: a status tag reading "Not
active", two zero counters, a third card for the coinbase, and a line promising
a chart later. A reader arriving to look up a block met a paragraph about a
feature that is not running.
It renders when the split is active and not otherwise, which also deletes the
inactive/scheduled/unknown copy, the fallback styling and the empty-chart note —
the states existed only to describe an absence.
/chains showed fifteen rows on every network, from a hardcoded table. Four of
them — T, R, I, O — are registered on no network at all; the node answers
"there is no ID with alias: T". A fifth, M, runs on devnet alone. And every
/chains/<slug> page printed the same four numbers: the primary network's
validator count, its total stake, its chain count, its threshold. Nothing on
Q-Chain's page was about Q-Chain. Fifteen pages, one set of facts.
The network already knows its own answer. platform.getBlockchains is the chain
list, and it is per-network for free: mainnet and testnet report nine chains,
devnet reports ten including M. That is what /chains lists now, joined with
configs/app/primaryChains.ts for presentation only — a chain the network
reports and we have no metadata for still gets a row, because the network is
the source of truth and our table is not.
Three facts per chain, each read from that chain:
* the blockchain ID, from the registry — the thing that actually tells one
chain from another, and it was already on screen while the numbers above
it were not
* whether the node has bootstrapped it, from info.isBootstrapped, which the
gateway routes for every chain. This is how Q-Chain's page now says
Syncing where the other nine say Bootstrapped: Q has not come up on any of
the three networks and is the sole reason all three report healthy:false
* its head, in its own dialect — eth_blockNumber for C, platform.getHeight
for P, xvm.getHeight for X. Not avm: that service is gone from this build,
and asking it returns "can't find service" rather than a height
Validators are the P-Chain's model, so they render on the P-Chain and nowhere
else. The head is labelled for the model it comes from — the X-Chain's is an
accepted height, not a block height.
Where a chain has no head to read, it says so. The gateway routes exactly P, X
and C on all three networks; D, A, B, Z, G, K and Q are registered and
bootstrapped on the node but their RPC 404s from a browser. Proof it is the
route and not the chain: C-Chain's own blockchain ID, /v1/bc/25td8att…/rpc,
404s while /v1/bc/C/rpc returns a height — same chain, same node. Those pages
read "No public RPC" and say plainly that the chain is registered and running
but not published. A zero there would have read as a measurement.
One proxy replaces the P-only one. /v1/pchain hardcoded /v1/bc/P; /v1/node/
<endpoint> resolves the endpoint against the primary-chain table and never
treats it as a URL. A chain the gateway does not route comes back 200 carrying
a JSON-RPC error rather than 502 — nothing is broken, and 502 put a red failed
request in the console of eight of the ten chain pages.
Deleted: CHAIN_INDEXER_URLS and the hook that read it — fifteen hostnames of
the shape api-indexer-<x>chain.lux.network, none of which resolve. Fifteen of
fifteen NXDOMAIN. It fetched them on every chain page and rendered the null.
Measured on mainnet, 1440x900 and 390x844, fresh context per URL: 20 of 20
chain/screen rows agree with RPC on both status and head — C-Chain 1,107,826
against a live head bracketed at 1,107,822..1,107,829, P and X at 0, seven
reading No public RPC, Q Syncing while the rest are Bootstrapped. /chains
lists the same ten chains the P-Chain reports, and 10 of 10 row links reach a
real page. Zero 4xx or 5xx from our own origin.
/bridge showed CONNECTED CHAINS 14, BRIDGE ROUTES 4, BRIDGE SIGNERS 5, ACTIVE
ROUTES 4 — the same four values on lux, hanzo and zoo, while its only backing
RPC 404'd twice per page load. Not one was measured:
* Connected Chains counted the keys of CHAIN_LABELS, a hardcoded lookup table,
plus an L1 list that comes back empty. It is 14 because the map has 14
entries.
* Bridge Routes and Active Routes counted four pairs unshifted into an array
three lines above, each hardcoded status: 'active'.
* Bridge Signers asked the B-Chain and, when that failed, printed the P-Chain
VALIDATOR count under a signer label. A real number, under the wrong name —
the worst of the four, because it moves.
The B-Chain is not served on any prefix: api.<brand>.network answers 404 to
/ext/bc/B/rpc and to /v1/bc/B/rpc alike, so there is no live bridge data to
show and the page has been reporting constants as measurements. Fixing the
path would have moved the 404, not the lie.
So the cards are gone, and with them lib/api/bchain, whose whole job was to
swallow that failure into zeroes. That leaves lib/api/luxnet with no caller —
the P-Chain hooks go through the /v1/pchain proxy, and getPChain/getInfo/
getHealth were re-exported and never called — so it goes too, and the luxnet
dependency with it: 889 lines of lockfile, an elliptic-curve SDK this app
never used at runtime. The retired /ext/ prefix now has no caller here at all.
What stays is what is true: the routes table, C/P/X atomic swaps marked active
and L1 transfers marked coming soon, and the paragraph that says so.
Measured, 1440x900: before, two POST https://api.lux.network/ext/bc/B/rpc per
load, both 404, four stat cards reading 14/4/5/4. After, zero /ext/ or /bc/
requests, zero stat cards, the four route rows unchanged.
Also: name every field when a broadcast becomes a block. toApiBlock spread the
raw EVMBlock and renamed three keys, so gas_used, gas_limit, base_fee_per_gas
and parent_hash stayed camelCase and arrived undefined — a live-pushed row
rendered 0 gas beside REST rows showing the real number, the same block reading
two ways depending on how it got here. It now builds the API shape explicitly,
field for field, matching formatBlock in luxfi/indexer.
Filter rendered twice on /tokens and dropped into the search input. Both
blocks in TokensActionBar are meant to exist — one for mobile with the sort
control, one for desktop inside the sticky ActionBar — and the Chakra port
turned each HStack into a bare div, losing display={{base,lg}} and gap. With
no breakpoint both drew at once, and with no flex the button, the sort and
the input stacked instead of sitting in a row. Every sibling action bar
(VerifiedContracts, NameDomains, Clusters) kept `flex lg:hidden` /
`hidden lg:flex`; tokens is the one file where the port dropped them, so this
restores that pattern rather than inventing a second one. Desktop now
measures one filter at x=32 w=92 and one input at x=136, no overlap.
The clipped label is a different fault underneath it. @luxfi/ui's ButtonFrame
sets inline-flex, alignItems, gap, a fixed height and overflow:hidden but
never flexDirection, and it is built on a react-native View, which lays out
in a column — so icon and label stacked and the fixed height cut the label
off. Swept 39 buttons over nine pages at both viewports: only three carry
more than one child and only Filter clips, so this says row at FilterButton,
the one component every filter in the app goes through. Inline because the
frame's own class outranks a utility. The primitive is still wrong and wants
fixing upstream.
The footer's social row was never a colour problem. Its branch of Footer
leaves the three left-hand blocks unwrapped, so a two-column grid takes them
as three children and the links land in the 470px track instead of the wide
one; five 160px columns do not fit there, the two mobile 1fr columns were
still the only explicit tracks at lg, and they collapsed to zero width with
Contribute, X (Twitter) and Discord painting on top of each other. Wrapping
the left column the way the custom-links branch already does, and dropping
the mobile tracks at lg, spreads them 576 to 1392 with nothing overlapping at
1440, 1024 or 390.
Addresses render check-summed again. The comment said the API always answers
in EIP-55; it case-folds now, deliberately, so a checksummed URL resolves
instead of 404ing, and `data.hash ?? getCheckedSummedAddress(hash)` took the
lower-case payload every time the address existed. Checksum whatever is
displayed instead — getAddress reads any case and is idempotent, so one call
covers a hash from the API, from the route, or from neither yet. Display
only; the backend keeps folding.
Co-authored-by: Hanzo Dev <[email protected]>
A token passed as -p is readable from the process list for the life of the
command. crane reads it from stdin instead. Pins crane to v0.21.7, the version
the copy and digest-verify steps were exercised against.
Co-authored-by: Hanzo Dev <[email protected]>
The brands are the same bytes — brand comes from the explore-env-<brand>
ConfigMap the pod reads at runtime — so a release is one build and N registry
copies. That copy step was a person, and the evidence a person is not a
mechanism is that v1.1.25 and v1.1.26 were never copied: hanzo, pars and zoo
sat on v1.1.22, four releases behind, with no image in their registries to
advance a pin to even if someone had edited one.
crane copy, not buildx imagetools create: the former preserves the manifest
digest, the latter republishes it and can produce a new digest for identical
content. Each copy is then asserted equal to the source digest per org — a
green copy command is not an artifact, and a brand running bytes other than
the ones that passed the test gate is the failure this exists to prevent.
The image org now follows github.repository_owner, so a brand that forks this
repo and fast-forwards to an upstream tag publishes to its own org and nowhere
else. Only upstream fans out; a fork must not publish for other brands.
Co-authored-by: Hanzo Dev <[email protected]>
The tag was pushed from the previous commit earlier today; reusing the number
would put two different builds behind one version.
Co-authored-by: Hanzo Dev <[email protected]>
The AMM subgraph has been indexed and at chain head the whole time; nothing
read it. /dex queried only the native CLOB at 0x9999, which has genuinely
never emitted an event, so the page reported MARKETS 0 / VOLUME 0 while ~1
swap per block was landing in the WLUX/LZOO pool.
Wire the amm subgraph in as a SECOND venue rather than folding it into the
first. The two are different exchanges and their figures are never summed —
each section reports only its own state, so "live" can never be borrowed by
the venue that is not trading.
Swaps were rendering as anonymous EVM traffic; they now read as trades:
pool-signed amounts resolved into sold/bought, scaled by each token's own
decimals, with symbols, pair, fee tier and links to sender, block and txn.
Status uses the words already in use elsewhere (useNetworkValidators,
useFeeSplit) — live/idle/empty/unavailable/unknown — defined once in
lib/api/venueStatus. There is deliberately no 'stale': separating a stalled
indexer from a quiet venue needs an indexer head this API does not serve.
Also decomplected while here: one fetcher for both subgraphs, and an
unreachable endpoint is no longer flattened into "empty" — an outage and a
venue that has never traded are different facts and now render differently.
Co-authored-by: Hanzo Dev <[email protected]>
Every never-transacted address showed `Balance Pending` instead of
`0 LUX`, permanently, on the live v1.1.23 build.
The RPC fallback was working the entire time. Captured on the wire:
eth_getBalance -> {"jsonrpc":"2.0","id":0,"result":"0x0"}. One line
disagreed. `if (!balance)` treats 0n as falsy, so a genuine zero balance
was reported as an RPC failure.
The consequence was not a broken path but a WRONG one, and the cascade
is why it looked like nothing: select() returned null -> isRpcQuery went
false and the fallback was discarded -> `query` reverted to the 404'd
apiQuery -> AddressDetails substituted error404Data (coin_balance: null)
-> AddressBalance's pruned-node guard fired. That guard's intent is
right ("coin_balance may be null while the address is known to exist,
show Pending instead of a misleading 0 LUX"); it just cannot tell a
genuine zero from a genuine failure when the caller has already
collapsed them. setRefetchEnabled(false) then made it permanent rather
than eventually-correct.
Nothing looked broken from outside: no hang, no skeleton, no error
alert, zero JS exceptions, and the counters path degraded correctly
under the very same 404 (Tokens/Transactions/Transfers all rendered 0).
Only the balance field was wrong.
`balance === null` is exact -- the type is already `bigint | null`.
Extracted the mapper as selectRpcAddress so the case is testable at all;
it shipped broken and was invisible from source. 5 tests, and they were
run against the OLD code first: reverting to `!balance` fails 3 of them.
A regression test that has never failed has not been tested.
Suite 322 -> 327.
Co-Authored-By: Hanzo Dev <[email protected]>
The release workflow has never published. Every run since v1.1.19 built
cleanly and then died on its own smoke gate:
image stamps <unset> but is published as v1.1.23
The gate was right. GIT_TAG and GIT_COMMIT_SHA were declared as ARG/ENV
only in the `builder` stage, and ENV does not cross a stage boundary, so
they reached the compiled bundle and never the runtime image. The footer
resolves at container start, not at build time — make_envs_script.sh
iterates the live process environment into window.__envs and
configs/app/ui.ts reads frontendVersion from there — so with nothing set
in the final stage, every image ever published reported no version.
`crane config ghcr.io/luxfi/explore:v1.1.23` confirms it: no
NEXT_PUBLIC_GIT_TAG in the config at all, and the deployed ConfigMaps do
not supply one either.
Declare both in the runner stage, after the copies so a new commit
invalidates only the ENV layer.
Co-authored-by: Hanzo Dev <[email protected]>
Only the lux image — zoo/hanzo/pars publish to their own registries and are
built separately, so bumping them here would pin tags that do not exist.
Co-authored-by: Hanzo Dev <[email protected]>
The early return for a disabled feature sits after the hooks, so the keydown
effect registered regardless and called preventDefault on Ctrl+K — the browser's
address-bar shortcut on Chrome and Firefox — on every deployment, including the
ones where the dialog never renders because the feature is off. Users would have
lost the shortcut and got nothing in exchange.
Measured both ways: disabled, `defaultPrevented` is false and no dialog mounts;
enabled, it is true and the dialog opens.
Co-authored-by: Hanzo Dev <[email protected]>
The placeholder was unconditional, but the assistant is off unless
NEXT_PUBLIC_AI_ASSISTANT_ENABLED is set — and no deployment sets it yet, because
AI_API_KEY has no KMS entry. On production the field would have invited a
question with no ⌘K chip and no way to ask one: the same class of lie as a stat
that reads 0 because the read failed.
Follows the feature instead. With the assistant on it reads "Search or ask
anything" and the chip advertises the shortcut; with it off it says what the
field actually does and Ctrl+K stays the browser's.
Co-authored-by: Hanzo Dev <[email protected]>
Missed in the auth deletion. `connectorType` no longer has a 'dynamic' member,
so the comparison was dead.
Co-authored-by: Hanzo Dev <[email protected]>
pnpm resolves `pnpm.patchedDependencies` during install, so the patch files have
to be in the context before `pnpm install --frozen-lockfile` runs. The deps
stage copies package.json and the lockfile but nothing else, so adding the
@luxfi/ui patch broke the image build while leaving local installs working —
they read patches/ straight from the repo.
Co-authored-by: Hanzo Dev <[email protected]>
The explorer shipped three sign-in systems. Two were Blockscout's and both
spoke to its /account/* backend, which this deployment does not run — measured,
every one of those endpoints 404s on api-explore.lux.network:
/account/get_csrf 404 /account/send_otp 404 /account/siwe_message 404
/account/user/info 404 /account/confirm_otp 404 /account/authenticate_via_* 404
So none of it could ever have worked. It is deleted, not re-pointed: the email
+ one-time-code modal, the SIWE wallet login, the CSRF handshake that fired on
every page render, the Dynamic.xyz provider, and the dev page that let you
paste a session cookie by hand. Rewriting any of it against our own backend
would have been building custom auth, which we do not do.
What is left is the standard OIDC client we already had, finished:
* PKCE (S256). This client is public — it runs in a browser and can hold no
secret — so the verifier is the only thing binding a redeemed code to the
page that asked for it. IAM advertises S256 in its discovery document and
we were not sending a challenge at all.
* userinfo went to <issuer>/api/userinfo, which is a 404 and an /api/ path we
do not serve. It is /v1/iam/oauth/userinfo, per IAM's own discovery.
* logout now ends the IdP session (end_session_endpoint) instead of only
dropping our cookie and leaving the user signed in upstream.
All three live in lib/oidc.ts, which already said never to rebuild these paths
at a call site.
The header's provider switch collapses to two states — signed in with IAM, or
account disabled and this is the wallet/settings menu. `rewards` is stated off:
its gate read authProvider === 'auth0', so it was already unreachable.
The ConfigMaps carried NEXT_PUBLIC_OIDC_CLIENT_ID=lux-explore-client-id, a
client that has never existed, so authorize answered 400 unknown client_id.
IAM names clients <org>-explore; all four brands now say so, and hanzo/zoo/pars
get the OIDC keys they never had.
Typecheck: no new error classes, production errors 26 -> 22.
Co-authored-by: Hanzo Dev <[email protected]>
The magnifying glass was drawn over the leading "S" of the placeholder. The
cause is not the search bar: @luxfi/ui's InputGroup measures its adornments
correctly and then throws the measurement away, because the clone is guarded on
`displayName === 'FieldInput'` — a component that does not exist in the package
— and the props it would have set (`ps`/`pe`) are Chakra style props that the
Tailwind/gui Input spreads onto the DOM and ignores. Both halves are leftovers
of the Chakra→Tailwind port, so EVERY InputGroup in the app is unpadded; the
header is just the one you look at every day. Patched at the primitive
(patches/@[email protected]) rather than hand-tuning padding at one call
site: the offset is the measured width of the actual adornment, so it stays
right when the adornment changes, and FilterInput / revoke's SearchInput / the
showcase are fixed by the same change. Measured at 1440x900: the icon overlapped
the text by 24px and the kbd chip by 52px; both are now 0.
The measurement also moves to a layout effect and stops blanking value and
placeholder on first render. That blanking was inert while the guard swallowed
the clone; with the clone live it flips the field from controlled to
uncontrolled on mount, which React warns about and which can eat a keystroke.
ClearButton spread `className` over its own visibility classes, so a caller
passing one clobbered `invisible` — the header showed a clear button on an empty
field. Merged instead, at the component.
Placeholder is now "Search or ask anything", and Cmd/Ctrl-K opens an assistant.
Nothing in this repo, @luxfi/ui or @hanzo/gui ships a command palette or chat
(no cmdk, no palette, one dialog primitive), so this is the smallest thing that
works: the existing Dialog, one global listener, and a server-side proxy that
holds the credential. The key is deliberately not NEXT_PUBLIC_, so a deployment
without one gets a dialog that says exactly that instead of failing silently.
Off unless NEXT_PUBLIC_AI_ASSISTANT_ENABLED is set — Ctrl+K is the browser's
address-bar shortcut and claiming it to show an error would be worse than not
claiming it.
Validators read 5 on a network running 20. useCurrentValidators asks ONE
P-Chain, the one this deployment points at, but Lux, Hanzo, Zoo and Pars are
each sovereign L1s with their own P-Chain and their own validator set — measured
2026-08-06, five each, with four disjoint NodeID sets. The registry gains the
node origin per chain; absence of it is the signal that a chain has no publicly
reachable node (SPC and Osage are in-cluster only), and those are reported
`unavailable` rather than counted as zero. Stake is deliberately NOT summed:
each chain bonds its own currency.
The proxy resolves `?chain=` through the registry and never from the request, so
it cannot be pointed at an arbitrary origin. The new envs are registered with
the envs-validator and documented in ENVS.md — the schema rejects unknown
NEXT_PUBLIC_* variables and runs at container start, so an undeclared one stops
the pod from booting.
Co-authored-by: Hanzo Dev <[email protected]>
All four brands run the same artifact — the per-org tags are byte-identical
copies of ghcr.io/luxfi/explore, brand comes from the ConfigMaps. Only lux
was moved to v1.1.22, so the other three still carried the SSE realtime crash
and rendered nothing but the error boundary.
luxfi was the worse half of it: the file said v1.1.12 while the cluster ran
v1.1.22, so a CD sync would have rolled the one working site back onto the bug.
Co-authored-by: Hanzo Dev <[email protected]>
The comment added in v1.1.22 claimed the attribute-selector form was
verified. It was verified by INJECTION into a live page, not in the
shipped bundle -- and the shipped bundle does not contain it. Three
forms have now gone out (html.light body.t_dark, html.light .t_dark,
html[class~="light"] .t_dark) and `.t_dark` occurs exactly once in the
served CSS every time: the media-query rule.
Narrowed but not solved. The file's other top-level rule
(html,body{font-family:Geist}) survives, so top-level rules are not
dropped wholesale. Running this file through the real postcss +
@tailwindcss/postcss pipeline locally keeps BOTH rules, so Tailwind is
not the cause -- something later in the Next build removes it.
The user-visible bug is fixed regardless: the media-query rule is live
and measured at 21.00:1 with 7,111 dark pixels in the heading band
where there were ~0. What remains uncovered is OS-dark +
stored-theme-light, which no UI can currently produce.
A comment that overstates what shipped is worse than no comment.
Co-Authored-By: Hanzo Dev <[email protected]>
`html.light .t_dark` never reached the served CSS in v1.1.20 or v1.1.21
-- `.light` occurs ZERO times in the whole bundle, because no content
file references that token (the app's own theme CSS only ever tests
`:not(.dark)`), so the scanner has nothing to keep it alive.
Only the media-query variant survived, which leaves OS-dark +
stored-theme-light rendering the original white-on-white: html gets
class="light" so the background turns white, but prefers-color-scheme
is false so --color stays #ffffff. Measured in that state: 1.00:1,
11,232/11,232 pure-white pixels in the h1 band. localStorage.theme is
honoured today even though no toggle is exposed, so a returning user
with a stale value already lands here.
The attribute form has identical (0,2,1) specificity and no class token
to miss. Verified by injection: 1.00 -> 21.00, and forcing html.dark
still gives 17.93.
Keep the media-query variant too -- it covers the pre-hydration window
where <html> is classless.
Co-Authored-By: Hanzo Dev <[email protected]>
v1.1.20 set --color on <body> and that part worked (verified #000000 on
body, html.light present, media query matching). The headings stayed
white because the provider mounts a SECOND `<span class="t_dark">`
INSIDE <body>: `:root .t_dark` matches body AND that span, so the span
re-set --color to #ffffff and every heading inherited from there. The
inheritance chain measured html -> body(#000000) -> div -> span.t_dark
(#ffffff) -> ... -> h1.
Dropping `body` from the selector reaches every Theme wrapper; (0,2,1)
still beats `:root .t_dark` (0,2,0).
Co-Authored-By: Hanzo Dev <[email protected]>
The gui provider pins `t_dark` on <body> client-side in BOTH schemes (the
server sends a bare <body>), and its theme block maps
.t_dark { --color: var(--c-color-background) } /* #ffffff */
against scheme-INVARIANT sources (--c-color-background #ffffff,
--c-color-text #000000). So every Tamagui text component resolved --color
to #ffffff unconditionally — correct by accident on dark, invisible on
light. Measured 1.00:1 at all four widths on /address/*, /validators and
the landing page: "Address details", the ADDRESS ITSELF, "Validators",
and six section headings. Pixel ground truth on the 1440 light heading
band: 135,034 of 136,000 pixels pure white, no glyphs at all.
A user clicking a Safe from lux.cloud landed on a page that never showed
which address they were looking at.
Only --color is corrected: the background already tracks html.light via
the Tailwind --lux-* layer. Not the earlier suspect — the circular
`--color-x: var(--color-x)` tokens are inert, because unlayered :root
beats @layer theme regardless of order.
Tamagui goes in cspell.jsonc words[], which is the real dictionary —
.cspell-words.txt is listed in ignorePaths, so it is a file cspell skips,
not a word source.
Co-Authored-By: Hanzo Dev <[email protected]>
Restoring the paths map got tsc through, and the image build moved on to the
next command on the same line:
> tsc -p ./tsconfig.json && tsc-alias -p ./tsconfig.json
tsc-alias error: compilerOptions.rootDir is required with implicit baseUrl
tsc infers a rootDir from the inputs and is content; tsc-alias will not, and
without baseUrl there is nothing for it to fall back to. The inferred value was
already the repo root — the emit lands in build/configs, build/lib, build/ui —
so stating it changes no output. Verified by emitting either way: the JS tree is
identical, and only tsconfig.tsbuildinfo moves, since its path is computed
relative to rootDir.
feature-reporter overrides the inherited paths map, which TypeScript
replaces rather than merges. That was survivable while the root config
set baseUrl -- bare specifiers resolved against it with no paths entry,
and this file's own "./nextjs/..." resolved against it too. The
TypeScript 7 migration dropped baseUrl for an explicit paths map, so the
override began yielding neither, and "./nextjs/..." started resolving
relative to feature-reporter/ where no such file exists.
tsc then failed on toolkit/* and types/*, which fails the image build at
Dockerfile:87 -- so no explore image has been produced since v1.1.13 and
production still serves it, including the homepage crash and the search
crash whose fixes have been on main since Aug 4.
Lists every alias the include-set reaches, as the working sibling
envs-validator already does. tsc -p is clean and emits.
The home page rendered the error boundary — "Oops! Something went wrong" —
within seconds of loading, on every explorer in the estate, with no interaction
at all:
TypeError: Cannot read properties of undefined (reading 'length')
at HashStringShorten (ui/shared/HashStringShorten.tsx:17)
The list on the home page is filled from the REST API and then kept current by
the realtime stream, and the two do not agree on what a block looks like.
/main-page/blocks answers with the API shape — `height`, and `miner` as an
address object. The indexer broadcasts a raw EVM block:
data: {"event":"blocks","chain":"cchain","data":{
"number":1090152,"miner":"0x0100...","txCount":4, ... }}
`new_block` put that straight into the same query cache, so LatestBlocksItem
handed `AddressEntity` a bare string. `address.hash` on a string is undefined,
the entity passed undefined down as its text, and HashStringShorten read
`.length` off it. Instrumented in a dev build: the four blocks that arrive over
the stream log `miner` as a string with no `height`, against six from REST that
carry both — and exactly four entities then render with no text.
TRANSLATIONS already exists as the one place a backend broadcast becomes app
data, and it already has a transform hook, so the shape is settled there rather
than at each reader. Everything downstream keeps knowing exactly one block
shape, which is what it already assumed.
The spec asserted the raw passthrough, so it encoded the bug; it now asserts
the mapped shape and covers `miner` specifically.