mirror of
https://github.com/luxfi/market.git
synced 2026-09-24 00:25:57 +00:00
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:
@@ -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
@@ -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’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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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’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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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’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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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]">→</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">→</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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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’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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) })
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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 })}`
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user