Files
zeekayandHanzo Dev cb6db81c52 fix(market): the holders figure counted holdings, and nothing may rank
The indexer's holders resource serves one row per HOLDING. On an ERC-20 that
is one row per holder; on a collection it is one row per held item, so Lux
Genesis came back as three rows for two addresses and the Uniswap position
manager as fifty rows that are all the same address. The site printed "3
holders" and "73 holders" and drew one address fifty times.

explorer.holders() folds the rows by address, which is what makes the answer
countable. The collection page counts the folded list and reads 2 for Genesis,
which agrees with ownerOf. The resource pages at 50 and reports no next page on
the page that fills, so a full page says "at least" rather than a flat count.
holders_count arrives as Token.holdings, named for what it counts, and only a
screen that knows the standard prints it.

Nothing is ranked any more. Collection cards carried a position, 1 through 10,
over the order the indexer happens to answer in — which is by holder count — on
a site whose own launches page says a default that puts one project on top is a
recommendation it is not entitled to make. Collections arrive by name.

Also read against the chain and corrected: /launches said the indexer reports
decimals as 0 for Zoo tokens that carry 18. Those contracts return 0 from
decimals() themselves, so the claim was false and the reason for omitting
supply is the real one. /support/projects claimed collection-level data was
complete, including holder count and full transfer history; it is a page of
transfers and a holdings count, and now says so.

An application no longer requires a session to compose. A filled-in form
produced nothing at all without Hanzo IAM, directly under a paragraph saying
signing in was not a condition of applying. Signed in it names the applicant;
signed out the record says the reply goes to the address it arrives from.

/item threw during render on ?id=abc and took the page to a blank screen.
Holdings drew a chain whose read had FAILED as no holdings at all. usd()
rounded a pool holding forty cents to $0, the same thing an empty pool prints.
The header lit both Launches and Apply on /launches, on a shared prefix.

The launches table read the indexer itself, with its own token type and its own
fetch; it uses lib/explorer like every other screen.

trailingSlash, because the export wrote support.html beside a support/
directory and a host that prefers the directory served a listing where the page
belongs. Every route is now its own index.html and needs no rewrite rules.

Removed from public/: reservoir's logos, 80 chain icons for chains Lux does not
run, ten foreign chain logos, and og-image.png — a Reservoir Explorer marketing
card advertising Bored Ape floor prices, sitting in the social-preview slot of a
site whose whole point is that it quotes no prices. The two radix packages whose
only consumers were the deleted listing dialogs go with them.

Co-authored-by: Hanzo Dev <[email protected]>
2026-08-29 18:19:00 -07:00

9.1 KiB

Lux Market

@luxfi/market — Next 15 static export, @luxfi/ui 7.x, wagmi + viem. lux.market reads the Lux chains and shows what is on them. It holds no assets, takes no custody and settles nothing.

What is actually on the chains

Measured 2026-08-29, and worth re-measuring before relying on any of it.

  • One collectible NFT contract exists on any Lux chain. Lux Genesis, 0x9e04fc57c20b2ee45627c4aa280eb471f2ca6ea5 on 96369, three tokens. The other ERC-721 is Uniswap's V3 position manager, which is an LP receipt.
  • No marketplace contract is deployed anywhere. Market.sol lives in ~/work/lux/standard/contracts/nft/Market.sol; every address the old deployments file recorded returns 0x from eth_getCode, and the Genesis collection's own market() reads the zero address. Nothing has ever been listed, bid on or sold.
  • The AMM is the deep data. Lux has 32 pools and Zoo 20, with both sides of every pair, fee tier, locked value, volume and transaction count.

The three reads, and which questions each answers

module source answers
lib/registry.ts /v1/explorer/admin/chains which chains exist, their ids, slugs, coins, RPCs, AMM contracts
lib/explorer.ts /v1/indexer/<slug>/… tokens, collections, holders, transfers, holdings, stats
lib/amm.ts /v1/graph/<slug>/amm/graphql factory totals and pools
lib/logs.ts the chain's own RPC the token id the indexer drops

hooks/queries.ts wraps all four in react-query. Nothing else fetches.

The registry is the chain list

There is no array of chains in this app. readChains() reads the indexer's registry and everything downstream carries a whole Chain record — slug for the indexer, id for a contract, coin for a label, explorer for a link out. The array this replaced had drifted twice: it missed Osage, and it offered SPC, whose RPC 404s and which the registry has never served.

Two things the registry does not hand over cleanly:

  • rpc is the indexer's own route, not a browser's. For Lux it is an in-cluster name over plain HTTP; the public route arrives as public_rpc. Take public_rpc first, then rpc if it is already https, otherwise admit there is none.
  • Its order is a Go map's, so it changes between requests. The default chain leads and the rest are sorted by name, or the chain switcher reshuffles on every load.

The explorer host is derived — api.X → explore.X, verified 200 on all five — rather than kept in a second list that can disagree with the first.

The indexer drops the token id, and it is one field

Every ERC-721 transfer row arrives with the ERC-20 shape: total.value "1", no id, and a token object carrying only address_hash and type. The chain still has it — eth_getLogs on the Transfer topic returns it in topics[3] — and the indexer's log_index is the EVM log index, so (block, logIndex) joins a row to its log exactly. lib/logs.ts does that join in bounded windows; rows outside the budget say the id was not read rather than printing #?.

