feat(market): rebuild the surface on the reads that answer

The market advertised a marketplace it does not have and could not open
a single collection. Every card linked to /collection/{chainId}/{address}
through a field the indexer does not send, into a route that did not
exist; the homepage printed four Genesis tiers nothing had read and named
Seaport and LSSVM as its protocols, neither of which is deployed on any
Lux chain; the activity feed rendered forty-five identical rows of
"NFT #?".

WHICH CHAINS EXIST IS NOW READ, NOT DECLARED. lib/chains.ts held a list
that had drifted twice — it missed Osage and it offered SPC, whose RPC
404s and which the indexer's registry has never served, and it pointed
Pars at an /ext/ path that 404s. All of it is gone. readChains() reads
/v1/explorer/admin/chains and every screen carries a whole chain record:
slug for the indexer, id for a contract, coin for a label, explorer for a
link out. Two things the registry does not hand over cleanly are handled
once, in one place: its `rpc` is the indexer's own in-cluster route and
the browser's arrives as `public_rpc`, and its order is a Go map's, so
the switcher sorts rather than reshuffling on every load.

THE ITEM ID IS RECOVERED FROM THE CHAIN. The indexer writes the ERC-20
shape into every ERC-721 transfer and discards topics[3], which is why
the feed had nothing to distinguish one row from the next. Its 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 and rows outside
the budget say the id was not read. Forty-five anonymous rows are now
forty-five named items.

/genesis WAS PROBING A CONTRACT WITH NO CODE. The collection is alive at
0x9e04fc57.. and answers everything: three minted, three billion LUX
bonded, a 2.5% royalty declared through ERC-2981, and a `market` address
of zero, which is the collection saying nothing is wired to sell it. It
also keeps a record per token, the only per-item metadata on any Lux
chain, so each token shows its real name, tier and bonded amount.

NEW: /collection and /item, the two routes a marketplace cannot be
without. They take the chain and address in the query because a static
export cannot render a path segment for a contract deployed after the
build. /pools reports the AMM — 32 pools on Lux, 20 on Zoo — with totals
read from the factory entity rather than summed over a partial page.
A footer reaches applying, support, terms and privacy from every page.

GONE: ListingForm, OfferForm, TraitFilter, the IMarket ABI and the LSSVM
pair ABI. Market.sol is deployed nowhere, so a listing form against it is
a button that lies. They come back when an address does.

Failed reads now throw instead of resolving to an empty page, because a
list cannot otherwise tell "there are none" from "we could not ask" — and
those mean opposite things. Every screen names the chain it read.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-29 18:18:58 -07:00
co-authored by Hanzo Dev
parent 4e02bd8b6e
commit f216bef77b
38 changed files with 2628 additions and 1703 deletions
+124 -30
View File
@@ -1,36 +1,130 @@
# Hanzo Marketplace
# Lux Market
## Overview
Lux NFT Marketplace - Genesis NFTs, trading, and NFT AMM
`@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.
## Tech Stack
- **Language**: TypeScript/JavaScript
## 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 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.
## 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.
- 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.
- 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
## Build & Run
```bash
pnpm install && pnpm build
pnpm test
pnpm install && pnpm build # static export to out/
pnpm dev # port 3100
```
## Structure
```
marketplace/
CLAUDE.md
Dockerfile
Dockerfile.package
next-env.d.ts
next.config.ts
out/
package.json
pnpm-lock.yaml
postcss.config.mjs
public/
src/
tsconfig.json
tsconfig.tsbuildinfo
yarn.lock
```
## Key Files
- `package.json` -- Dependencies and scripts
- `Dockerfile` -- Container build
The export writes `collections.html`, not `collections/index.html`, so the host
has to map `/x` → `x.html`.
+89 -39
View File
@@ -1,49 +1,99 @@
'use client'
import { Header } from '@/components/Header'
import { useMemo } from 'react'
import { ActivityRow } from '@/components/ActivityRow'
import { useChainContext } from '@/hooks/useChain'
import { useNftActivity } from '@/hooks/useNFTData'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Card } from '@/components/ui/card'
import { useActivity, useCollections, useTransferIds } from '@/hooks/queries'
import { idFor } from '@/lib/logs'
import type { Chain } from '@/lib/registry'
export default function ActivityPage() {
const { chainId } = useChainContext()
const { data, isLoading } = useNftActivity(chainId)
// THE FEED USED TO BE FORTY-FIVE ROWS OF "NFT #?".
//
// Two fields are missing from every transfer the indexer serves. It writes the
// ERC-20 shape into `total` and discards the token id, and it sends the token
// as an address and a type with no name on it. So the rows were
// indistinguishable: same collection, same pair of addresses, same day, no id.
//
// Both are recoverable without waiting on the indexer. The name comes from the
// chain's own token list, joined on the address. The id comes from the chain:
// eth_getLogs on the Transfer topic returns it in topics[3], and the indexer's
// log_index is the EVM log index, so a row joins its log exactly. Rows whose
// log fell outside the read say so rather than printing a question mark.
function Feed({ chain }: { chain: Chain }) {
const activity = useActivity(chain)
const tokens = useCollections(chain)
const ids = useTransferIds(chain, activity.data)
const byAddress = useMemo(
() => new Map((tokens.data ?? []).map((t) => [t.address, t])),
[tokens.data],
)
if (activity.isLoading)
return <Card className="p-6 text-sm text-muted-foreground">Reading {chain.name}…</Card>
if (activity.isError)
return (
<Card className="p-6 text-sm text-muted-foreground">
The indexer did not answer: {(activity.error as Error).message}
</Card>
)
if (!activity.data?.length)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
No ERC-721 or ERC-1155 transfer appears in the feed the indexer returns for {chain.name}.
</Card>
)
const unread = ids.data
? activity.data.filter((t) => idFor(ids.data, t) === undefined).length
: activity.data.length
return (
<div>
<Header />
<main className="max-w-[1200px] mx-auto px-6 py-8">
<h1 className="text-3xl font-bold mb-2">Activity</h1>
<p className="text-muted-foreground mb-8 text-sm">
Recent NFT transfers, mints, and sales across all collections.
<>
<div className="flex items-center gap-3 border-b border-border py-2 text-[11px] uppercase tracking-widest text-muted-foreground">
<div className="w-[72px] text-center">Event</div>
<div className="flex-1">Item</div>
<div className="w-[196px] text-center">From → to</div>
<div className="w-24 text-right">Date</div>
<div className="w-5" />
</div>
{activity.data.map((transfer) => (
<ActivityRow
key={`${transfer.block}:${transfer.logIndex}`}
transfer={transfer}
chain={chain}
token={byAddress.get(transfer.token.address)}
id={ids.data ? idFor(ids.data, transfer) : undefined}
/>
))}
{unread > 0 ? (
<p className="mt-4 max-w-[76ch] text-[13px] leading-relaxed text-muted-foreground">
{unread} of {activity.data.length} rows show no item id. The indexer does not keep one, so
each id is read back from the chain&rsquo;s Transfer logs, and that read is bounded — rows
whose block fell outside it are left unnamed rather than guessed at.
</p>
) : null}
</>
)
}
{/* Column headers */}
<div className="flex items-center gap-3 py-2 border-b border-border text-[11px] text-muted-foreground uppercase tracking-widest">
<div className="w-[72px] text-center">Event</div>
<div className="flex-1">Item</div>
<div className="w-[200px] text-center">From / To</div>
<div className="w-10 text-right">Chain</div>
<div className="w-20 text-right">Date</div>
<div className="w-5" />
</div>
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Loading activity...</div>
) : !data?.items?.length ? (
<div className="text-center py-12 text-muted-foreground">
No NFT activity found on this chain yet.
</div>
) : (
data.items.map((transfer, i) => (
<ActivityRow
key={`${transfer.transaction_hash}-${transfer.block_number}-${i}`}
transfer={transfer}
chainId={chainId}
/>
))
)}
</main>
</div>
export default function ActivityPage() {
return (
<Page
title="Activity"
intro="Every ERC-721 and ERC-1155 transfer in the feed the indexer serves for the selected chain. Mints are transfers from the null address; there are no sales, because no marketplace contract is deployed to make one."
>
{({ chain }) => (
<>
<Source>Read live from {chain.name}.</Source>
<Feed chain={chain} />
</>
)}
</Page>
)
}
+289
View File
@@ -0,0 +1,289 @@
'use client'
import { ExternalLink } from '@luxfi/ui/icons'
import { useSearchParams } from 'next/navigation'
import { Suspense, useEffect, useMemo } from 'react'
import { formatUnits } from 'viem'
import { useReadContracts } from 'wagmi'
import { ItemCard } from '@/components/ItemCard'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Stat } from '@/components/Stat'
import { Card } from '@/components/ui/card'
import type { ChainState } from '@/hooks/chain'
import { useCollection, useCounters, useHolders, useTransfers, useTransferIds } from '@/hooks/queries'
import { ERC165_ABI, ERC2981_ABI, ERC721_ABI, INTERFACE, ONE } from '@/lib/contracts'
import { idFor } from '@/lib/logs'
import { addressUrl, type Chain } from '@/lib/registry'
import { shortenAddress } from '@/lib/utils'
// THE PAGE EVERY COLLECTION CARD ON THIS SITE LINKED TO AND WHICH DID NOT EXIST.
//
// The cards pointed at /collection/{chainId}/{address}; src/app had no dynamic
// segment at all, so every one of them returned 404. A path segment cannot hold
// a contract address under `output: 'export'` — the route would have to be
// listed at build time and a contract deployed afterwards could never be
// reached — so the chain and the address ride in the query instead.
type Ready = Extract<ChainState, { status: 'ready' }>
/** What the contract says it is. ERC-165 is the only self-description there is. */
function Declares({ chain, address }: { chain: Chain; address: string }) {
const contract = { chainId: chain.id, address: address as `0x${string}` }
const { data } = useReadContracts({
allowFailure: true,
contracts: [
{ ...contract, abi: ERC165_ABI, functionName: 'supportsInterface', args: [INTERFACE.erc721] },
{ ...contract, abi: ERC165_ABI, functionName: 'supportsInterface', args: [INTERFACE.erc1155] },
{ ...contract, abi: ERC165_ABI, functionName: 'supportsInterface', args: [INTERFACE.royalty] },
{ ...contract, abi: ERC2981_ABI, functionName: 'royaltyInfo', args: [0n, ONE] },
{ ...contract, abi: ERC721_ABI, functionName: 'totalSupply' },
],
query: { enabled: Boolean(chain.rpc) },
})
if (!data) return null
const [erc721, erc1155, royalty, quote, supply] = data
const standards = [
erc721.result === true ? 'ERC-721' : null,
erc1155.result === true ? 'ERC-1155' : null,
royalty.result === true ? 'ERC-2981' : null,
].filter(Boolean)
const rate =
royalty.result === true && quote.status === 'success'
? (quote.result as readonly [string, bigint])
: null
return (
<Card className="mb-8 max-w-[76ch] space-y-2 p-6 text-sm">
<div className="font-semibold">What the contract says it is</div>
<p className="leading-relaxed text-muted-foreground">
{standards.length
? `supportsInterface answers true for ${standards.join(', ')}.`
: 'The contract answers supportsInterface for none of ERC-721, ERC-1155 or ERC-2981.'}{' '}
{supply.status === 'success'
? `totalSupply is ${(supply.result as bigint).toLocaleString()}.`
: 'totalSupply does not answer.'}
</p>
{rate ? (
<p className="leading-relaxed text-muted-foreground">
royaltyInfo quotes {Number(formatUnits(rate[1], 18)) * 100}% of a sale to{' '}
<code className="font-mono text-xs">{rate[0]}</code>. That is what the contract would
enforce on a marketplace that honours it; none is deployed on Lux, so nothing enforces it
today.
</p>
) : null}
</Card>
)
}
function Items({
chain,
address,
name,
supply,
}: {
chain: Chain
address: string
name: string | null
supply: string | null
}) {
const transfers = useTransfers(chain, address)
const ids = useTransferIds(chain, transfers.data)
const found = useMemo(() => {
if (!ids.data || !transfers.data) return []
const seen = new Set<string>()
for (const t of transfers.data) {
const id = idFor(ids.data, t)
if (id !== undefined) seen.add(id)
}
return [...seen].sort((a, b) => Number(BigInt(a) - BigInt(b)))
}, [ids.data, transfers.data])
if (transfers.isLoading || ids.isLoading)
return <Card className="p-6 text-sm text-muted-foreground">Reading transfers…</Card>
if (!found.length)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
No item id could be read. The indexer never populates its per-item resource for any
collection, so ids come back from the chain&rsquo;s own Transfer logs, and that read either
found nothing in range or the chain has no browser-reachable node.
</Card>
)
const whole = supply !== null && found.length >= Number(supply)
return (
<>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{found.map((id) => (
<ItemCard key={id} chain={chain} address={address} id={id} collection={name} />
))}
</div>
{!whole ? (
<p className="mt-4 max-w-[76ch] text-[13px] leading-relaxed text-muted-foreground">
{found.length} of {supply ?? 'an unreported number of'} items. The indexer serves one page
of transfers and holds no per-item record, so this is what could be read back from the
logs, not the whole collection.
</p>
) : null}
</>
)
}
function Holders({ chain, address }: { chain: Chain; address: string }) {
const { data, isLoading } = useHolders(chain, address)
if (isLoading) return <Card className="p-6 text-sm text-muted-foreground">Reading holders…</Card>
if (!data?.length)
return <Card className="p-6 text-sm text-muted-foreground">The indexer reports no holders.</Card>
return (
<Card className="divide-y divide-border">
{data.map((h, i) => {
const href = addressUrl(chain, h.address)
return (
<div key={`${h.address}-${i}`} className="flex items-center gap-3 px-4 py-3 text-[13px]">
<span className="w-6 text-center font-mono text-muted-foreground">{i + 1}</span>
<span className="min-w-0 flex-1 truncate font-mono">
{href ? (
<a href={href} target="_blank" rel="noopener noreferrer" className="no-underline hover:underline">
{h.address}
</a>
) : (
h.address
)}
</span>
<span className="font-mono">{h.count}</span>
</div>
)
})}
</Card>
)
}
function Body({ state, slug, address }: { state: Ready; slug: string; address: string }) {
const chain = state.chains.find((c) => c.slug === slug)
const { select } = state
// The URL names the chain, so the selector follows it rather than contradicting it.
useEffect(() => {
if (chain) select(chain.slug)
}, [chain, select])
const token = useCollection(chain ?? state.chain, address)
const counters = useCounters(chain ?? state.chain, address)
if (!chain)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
The registry serves no chain called <code className="font-mono text-xs">{slug}</code>. It
currently serves {state.chains.map((c) => c.slug).join(', ')}.
</Card>
)
if (token.isLoading)
return <Card className="p-6 text-sm text-muted-foreground">Reading {address} on {chain.name}…</Card>
if (token.isError || !token.data)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
The indexer does not record a token at <code className="font-mono text-xs">{address}</code>{' '}
on {chain.name}.
</Card>
)
const href = addressUrl(chain, address)
return (
<>
<Source>Read live from {chain.name}.</Source>
<div className="mb-8 flex items-center gap-4">
<div className="flex h-16 w-16 shrink-0 items-center justify-center rounded-xl bg-secondary text-2xl font-bold text-muted-foreground">
{token.data.symbol?.charAt(0) ?? '?'}
</div>
<div className="min-w-0">
<div className="text-2xl font-bold">{token.data.name ?? shortenAddress(address)}</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span className="font-mono">{token.data.symbol}</span>
<span>{token.data.type}</span>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 no-underline hover:text-foreground"
>
<span className="font-mono">{shortenAddress(address, 6)}</span>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="font-mono">{shortenAddress(address, 6)}</span>
)}
</div>
</div>
</div>
<div className="mb-8 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
<Stat label="Items" value={token.data.supply} />
<Stat label="Holders" value={token.data.holders} />
<Stat
label="Transfers"
value={counters.data ? counters.data.transfers.toLocaleString() : null}
/>
</div>
<Declares chain={chain} address={address} />
<h2 className="mb-4 text-xl font-semibold">Items</h2>
<div className="mb-10">
<Items
chain={chain}
address={address}
name={token.data.name}
supply={token.data.supply}
/>
</div>
<h2 className="mb-4 text-xl font-semibold">Holders</h2>
<Holders chain={chain} address={address} />
</>
)
}
function FromQuery({ state }: { state: Ready }) {
const params = useSearchParams()
const slug = params.get('chain')
const address = params.get('address')?.toLowerCase()
if (!slug || !address)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
This page needs a chain and a contract address. Reach it from{' '}
<a href="/collections" className="text-foreground hover:underline">
collections
</a>
.
</Card>
)
return <Body state={state} slug={slug} address={address} />
}
export default function CollectionPage() {
return (
<Page
title="Collection"
intro="Everything the indexer and the contract will say about one collection: what it declares itself to be, how many items exist, who holds them, and the ids that could be read back from its Transfer logs."
>
{(state) => (
<Suspense fallback={null}>
<FromQuery state={state} />
</Suspense>
)}
</Page>
)
}
+69 -54
View File
@@ -1,63 +1,78 @@
'use client'
import { useState } from 'react'
import { Header } from '@/components/Header'
import { CollectionCard } from '@/components/CollectionCard'
import { useChainContext } from '@/hooks/useChain'
import { useCollections } from '@/hooks/useNFTData'
import { CHAIN_INFO } from '@/lib/chains'
import { Input } from '@/components/ui/input'
import { Card } from '@/components/ui/card'
import { Search } from '@luxfi/ui/icons'
import Link from 'next/link'
import { useState } from 'react'
import { CollectionCard } from '@/components/CollectionCard'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Card } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { useCollections } from '@/hooks/queries'
import type { Chain } from '@/lib/registry'
export default function CollectionsPage() {
const { chainId } = useChainContext()
const [search, setSearch] = useState('')
const { data: collections, isLoading } = useCollections(chainId, search || undefined)
const chainInfo = CHAIN_INFO[chainId]
function List({ chain, search }: { chain: Chain; search: string }) {
const { data, isLoading, isError, error } = useCollections(chain, search || undefined)
if (isLoading) return <Card className="p-6 text-sm text-muted-foreground">Reading {chain.name}…</Card>
if (isError)
return (
<Card className="p-6 text-sm text-muted-foreground">
The indexer did not answer: {(error as Error).message}
</Card>
)
if (!data?.length)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
{search
? `Nothing on ${chain.name} matches “${search}”.`
: (
<>
The indexer records no ERC-721 or ERC-1155 contract on {chain.name}. Deploy one and it
appears here within a block or two, or{' '}
<Link href="/launch" className="text-foreground hover:underline">
apply
</Link>
.
</>
)}
</Card>
)
return (
<div>
<Header />
<main className="max-w-[1200px] mx-auto px-6 py-8">
<div className="flex justify-between items-center mb-2">
<h1 className="text-3xl font-bold">Collections</h1>
</div>
<p className="text-muted-foreground mb-6 text-sm">
Browse NFT collections on{' '}
<span className="font-semibold text-foreground">{chainInfo?.name ?? 'Lux'}</span>.
Collections with LSSVM pools have instant buy/sell liquidity.
</p>
{/* Search */}
<div className="mb-6 relative max-w-[400px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search collections by name..."
className="pl-9 h-10 rounded-[10px] bg-card"
/>
</div>
{/* Collection list */}
{isLoading ? (
<div className="text-center py-12 text-muted-foreground">Loading collections...</div>
) : !collections?.items?.length ? (
<Card className="text-center py-12 text-muted-foreground">
{search
? `No collections matching "${search}" on ${chainInfo?.name ?? 'this chain'}.`
: `No NFT collections found on ${chainInfo?.name ?? 'this chain'} yet.`}
</Card>
) : (
<div className="flex flex-col gap-2">
{collections.items.map((token, i) => (
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
))}
</div>
)}
</main>
<div className="flex flex-col gap-2">
{data.map((token, i) => (
<CollectionCard key={token.address} token={token} chain={chain} rank={i + 1} />
))}
</div>
)
}
export default function CollectionsPage() {
const [search, setSearch] = useState('')
return (
<Page
title="Collections"
intro="Every ERC-721 and ERC-1155 contract the indexer records on the selected chain, with the holder count and item count it reports for each."
>
{({ chain }) => (
<>
<Source>Read live from {chain.name}.</Source>
<div className="relative mb-6 max-w-[400px]">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by name, symbol or address"
className="h-10 rounded-[10px] bg-card pl-9"
/>
</div>
<List chain={chain} search={search} />
</>
)}
</Page>
)
}
+156 -100
View File
@@ -1,114 +1,170 @@
'use client'
import { ExternalLink } from '@luxfi/ui/icons'
import Link from 'next/link'
import { formatUnits } from 'viem'
import { useReadContracts } from 'wagmi'
import { ArrowRight, ExternalLink } from '@luxfi/ui/icons'
import { Header } from '@/components/Header'
import { GenesisMeta } from '@/components/Genesis'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Stat } from '@/components/Stat'
import { Card } from '@/components/ui/card'
import { useChainContext } from '@/hooks/useChain'
import { CHAIN_INFO, explorerUrl } from '@/lib/chains'
import { GENESIS_ABI, GENESIS_NFT } from '@/lib/contracts'
import {
ERC2981_ABI,
GENESIS,
GENESIS_ABI,
GENESIS_TOKEN_ABI,
ONE,
} from '@/lib/contracts'
import * as links from '@/lib/links'
import { addressUrl, type Chain } from '@/lib/registry'
// THE PAGE WAS PROBING A CONTRACT THAT DOES NOT EXIST.
//
// It read 0x004287c4..76c6, which returns 0x from eth_getCode, and reported —
// correctly, and to its credit — that supply and bonded LUX were unavailable.
// The collection is alive at 0x9e04fc57..; the indexer's own search finds it.
// Repointed, every figure on this page is a live read: three tokens, three
// billion LUX bonded, a 2.5% royalty declared through ERC-2981, and a
// marketplace address of zero, which is the collection saying that nothing is
// wired to sell it.
function Collection({ chain, address }: { chain: Chain; address: `0x${string}` }) {
const contract = { chainId: chain.id, address }
const { data, isLoading } = useReadContracts({
allowFailure: true,
contracts: [
{ ...contract, abi: GENESIS_ABI, functionName: 'totalMinted' },
{ ...contract, abi: GENESIS_ABI, functionName: 'totalLuxLocked' },
{ ...contract, abi: GENESIS_ABI, functionName: 'owner' },
{ ...contract, abi: GENESIS_TOKEN_ABI, functionName: 'market' },
{ ...contract, abi: ERC2981_ABI, functionName: 'royaltyInfo', args: [0n, ONE] },
],
query: { enabled: Boolean(chain.rpc) },
})
if (isLoading || !data)
return <Card className="p-6 text-sm text-muted-foreground">Reading the collection on {chain.name}…</Card>
const [minted, locked, owner, market, quote] = data
const count = minted.status === 'success' ? Number(minted.result as bigint) : null
const rate =
quote.status === 'success' ? (quote.result as readonly [string, bigint]) : null
const marketSet =
market.status === 'success' && (market.result as string) !== '0x0000000000000000000000000000000000000000'
const href = addressUrl(chain, address)
/** One tile. `value` is always a measurement; there is no default to fall back on. */
function Stat({ label, value }: { label: string; value: string }) {
return (
<Card className="p-5">
<div className="text-[13px] text-muted-foreground mb-1">{label}</div>
<div className="text-2xl font-bold font-mono">{value}</div>
</Card>
<>
<div className="mb-8 grid grid-cols-[repeat(auto-fit,minmax(200px,1fr))] gap-3">
<Stat label="Minted" value={count === null ? null : count.toLocaleString()} />
<Stat
label={`${chain.coin} bonded`}
value={
locked.status === 'success'
? Number(formatUnits(locked.result as bigint, 18)).toLocaleString(undefined, {
maximumFractionDigits: 0,
})
: null
}
note="permanent"
/>
<Stat
label="Royalty"
value={rate ? `${Number(formatUnits(rate[1], 18)) * 100}%` : null}
note="declared through ERC-2981"
/>
<Stat
label="Marketplace"
value={market.status !== 'success' ? null : marketSet ? 'set' : 'none'}
note={marketSet ? (market.result as string) : 'the contract routes no sale'}
/>
</div>
<Card className="mb-10 max-w-[76ch] space-y-3 p-6 text-sm leading-relaxed">
<p className="text-muted-foreground">
Each token bonds {chain.coin} to itself permanently — GenesisNFTs.sol adds the amount to{' '}
<code className="font-mono text-xs">totalLuxLocked</code> at mint and has no path that
takes it back out. The tier decides how much: 1B for Genesis, 100M for Validator, 10M for
Mini, 1M for Nano, and each token below reports its own.
</p>
<p className="text-muted-foreground">
The collection declares a {rate ? `${Number(formatUnits(rate[1], 18)) * 100}%` : ''}{' '}
royalty through ERC-2981, which a marketplace has to read and honour. There is no
marketplace contract on any Lux chain to read it, and this collection&rsquo;s own{' '}
<code className="font-mono text-xs">market</code> address is zero.
</p>
{owner.status === 'success' ? (
<p className="text-muted-foreground">
Owned by <code className="font-mono text-xs">{owner.result as string}</code>.
</p>
) : null}
<div className="flex gap-4 pt-1">
<Link
href={links.collection(chain, address)}
className="text-foreground no-underline hover:underline"
>
Holders and transfers
</Link>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-foreground no-underline hover:underline"
>
Contract <ExternalLink className="h-3 w-3" />
</a>
) : null}
</div>
</Card>
<h2 className="mb-4 text-xl font-semibold">The tokens</h2>
{count === null ? (
<Card className="p-6 text-sm text-muted-foreground">
<code className="font-mono text-xs">totalMinted</code> does not answer, so there is no
list of tokens to draw.
</Card>
) : (
<div className="grid grid-cols-[repeat(auto-fill,minmax(300px,1fr))] gap-3">
{Array.from({ length: Math.min(count, 24) }, (_, id) => (
<Link
key={id}
href={links.item(chain, address, String(id))}
className="text-inherit no-underline"
>
<GenesisMeta chain={chain} address={address} id={String(id)} />
</Link>
))}
</div>
)}
</>
)
}
export default function GenesisPage() {
const { chainId } = useChainContext()
const chain = CHAIN_INFO[chainId]
const address = GENESIS_NFT[chainId]
const { data, isLoading, isError } = useReadContracts({
allowFailure: false,
contracts: [
{ chainId, address, abi: GENESIS_ABI, functionName: 'totalMinted' },
{ chainId, address, abi: GENESIS_ABI, functionName: 'totalLuxLocked' },
],
query: { enabled: Boolean(address) },
})
const [minted, luxLocked] = data ?? []
return (
<div>
<Header />
<main className="max-w-[1200px] mx-auto px-6 py-8">
<section className="text-center mb-10">
<h1 className="text-4xl font-bold mb-3">Genesis NFTs</h1>
<p className="text-base text-muted-foreground max-w-[600px] mx-auto">
Each Genesis NFT bonds LUX permanently and routes the validator staking rewards on that
bond to whoever holds the NFT. Both figures below are read from the collection contract
on every page load.
</p>
</section>
{!address ? (
<Card className="p-6 text-center text-muted-foreground">
Genesis NFTs are a Lux Network collection. No Genesis contract is registered for{' '}
{chain?.name ?? `chain ${chainId}`}.
</Card>
) : isLoading ? (
<Card className="p-6 text-center text-muted-foreground">
Reading the Genesis contract on {chain?.name ?? `chain ${chainId}`}…
</Card>
) : isError || data === undefined ? (
<Card className="p-6 space-y-3">
<div className="font-semibold">Supply and bonded LUX are unavailable.</div>
<p className="text-sm text-muted-foreground">
The Genesis collection is registered at{' '}
<code className="font-mono text-xs">{address}</code> on{' '}
{chain?.name ?? `chain ${chainId}`}, but the contract does not answer:{' '}
<code className="font-mono text-xs">eth_getCode</code> returns{' '}
<code className="font-mono text-xs">0x</code> and every view call reverts, so there is
no minted count and no bonded balance to show. Nothing is reported here until the
contract answers again.
</p>
<a
className="inline-flex items-center gap-1.5 text-sm hover:underline"
href={`${explorerUrl(chainId)}/address/${address}`}
target="_blank"
rel="noreferrer"
>
Inspect the address <ExternalLink className="h-3.5 w-3.5" />
</a>
</Card>
) : (
<div className="grid grid-cols-[repeat(auto-fit,minmax(240px,1fr))] gap-4">
<Stat label="Minted" value={minted!.toLocaleString()} />
<Stat
label="LUX bonded (permanent)"
value={Number(formatUnits(luxLocked!, 18)).toLocaleString(undefined, {
maximumFractionDigits: 0,
})}
/>
</div>
)}
<Card className="p-6 mt-10 text-center">
<h3 className="text-base font-semibold mb-4">How rewards reach a holder</h3>
<div className="flex items-center justify-center gap-4 flex-wrap text-sm">
<span>Validator staking</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">ValidatorVault.depositRewards()</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-xs">LiquidLUX.depositValidatorRewards()</span>
<ArrowRight className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">xLUX holders</span>
</div>
<p className="text-[13px] text-muted-foreground mt-4">
The call path above is the design in{' '}
<code className="font-mono text-xs">standard/contracts/nft/GenesisNFTs.sol</code>. It is
not a claim about rewards paid — no reward figure appears on this page unless it was
read from chain.
</p>
</Card>
</main>
</div>
<Page
title="Genesis"
intro="A Genesis NFT bonds LUX to itself permanently. Everything below is read from the collection contract on each page load; nothing is printed that a call did not return."
>
{({ chain }) => {
const address = GENESIS[chain.id]
if (!address)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
Genesis is a Lux Network collection. No Genesis contract is registered for{' '}
{chain.name}, and none is deployed there.
</Card>
)
return (
<>
<Source>Read live from {chain.name}.</Source>
<Collection chain={chain} address={address} />
</>
)
}}
</Page>
)
}
+261
View File
@@ -0,0 +1,261 @@
'use client'
import { ExternalLink } from '@luxfi/ui/icons'
import { useQuery } from '@tanstack/react-query'
import Link from 'next/link'
import { useSearchParams } from 'next/navigation'
import { Suspense, useMemo } from 'react'
import { formatUnits } from 'viem'
import { useReadContracts } from 'wagmi'
import { GenesisMeta } from '@/components/Genesis'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Card } from '@/components/ui/card'
import type { ChainState } from '@/hooks/chain'
import { useCollection, useTransferIds, useTransfers } from '@/hooks/queries'
import { ERC165_ABI, ERC2981_ABI, ERC721_ABI, GENESIS, INTERFACE, ONE } from '@/lib/contracts'
import * as links from '@/lib/links'
import { idFor } from '@/lib/logs'
import { addressUrl, txUrl, type Chain } from '@/lib/registry'
import { shortenAddress } from '@/lib/utils'
// A MARKETPLACE WITH NO ROUTE FOR A SINGLE ITEM. THIS IS THAT ROUTE.
//
// Nothing about one token exists on the read path: /tokens/{addr}/instances is
// empty on every collection the indexer holds, and /instances/{id} is not a
// registered route at all. So everything here comes from the chain — ownerOf
// and tokenURI on the contract, and the item's own Transfer logs — and the page
// says which of those answered.
type Ready = Extract<ChainState, { status: 'ready' }>
type Metadata = { name?: string; description?: string; image?: string; image_url?: string }
/** ipfs:// and ar:// are addresses, not URLs. A gateway makes them fetchable. */
function resolve(uri: string): string {
if (uri.startsWith('ipfs://')) return `https://ipfs.io/ipfs/${uri.slice(7)}`
if (uri.startsWith('ar://')) return `https://arweave.net/${uri.slice(5)}`
return uri
}
function Metadata({ uri }: { uri: string }) {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['metadata', uri],
queryFn: async (): Promise<Metadata> => {
const res = await fetch(resolve(uri))
if (!res.ok) throw new Error(`${res.status}`)
return (await res.json()) as Metadata
},
retry: false,
})
if (isLoading) return <p className="text-sm text-muted-foreground">Fetching the token URI…</p>
if (isError)
return (
<p className="max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
The token URI does not resolve: <code className="font-mono text-xs">{error.message}</code>.
The indexer keeps no per-item record for any collection, so there is no image to fall back
on.
</p>
)
const image = data?.image ?? data?.image_url
return (
<div className="space-y-3">
{data?.name ? <div className="text-lg font-semibold">{data.name}</div> : null}
{data?.description ? (
<p className="max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
{data.description}
</p>
) : null}
{image ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={resolve(image)}
alt={data?.name ?? 'item'}
className="max-w-[420px] rounded-xl border border-border"
/>
) : (
<p className="text-sm text-muted-foreground">The token URI names no image.</p>
)}
</div>
)
}
function History({ chain, address, id }: { chain: Chain; address: string; id: string }) {
const transfers = useTransfers(chain, address)
const ids = useTransferIds(chain, transfers.data)
const mine = useMemo(() => {
if (!transfers.data || !ids.data) return []
return transfers.data.filter((t) => idFor(ids.data, t) === id)
}, [transfers.data, ids.data, id])
if (transfers.isLoading || ids.isLoading)
return <p className="text-sm text-muted-foreground">Reading transfers…</p>
if (!mine.length)
return (
<p className="max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
No transfer of this item appears in the page of transfers the indexer serves for the
collection.
</p>
)
return (
<Card className="divide-y divide-border">
{mine.map((t) => {
const href = txUrl(chain, t.tx)
return (
<div key={`${t.block}:${t.logIndex}`} className="flex items-center gap-3 px-4 py-3 text-[13px]">
<span className="w-20 shrink-0 font-semibold">
{t.from === '0x0000000000000000000000000000000000000000' ? 'Mint' : 'Transfer'}
</span>
<span className="min-w-0 flex-1 truncate font-mono text-xs text-muted-foreground">
{shortenAddress(t.from)} → {shortenAddress(t.to)}
</span>
<span className="shrink-0 text-muted-foreground">
{t.time ? new Date(t.time).toLocaleDateString() : 'no timestamp'}
</span>
{href ? (
<a href={href} target="_blank" rel="noopener noreferrer" className="shrink-0 text-muted-foreground hover:text-foreground">
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
)
})}
</Card>
)
}
function Body({ state, slug, address, id }: { state: Ready; slug: string; address: string; id: string }) {
const chain = state.chains.find((c) => c.slug === slug)
const token = useCollection(chain ?? state.chain, address)
const contract = { chainId: chain?.id ?? 0, address: address as `0x${string}` }
const { data: onChain } = useReadContracts({
allowFailure: true,
contracts: [
{ ...contract, abi: ERC721_ABI, functionName: 'ownerOf', args: [BigInt(id)] },
{ ...contract, abi: ERC721_ABI, functionName: 'tokenURI', args: [BigInt(id)] },
{ ...contract, abi: ERC165_ABI, functionName: 'supportsInterface', args: [INTERFACE.royalty] },
{ ...contract, abi: ERC2981_ABI, functionName: 'royaltyInfo', args: [BigInt(id), ONE] },
],
query: { enabled: Boolean(chain?.rpc) },
})
if (!chain)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
The registry serves no chain called <code className="font-mono text-xs">{slug}</code>.
</Card>
)
const [owner, uri, royalty, quote] = onChain ?? []
const rate =
royalty?.result === true && quote?.status === 'success'
? (quote.result as readonly [string, bigint])
: null
return (
<>
<Source>Read live from {chain.name}.</Source>
<div className="mb-8">
<div className="text-sm text-muted-foreground">
<Link href={links.collection(chain, address)} className="no-underline hover:underline">
{token.data?.name ?? shortenAddress(address)}
</Link>
</div>
<div className="font-mono text-3xl font-bold">#{id}</div>
</div>
<Card className="mb-8 max-w-[76ch] space-y-3 p-6 text-sm">
<div>
<span className="text-muted-foreground">Owner </span>
{owner?.status === 'success' ? (
addressUrl(chain, owner.result as string) ? (
<a
href={addressUrl(chain, owner.result as string)!}
target="_blank"
rel="noopener noreferrer"
className="font-mono no-underline hover:underline"
>
{owner.result as string}
</a>
) : (
<span className="font-mono">{owner.result as string}</span>
)
) : (
<span className="text-muted-foreground">ownerOf does not answer for this id.</span>
)}
</div>
<div className="break-all">
<span className="text-muted-foreground">Token URI </span>
{uri?.status === 'success' ? (
<span className="font-mono text-xs">{uri.result as string}</span>
) : (
<span className="text-muted-foreground">tokenURI does not answer.</span>
)}
</div>
{rate ? (
<div>
<span className="text-muted-foreground">Royalty </span>
{Number(formatUnits(rate[1], 18)) * 100}% to{' '}
<span className="font-mono text-xs">{rate[0]}</span>, declared through ERC-2981.
</div>
) : null}
</Card>
<h2 className="mb-4 text-xl font-semibold">Metadata</h2>
<div className="mb-10 space-y-4">
{/* One collection on Lux keeps a record per token in its own storage. */}
{GENESIS[chain.id] === address ? (
<GenesisMeta chain={chain} address={address} id={id} />
) : null}
{uri?.status === 'success' ? (
<Metadata uri={uri.result as string} />
) : (
<p className="max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
The contract does not answer tokenURI for this id, and the indexer holds no per-item
record for any collection.
</p>
)}
</div>
<h2 className="mb-4 text-xl font-semibold">History</h2>
<History chain={chain} address={address} id={id} />
</>
)
}
function FromQuery({ state }: { state: Ready }) {
const params = useSearchParams()
const slug = params.get('chain')
const address = params.get('address')?.toLowerCase()
const id = params.get('id')
if (!slug || !address || id === null)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
This page needs a chain, a contract address and a token id. Reach it from a collection or
from <Link href="/portfolio" className="text-foreground hover:underline">holdings</Link>.
</Card>
)
return <Body state={state} slug={slug} address={address} id={id} />
}
export default function ItemPage() {
return (
<Page title="Item" intro="One token: its owner, what its contract publishes about it, and every transfer of it that could be read.">
{(state) => (
<Suspense fallback={null}>
<FromQuery state={state} />
</Suspense>
)}
</Page>
)
}
+5 -4
View File
@@ -15,13 +15,14 @@ const jetbrainsMono = JetBrains_Mono({
export const metadata: Metadata = {
title: {
default: 'Lux Market - NFT Marketplace',
default: 'Lux Market',
template: '%s | Lux Market',
},
description: 'Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity pools.',
description:
'Collections, holders, transfers and AMM liquidity on every Lux chain, read live from the indexer.',
openGraph: {
title: 'Lux Market',
description: 'The NFT marketplace for the Lux ecosystem',
description: 'What is on the Lux chains, read live.',
url: 'https://lux.market',
siteName: 'Lux Market',
type: 'website',
@@ -29,7 +30,7 @@ export const metadata: Metadata = {
twitter: {
card: 'summary_large_image',
title: 'Lux Market',
description: 'Trade NFTs across all Lux chains',
description: 'What is on the Lux chains, read live.',
},
robots: { index: true, follow: true },
}
+149 -110
View File
@@ -1,120 +1,159 @@
'use client'
import { Header } from '@/components/Header'
import { SearchBar } from '@/components/SearchBar'
import { CollectionCard } from '@/components/CollectionCard'
import { useChainContext } from '@/hooks/useChain'
import { useCollections } from '@/hooks/useNFTData'
import Link from 'next/link'
import { CHAIN_INFO } from '@/lib/chains'
import { Card } from '@/components/ui/card'
import { ArrowRight } from '@luxfi/ui/icons'
import Link from 'next/link'
import { CollectionCard } from '@/components/CollectionCard'
import { Page } from '@/components/Page'
import { SearchBar } from '@/components/SearchBar'
import { Source } from '@/components/Source'
import { Stat } from '@/components/Stat'
import { Card } from '@/components/ui/card'
import { useCollections, useFactory } from '@/hooks/queries'
import { usd } from '@/lib/amm'
import type { Chain } from '@/lib/registry'
const GENESIS_TIERS = [
{ name: 'Genesis', locked: '1B LUX', tier: 'Tier 1', multiplier: '10x' },
{ name: 'Validator', locked: '100M LUX', tier: 'Tier 2', multiplier: '5x' },
{ name: 'Mini', locked: '10M LUX', tier: 'Tier 3', multiplier: '2x' },
{ name: 'Nano', locked: '1M LUX', tier: 'Tier 4', multiplier: '1x' },
]
// WHAT THIS PAGE USED TO SAY, AND WHY NONE OF IT IS HERE.
//
// A row of four Genesis tiers — Genesis, Validator, Mini, Nano at 1B, 100M, 10M
// and 1M LUX with 10x to 1x rewards — none of which was read from anything. The
// real collection is three tokens with no per-token metadata at all; the one
// figure the tier table got right, a billion LUX bonded per token, is on
// /genesis now because the contract answers it.
//
// And a stat row asserting "Trading Protocol: Seaport" and "AMM Protocol:
// LSSVM". Neither is deployed on any Lux chain. The tiles below are the four
// numbers this surface can actually read.
export default function Home() {
const { chainId } = useChainContext()
const { data: collections, isLoading } = useCollections(chainId)
const chainInfo = CHAIN_INFO[chainId]
function Headline({ chain }: { chain: Chain }) {
const collections = useCollections(chain)
const factory = useFactory(chain)
return (
<div>
<Header />
<main className="max-w-[1200px] mx-auto px-6 py-8">
{/* Hero */}
<section className="text-center mb-12">
<h1 className="text-5xl font-bold mb-3 tracking-tight">Lux Market</h1>
<p className="text-base text-muted-foreground max-w-[560px] mx-auto mb-6">
Trade NFTs across all Lux chains. Seaport-powered P2P trading with LSSVM AMM liquidity.
</p>
<div className="max-w-[480px] mx-auto">
<SearchBar />
</div>
</section>
{/* Genesis Featured */}
<section className="mb-12">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">Genesis Collection</h2>
<Link href="/genesis" className="text-primary no-underline text-[13px] font-medium flex items-center gap-1 hover:underline">
View All <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
<div className="grid grid-cols-[repeat(auto-fill,minmax(200px,1fr))] gap-3">
{GENESIS_TIERS.map((nft) => (
<Link key={nft.name} href="/genesis" className="no-underline text-inherit group">
<Card className="overflow-hidden transition-all duration-200 hover:border-muted-foreground/30 hover:-translate-y-0.5">
<div className="aspect-square bg-gradient-to-br from-secondary to-muted flex flex-col items-center justify-center gap-1.5">
<span className="text-[11px] text-muted-foreground uppercase tracking-widest">
{nft.tier}
</span>
<span className="text-2xl font-bold">{nft.name}</span>
<span className="text-xs text-primary">{nft.multiplier} rewards</span>
</div>
<div className="px-3 py-2.5">
<div className="text-[11px] text-muted-foreground">Permanently Locked</div>
<div className="text-sm font-semibold mt-0.5">{nft.locked}</div>
</div>
</Card>
</Link>
))}
</div>
</section>
{/* Collections on selected chain */}
<section className="mb-12">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">
Collections on{' '}
<span className="text-foreground">{chainInfo?.name ?? 'Lux'}</span>
</h2>
<Link
href="/collections"
className="text-primary no-underline text-[13px] font-medium flex items-center gap-1 hover:underline"
>
View All <ArrowRight className="h-3.5 w-3.5" />
</Link>
</div>
{isLoading ? (
<div className="text-center py-8 text-muted-foreground">Loading collections...</div>
) : !collections?.items?.length ? (
<Card className="text-center py-8 text-muted-foreground">
No NFT collections found on {chainInfo?.name ?? 'this chain'} yet. Deploy an ERC-721 or ERC-1155 to get started.
</Card>
) : (
<div className="flex flex-col gap-2">
{collections.items.slice(0, 10).map((token, i) => (
<CollectionCard key={token.address} token={token} chainId={chainId} rank={i + 1} />
))}
</div>
)}
</section>
{/* Stats */}
<section className="mb-12">
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
{[
{ label: 'Supported Chains', value: '5' },
{ label: 'Collections', value: collections?.items?.length?.toString() ?? '---' },
{ label: 'Trading Protocol', value: 'Seaport' },
{ label: 'AMM Protocol', value: 'LSSVM' },
].map((stat) => (
<Card key={stat.label} className="px-5 py-4">
<div className="text-[11px] text-muted-foreground mb-1 uppercase tracking-widest">
{stat.label}
</div>
<div className="text-xl font-bold">{stat.value}</div>
</Card>
))}
</div>
</section>
</main>
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
<Stat
label="Collections"
value={collections.data ? collections.data.length.toLocaleString() : null}
note={`ERC-721 and ERC-1155 on ${chain.name}`}
/>
<Stat
label="Pools"
value={factory.isLoading ? null : (factory.data?.pools.toLocaleString() ?? '0')}
note={factory.data ? 'Uniswap-shaped AMM' : 'no factory deployed'}
/>
<Stat
label="Value locked"
value={factory.data ? usd(factory.data.locked) : null}
note="reported by the subgraph"
/>
<Stat
label="Swaps"
value={factory.data ? factory.data.transactions.toLocaleString() : null}
note="all time"
/>
</div>
)
}
function Collections({ chain }: { chain: Chain }) {
const { data, isLoading, isError, error } = useCollections(chain)
if (isLoading) return <Card className="p-6 text-sm text-muted-foreground">Reading {chain.name}…</Card>
if (isError)
return (
<Card className="p-6 text-sm text-muted-foreground">
The indexer did not answer for {chain.name}: {(error as Error).message}
</Card>
)
if (!data?.length)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
The indexer records no ERC-721 or ERC-1155 contract on {chain.name}. That is the chain, not
the read — it has none. Deploy one and it appears here within a block or two, or{' '}
<Link href="/launch" className="text-foreground hover:underline">
apply
</Link>{' '}
if you want a hand doing it.
</Card>
)
return (
<div className="flex flex-col gap-2">
{data.slice(0, 10).map((token, i) => (
<CollectionCard key={token.address} token={token} chain={chain} rank={i + 1} />
))}
</div>
)
}
function Section({ title, href, children }: { title: string; href?: string; children: React.ReactNode }) {
return (
<section className="mb-12">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-xl font-semibold">{title}</h2>
{href ? (
<Link
href={href}
className="flex items-center gap-1 text-[13px] font-medium no-underline hover:underline"
>
View all <ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
</div>
{children}
</section>
)
}
export default function Home() {
return (
<Page
title="Lux Market"
intro={
<>
Collections, the addresses that hold them and every transfer between them, read from the
Lux indexer on each page load, alongside the AMM pools behind the tokens those chains
trade. Nothing here is for sale: no marketplace contract is deployed on any Lux chain, so
nothing has ever been listed, bid on or sold, and this surface will not pretend otherwise
until one is.
</>
}
>
{({ chain, chains }) => (
<>
<Source>Read live from {chain.name}.</Source>
<div className="mb-10 max-w-[480px]">
<SearchBar chain={chain} />
</div>
<Section title={`Collections on ${chain.name}`} href="/collections">
<Collections chain={chain} />
</Section>
<Section title="What this chain reports" href="/pools">
<Headline chain={chain} />
</Section>
<Section title="Bring something here">
<Card className="max-w-[76ch] space-y-3 p-6">
<p className="text-sm leading-relaxed text-muted-foreground">
Every contract on{' '}
{chains.map((c) => c.name).join(', ')}{' '}
turns up on these pages without asking anyone, because they print what the indexer
returns. Applying is for the parts a contract cannot do by existing — deploying one
in the first place, running your own L1, or telling us that something here reads
wrong.
</p>
<Link
href="/launch"
className="inline-flex items-center gap-1 text-sm font-medium no-underline hover:underline"
>
Apply <ArrowRight className="h-3.5 w-3.5" />
</Link>
</Card>
</Section>
</>
)}
</Page>
)
}
+140
View File
@@ -0,0 +1,140 @@
'use client'
import { ExternalLink } from '@luxfi/ui/icons'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Stat } from '@/components/Stat'
import { Card } from '@/components/ui/card'
import { useFactory, usePools } from '@/hooks/queries'
import { feeLabel, usd, type Pool } from '@/lib/amm'
import { addressUrl, type Chain } from '@/lib/registry'
// The deepest data on this surface, by a distance.
//
// Two chains have a Uniswap-shaped AMM deployed and their subgraph answers with
// both sides of every pool, the fee tier, locked value, traded volume and a
// transaction count. The chain totals come from the factory entity rather than
// a sum over one page of pools, so the headline figures are read rather than
// added up from a partial list.
//
// Nothing here swaps. Quoting and routing are lux.exchange's job and duplicating
// them would put a second, disagreeing router on the estate.
function Row({ pool, chain }: { pool: Pool; chain: Chain }) {
const href = addressUrl(chain, pool.address)
const [a, b] = pool.tokens
return (
<div className="flex items-center gap-3 border-b border-border py-3 text-[13px]">
<div className="min-w-0 flex-1">
<div className="font-medium">
{a.symbol} / {b.symbol}
</div>
<div className="truncate text-xs text-muted-foreground">
{a.name} and {b.name}
</div>
</div>
<div className="w-16 text-right font-mono text-xs text-muted-foreground">
{feeLabel(pool.fee)}
</div>
<div className="w-28 text-right font-mono">{usd(pool.locked)}</div>
<div className="w-28 text-right font-mono text-muted-foreground">{usd(pool.volume)}</div>
<div className="w-24 text-right font-mono text-muted-foreground">
{pool.transactions.toLocaleString()}
</div>
<div className="w-5 text-right">
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
title="Open the pool on the explorer"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</div>
)
}
function Liquidity({ chain }: { chain: Chain }) {
const factory = useFactory(chain)
const pools = usePools(chain)
if (factory.isLoading)
return (
<Card className="p-6 text-sm text-muted-foreground">Reading the {chain.name} subgraph…</Card>
)
if (factory.isError)
return (
<Card className="p-6 text-sm text-muted-foreground">
The subgraph did not answer: {(factory.error as Error).message}
</Card>
)
if (!factory.data)
return (
<Card className="max-w-[76ch] p-6 text-sm leading-relaxed text-muted-foreground">
No AMM factory is deployed on {chain.name}. The registry lists no factory address for it and
its subgraph holds no pools, so there is no liquidity to report — not zero liquidity in
deployed pools, no pools.
</Card>
)
return (
<>
<div className="mb-8 grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3">
<Stat label="Pools" value={factory.data.pools.toLocaleString()} />
<Stat label="Value locked" value={usd(factory.data.locked)} />
<Stat label="Volume" value={usd(factory.data.volume)} note="all time" />
<Stat label="Swaps" value={factory.data.transactions.toLocaleString()} note="all time" />
</div>
<div className="flex items-center gap-3 border-b border-border py-2 text-[11px] uppercase tracking-widest text-muted-foreground">
<div className="flex-1">Pair</div>
<div className="w-16 text-right">Fee</div>
<div className="w-28 text-right">Locked</div>
<div className="w-28 text-right">Volume</div>
<div className="w-24 text-right">Swaps</div>
<div className="w-5" />
</div>
{pools.isLoading ? (
<div className="py-6 text-sm text-muted-foreground">Reading pools…</div>
) : pools.isError ? (
<div className="py-6 text-sm text-muted-foreground">
The subgraph answered for the totals but not for the pools:{' '}
{(pools.error as Error).message}
</div>
) : (
(pools.data ?? []).map((pool) => <Row key={pool.address} pool={pool} chain={chain} />)
)}
</>
)
}
export default function PoolsPage() {
return (
<Page
title="Pools"
intro={
<>
Liquidity on the selected chain, read from its AMM subgraph. Swapping happens on{' '}
<a href="https://lux.exchange" className="text-foreground hover:underline">
lux.exchange
</a>
, which owns quoting and routing; this page only reports what the pools hold.
</>
}
>
{({ chain }) => (
<>
<Source>Read live from the {chain.name} AMM subgraph.</Source>
<Liquidity chain={chain} />
</>
)}
</Page>
)
}
+113 -68
View File
@@ -1,87 +1,132 @@
'use client'
import { useAccount } from 'wagmi'
import { Header } from '@/components/Header'
import { NFTCard } from '@/components/NFTCard'
import { useChainContext } from '@/hooks/useChain'
import { usePortfolioNFTs } from '@/hooks/useNFTData'
import { CHAIN_INFO } from '@/lib/chains'
import { Card } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Wallet } from '@luxfi/ui/icons'
import { useConnect } from 'wagmi'
import { formatUnits } from 'viem'
import { useAccount, useConnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { ItemCard } from '@/components/ItemCard'
import { Page } from '@/components/Page'
import { Source } from '@/components/Source'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { useHoldings } from '@/hooks/queries'
import { isNft } from '@/lib/explorer'
import { addressUrl, type Chain } from '@/lib/registry'
const ALL_CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
// /addresses/{hash}/tokens is the ONE indexer route that names an item: every
// row carries its token id. Holdings therefore work where a collection's own
// item list does not, and this page needs no log read to say which token is
// held. Fungible balances arrive on the same route, so they sit beside them
// rather than on a page of their own.
//
// Every wallet hook lives below the registry gate, because wagmi's config IS
// the registry's chain list — reading one above the gate asks for a provider
// that has not mounted yet.
function ChainPortfolio({ chainId, walletAddress }: { chainId: number; walletAddress: string }) {
const { data, isLoading } = usePortfolioNFTs(chainId, walletAddress)
const chainInfo = CHAIN_INFO[chainId]
function Amount({ value, decimals }: { value: string; decimals: string | null }) {
if (decimals === null) return <>{value}</>
return <>{Number(formatUnits(BigInt(value), Number(decimals))).toLocaleString()}</>
}
if (isLoading) return <div className="text-muted-foreground py-3">Loading {chainInfo?.name}...</div>
if (!data?.items?.length) return null
function OnChain({ chain, wallet }: { chain: Chain; wallet: string }) {
const { data, isLoading, isError } = useHoldings(chain, wallet)
if (isLoading || isError || !data?.length) return null
const items = data.filter((h) => isNft(h.token.type) && h.id !== null)
const tokens = data.filter((h) => !isNft(h.token.type))
return (
<section className="mb-8">
<h3 className="text-base font-semibold mb-3 text-foreground">
{chainInfo?.name} ({data.items.length})
</h3>
<div className="grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{data.items.map((item) => (
<NFTCard
key={`${item.token.address}-${item.token_id}`}
contractAddress={item.token.address}
tokenId={item.token_id ?? '0'}
collectionName={item.token.name ?? undefined}
chainId={chainId}
instance={item.token_instance ?? undefined}
/>
))}
</div>
<section className="mb-10">
<h2 className="mb-3 text-base font-semibold">{chain.name}</h2>
{items.length > 0 && (
<div className="mb-4 grid grid-cols-[repeat(auto-fill,minmax(180px,1fr))] gap-3">
{items.map((h) => (
<ItemCard
key={`${h.token.address}-${h.id}`}
chain={chain}
address={h.token.address}
id={h.id!}
collection={h.token.name}
/>
))}
</div>
)}
{tokens.length > 0 && (
<Card className="divide-y divide-border">
{tokens.map((h) => (
<div key={h.token.address} className="flex items-center gap-3 px-4 py-3 text-[13px]">
<div className="min-w-0 flex-1 truncate">
{h.token.name ?? h.token.address}{' '}
<span className="font-mono text-xs text-muted-foreground">{h.token.symbol}</span>
</div>
<div className="font-mono">
<Amount value={h.value} decimals={h.token.decimals} />
</div>
</div>
))}
</Card>
)}
</section>
)
}
export default function PortfolioPage() {
const { address, isConnected } = useAccount()
function Connect() {
const { connect } = useConnect()
return (
<div>
<Header />
<main className="max-w-[1200px] mx-auto px-6 py-8">
<h1 className="text-3xl font-bold mb-6">My NFTs</h1>
<Card className="px-6 py-16 text-center">
<Wallet className="mx-auto mb-4 h-12 w-12 text-muted-foreground" />
<h2 className="mb-2 text-xl font-semibold">Connect a wallet</h2>
<p className="mb-6 text-sm text-muted-foreground">
The address is read, never asked to sign. Nothing on this surface broadcasts.
</p>
<Button variant="connect" size="lg" onClick={() => connect({ connector: injected() })}>
<Wallet className="h-4 w-4" />
Connect
</Button>
</Card>
)
}
{!isConnected ? (
<Card className="text-center py-16 px-6">
<Wallet className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
<h2 className="text-xl font-semibold mb-2">Connect your wallet</h2>
<p className="text-muted-foreground text-sm mb-6">
Connect a wallet to view your NFTs across all Lux chains.
</p>
<Button
variant="connect"
size="lg"
onClick={() => connect({ connector: injected() })}
>
<Wallet className="h-4 w-4" />
Connect Wallet
</Button>
</Card>
) : (
<div>
<Card className="flex items-center gap-2 mb-6 px-4 py-3 text-[13px]">
<span className="text-muted-foreground">Wallet:</span>
<span className="font-mono">{address}</span>
</Card>
function Held({ chain, chains }: { chain: Chain; chains: Chain[] }) {
const { address, isConnected } = useAccount()
if (!isConnected || !address) return <Connect />
{/* NFTs grouped by chain */}
{ALL_CHAIN_IDS.map((cId) => (
<ChainPortfolio key={cId} chainId={cId} walletAddress={address!} />
))}
</div>
)}
</main>
</div>
const href = addressUrl(chain, address)
return (
<>
<Source>
Read live from every chain in the registry: {chains.map((c) => c.name).join(', ')}.
</Source>
<Card className="mb-6 flex items-center gap-2 px-4 py-3 text-[13px]">
<span className="text-muted-foreground">Wallet</span>
<span className="font-mono">{address}</span>
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="ml-auto text-muted-foreground no-underline hover:text-foreground"
>
Open on {chain.name}
</a>
) : null}
</Card>
{chains.map((c) => (
<OnChain key={c.slug} chain={c} wallet={address} />
))}
</>
)
}
export default function PortfolioPage() {
return (
<Page
title="Holdings"
intro="What the connected wallet holds on every chain the registry serves — items by token id, and fungible balances beside them."
>
{({ chain, chains }) => <Held chain={chain} chains={chains} />}
</Page>
)
}
+69 -27
View File
@@ -3,32 +3,47 @@
import { AppProvider } from '@luxfi/ui'
import { AuthProvider } from '@luxfi/ui/auth'
import { QueryClient } from '@tanstack/react-query'
import { useEffect, useState, type ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import type { Chain as ViemChain } from 'viem'
import { WagmiProvider, createConfig, http } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { ChainContext } from '@/hooks/useChain'
import { supportedChains } from '@/lib/chains'
import { ChainContext, type ChainState } from '@/hooks/chain'
import { readChains, type Chain } from '@/lib/registry'
// Two identities meet on this surface and they are NOT the same thing:
//
// the WALLET — wagmi, an address, what signs a listing;
// the WALLET — wagmi, an address, what signs a transaction;
// the ACCOUNT — IAM, who you are, which org you act in.
//
// The market had only the first, so its header was a Connect button and there
// was no way to tell lux.market from zoo.market except the copy. `AppProvider`
// carries the white-label (brand, gui theme, OIDC issuer, IAM client — all from
// the Host header) and `AuthProvider` the account — the identity read AND the
// PKCE login, in ONE component, exactly as every other Lux surface does. This
// file used to repeat `configureIam` + `startLogin` + `logout` verbatim from
// lux/mpc/dashboard; that flow now lives in @luxfi/ui/auth. Wallet state stays
// where it was.
// `AppProvider` carries the white-label (brand, gui theme, OIDC issuer, IAM
// client — all from the Host header) and `AuthProvider` the account: the
// identity read AND the PKCE login, in one component, exactly as every other
// Lux surface does. Wallet state sits inside both.
//
// The chain list sits inside all three, because it is read rather than
// declared. Until the registry answers there is no wagmi config to build, so
// the provider publishes what it knows — reading, failed, or ready — and the
// screens that need a chain say which of the three they are in.
function createWagmiConfig() {
function viemChain(c: Chain): ViemChain {
return {
id: c.id,
name: c.name,
nativeCurrency: { name: c.coin, symbol: c.coin, decimals: 18 },
rpcUrls: { default: { http: [c.rpc!] } },
...(c.explorer
? { blockExplorers: { default: { name: 'Explore', url: c.explorer } } }
: {}),
}
}
/** Only chains with a browser-reachable RPC can carry a transaction. */
function wagmi(chains: Chain[]) {
const reachable = chains.filter((c) => c.rpc).map(viemChain)
return createConfig({
chains: supportedChains,
chains: reachable as [ViemChain, ...ViemChain[]],
connectors: [injected()],
transports: Object.fromEntries(supportedChains.map((c) => [c.id, http()])) as any,
ssr: true,
transports: Object.fromEntries(reachable.map((c) => [c.id, http(c.rpcUrls.default.http[0])])),
})
}
@@ -36,15 +51,25 @@ function createWagmiConfig() {
// stands in for. Unset in every deployment, where the real Host header answers.
const DEV_HOST = process.env.NEXT_PUBLIC_HOST
function Wallet({ chains, children }: { chains: Chain[]; children: ReactNode }) {
const [config] = useState(() => wagmi(chains))
return <WagmiProvider config={config}>{children}</WagmiProvider>
}
export function Providers({ children }: { children: ReactNode }) {
const [chainId, setChainId] = useState(96369)
const [config] = useState(() => createWagmiConfig())
const [chains, setChains] = useState<Chain[] | null>(null)
const [failure, setFailure] = useState<string | null>(null)
const [slug, setSlug] = useState<string | null>(null)
const [host, setHost] = useState<string | undefined>(undefined)
// ONE query cache for the surface: AppProvider owns the provider, so wagmi
// and the chrome share a client instead of running two.
const [queryClient] = useState(() => new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 2, staleTime: 60_000 } },
}))
const [host, setHost] = useState<string | undefined>(undefined)
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: { queries: { refetchOnWindowFocus: false, retry: 2, staleTime: 60_000 } },
}),
)
useEffect(() => {
const real = window.location.host
@@ -52,14 +77,31 @@ export function Providers({ children }: { children: ReactNode }) {
setHost(local && DEV_HOST ? DEV_HOST : real)
}, [])
useEffect(() => {
let live = true
readChains()
.then((c) => live && setChains(c))
.catch((e: Error) => live && setFailure(e.message))
return () => {
live = false
}
}, [])
const select = useCallback((next: string) => setSlug(next), [])
const state = useMemo<ChainState>(() => {
if (failure) return { status: 'failed', reason: failure }
if (!chains?.length) return { status: 'reading' }
const chain = chains.find((c) => c.slug === slug) ?? chains[0]
return { status: 'ready', chain, chains, select }
}, [chains, failure, select, slug])
return (
<AppProvider host={host} queryClient={queryClient}>
<AuthProvider redirect="/">
<WagmiProvider config={config}>
<ChainContext.Provider value={{ chainId, setChainId }}>
{children}
</ChainContext.Provider>
</WagmiProvider>
<ChainContext.Provider value={state}>
{chains?.length ? <Wallet chains={chains}>{children}</Wallet> : children}
</ChainContext.Provider>
</AuthProvider>
</AppProvider>
)
+77 -76
View File
@@ -1,96 +1,97 @@
'use client'
import Link from 'next/link'
import { CHAIN_INFO, explorerUrl } from '@/lib/chains'
import { ExternalLink } from '@luxfi/ui/icons'
import type { ExplorerTransfer } from '@/lib/explorer'
import Link from 'next/link'
import type { Token, Transfer } from '@/lib/explorer'
import * as links from '@/lib/links'
import { addressUrl, txUrl, type Chain } from '@/lib/registry'
import { shortenAddress } from '@/lib/utils'
function getEventType(method: string): { label: string } {
const m = method.toLowerCase()
if (m.includes('mint') || m === 'transfer') return { label: 'Transfer' }
if (m.includes('safe')) return { label: 'Transfer' }
return { label: method || 'Transfer' }
const ZERO = '0x0000000000000000000000000000000000000000'
function Hash({ chain, hash }: { chain: Chain; hash: string }) {
const href = addressUrl(chain, hash)
const text = hash === ZERO ? 'null address' : shortenAddress(hash)
if (!href) return <span className="font-mono text-xs text-muted-foreground">{text}</span>
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-muted-foreground no-underline hover:text-foreground"
>
{text}
</a>
)
}
interface ActivityRowProps {
transfer: ExplorerTransfer
chainId: number
}
export function ActivityRow({ transfer, chainId }: ActivityRowProps) {
const chainInfo = CHAIN_INFO[chainId]
const explorerBase = explorerUrl(chainId)
const event = getEventType(transfer.method ?? '')
const isMint = transfer.from.hash === '0x0000000000000000000000000000000000000000'
const tokenId = transfer.total?.token_id ?? '?'
export function ActivityRow({
transfer,
chain,
token,
id,
}: {
transfer: Transfer
chain: Chain
/** The collection, matched by address against the chain's token list. */
token?: Token
/** Token id read from the Transfer log, when the log was in reach. */
id?: string
}) {
const mint = transfer.from === ZERO
const name = token?.name ?? token?.symbol ?? shortenAddress(transfer.token.address)
const tx = txUrl(chain, transfer.tx)
return (
<div className="flex items-center gap-3 py-3 border-b border-border text-[13px]">
{/* Event type */}
<div
className={`px-2.5 py-1 rounded-md text-xs font-semibold w-[72px] text-center shrink-0 ${
isMint
? 'bg-success/10 text-success'
: 'bg-muted-foreground/10 text-muted-foreground'
}`}
>
{isMint ? 'Mint' : event.label}
<div className="flex items-center gap-3 border-b border-border py-3 text-[13px]">
<div className="w-[72px] shrink-0 rounded-md bg-muted px-2.5 py-1 text-center text-xs font-semibold">
{mint ? 'Mint' : 'Transfer'}
</div>
{/* Item */}
<div className="flex-1 min-w-0">
<Link
href={`/nft/${chainId}/${transfer.token.address}/${tokenId}`}
className="text-foreground no-underline font-medium hover:underline"
>
{transfer.token.name ?? transfer.token.symbol ?? 'NFT'} #{tokenId}
</Link>
<div className="min-w-0 flex-1">
{id !== undefined ? (
<Link
href={links.item(chain, transfer.token.address, id)}
className="font-medium text-foreground no-underline hover:underline"
>
{name} #{id}
</Link>
) : (
<span>
<Link
href={links.collection(chain, transfer.token.address)}
className="font-medium text-foreground no-underline hover:underline"
>
{name}
</Link>
<span className="ml-2 text-muted-foreground">item id not read</span>
</span>
)}
</div>
{/* From -> To */}
<div className="flex items-center gap-1.5 text-muted-foreground shrink-0">
<a
href={`${explorerBase}/address/${transfer.from.hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground no-underline font-mono text-xs hover:text-foreground"
>
{isMint ? 'NullAddress' : shortenAddress(transfer.from.hash)}
</a>
<span className="text-[10px]">&rarr;</span>
<a
href={`${explorerBase}/address/${transfer.to.hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground no-underline font-mono text-xs hover:text-foreground"
>
{shortenAddress(transfer.to.hash)}
</a>
<div className="flex shrink-0 items-center gap-1.5">
<Hash chain={chain} hash={transfer.from} />
<span className="text-[10px] text-muted-foreground">&rarr;</span>
<Hash chain={chain} hash={transfer.to} />
</div>
{/* Chain */}
{chainInfo && (
<div className="text-[11px] font-semibold w-10 text-right shrink-0">
{chainInfo.name}
</div>
)}
{/* Time */}
<div className="text-muted-foreground text-xs w-20 text-right shrink-0">
{transfer.timestamp ? new Date(transfer.timestamp).toLocaleDateString() : '---'}
<div className="w-24 shrink-0 text-right text-xs text-muted-foreground">
{transfer.time ? new Date(transfer.time).toLocaleDateString() : 'no timestamp'}
</div>
{/* Tx link */}
<a
href={`${explorerBase}/tx/${transfer.transaction_hash}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground shrink-0 hover:text-foreground transition-colors"
title="View on explorer"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<div className="w-5 shrink-0 text-right">
{tx ? (
<a
href={tx}
target="_blank"
rel="noopener noreferrer"
title="Open the transaction on the explorer"
className="text-muted-foreground transition-colors hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
) : null}
</div>
</div>
)
}
+35 -37
View File
@@ -1,57 +1,55 @@
'use client'
import Link from 'next/link'
import { CHAIN_INFO } from '@/lib/chains'
import type { ExplorerToken } from '@/lib/explorer'
import * as links from '@/lib/links'
import type { Token } from '@/lib/explorer'
import type { Chain } from '@/lib/registry'
interface CollectionCardProps {
token: ExplorerToken
chainId: number
rank?: number
// Supply and holder count come from `total_supply` and `holders_count`. The
// card used to read `token.address` and `token.holders`, which the indexer does
// not send under those names, so every link pointed at `undefined`, every row
// shared one React key, and every holder count printed three dashes.
function Figure({ value, unit }: { value: string | null; unit: string }) {
if (value === null) return <span className="text-muted-foreground">no {unit} reported</span>
return <>{Number(value).toLocaleString()} {unit}</>
}
export function CollectionCard({ token, chainId, rank }: CollectionCardProps) {
const chainInfo = CHAIN_INFO[chainId]
export function CollectionCard({
token,
chain,
rank,
}: {
token: Token
chain: Chain
rank?: number
}) {
return (
<Link
href={`/collection/${chainId}/${token.address}`}
className="no-underline text-inherit"
>
<div className="bg-card rounded-xl p-4 border border-border flex items-center gap-4 transition-colors cursor-pointer hover:border-muted-foreground/30 hover:bg-secondary/50">
<Link href={links.collection(chain, token.address)} className="text-inherit no-underline">
<div className="flex cursor-pointer items-center gap-4 rounded-xl border border-border bg-card p-4 transition-colors hover:border-muted-foreground/30 hover:bg-secondary/50">
{rank !== undefined && (
<div className="text-sm font-bold text-muted-foreground w-6 text-center font-mono">
<div className="w-6 text-center font-mono text-sm font-bold text-muted-foreground">
{rank}
</div>
)}
{/* Collection icon */}
<div
className="w-14 h-14 rounded-[10px] shrink-0 flex items-center justify-center text-xl font-bold text-muted-foreground/30 bg-cover bg-center"
style={
token.icon_url
? { backgroundImage: `url(${token.icon_url})` }
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
}
>
{!token.icon_url && (token.symbol?.charAt(0) ?? '?')}
<div className="flex h-14 w-14 shrink-0 items-center justify-center rounded-[10px] bg-secondary text-xl font-bold text-muted-foreground">
{token.symbol?.charAt(0) ?? '?'}
</div>
<div className="flex-1 min-w-0">
<div className="text-[15px] font-semibold truncate">
{token.name}
<div className="min-w-0 flex-1">
<div className="truncate text-[15px] font-semibold">
{token.name ?? token.address}
</div>
<div className="flex gap-4 mt-1 text-xs text-muted-foreground">
<div className="mt-1 flex gap-4 text-xs text-muted-foreground">
<span>{token.type}</span>
{chainInfo && (
<span className="text-foreground">{chainInfo.name}</span>
)}
<span className="font-mono">{token.symbol}</span>
</div>
</div>
<div className="text-right shrink-0">
<div className="text-[13px]">
{token.holders ? Number(token.holders).toLocaleString() : '---'} holders
<div className="shrink-0 text-right text-[13px]">
<div>
<Figure value={token.holders} unit="holders" />
</div>
<div className="text-[11px] text-muted-foreground mt-0.5">
{token.total_supply ? Number(token.total_supply).toLocaleString() : '---'} items
<div className="mt-0.5 text-[11px] text-muted-foreground">
<Figure value={token.supply} unit="items" />
</div>
</div>
</div>
+67
View File
@@ -0,0 +1,67 @@
import Link from 'next/link'
// The links a marketplace is expected to have and this one did not: how to
// apply, where to get help, and the two documents that say what the site does
// with a visit. /launch and /support are written elsewhere in this app; the
// footer's job is only to make them reachable from every page.
const COLUMNS: { heading: string; links: { href: string; label: string }[] }[] = [
{
heading: 'Market',
links: [
{ href: '/collections', label: 'Collections' },
{ href: '/activity', label: 'Activity' },
{ href: '/pools', label: 'Pools' },
{ href: '/genesis', label: 'Genesis' },
],
},
{
heading: 'Build',
links: [
{ href: '/launch', label: 'Apply' },
{ href: '/launches', label: 'Launches' },
{ href: '/support/projects', label: 'For projects' },
],
},
{
heading: 'Help',
links: [
{ href: '/support', label: 'Support' },
{ href: '/support/buyers', label: 'For buyers' },
{ href: '/support/terms', label: 'Terms' },
{ href: '/support/privacy', label: 'Privacy' },
],
},
]
export function Footer() {
return (
<footer className="mt-16 border-t border-border">
<div className="mx-auto grid max-w-[1200px] grid-cols-[repeat(auto-fit,minmax(160px,1fr))] gap-8 px-6 py-10">
{COLUMNS.map((column) => (
<div key={column.heading}>
<div className="mb-3 text-[11px] uppercase tracking-widest text-muted-foreground">
{column.heading}
</div>
<ul className="space-y-2">
{column.links.map((link) => (
<li key={link.href}>
<Link
href={link.href}
className="text-[13px] text-muted-foreground no-underline hover:text-foreground"
>
{link.label}
</Link>
</li>
))}
</ul>
</div>
))}
<div className="text-[13px] leading-relaxed text-muted-foreground">
Lux Market reads the Lux chains and shows what is on them. It holds no assets, takes no
custody and settles nothing.
</div>
</div>
</footer>
)
}
+64
View File
@@ -0,0 +1,64 @@
'use client'
import { formatUnits } from 'viem'
import { useReadContract } from 'wagmi'
import { Card } from '@/components/ui/card'
import { GENESIS_TOKEN_ABI, NFT_TYPE, TIER } from '@/lib/contracts'
import type { Chain } from '@/lib/registry'
// THE ONLY PER-ITEM METADATA ON ANY LUX CHAIN.
//
// GenesisNFTs.sol keeps `tokenMeta` per token and its generated getter answers,
// so a Genesis token has a name, a type, a tier, a bonded amount and a mint
// timestamp — all read from the contract. Nothing else on Lux publishes any of
// that: the indexer's per-item resource is empty for every collection, and this
// collection's tokenURI is one malformed string shared by all three tokens.
//
// It is contract-specific on purpose. This reads GenesisNFTs.sol because that
// contract is deployed and answers; it is not a shape any ERC-721 has.
export type Meta = readonly [number, number, string, bigint, bigint, bigint, boolean]
export function useGenesisMeta(chain: Chain, address: string, id: string) {
return useReadContract({
chainId: chain.id,
address: address as `0x${string}`,
abi: GENESIS_TOKEN_ABI,
functionName: 'tokenMeta',
args: [BigInt(id)],
query: { enabled: Boolean(chain.rpc) },
})
}
export function GenesisMeta({ chain, address, id }: { chain: Chain; address: string; id: string }) {
const { data, isLoading, isError } = useGenesisMeta(chain, address, id)
if (isLoading) return <p className="text-sm text-muted-foreground">Reading the token record…</p>
if (isError || !data)
return (
<p className="text-sm text-muted-foreground">
The collection keeps a record per token and it does not answer for this id.
</p>
)
const [nftType, tier, name, , locked, timestamp] = data as Meta
return (
<Card className="h-full space-y-2 p-6 text-sm transition-colors hover:border-muted-foreground/30">
<div className="font-mono text-xs text-muted-foreground">#{id}</div>
<div className="text-lg font-semibold">{name}</div>
<div className="text-muted-foreground">
{TIER[tier] ?? `tier ${tier}`} tier, {NFT_TYPE[nftType] ?? `type ${nftType}`} NFT.
</div>
<div className="text-muted-foreground">
<span className="font-mono text-foreground">
{Number(formatUnits(locked, 18)).toLocaleString()} {chain.coin}
</span>{' '}
bonded permanently to this token.
</div>
<div className="text-muted-foreground">
Minted {new Date(Number(timestamp) * 1000).toLocaleDateString()}.
</div>
</Card>
)
}
+33 -41
View File
@@ -1,62 +1,55 @@
'use client'
import { AppNav } from '@luxfi/ui'
import { LogOut, Wallet } from '@luxfi/ui/icons'
import { usePathname } from 'next/navigation'
import { useAccount, useConnect, useDisconnect } from 'wagmi'
import { injected } from 'wagmi/connectors'
import { useChainContext } from '@/hooks/useChain'
import { CHAIN_INFO } from '@/lib/chains'
import { Button } from '@/components/ui/button'
import { Wallet, LogOut } from '@luxfi/ui/icons'
import { cn } from '@/lib/utils'
import { shortenAddress } from '@/lib/utils'
import { useChain } from '@/hooks/chain'
import { cn, shortenAddress } from '@/lib/utils'
// The chrome is `AppNav` from @luxfi/ui — brand, org switcher, links, settings
// and the user menu, identical to every other Lux surface. What used to be here
// was a second header: its own sticky bar, its own link styling, a `flex` nav
// with no phone presentation at all, and a wallet control standing in for an
// account menu it did not have.
//
// The chain selector and the wallet button stay, in the nav's free slot: they
// are this surface's own controls, not chrome. A wallet is not an account.
// and the user menu, identical to every other Lux surface. The chain selector
// and the wallet button sit in its free slot: they are this surface's own
// controls, not chrome. A wallet is not an account.
const NAV_LINKS = [
{ href: '/', label: 'Explore' },
const LINKS = [
{ href: '/collections', label: 'Collections' },
{ href: '/activity', label: 'Activity' },
{ href: '/pools', label: 'Pools' },
{ href: '/genesis', label: 'Genesis' },
{ href: '/launches', label: 'Launches' },
{ href: '/portfolio', label: 'Portfolio' },
{ href: '/launch', label: 'Apply' },
]
const CHAIN_IDS = [96369, 200200, 36963, 36911, 494949]
/** Which chain the marketplace reads — a market control, not chrome. */
/** Which chain the screens read. The list is the registry's, never a constant. */
function ChainSelector() {
const { chainId, setChainId } = useChainContext()
const state = useChain()
if (state.status !== 'ready') return null
return (
<div className="hidden gap-0.5 rounded-lg bg-card p-0.5 sm:flex">
{CHAIN_IDS.map((id) => {
const info = CHAIN_INFO[id]
const isActive = chainId === id
return (
<button
key={id}
onClick={() => setChainId(id)}
title={info.name}
className={cn(
'cursor-pointer rounded-md border-none px-2.5 py-1.5 text-xs font-semibold transition-colors',
isActive ? 'bg-primary text-primary-foreground' : 'bg-transparent text-muted-foreground hover:text-foreground',
)}
>
{info.name}
</button>
)
})}
{state.chains.map((c) => (
<button
key={c.slug}
onClick={() => state.select(c.slug)}
title={c.name}
className={cn(
'cursor-pointer rounded-md border-none px-2.5 py-1.5 text-xs font-semibold transition-colors',
c.slug === state.chain.slug
? 'bg-primary text-primary-foreground'
: 'bg-transparent text-muted-foreground hover:text-foreground',
)}
>
{c.coin}
</button>
))}
</div>
)
}
/** The wallet that signs listings — distinct from the IAM account above it. */
/** The wallet that signs, distinct from the IAM account in the menu above it. */
function WalletButton() {
const { address, isConnected } = useAccount()
const { connect } = useConnect()
@@ -84,16 +77,15 @@ function WalletButton() {
export function Header() {
const pathname = usePathname()
const state = useChain()
return (
<AppNav
brand="Lux Market"
links={NAV_LINKS.map((l) => ({
...l,
active: l.href === '/' ? pathname === '/' : pathname.startsWith(l.href),
}))}
links={LINKS.map((l) => ({ ...l, active: pathname.startsWith(l.href) }))}
>
<ChainSelector />
<WalletButton />
{/* wagmi mounts only once the registry has named the chains it can reach. */}
{state.status === 'ready' ? <WalletButton /> : null}
</AppNav>
)
}
+48
View File
@@ -0,0 +1,48 @@
'use client'
import Link from 'next/link'
import { Card } from '@/components/ui/card'
import * as links from '@/lib/links'
import type { Chain } from '@/lib/registry'
// NO ITEM ON ANY LUX CHAIN HAS AN IMAGE TO SHOW.
//
// The indexer never populates /tokens/{addr}/instances — it is empty on the
// three-token collection and equally empty on the 149-token one — so there is
// no image, no trait and no name for a single token anywhere on the read path.
// The only collection with a tokenURI points all three of its tokens at one
// malformed string on a host that no longer resolves.
//
// So the tile leads with the identity that does exist, the id, and says what is
// missing rather than drawing a grey square and letting it read as loading.
export function ItemCard({
chain,
address,
id,
collection,
}: {
chain: Chain
address: string
id: string
collection?: string | null
}) {
return (
<Link href={links.item(chain, address, id)} className="text-inherit no-underline">
<Card className="overflow-hidden transition-colors hover:border-muted-foreground/30">
<div className="flex aspect-square flex-col items-center justify-center gap-2 bg-secondary">
<span className="font-mono text-3xl font-bold">#{id}</span>
<span className="text-[11px] uppercase tracking-widest text-muted-foreground">
no image published
</span>
</div>
<div className="px-3 py-2.5">
<div className="truncate text-sm font-semibold">{collection ?? 'Unnamed collection'}</div>
<div className="mt-0.5 truncate text-[11px] uppercase tracking-wider text-muted-foreground">
{chain.name}
</div>
</div>
</Card>
</Link>
)
}
-103
View File
@@ -1,103 +0,0 @@
'use client'
import { useState } from 'react'
import { parseEther, type Address } from 'viem'
import { useWriteContract, useAccount, useChainId } from 'wagmi'
import { CONTRACTS, MARKET_ABI, ERC721_ABI } from '@/lib/contracts'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
interface ListingFormProps {
contractAddress: Address
tokenId: string
}
export function ListingForm({ contractAddress, tokenId }: ListingFormProps) {
const [price, setPrice] = useState('')
const [duration, setDuration] = useState('7') // days
const chainId = useChainId()
const { address } = useAccount()
const { writeContract, isPending } = useWriteContract()
const market = CONTRACTS[chainId]?.market
if (!market) return null
const handleApproveAndList = async () => {
if (!address || !price) return
// First approve the Market contract
writeContract({
address: contractAddress,
abi: ERC721_ABI,
functionName: 'approve',
args: [market, BigInt(tokenId)],
})
}
const handleList = () => {
if (!price) return
const durationSeconds = BigInt(Number(duration) * 86400)
writeContract({
address: market,
abi: MARKET_ABI,
functionName: 'list',
args: [
contractAddress,
BigInt(tokenId),
'0x0000000000000000000000000000000000000000' as Address, // native LUX
parseEther(price),
durationSeconds,
],
})
}
return (
<div className="flex flex-col gap-3">
<h3 className="text-base font-semibold">List for Sale</h3>
<div>
<label className="text-xs text-muted-foreground block mb-1">Price (LUX)</label>
<Input
type="number"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="0.0"
className="h-10 text-base bg-card"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">
Duration (days)
</label>
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full h-10 px-3 bg-card border border-input rounded-md text-foreground text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
<option value="30">30 days</option>
</select>
</div>
<div className="flex gap-2">
<Button
variant="outline"
className="flex-1"
onClick={handleApproveAndList}
disabled={isPending || !price}
>
Approve
</Button>
<Button
className="flex-1"
onClick={handleList}
disabled={isPending || !price}
>
{isPending ? 'Listing...' : 'List NFT'}
</Button>
</div>
</div>
)
}
-75
View File
@@ -1,75 +0,0 @@
'use client'
import Image from 'next/image'
import Link from 'next/link'
import { formatEther } from 'viem'
import { getNftImageUrl, type ExplorerTokenInstance } from '@/lib/explorer'
import { CHAIN_INFO } from '@/lib/chains'
import { Card } from '@/components/ui/card'
export interface NFTCardProps {
contractAddress: string
tokenId: string
name?: string
imageUrl?: string
collectionName?: string
price?: bigint
chainId: number
instance?: ExplorerTokenInstance
}
export function NFTCard({ contractAddress, tokenId, name, imageUrl, collectionName, price, chainId, instance }: NFTCardProps) {
const resolvedImage = instance ? getNftImageUrl(instance) : imageUrl
const resolvedName = instance?.metadata?.name ?? name
const resolvedCollection = instance?.token?.name ?? collectionName
const chainInfo = CHAIN_INFO[chainId]
return (
<Link
href={`/nft/${chainId}/${contractAddress}/${tokenId}`}
className="no-underline text-inherit group"
>
<Card className="overflow-hidden transition-all duration-200 hover:border-muted-foreground/30 hover:-translate-y-0.5 hover:shadow-lg hover:shadow-black/30">
<div className="aspect-square relative bg-secondary">
{resolvedImage ? (
<Image
src={resolvedImage}
alt={resolvedName ?? `#${tokenId}`}
fill
className="object-cover"
unoptimized
sizes="(max-width: 768px) 50vw, 25vw"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-gradient-to-br from-secondary to-muted text-4xl font-bold text-muted-foreground/30">
#{tokenId}
</div>
)}
{/* Chain badge */}
{chainInfo && (
<div
className="absolute top-2 right-2 px-2 py-0.5 rounded-md bg-foreground/70 text-background backdrop-blur-sm text-[10px] font-semibold"
>
{chainInfo.name}
</div>
)}
</div>
<div className="px-3 py-2.5">
{resolvedCollection && (
<div className="text-[11px] text-muted-foreground uppercase tracking-wider truncate">
{resolvedCollection}
</div>
)}
<div className="text-sm font-semibold mt-0.5 truncate">
{resolvedName ?? `#${tokenId}`}
</div>
{price !== undefined && price > 0n && (
<div className="text-[13px] text-primary mt-1.5 font-medium font-mono">
{formatEther(price)} {chainInfo?.symbol ?? 'LUX'}
</div>
)}
</div>
</Card>
</Link>
)
}
-75
View File
@@ -1,75 +0,0 @@
'use client'
import { useState } from 'react'
import { parseEther, type Address } from 'viem'
import { useWriteContract, useChainId } from 'wagmi'
import { CONTRACTS, MARKET_ABI, WRAPPED_NATIVE } from '@/lib/contracts'
import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button'
interface OfferFormProps {
contractAddress: Address
tokenId: string
}
export function OfferForm({ contractAddress, tokenId }: OfferFormProps) {
const [amount, setAmount] = useState('')
const [duration, setDuration] = useState('3')
const chainId = useChainId()
const { writeContract, isPending } = useWriteContract()
const market = CONTRACTS[chainId]?.market
const paymentToken = WRAPPED_NATIVE[chainId]
// makeOffer reverts on address(0) — an offer needs an LRC20 to escrow against.
if (!market || !paymentToken) return null
const handleOffer = () => {
if (!amount) return
const durationSeconds = BigInt(Number(duration) * 86400)
writeContract({
address: market,
abi: MARKET_ABI,
functionName: 'makeOffer',
args: [contractAddress, BigInt(tokenId), paymentToken, parseEther(amount), durationSeconds],
})
}
return (
<div className="flex flex-col gap-3">
<h3 className="text-base font-semibold">Make an Offer</h3>
<div>
<label className="text-xs text-muted-foreground block mb-1">
Offer Amount (WLUX)
</label>
<Input
type="number"
value={amount}
onChange={(e) => setAmount(e.target.value)}
placeholder="0.0"
className="h-10 text-base bg-card"
/>
</div>
<div>
<label className="text-xs text-muted-foreground block mb-1">Expires in</label>
<select
value={duration}
onChange={(e) => setDuration(e.target.value)}
className="w-full h-10 px-3 bg-card border border-input rounded-md text-foreground text-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
>
<option value="1">1 day</option>
<option value="3">3 days</option>
<option value="7">7 days</option>
<option value="14">14 days</option>
</select>
</div>
<Button
className="w-full"
onClick={handleOffer}
disabled={isPending || !amount}
>
{isPending ? 'Submitting...' : 'Make Offer'}
</Button>
</div>
)
}
+84
View File
@@ -0,0 +1,84 @@
'use client'
import type { ReactNode } from 'react'
import { Footer } from '@/components/Footer'
import { Header } from '@/components/Header'
import { Card } from '@/components/ui/card'
import { useChain } from '@/hooks/chain'
import type { ChainState } from '@/hooks/chain'
// Every screen is chrome, a title, an introduction and a body, and the ones
// that read a chain are also three states rather than one: the registry has not
// answered yet, it did not answer, or here is the chain we read. Writing that
// out eight times is eight chances for one screen to quietly drop the
// distinction, so it is written once and a page hands in a function to say it
// needs a chain. What a page then says it read is the page's own business —
// most read the selected chain, holdings reads every one — so the provenance
// line is a component a page places, not a sentence this shell assumes.
/** The registry answered. A page body only ever runs in this state. */
type Ready = Extract<ChainState, { status: 'ready' }>
function Reading() {
return (
<Card className="p-6 text-sm text-muted-foreground">Reading the chain registry…</Card>
)
}
function Failed({ reason }: { reason: string }) {
return (
<Card className="space-y-3 p-6">
<div className="font-semibold">The chain registry did not answer.</div>
<p className="max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
Which chains exist, what they are called and where their nodes are is read from the
indexer&rsquo;s registry rather than kept in a list here, so nothing on this page is known
until that read succeeds. The request failed with{' '}
<code className="font-mono text-xs">{reason}</code>.
</p>
<button
onClick={() => window.location.reload()}
className="cursor-pointer text-sm font-medium hover:underline"
>
Try again
</button>
</Card>
)
}
export function Page({
title,
intro,
children,
}: {
title: string
intro?: ReactNode
children: ReactNode | ((chain: Ready) => ReactNode)
}) {
const state = useChain()
const needsChain = typeof children === 'function'
return (
<div className="flex min-h-screen flex-col">
<Header />
<main className="mx-auto w-full max-w-[1200px] flex-1 px-6 py-8">
<h1 className="mb-3 text-3xl font-bold tracking-tight">{title}</h1>
{intro ? (
<div className="mb-6 max-w-[76ch] text-sm leading-relaxed text-muted-foreground">
{intro}
</div>
) : null}
{!needsChain ? (
(children as ReactNode)
) : state.status === 'reading' ? (
<Reading />
) : state.status === 'failed' ? (
<Failed reason={state.reason} />
) : (
(children as (chain: Ready) => ReactNode)(state)
)}
</main>
<Footer />
</div>
)
}
+32 -41
View File
@@ -1,72 +1,63 @@
'use client'
import { useState, useRef, useEffect } from 'react'
import Link from 'next/link'
import { useSearchCollections } from '@/hooks/useNFTData'
import { useChainContext } from '@/hooks/useChain'
import { Input } from '@/components/ui/input'
import { Search } from '@luxfi/ui/icons'
import Link from 'next/link'
import { useEffect, useRef, useState } from 'react'
import { Input } from '@/components/ui/input'
import { useCollections } from '@/hooks/queries'
import * as links from '@/lib/links'
import type { Chain } from '@/lib/registry'
export function SearchBar() {
export function SearchBar({ chain }: { chain: Chain }) {
const [query, setQuery] = useState('')
const [isOpen, setIsOpen] = useState(false)
const { chainId } = useChainContext()
const { data: results } = useSearchCollections(chainId, query)
const ref = useRef<HTMLDivElement>(null)
const [open, setOpen] = useState(false)
const { data } = useCollections(chain, query.trim() || undefined)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setIsOpen(false)
}
const away = (e: MouseEvent) => {
if (box.current && !box.current.contains(e.target as Node)) setOpen(false)
}
document.addEventListener('mousedown', handleClick)
return () => document.removeEventListener('mousedown', handleClick)
document.addEventListener('mousedown', away)
return () => document.removeEventListener('mousedown', away)
}, [])
const nftResults = results?.items ?? []
const results = query.trim().length >= 2 ? (data ?? []) : []
return (
<div ref={ref} className="relative w-full max-w-[480px]">
<div ref={box} className="relative w-full max-w-[480px]">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="text"
value={query}
onChange={(e) => {
setQuery(e.target.value)
setIsOpen(true)
setOpen(true)
}}
onFocus={() => setIsOpen(true)}
placeholder="Search collections, NFTs..."
className="pl-9 h-10 rounded-[10px] bg-card"
onFocus={() => setOpen(true)}
placeholder={`Search collections on ${chain.name}`}
className="h-10 rounded-[10px] bg-card pl-9"
/>
</div>
{isOpen && query.length >= 2 && nftResults.length > 0 && (
<div className="absolute top-full left-0 right-0 mt-1 bg-card border border-border rounded-xl p-2 max-h-80 overflow-y-auto z-[200] shadow-xl shadow-black/40">
{nftResults.map((item) => (
{open && results.length > 0 && (
<div className="absolute left-0 right-0 top-full z-[200] mt-1 max-h-80 overflow-y-auto rounded-xl border border-border bg-card p-2 shadow-xl">
{results.map((token) => (
<Link
key={item.address}
href={`/collection/${chainId}/${item.address}`}
key={token.address}
href={links.collection(chain, token.address)}
onClick={() => {
setIsOpen(false)
setOpen(false)
setQuery('')
}}
className="flex items-center gap-3 px-3 py-2 rounded-lg no-underline text-foreground transition-colors hover:bg-secondary"
className="flex items-center gap-3 rounded-lg px-3 py-2 text-foreground no-underline transition-colors hover:bg-secondary"
>
<div
className="w-9 h-9 rounded-lg shrink-0 flex items-center justify-center text-sm font-bold text-muted-foreground/50 bg-cover bg-center"
style={
item.icon_url
? { backgroundImage: `url(${item.icon_url})` }
: { background: 'linear-gradient(135deg, hsl(var(--secondary)), hsl(var(--muted)))' }
}
>
{!item.icon_url && (item.symbol?.charAt(0) ?? '?')}
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg bg-secondary text-sm font-bold text-muted-foreground">
{token.symbol?.charAt(0) ?? '?'}
</div>
<div>
<div className="text-sm font-medium">{item.name}</div>
<div className="text-[11px] text-muted-foreground">{item.type}</div>
<div className="text-sm font-medium">{token.name ?? token.address}</div>
<div className="text-[11px] text-muted-foreground">{token.type}</div>
</div>
</Link>
))}
+6
View File
@@ -0,0 +1,6 @@
import type { ReactNode } from 'react'
/** Where the figures on a screen came from. Every screen that reads says so. */
export function Source({ children }: { children: ReactNode }) {
return <p className="mb-6 text-[13px] text-muted-foreground">{children}</p>
}
+21
View File
@@ -0,0 +1,21 @@
import { Card } from '@/components/ui/card'
// One tile, one measurement. `value` is null when the read did not answer, and
// the tile says so rather than printing a dash, a zero or an empty box — the
// three ways a missing number gets mistaken for a real one.
export function Stat({ label, value, note }: { label: string; value: string | null; note?: string }) {
return (
<Card className="px-5 py-4">
<div className="mb-1 text-[11px] uppercase tracking-widest text-muted-foreground">
{label}
</div>
{value === null ? (
<div className="text-sm text-muted-foreground">unavailable</div>
) : (
<div className="font-mono text-xl font-bold">{value}</div>
)}
{note ? <div className="mt-1 text-[11px] text-muted-foreground">{note}</div> : null}
</Card>
)
}
-125
View File
@@ -1,125 +0,0 @@
'use client'
import { useState } from 'react'
import { ChevronUp, ChevronDown } from '@luxfi/ui/icons'
import { cn } from '@/lib/utils'
import type { NFTTrait } from '@/lib/explorer'
interface TraitGroup {
trait_type: string
values: { value: string; count: number }[]
}
interface TraitFilterProps {
instances: Array<{ metadata?: { attributes?: NFTTrait[] } | null }>
selectedTraits: Record<string, Set<string>>
onTraitToggle: (traitType: string, value: string) => void
onClear: () => void
}
export function TraitFilter({ instances, selectedTraits, onTraitToggle, onClear }: TraitFilterProps) {
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set())
// Aggregate trait groups
const traitGroups: TraitGroup[] = []
const groupMap = new Map<string, Map<string, number>>()
for (const inst of instances) {
const attrs = inst.metadata?.attributes
if (!attrs) continue
for (const attr of attrs) {
if (!attr.trait_type) continue
let valueMap = groupMap.get(attr.trait_type)
if (!valueMap) {
valueMap = new Map()
groupMap.set(attr.trait_type, valueMap)
}
const val = String(attr.value)
valueMap.set(val, (valueMap.get(val) ?? 0) + 1)
}
}
for (const [trait_type, valueMap] of groupMap) {
const values = Array.from(valueMap.entries())
.map(([value, count]) => ({ value, count }))
.sort((a, b) => b.count - a.count)
traitGroups.push({ trait_type, values })
}
traitGroups.sort((a, b) => a.trait_type.localeCompare(b.trait_type))
const hasSelected = Object.values(selectedTraits).some((s) => s.size > 0)
if (traitGroups.length === 0) return null
return (
<div className="bg-card rounded-xl border border-border p-4 w-60 shrink-0">
<div className="flex justify-between items-center mb-3">
<div className="text-sm font-semibold">Traits</div>
{hasSelected && (
<button
onClick={onClear}
className="bg-transparent border-none text-primary text-xs cursor-pointer hover:underline"
>
Clear all
</button>
)}
</div>
{traitGroups.map((group) => {
const isExpanded = expandedGroups.has(group.trait_type)
const selected = selectedTraits[group.trait_type]
const activeCount = selected?.size ?? 0
return (
<div key={group.trait_type} className="mb-1">
<button
onClick={() => {
const next = new Set(expandedGroups)
if (isExpanded) next.delete(group.trait_type)
else next.add(group.trait_type)
setExpandedGroups(next)
}}
className="w-full flex justify-between items-center py-2 bg-transparent border-none text-foreground cursor-pointer text-[13px] font-medium"
>
<span>
{group.trait_type}
{activeCount > 0 && (
<span className="ml-1.5 text-primary text-[11px]">({activeCount})</span>
)}
</span>
{isExpanded ? (
<ChevronUp className="h-3 w-3 text-muted-foreground" />
) : (
<ChevronDown className="h-3 w-3 text-muted-foreground" />
)}
</button>
{isExpanded && (
<div className="pl-1 pb-2 flex flex-col gap-0.5">
{group.values.slice(0, 20).map((v) => {
const isActive = selected?.has(v.value) ?? false
return (
<button
key={v.value}
onClick={() => onTraitToggle(group.trait_type, v.value)}
className={cn(
'flex justify-between items-center px-2 py-1 rounded-md cursor-pointer text-xs text-left border transition-colors',
isActive
? 'bg-primary/10 border-primary/50 text-foreground'
: 'bg-transparent border-transparent text-muted-foreground hover:text-foreground hover:bg-secondary'
)}
>
<span className="overflow-hidden text-ellipsis whitespace-nowrap">
{v.value}
</span>
<span className="text-[11px] shrink-0 ml-2 font-mono">{v.count}</span>
</button>
)
})}
</div>
)}
</div>
)
})}
</div>
)
}
-121
View File
@@ -1,121 +0,0 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "@luxfi/ui/icons"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
-199
View File
@@ -1,199 +0,0 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "@luxfi/ui/icons"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}
+26
View File
@@ -0,0 +1,26 @@
'use client'
import { createContext, useContext } from 'react'
import type { Chain } from '@/lib/registry'
// Which chain a screen reads is one piece of state for the whole surface, and
// it is a whole registry record rather than an id: a screen needs the slug to
// reach the indexer, the id to reach a contract, the coin to label a figure and
// the explorer to link out. Looking each of those up separately is how a
// second, disagreeing chain list gets born.
//
// The registry is read, so the state has three shapes and not one. A screen
// that treats "not yet" as "none" prints an empty list where it should print a
// sentence, which is the whole failure this app is being rebuilt away from.
export type ChainState =
| { status: 'reading' }
| { status: 'failed'; reason: string }
| { status: 'ready'; chain: Chain; chains: Chain[]; select: (slug: string) => void }
export const ChainContext = createContext<ChainState | null>(null)
export function useChain(): ChainState {
const state = useContext(ChainContext)
if (!state) throw new Error('useChain outside the chain provider')
return state
}
+74
View File
@@ -0,0 +1,74 @@
'use client'
// React-query wrappers over the read modules. Nothing here fetches; each one
// names a cache key and delegates, so there is one place that knows how to ask
// the indexer and one place that knows how long an answer stays fresh.
import { useQuery } from '@tanstack/react-query'
import * as amm from '@/lib/amm'
import * as explorer from '@/lib/explorer'
import { transferIds } from '@/lib/logs'
import type { Chain } from '@/lib/registry'
export const useCollections = (chain: Chain, search?: string) =>
useQuery({
queryKey: ['collections', chain.slug, search ?? ''],
queryFn: () => explorer.collections(chain, search),
})
export const useCollection = (chain: Chain, address: string) =>
useQuery({
queryKey: ['collection', chain.slug, address],
queryFn: () => explorer.collection(chain, address),
enabled: Boolean(address),
})
export const useCounters = (chain: Chain, address: string) =>
useQuery({
queryKey: ['counters', chain.slug, address],
queryFn: () => explorer.counters(chain, address),
enabled: Boolean(address),
})
export const useHolders = (chain: Chain, address: string) =>
useQuery({
queryKey: ['holders', chain.slug, address],
queryFn: () => explorer.holders(chain, address),
enabled: Boolean(address),
})
export const useTransfers = (chain: Chain, address: string) =>
useQuery({
queryKey: ['transfers', chain.slug, address],
queryFn: () => explorer.transfers(chain, address),
enabled: Boolean(address),
})
export const useActivity = (chain: Chain) =>
useQuery({ queryKey: ['activity', chain.slug], queryFn: () => explorer.activity(chain) })
export const useHoldings = (chain: Chain, address?: string) =>
useQuery({
queryKey: ['holdings', chain.slug, address ?? ''],
queryFn: () => explorer.holdings(chain, address!),
enabled: Boolean(address),
})
export const useStats = (chain: Chain) =>
useQuery({ queryKey: ['stats', chain.slug], queryFn: () => explorer.stats(chain) })
/**
* Token ids for a set of transfer rows, read from the chain's own Transfer
* logs. Depends on the rows, so it runs after them and never blocks the list.
*/
export const useTransferIds = (chain: Chain, rows: explorer.Transfer[] | undefined) =>
useQuery({
queryKey: ['ids', chain.slug, rows?.map((r) => `${r.block}:${r.logIndex}`).join(',') ?? ''],
queryFn: () => transferIds(chain, rows!),
enabled: Boolean(chain.rpc && rows?.length),
})
export const useFactory = (chain: Chain) =>
useQuery({ queryKey: ['factory', chain.slug], queryFn: () => amm.factory(chain) })
export const usePools = (chain: Chain) =>
useQuery({ queryKey: ['pools', chain.slug], queryFn: () => amm.pools(chain) })
-15
View File
@@ -1,15 +0,0 @@
import { createContext, useContext } from 'react'
export type ChainContextValue = {
chainId: number
setChainId: (id: number) => void
}
export const ChainContext = createContext<ChainContextValue>({
chainId: 96369,
setChainId: () => {},
})
export function useChainContext(): ChainContextValue {
return useContext(ChainContext)
}
-74
View File
@@ -1,74 +0,0 @@
'use client'
// React-query wrappers over the one data-access layer in @/lib/explorer.
import { useQuery } from '@tanstack/react-query'
import {
getAddressTokenInstances,
getCollections,
getNftTransfers,
getToken,
getTokenInstance,
getTokenInstances,
getTokenInstanceTransfers,
getTokenTransfers,
searchTokens,
} from '@/lib/explorer'
export const useCollections = (chainId: number, search?: string) =>
useQuery({
queryKey: ['collections', chainId, search ?? ''],
queryFn: () => getCollections(chainId, search),
})
export const useCollection = (chainId: number, address: string) =>
useQuery({
queryKey: ['collection', chainId, address],
queryFn: () => getToken(chainId, address),
enabled: Boolean(address),
})
export const useTokenInstances = (chainId: number, address: string) =>
useQuery({
queryKey: ['instances', chainId, address],
queryFn: () => getTokenInstances(chainId, address),
enabled: Boolean(address),
})
export const useTokenInstance = (chainId: number, address: string, id: string) =>
useQuery({
queryKey: ['instance', chainId, address, id],
queryFn: () => getTokenInstance(chainId, address, id),
enabled: Boolean(address && id),
})
export const useTokenInstanceTransfers = (chainId: number, address: string, id: string) =>
useQuery({
queryKey: ['instance-transfers', chainId, address, id],
queryFn: () => getTokenInstanceTransfers(chainId, address, id),
enabled: Boolean(address && id),
})
export const useCollectionTransfers = (chainId: number, address: string) =>
useQuery({
queryKey: ['collection-transfers', chainId, address],
queryFn: () => getTokenTransfers(chainId, address),
enabled: Boolean(address),
})
/** Chain-wide NFT activity — used by /activity when no collection is selected. */
export const useNftActivity = (chainId: number) =>
useQuery({ queryKey: ['nft-activity', chainId], queryFn: () => getNftTransfers(chainId) })
export const usePortfolioNFTs = (chainId: number, address?: string) =>
useQuery({
queryKey: ['portfolio', chainId, address ?? ''],
queryFn: () => getAddressTokenInstances(chainId, address!),
enabled: Boolean(address),
})
export const useSearchCollections = (chainId: number, query: string) =>
useQuery({
queryKey: ['search', chainId, query],
queryFn: () => searchTokens(chainId, query),
enabled: query.trim().length > 0,
})
+95
View File
@@ -0,0 +1,95 @@
// The AMM subgraph. Every chain the registry serves declares one; only the two
// with a factory deployed have anything in it.
//
// This is the deepest data on the surface by a wide margin — pools carry both
// tokens with symbol and decimals, the fee tier, locked value, traded volume
// and a transaction count, and the factory carries the totals directly, so the
// headline numbers are read rather than summed over a partial page.
import { GRAPH, type Chain } from '@/lib/registry'
export type Factory = {
pools: number
locked: string
volume: string
transactions: number
}
export type Pool = {
address: string
/** Hundredths of a basis point, as Uniswap writes it: 3000 = 0.30%. */
fee: number
locked: string
volume: string
transactions: number
tokens: [Side, Side]
}
export type Side = { address: string; symbol: string; name: string }
async function query<T>(chain: Chain, subgraph: string, text: string): Promise<T> {
const res = await fetch(`${GRAPH}/${chain.slug}/${subgraph}/graphql`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json' },
body: JSON.stringify({ query: text }),
})
if (!res.ok) throw new Error(`${chain.slug}/${subgraph} → ${res.status}`)
const body = (await res.json()) as { data?: T; errors?: { message: string }[] }
if (body.errors?.length) throw new Error(body.errors[0].message)
if (!body.data) throw new Error(`${chain.slug}/${subgraph} returned no data`)
return body.data
}
type RawSide = { id: string; symbol: string; name: string }
const side = (t: RawSide): Side => ({ address: t.id, symbol: t.symbol, name: t.name })
/** Chain-wide totals, or null where no factory is deployed. */
export async function factory(chain: Chain): Promise<Factory | null> {
const data = await query<{
factories: { poolCount: number; totalValueLockedUSD: string; totalVolumeUSD: string; txCount: number }[]
}>(chain, 'amm', '{ factories { poolCount totalValueLockedUSD totalVolumeUSD txCount } }')
const f = data.factories[0]
if (!f) return null
return {
pools: f.poolCount,
locked: f.totalValueLockedUSD,
volume: f.totalVolumeUSD,
transactions: f.txCount,
}
}
export async function pools(chain: Chain): Promise<Pool[]> {
const data = await query<{
pools: {
id: string
feeTier: number
totalValueLockedUSD: string
volumeUSD: string
txCount: number
token0: RawSide
token1: RawSide
}[]
}>(
chain,
'amm',
`{ pools(first: 1000, orderBy: totalValueLockedUSD, orderDirection: desc) {
id feeTier totalValueLockedUSD volumeUSD txCount
token0 { id symbol name } token1 { id symbol name }
} }`,
)
return data.pools.map((p) => ({
address: p.id,
fee: p.feeTier,
locked: p.totalValueLockedUSD,
volume: p.volumeUSD,
transactions: p.txCount,
tokens: [side(p.token0), side(p.token1)],
}))
}
/** 3000 → "0.30%". */
export const feeLabel = (fee: number) => `${(fee / 10_000).toFixed(2)}%`
/** The subgraph reports value as a decimal string of dollars. */
export const usd = (value: string) =>
`$${Number(value).toLocaleString(undefined, { maximumFractionDigits: 0 })}`
-89
View File
@@ -1,89 +0,0 @@
import type { Chain } from 'viem'
// Public RPC form is `/v1/bc/<alias>/rpc` on each network's api host.
// Every URL below except SPC was verified live against eth_chainId.
export const luxMainnet: Chain = {
id: 96369,
name: 'Lux',
nativeCurrency: { name: 'Lux', symbol: 'LUX', decimals: 18 },
rpcUrls: { default: { http: ['https://api.lux.network/v1/bc/C/rpc'] } },
blockExplorers: { default: { name: 'Explore', url: 'https://explore.lux.network' } },
}
export const zooMainnet: Chain = {
id: 200200,
name: 'Zoo',
nativeCurrency: { name: 'Zoo', symbol: 'ZOO', decimals: 18 },
rpcUrls: { default: { http: ['https://api.zoo.network/v1/bc/C/rpc'] } },
blockExplorers: { default: { name: 'Explore Zoo', url: 'https://explore.zoo.network' } },
}
export const hanzoMainnet: Chain = {
id: 36963,
name: 'Hanzo',
nativeCurrency: { name: 'Hanzo', symbol: 'HANZO', decimals: 18 },
rpcUrls: { default: { http: ['https://api.hanzo.network/v1/bc/C/rpc'] } },
blockExplorers: { default: { name: 'Explore Hanzo', url: 'https://explore.hanzo.network' } },
}
// SPC has no public RPC yet — the indexer reaches it in-cluster only.
export const spcMainnet: Chain = {
id: 36911,
name: 'SPC',
nativeCurrency: { name: 'SPC', symbol: 'SPC', decimals: 18 },
rpcUrls: { default: { http: ['https://api.lux.network/v1/bc/spc/rpc'] } },
blockExplorers: { default: { name: 'Explore SPC', url: 'https://explore-spc.lux.network' } },
}
export const parsMainnet: Chain = {
id: 494949,
name: 'Pars',
nativeCurrency: { name: 'Pars', symbol: 'PARS', decimals: 18 },
rpcUrls: { default: { http: ['https://api.pars.network/v1/bc/C/rpc'] } },
blockExplorers: { default: { name: 'Explore Pars', url: 'https://explore.pars.network' } },
}
export const supportedChains = [luxMainnet, zooMainnet, hanzoMainnet, spcMainnet, parsMainnet] as const
// One unified indexer serves every chain behind a per-chain slug:
// GET https://api-explore.lux.network/v1/indexer/<slug>/<resource>
// Slugs are the indexer's own, from GET /v1/explorer/admin/chains.
const INDEXER_ROOT = 'https://api-explore.lux.network/v1/indexer'
export const INDEXER_SLUG: Record<number, string> = {
[luxMainnet.id]: 'cchain',
[zooMainnet.id]: 'zoo',
[hanzoMainnet.id]: 'hanzo',
[spcMainnet.id]: 'spc',
[parsMainnet.id]: 'pars',
}
export const EXPLORER_API: Record<number, string> = Object.fromEntries(
Object.entries(INDEXER_SLUG).map(([id, slug]) => [Number(id), `${INDEXER_ROOT}/${slug}`]),
)
/** Human-facing explorer for a chain. Single source of truth is the chain object. */
export function explorerUrl(chainId: number): string {
const chain = supportedChains.find((c) => c.id === chainId)
return chain?.blockExplorers?.default.url ?? luxMainnet.blockExplorers!.default.url
}
/**
* A chain is a NAME and a SYMBOL. It used to carry a `color` too — five hexes
* lifted wholesale from GitHub's dark-syntax palette (#7ee787 entity-green,
* #d2a8ff entity, #ffa657 variable — a literal tan, #ff7b72 keyword) plus a
* cyan #55ccff for Lux itself, painted as inline `style={{ color }}` on a
* surface whose every other pixel is achromatic.
*
* Deleted rather than re-tokenised: the field existed to make five chains
* distinguishable, and the chain NAME already does that. Monochrome carries
* state by weight and label, never by hue — and an inline hex is unreachable
* by white-label-by-hostname no matter what value it holds.
*/
export const CHAIN_INFO: Record<number, { name: string; symbol: string }> = {
[luxMainnet.id]: { name: 'Lux', symbol: 'LUX' },
[zooMainnet.id]: { name: 'Zoo', symbol: 'ZOO' },
[hanzoMainnet.id]: { name: 'Hanzo', symbol: 'HANZO' },
[spcMainnet.id]: { name: 'SPC', symbol: 'SPC' },
[parsMainnet.id]: { name: 'Pars', symbol: 'PARS' },
}
+85 -81
View File
@@ -1,107 +1,111 @@
// Marketplace wiring. ABIs mirror the real source in
// ~/work/lux/standard/contracts/nft/Market.sol (IMarket) — do not invent entries.
// Contracts this app reads. Every address and every function below answers on
// chain today; nothing here describes a deployment that does not exist.
//
// What used to be here and is gone: a marketplace address map, the IMarket ABI
// for list/buy/makeOffer, and an LSSVM pair ABI. Market.sol lives in
// ~/work/lux/standard/contracts/nft/Market.sol and is deployed on no Lux chain
// — eth_getCode returns 0x at every address the old deployments file recorded,
// and the current generated Addresses.sol dropped them because its generator
// verifies code before emitting a constant. A listing form against a contract
// that cannot settle is a button that lies, so there is no listing form and
// there is no ABI for one. Both come back the day an address does.
/**
* Per-chain marketplace addresses.
* Lux Genesis, the only collectible NFT contract on any Lux chain.
*
* EMPTY ON PURPOSE: `Market.sol` is not deployed on any Lux chain today. The
* address recorded for lux-mainnet in
* standard/contracts/deployments/addresses.json (markets/Markets
* 0xefba7dbf4b9e2855b84f410f61b15975629cdb38) returns `0x` from eth_getCode on
* 96369 — it was wiped by the 2026-07-10 fresh genesis and never redeployed.
* ListingForm / OfferForm read this map and render nothing while it is empty,
* which is the correct behaviour: no listing can be signed against a contract
* that does not exist. Add an entry only after verifying code at the address.
* Measured on 96369 at https://api.lux.network/v1/bc/C/rpc: 21121 bytes of
* code, name() "Lux Genesis", symbol() "GENESIS", totalSupply() 3,
* supportsInterface(0x80ac58cd) and (0x2a55205a) both true.
*
* This replaces 0x004287c4..76c6, which the app probed for a year and which
* has no code at all. The indexer's own search finds the live one:
* /v1/indexer/cchain/search?q=genesis.
*/
export const CONTRACTS: Record<number, { market?: `0x${string}`; router?: `0x${string}` }> = {}
/**
* Wrapped native token per chain. `Market.makeOffer` rejects address(0)
* (`if (paymentToken == address(0)) revert InvalidPrice()` — Market.sol:418),
* so offers must be denominated in an LRC20. Only entries verified on-chain
* belong here: WLUX below returns 1730 bytes of code / symbol "WLUX" /
* decimals 18 on 96369.
*/
export const WRAPPED_NATIVE: Record<number, `0x${string}`> = {
96369: '0x4888E4a2Ee0F03051c72D2BD3ACf755eD3498B3E',
export const GENESIS: Record<number, `0x${string}`> = {
96369: '0x9e04fc57c20b2ee45627c4aa280eb471f2ca6ea5',
}
/**
* Genesis NFT collection per chain — standard/contracts/nft/GenesisNFTs.sol.
*
* Unlike CONTRACTS above, an address here does not enable a transaction; it
* only tells /genesis where to probe. The probe is the point: this address is
* where the indexer recorded 118 mints on 2026-07-09 (block 1085879), but it
* has NO CODE at head. Measured against https://api.lux.network/v1/bc/C/rpc at
* block 1098191, with WLUX 0x4888e4a2.. as the positive control:
*
* 0x004287c4..76c6 eth_getCode -> "0x" totalSupply() -> "0x" (revert)
* 0x4888e4a2..8b3e eth_getCode -> 3462 totalSupply() -> 159126795.518e18
*
* So /genesis reads totalMinted()/totalLuxLocked() live and reports the read as
* unavailable when it reverts, instead of printing the tier table that used to
* be hard-coded into the page.
* GenesisNFTs.sol state, both public and both answering: totalMinted() → 3,
* totalLuxLocked() → 3e27 wei, which is 3,000,000,000 LUX bonded across the
* three tokens.
*/
export const GENESIS_NFT: Record<number, `0x${string}`> = {
96369: '0x004287c47efc912fec391979154454a8017a76c6',
}
/** GenesisNFTs.sol reads: totalMinted():829, totalLuxLocked():214 (public state). */
export const GENESIS_ABI = [
{ type: 'function', name: 'totalMinted', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'totalLuxLocked', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'owner', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
] as const
export const ERC721_ABI = [
{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'ownerOf', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },
{ type: 'function', name: 'tokenURI', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'string' }] },
{ type: 'function', name: 'name', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
{ type: 'function', name: 'symbol', stateMutability: 'view', inputs: [], outputs: [{ type: 'string' }] },
{ type: 'function', name: 'totalSupply', stateMutability: 'view', inputs: [], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'getApproved', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },
{ type: 'function', name: 'approve', stateMutability: 'nonpayable', inputs: [{ name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] },
{ type: 'function', name: 'setApprovalForAll', stateMutability: 'nonpayable', inputs: [{ name: 'operator', type: 'address' }, { name: 'approved', type: 'bool' }], outputs: [] },
{ type: 'function', name: 'transferFrom', stateMutability: 'nonpayable', inputs: [{ name: 'from', type: 'address' }, { name: 'to', type: 'address' }, { name: 'tokenId', type: 'uint256' }], outputs: [] },
{ type: 'function', name: 'ownerOf', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'address' }] },
{ type: 'function', name: 'tokenURI', stateMutability: 'view', inputs: [{ name: 'tokenId', type: 'uint256' }], outputs: [{ type: 'string' }] },
] as const
export const ERC1155_ABI = [
{ type: 'function', name: 'balanceOf', stateMutability: 'view', inputs: [{ name: 'owner', type: 'address' }, { name: 'id', type: 'uint256' }], outputs: [{ type: 'uint256' }] },
{ type: 'function', name: 'uri', stateMutability: 'view', inputs: [{ name: 'id', type: 'uint256' }], outputs: [{ type: 'string' }] },
/** ERC-165. `supportsInterface` is how a collection declares what it is. */
export const ERC165_ABI = [
{ type: 'function', name: 'supportsInterface', stateMutability: 'view', inputs: [{ name: 'id', type: 'bytes4' }], outputs: [{ type: 'bool' }] },
] as const
/** IMarket — standard/contracts/nft/Market.sol:37-48. */
export const MARKET_ABI = [
/**
* ERC-2981 quotes a royalty for a sale price rather than publishing a rate, so
* the rate is read by asking for one whole token: royaltyInfo(id, 1e18)
* returns the receiver and the share. Lux Genesis answers 2.5e16 — 2.5% — to
* the collection owner.
*/
export const ERC2981_ABI = [
{
type: 'function',
name: 'list',
stateMutability: 'nonpayable',
inputs: [
{ name: 'nftContract', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'paymentToken', type: 'address' },
{ name: 'price', type: 'uint256' },
{ name: 'duration', type: 'uint256' },
],
outputs: [{ type: 'bytes32' }],
name: 'royaltyInfo',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }, { name: 'salePrice', type: 'uint256' }],
outputs: [{ type: 'address' }, { type: 'uint256' }],
},
{ type: 'function', name: 'cancelListing', stateMutability: 'nonpayable', inputs: [{ name: 'listingId', type: 'bytes32' }], outputs: [] },
{ type: 'function', name: 'buy', stateMutability: 'payable', inputs: [{ name: 'listingId', type: 'bytes32' }], outputs: [] },
{
type: 'function',
name: 'makeOffer',
stateMutability: 'nonpayable',
inputs: [
{ name: 'nftContract', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'paymentToken', type: 'address' },
{ name: 'amount', type: 'uint256' },
{ name: 'duration', type: 'uint256' },
],
outputs: [{ type: 'bytes32' }],
},
{ type: 'function', name: 'cancelOffer', stateMutability: 'nonpayable', inputs: [{ name: 'offerId', type: 'bytes32' }], outputs: [] },
{ type: 'function', name: 'acceptOffer', stateMutability: 'nonpayable', inputs: [{ name: 'offerId', type: 'bytes32' }], outputs: [] },
] as const
export const LSSVM_PAIR_ABI = [] as const
export const INTERFACE = {
erc721: '0x80ac58cd',
erc1155: '0xd9b67a26',
royalty: '0x2a55205a',
} as const
/** The unit royaltyInfo is quoted against, so the answer reads as a rate. */
export const ONE = 10n ** 18n
/**
* GenesisNFTs.sol keeps a record per token — `mapping(uint256 => TokenMeta)
* public tokenMeta` — and its generated getter answers on chain today. It is
* the only per-item metadata that exists anywhere on Lux: the indexer holds
* none, and the collection's tokenURI is one malformed string shared by all
* three tokens. Every token has a name in it: #0 "Genesis Validator #51",
* #1 "Genesis Validator #100", #2 "The Terminator".
*
* `market` is the marketplace the collection would route a sale through. It
* reads the zero address, which is the contract itself saying no marketplace is
* wired to it.
*/
export const GENESIS_TOKEN_ABI = [
{
type: 'function',
name: 'tokenMeta',
stateMutability: 'view',
inputs: [{ name: 'tokenId', type: 'uint256' }],
outputs: [
{ name: 'nftType', type: 'uint8' },
{ name: 'tier', type: 'uint8' },
{ name: 'name', type: 'string' },
{ name: 'originTokenId', type: 'uint256' },
{ name: 'luxLocked', type: 'uint256' },
{ name: 'timestamp', type: 'uint256' },
{ name: 'reserved', type: 'bool' },
],
},
{ type: 'function', name: 'market', stateMutability: 'view', inputs: [], outputs: [{ type: 'address' }] },
{ type: 'function', name: 'salesOpen', stateMutability: 'view', inputs: [], outputs: [{ type: 'bool' }] },
] as const
/** GenesisNFTs.sol:129 and :134, in declaration order. */
export const NFT_TYPE = ['Validator', 'Card', 'Coin'] as const
export const TIER = ['Genesis', 'Validator', 'Mini', 'Nano'] as const
+161 -119
View File
@@ -1,151 +1,193 @@
// Read path for NFT data. The unified Lux indexer serves a Blockscout-shaped
// REST API per chain; EXPLORER_API maps chainId -> its base URL.
import { EXPLORER_API } from '@/lib/chains'
// The read path. One unified indexer serves every chain behind its slug, in a
// Blockscout-shaped REST API.
//
// A failed read THROWS here rather than resolving to an empty page. The two
// states look identical once they reach a list — and they mean opposite things,
// so the screens above have to be able to tell "there are none" from "we could
// not ask". Every caller runs through react-query, which carries the error.
import { INDEXER, type Chain } from '@/lib/registry'
export type NFTTrait = { trait_type: string; value: string | number }
const NFT = new Set(['ERC-721', 'ERC-1155'])
export type NFTMetadata = {
name?: string
description?: string
image?: string
image_url?: string
animation_url?: string
attributes?: NFTTrait[]
}
export const isNft = (type: string | undefined) => NFT.has(type ?? '')
export type ExplorerToken = {
export type Token = {
address: string
name: string | null
symbol: string | null
type: string
decimals: string | null
/** total_supply — for a collection, the number of items minted. */
supply: string | null
holders: string | null
total_supply: string | null
icon_url: string | null
decimals: string | null
icon: string | null
}
export type ExplorerTokenInstance = {
id: string
token: ExplorerToken
owner: { hash: string } | null
image_url: string | null
animation_url: string | null
metadata: NFTMetadata | null
export type Transfer = {
block: number
/** EVM log index. With `block` it identifies the Transfer log exactly. */
logIndex: number
time: string | null
tx: string
from: string
to: string
token: { address: string; type: string }
}
export type ExplorerTransfer = {
block_number: number
timestamp: string | null
transaction_hash: string
method?: string
from: { hash: string }
to: { hash: string }
token: ExplorerToken
total: { value: string | null; token_id: string | null; decimals: string | null } | null
}
export type ExplorerAddressToken = {
token: ExplorerToken
token_id: string | null
token_instance?: ExplorerTokenInstance | null
export type Holding = {
token: Token
/** Decimal token id, or null on a fungible row. */
id: string | null
/** Raw balance: 1 for an NFT, an undivided amount for a fungible token. */
value: string
}
export type PaginatedResponse<T> = { items: T[]; next_page_params: null }
export type Holder = { address: string; count: string }
const NFT_TYPES = new Set(['ERC-721', 'ERC-1155'])
export type Counters = { holders: number; transfers: number }
const empty = <T,>(): PaginatedResponse<T> => ({ items: [], next_page_params: null })
export type Stats = {
blocks: string | null
transactions: string | null
addresses: string | null
}
async function get<T>(chainId: number, path: string, fallback: T): Promise<T> {
const base = EXPLORER_API[chainId]
if (!base) return fallback
try {
const res = await fetch(`${base}${path}`, { headers: { accept: 'application/json' } })
if (!res.ok) return fallback
return (await res.json()) as T
} catch {
return fallback
}
type Page<T> = { items: T[] }
async function read<T>(chain: Chain, path: string): Promise<T> {
const res = await fetch(`${INDEXER}/${chain.slug}${path}`, {
headers: { accept: 'application/json' },
})
if (!res.ok) throw new Error(`${chain.slug}${path} → ${res.status}`)
return (await res.json()) as T
}
/**
* NFT collections on a chain. The indexer ignores `?type=`, so the ERC-721 /
* ERC-1155 filter is applied here.
* The indexer names a token's address `address_hash` under /tokens and
* `address` under /addresses/{hash}/tokens. Both shapes are normalised here,
* once, so nothing above this file has to know which endpoint it came from.
*/
export async function getCollections(
chainId: number,
search?: string,
): Promise<PaginatedResponse<ExplorerToken>> {
const page = await get(chainId, '/tokens', empty<ExplorerToken>())
type RawToken = {
address_hash?: string
address?: string
name: string | null
symbol: string | null
type: string
total_supply: string | null
holders_count?: string | null
decimals: string | null
icon_url: string | null
}
const token = (t: RawToken): Token => ({
address: (t.address_hash ?? t.address ?? '').toLowerCase(),
name: t.name,
symbol: t.symbol,
type: t.type,
supply: t.total_supply,
holders: t.holders_count ?? null,
decimals: t.decimals,
icon: t.icon_url,
})
type RawTransfer = {
block_number: number
log_index: number
timestamp: string | null
transaction_hash: string
from: { hash: string }
to: { hash: string }
token: { address_hash: string; type: string }
}
const transfer = (t: RawTransfer): Transfer => ({
block: t.block_number,
logIndex: t.log_index,
time: t.timestamp,
tx: t.transaction_hash,
from: t.from.hash,
to: t.to.hash,
token: { address: t.token.address_hash.toLowerCase(), type: t.token.type },
})
/** A 32-byte hex token id as the decimal a person reads. */
const id = (raw: string | null): string | null => {
if (raw === null) return null
try {
return BigInt(raw).toString()
} catch {
return null
}
}
/** Every token the chain indexes, fungible and not. */
export async function tokens(chain: Chain): Promise<Token[]> {
const page = await read<Page<RawToken>>(chain, '/tokens')
return page.items.map(token)
}
/** The NFT collections among them. The indexer ignores `?type=`. */
export async function collections(chain: Chain, search?: string): Promise<Token[]> {
const q = search?.trim().toLowerCase()
const items = page.items.filter(
return (await tokens(chain)).filter(
(t) =>
NFT_TYPES.has(t.type) &&
isNft(t.type) &&
(!q || `${t.name ?? ''} ${t.symbol ?? ''} ${t.address}`.toLowerCase().includes(q)),
)
return { items, next_page_params: null }
}
export const getToken = (chainId: number, address: string) =>
get<ExplorerToken | null>(chainId, `/tokens/${address}`, null)
export const getTokenInstances = (chainId: number, address: string) =>
get(chainId, `/tokens/${address}/instances`, empty<ExplorerTokenInstance>())
export const getTokenInstance = (chainId: number, address: string, id: string) =>
get<ExplorerTokenInstance | null>(chainId, `/tokens/${address}/instances/${id}`, null)
export const getTokenTransfers = (chainId: number, address: string) =>
get(chainId, `/tokens/${address}/transfers`, empty<ExplorerTransfer>())
/** Chain-wide NFT activity feed. The indexer has no type filter, so filter here. */
export async function getNftTransfers(
chainId: number,
): Promise<PaginatedResponse<ExplorerTransfer>> {
const page = await get(chainId, '/token-transfers', empty<ExplorerTransfer>())
return { items: page.items.filter((t) => NFT_TYPES.has(t.token?.type)), next_page_params: null }
export async function collection(chain: Chain, address: string): Promise<Token> {
return token(await read<RawToken>(chain, `/tokens/${address}`))
}
export const getTokenInstanceTransfers = (chainId: number, address: string, id: string) =>
get(chainId, `/tokens/${address}/instances/${id}/transfers`, empty<ExplorerTransfer>())
export const getAddressTokens = (chainId: number, address: string) =>
get(chainId, `/addresses/${address}/tokens`, empty<ExplorerAddressToken>())
/** NFTs held by an address — the same holdings feed, minus fungible rows. */
export async function getAddressTokenInstances(
chainId: number,
address: string,
): Promise<PaginatedResponse<ExplorerAddressToken>> {
const page = await getAddressTokens(chainId, address)
return { items: page.items.filter((r) => NFT_TYPES.has(r.token?.type)), next_page_params: null }
}
export async function searchTokens(
chainId: number,
query: string,
): Promise<PaginatedResponse<ExplorerToken>> {
if (!query.trim()) return empty<ExplorerToken>()
return getCollections(chainId, query)
}
export function resolveMediaUrl(url: string | null | undefined): string | null {
if (!url) return null
if (url.startsWith('ipfs://')) return `https://ipfs.io/ipfs/${url.slice(7)}`
if (url.startsWith('ar://')) return `https://arweave.net/${url.slice(5)}`
return url
}
export function getNftImageUrl(instance: ExplorerTokenInstance | null | undefined): string | null {
if (!instance) return null
const m = instance.metadata
return resolveMediaUrl(
instance.image_url ??
m?.image_url ??
m?.image ??
instance.animation_url ??
m?.animation_url ??
null,
export async function counters(chain: Chain, address: string): Promise<Counters> {
const c = await read<{ token_holders_count: string; transfers_count: string }>(
chain,
`/tokens/${address}/counters`,
)
return { holders: Number(c.token_holders_count), transfers: Number(c.transfers_count) }
}
export async function holders(chain: Chain, address: string): Promise<Holder[]> {
const page = await read<Page<{ address: { hash: string }; value: string }>>(
chain,
`/tokens/${address}/holders`,
)
return page.items.map((h) => ({ address: h.address.hash.toLowerCase(), count: h.value }))
}
export async function transfers(chain: Chain, address: string): Promise<Transfer[]> {
const page = await read<Page<RawTransfer>>(chain, `/tokens/${address}/transfers`)
return page.items.map(transfer)
}
/** Chain-wide NFT movement. The feed has no type filter, so it is applied here. */
export async function activity(chain: Chain): Promise<Transfer[]> {
const page = await read<Page<RawTransfer>>(chain, '/token-transfers')
return page.items.filter((t) => isNft(t.token?.type)).map(transfer)
}
/**
* What an address holds. This is the ONE indexer route that carries an item's
* identity: every row names its token id.
*/
export async function holdings(chain: Chain, address: string): Promise<Holding[]> {
const page = await read<Page<{ token: RawToken; token_id: string | null; value: string }>>(
chain,
`/addresses/${address}/tokens`,
)
return page.items.map((r) => ({ token: token(r.token), id: id(r.token_id), value: r.value }))
}
export async function stats(chain: Chain): Promise<Stats> {
const s = await read<{
total_blocks: string | null
total_transactions: string | null
total_addresses: string | null
}>(chain, '/stats')
return {
blocks: s.total_blocks,
transactions: s.total_transactions,
addresses: s.total_addresses,
}
}
+14
View File
@@ -0,0 +1,14 @@
// Routes that carry a chain and an address.
//
// `output: 'export'` renders every route ahead of time, so a path segment can
// only exist for a value known at build. Collections and items are neither —
// a contract deployed after the build has to be reachable, and a dynamic
// segment would 404 on it. The chain and the address ride in the query instead:
// one page each, no build-time list of what may be looked at.
import type { Chain } from '@/lib/registry'
export const collection = (chain: Chain, address: string) =>
`/collection?chain=${chain.slug}&address=${address}`
export const item = (chain: Chain, address: string, id: string) =>
`/item?chain=${chain.slug}&address=${address}&id=${id}`
+113
View File
@@ -0,0 +1,113 @@
// THE INDEXER DROPS THE ITEM ID, AND IT IS ONE FIELD.
//
// Every ERC-721 transfer row arrives with the ERC-20 shape — total.value "1",
// no token id — because the indexer writes topics[3] nowhere. So a feed built
// on it can name the collection and the two addresses and nothing else, which
// is how a page ends up printing a column of anonymous rows.
//
// The chain still has it. eth_getLogs on the Transfer topic returns the id in
// topics[3], and the indexer's own `log_index` is the EVM log index, so
// (block, logIndex) joins a row to its log exactly. Verified against the
// Uniswap position manager, whose logs land at indices 1, 3, 5, 7 … in one
// block and match row for row.
//
// This is a read, not a workaround for a missing system: fixing the indexer to
// keep topics[3] would make it unnecessary, and until then the browser can ask
// the chain directly.
import type { Chain } from '@/lib/registry'
const TRANSFER = '0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef'
/**
* Blocks per request, and requests per lookup. Wide ranges make the node time
* out — a 500k-block window failed where both narrower and wider ones answered
* — so the window is small enough to be reliable and the count is bounded.
* Rows beyond the budget simply have no id, which the screens report as such.
*/
const WINDOW = 1024
const REQUESTS = 8
export type Row = { block: number; logIndex: number; token: { address: string; type: string } }
const key = (block: number, logIndex: number) => `${block}:${logIndex}`
type Log = { blockNumber: string; logIndex: string; topics: string[] }
async function logs(rpc: string, address: string, from: number, to: number): Promise<Log[]> {
const res = await fetch(rpc, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'eth_getLogs',
params: [
{
address,
topics: [TRANSFER],
fromBlock: `0x${from.toString(16)}`,
toBlock: `0x${to.toString(16)}`,
},
],
}),
})
if (!res.ok) throw new Error(`eth_getLogs ${res.status}`)
const body = (await res.json()) as { result?: Log[]; error?: { message: string } }
if (body.error) throw new Error(body.error.message)
return body.result ?? []
}
/** Contiguous windows covering `blocks`, each at most WINDOW wide. */
function windows(blocks: number[]): [number, number][] {
const sorted = [...new Set(blocks)].sort((a, b) => a - b)
const out: [number, number][] = []
for (const b of sorted) {
const last = out[out.length - 1]
if (last && b - last[0] <= WINDOW) last[1] = b
else out.push([b, b])
}
return out
}
/**
* Token ids for indexer transfer rows, keyed `block:logIndex`.
*
* ERC-721 only. ERC-1155 announces itself with TransferSingle/TransferBatch and
* carries the id in the data rather than a topic; no ERC-1155 contract is
* indexed on any Lux chain, so reading one would be code for a case that does
* not exist.
*/
export async function transferIds(chain: Chain, rows: Row[]): Promise<Map<string, string>> {
const found = new Map<string, string>()
if (!chain.rpc) return found
const byContract = new Map<string, number[]>()
for (const r of rows) {
if (r.token.type !== 'ERC-721') continue
const blocks = byContract.get(r.token.address) ?? []
blocks.push(r.block)
byContract.set(r.token.address, blocks)
}
const asks: { address: string; from: number; to: number }[] = []
for (const [address, blocks] of byContract)
for (const [from, to] of windows(blocks)) asks.push({ address, from, to })
const results = await Promise.all(
asks.slice(0, REQUESTS).map((a) =>
logs(chain.rpc!, a.address, a.from, a.to).catch(() => [] as Log[]),
),
)
for (const batch of results)
for (const log of batch) {
if (log.topics.length < 4) continue
found.set(
key(Number(BigInt(log.blockNumber)), Number(BigInt(log.logIndex))),
BigInt(log.topics[3]).toString(),
)
}
return found
}
export const idFor = (ids: Map<string, string>, row: Row) => ids.get(key(row.block, row.logIndex))
+129
View File
@@ -0,0 +1,129 @@
// WHICH CHAINS EXIST IS NOT A CONSTANT IN THIS APP.
//
// The indexer publishes its own registry and that is the authority. It carries
// the slug the indexer answers on, the chain id, the coin, the browser RPC and
// the AMM contracts, so these pages cannot drift from the indexer the way a
// hand-kept array does. The array this replaced had drifted twice: it missed
// Osage, and it offered SPC, whose RPC 404s and which the registry has never
// served.
const REGISTRY = 'https://api-explore.lux.network/v1/explorer/admin/chains'
/** One unified indexer, one slug per chain: `/v1/indexer/<slug>/<resource>`. */
export const INDEXER = 'https://api-explore.lux.network/v1/indexer'
/** One graph per chain and subgraph: `/v1/graph/<slug>/<name>/graphql`. */
export const GRAPH = 'https://api-explore.lux.network/v1/graph'
/** Uniswap-shaped contracts, present only where an AMM is deployed. */
export type Amm = {
factoryV2: string
factoryV3: string
router: string
quoter: string
/** Wrapped native token — the AMM quotes against it. */
native: string
}
export type Chain = {
slug: string
name: string
id: number
coin: string
/** Reachable from a browser, or null. See `browserRpc`. */
rpc: string | null
/** Human explorer, or null when no public host can be derived. */
explorer: string | null
/** Subgraph names this chain answers on, from the registry. */
graphs: string[]
amm: Amm | null
}
/**
* THE REGISTRY'S `rpc` IS THE INDEXER'S OWN ROUTE AND IS NOT ALWAYS A BROWSER'S.
*
* For Lux it reads http://luxd-headless.lux-mainnet.svc.cluster.local:9630/…,
* an in-cluster name over plain HTTP that no page can reach, and the public
* route arrives separately as `public_rpc`. The other four chains publish an
* https api.* host in `rpc` and leave `public_rpc` empty. So take the public
* route when there is one, take `rpc` when it is already public, and otherwise
* admit there is none rather than render a URL that cannot answer.
*/
function browserRpc(c: { rpc: string; public_rpc?: string }): string | null {
if (c.public_rpc) return c.public_rpc
return c.rpc.startsWith('https://') ? c.rpc : null
}
/**
* The explorer host follows the api host, so it is derived rather than kept in
* a second list that can disagree with the first. Verified 200 on every chain
* the registry serves: api.lux.network → explore.lux.network, api.zoo.ngo →
* explore.zoo.ngo, and the same for hanzo, pars and osage.
*/
function explorerFor(rpc: string | null): string | null {
if (!rpc) return null
const host = new URL(rpc).host
return host.startsWith('api.') ? `https://explore.${host.slice(4)}` : null
}
function amm(c: Record<string, string>): Amm | null {
if (!c.factory_v2 && !c.factory_v3) return null
return {
factoryV2: c.factory_v2,
factoryV3: c.factory_v3,
router: c.router,
quoter: c.quoter_v2,
native: c.native,
}
}
type Row = {
slug: string
name: string
chain_id: number
coin: string
enabled: boolean
default?: boolean
rpc: string
public_rpc?: string
factory_v2: string
factory_v3: string
router: string
quoter_v2: string
native: string
graph?: { subgraphs?: { name: string; enabled: boolean }[] }
}
export async function readChains(): Promise<Chain[]> {
const res = await fetch(REGISTRY, { headers: { accept: 'application/json' } })
if (!res.ok) throw new Error(`registry ${res.status}`)
const body = (await res.json()) as { chains: Row[] }
const rows = body.chains.filter((c) => c.enabled)
// The registry answers from a Go map, so the order it publishes changes
// between requests — the chain switcher would reshuffle on every load. The
// one chain it names as default leads; the rest go alphabetically.
rows.sort(
(a, b) =>
Number(Boolean(b.default)) - Number(Boolean(a.default)) || a.name.localeCompare(b.name),
)
return rows.map((c) => {
const rpc = browserRpc(c)
return {
slug: c.slug,
name: c.name,
id: c.chain_id,
coin: c.coin,
rpc,
explorer: explorerFor(rpc),
graphs: (c.graph?.subgraphs ?? []).filter((g) => g.enabled).map((g) => g.name),
amm: amm(c as unknown as Record<string, string>),
}
})
}
/** Address page on a chain's explorer, or null when it has no public host. */
export const addressUrl = (chain: Chain, hash: string) =>
chain.explorer ? `${chain.explorer}/address/${hash}` : null
/** Transaction page on a chain's explorer, or null. */
export const txUrl = (chain: Chain, hash: string) =>
chain.explorer ? `${chain.explorer}/tx/${hash}` : null