Fixing this upstream is a small change in ~/work/lux/indexer (write topics[3] into token_id, populate instances, carry name/symbol on the transfer's token). Do not apply migrations/004_nft_marketplace.sql — it is a Postgres marketplace schema nothing writes to, and its companion evm/defi/nft.go holds event hashes that do not match cast keccak.

Also true, and the reason there is no item resource: /tokens/{addr}/instances returns {"items":[]} for every collection, including the 149-token one.

The holders resource counts holdings, not holders

/tokens/{addr}/holders serves one row per HOLDING. On an ERC-20 that is one row per holder and the rows are distinct — LZOO returns 11 rows for 11 addresses. On a collection it is one row per held item: Lux Genesis returns three rows for two addresses, and the Uniswap position manager returns fifty rows that are all 0xd0ebbdcd…cee8. holders_count on /tokens and token_holders_count on /counters count the same rows, so both read 3 for Genesis and 73 for the position manager, and neither is a holder count on a collection.

explorer.holders() folds the rows by address, which is what makes the answer countable: the collection page counts the folded list rather than printing the indexer's figure, and reads "2 holders" for Genesis where the field says 3. The resource also pages at 50 and reports next_page_params: null on the page that fills, so a full page cannot be told from a complete one — whole is false there and the screen says "at least".

Token.holdings carries the raw field under the name of what it counts. Only a screen that knows the standard may print it: the launches table shows it for a fungible token and says "counted per item" on a collection.

The one place per-item metadata exists

GenesisNFTs.sol keeps mapping(uint256 => TokenMeta) public tokenMeta and its generated getter answers. Each token has a name (#0 "Genesis Validator #51", #1 "Genesis Validator #100", #2 "The Terminator"), a tier, and the LUX bonded to it. components/Genesis.tsx reads it. It is contract-specific on purpose; no other collection publishes anything per item.

Two things that will bite

The gui engine and the Lux token set share the name --background. @hanzo/gui injects :root.t_dark { --background: … ; --color: … } and body { background: var(--background); color: var(--color) } at runtime, later in the cascade and more specific than @luxfi/ui/tokens.css. The page ground came from the wrong system and card text measured 1.03:1 against its own card. globals.css reads the ground from --color-bg-body, which only the Lux token set defines and which tracks the same rungs. Every other token is uncontested.

TypeScript must stay on 6.x. Next 15 drives the JavaScript compiler API for tsconfig paths and typechecking; TS 7's native compiler does not expose it, so next dev refuses to start, @/* stops resolving in next build, and next.config.ts cannot load at all (hence next.config.mjs).

Routes

/ /collections /collection /item /activity /pools /genesis /portfolio — the read surface. /launch /launches /support (+ buyers, projects, terms, privacy) — applying and help.

output: 'export' renders every route ahead of time, so a path segment cannot hold a contract address deployed after the build. /collection and /item take the chain and address in the query instead; lib/links.ts builds those URLs. A query value is whatever someone typed, so /item checks the id is a whole number before it reaches BigInt — ?id=abc used to throw during render and take the page down to a blank screen.

Rules this surface is held to

  • No invented figures. No floor price nothing computed, no holder count nothing counted, no activity row nothing observed, no volume nothing summed. Chain totals come from the factory entity, not a sum over one page of pools. Where the indexer's own figure disagrees with the chain, the chain wins and the screen counts for itself.
  • Unavailable is never drawn as empty or as zero. A failed read throws in lib/explorer.ts so a screen can tell "there are none" from "we could not ask", and says which it is — including one chain among several, which is why holdings draws a sentence per chain that did not answer rather than nothing. A value that rounds to zero is not zero either: usd() prints <$1.
  • Nothing is ranked. Collections arrive by name, the launches table sorts by name until a reader picks a column, and no card carries a position. The indexer answers in holder order and printing that unlabelled would rank one project above another on a figure nobody chose.
  • Every screen names the chain it read (components/Source.tsx).
  • Colour through tokens only. Monochrome carries state by weight and label, never by hue.
  • Nothing signs or broadcasts here. The user's own wallet signs, and today nothing asks it to.
  • No transactional UI against a contract that is not deployed. When Market.sol lands somewhere, the ABI and the forms come back with it.

Build

pnpm install && pnpm build     # static export to out/
pnpm dev                       # port 3100

trailingSlash: true, so every route exports as its own index.html and any static host resolves it without rewrite rules. Without it the export wrote support.html beside a support/ directory and a host that prefers the directory served a listing where the page belongs.

Applying

/launch composes the application and hands it over; there is no server here to receive one and no intake endpoint was found on api.lux.financial or api.hanzo.ai, so the page says so rather than accepting a submission it would drop. Signing in with Hanzo IAM names the applicant and is not a condition of applying — the composed record says the reply goes to the address it arrives from. The contract address is read live with eth_getCode and labelled "observed", which is the one line on the application nobody typed.

A real intake would be one constant and one handler. A queue that holds applications is what would make this a pipeline rather than a mailbox.