feat(ui): a market panel on the network page, from settled swaps only

Shows the token's price on NEAR Intents per counter token, volume-weighted
over its latest trades with the trade count beside every figure, and in that
token rather than in dollars. Before anything has settled it shows the
evidence instead: supply bridged to NEAR, what intents.near holds, whether
1Click lists it, and zero swaps over the NEAR blocks read — so a rate quoted
elsewhere has something true to stand against.

Renders nothing when the chain answers `no_intents`, which it does while the
integration is off. The asset is labelled by its own ft_metadata symbol,
returned as `asset_symbol`, rather than assumed to be the chain's token:
checked by rendering it with ZEC configured, where the first cut called
143,730 ZEC "QTC".

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
2026-10-03 18:35:32 +03:00
co-authored by Claude Opus 5.5
parent d40e17bb1f
commit 0d5ebd19ee
7 changed files with 217 additions and 0 deletions
+5
View File
@@ -15,6 +15,11 @@ chain: ChainId,
* The tracked asset, as a multi-token id.
*/
asset: string,
/**
* Its own `ft_metadata.symbol`, so the page labels the asset as NEAR
* describes it rather than assuming it is the chain's token. Display only.
*/
asset_symbol: string | null,
/**
* Its decimals, from its own `ft_metadata`.
*/
+12
View File
@@ -17,6 +17,7 @@ import type { ChainSeries } from './generated/ChainSeries'
import type { ChainRoles } from './generated/ChainRoles'
import type { ChainState } from './generated/ChainState'
import type { GenesisDetail } from './generated/GenesisDetail'
import type { IntentsPrice } from './generated/IntentsPrice'
import type { ReversibleState } from './generated/ReversibleState'
import type { RecentBlock } from './generated/RecentBlock'
import type { MinerDetail } from './generated/MinerDetail'
@@ -278,3 +279,14 @@ export async function fetchWormhole(chain: string, signal?: AbortSignal): Promis
export async function fetchVesting(chain: string, signal?: AbortSignal): Promise<VestingSummary> {
return get<VestingSummary>(`/chains/${chain}/vesting`, signal)
}
/**
* The chain's token as NEAR Intents settles it: per counter token, from trades
* only. A chain that tracks nothing there answers 404 `no_intents`.
*/
export async function fetchIntentsPrice(
chain: string,
signal?: AbortSignal,
): Promise<IntentsPrice> {
return get<IntentsPrice>(`/chains/${chain}/intents/price`, signal)
}
+178
View File
@@ -0,0 +1,178 @@
/**
* The chain's token as NEAR Intents settles it — or the evidence that nothing
* has settled yet.
*
* NEAR Intents has no pool and stores no price. A price exists only as the
* ratio inside a trade that executed, so that is the only figure here: no
* quote, no listing price, and no dollar value until a stablecoin pairing
* exists to derive one from. blackbeard/observer#27.
*
* The empty state is the point of the panel as much as the price is. Before a
* single swap settles, a rate for this token can still be shown elsewhere — a
* wallet's swap screen did exactly that from a constant — and the honest answer
* to "what is it worth" is the supply on NEAR, the listing, and a count of zero,
* printed with the range of blocks that count covers.
*
* A chain that tracks nothing on NEAR answers 404 `no_intents`, and the panel
* renders nothing at all.
*/
import { useEffect, useState } from 'react'
import type { IntentsPrice } from '../api/generated/IntentsPrice'
import { RequestFailed, fetchIntentsPrice } from '../api/rest'
import { ago, height as fmtHeight, price as fmtPrice, tokens } from '../lib/format'
import { Field } from './Field'
/** A multi-token id short enough for a label: `nep141:zec.omft.near` → `zec.omft.near`. */
function shortToken(id: string): string {
const contract = id.replace(/^nep\d+:/, '')
return contract.length > 24 ? `${contract.slice(0, 10)}…${contract.slice(-10)}` : contract
}
export function IntentsPanel({
chain,
decimals,
symbol,
}: {
chain: string
decimals: number
symbol: string
}) {
const [data, setData] = useState<IntentsPrice | null>(null)
const [absent, setAbsent] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setData(null)
setAbsent(false)
setError(null)
fetchIntentsPrice(chain, controller.signal)
.then(setData)
.catch((e: unknown) => {
if (controller.signal.aborted) return
if (e instanceof RequestFailed && e.code === 'no_intents') setAbsent(true)
else setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain])
if (absent) return null
const head = (eyebrow: string) => (
<div className="panel-head">
<h2 className="panel-title">Market</h2>
<span className="eyebrow">{eyebrow}</span>
</div>
)
if (error)
return (
<section className="panel" style={{ marginBottom: 26 }}>
{head('NEAR Intents')}
<p className="empty">{error}</p>
</section>
)
if (!data)
return (
<section className="panel" style={{ marginBottom: 26 }}>
{head('NEAR Intents')}
<p className="empty">Reading NEAR…</p>
</section>
)
const assetDecimals = data.asset_decimals ?? decimals
// The asset as NEAR names it. Normally the chain's own token; falling back to
// the chain's symbol only until the metadata has been read.
const asset = data.asset_symbol ?? symbol
const amount = (raw: string | null | undefined) =>
raw ? `${tokens(raw, assetDecimals)} ${asset}` : '—'
const listing = data.listing
const range =
data.indexed_from !== null && data.indexed_to !== null
? `NEAR blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}`
: 'nothing read yet'
const nothingBridged = listing?.total_supply === '0'
const thin = data.counters.some((c) => c.trades < 5)
const listingFields = (
<>
<Field
label="Bridged to NEAR"
value={amount(listing?.total_supply)}
note="the token contract's total supply"
/>
<Field
label="Inside NEAR Intents"
value={amount(listing?.in_intents)}
note="what can be traded at all"
/>
<Field
label="Listed on 1Click"
value={
listing?.oneclick_listed === undefined || listing.oneclick_listed === null
? '—'
: listing.oneclick_listed
? 'yes'
: 'no'
}
note="a listing, not a trade"
/>
</>
)
if (data.counters.length === 0)
return (
<section className="panel" style={{ marginBottom: 26 }}>
{head(`NEAR Intents · ${range}`)}
<div className="node-grid">
{listingFields}
<Field label="Settled swaps" value="0" note="in the NEAR blocks read" />
</div>
<p className="panel-note">
<strong>There is no {asset} price yet.</strong> NEAR Intents has no pool and stores no
price: a price exists only as the ratio inside a trade that has settled, and none has.{' '}
{nothingBridged &&
`No ${asset} has been bridged to NEAR, so none can have traded there. `}
A rate for {asset} shown anywhere before a trade appears here is an estimate or an offer,
not something anyone paid.
</p>
<p className="panel-note">What is read: {data.reads}</p>
</section>
)
return (
<section className="panel" style={{ marginBottom: 26 }}>
{head(`NEAR Intents · ${range}`)}
<div className="node-grid">
{data.counters.map((c) => {
const unit = c.symbol ?? shortToken(c.counter_token)
return (
<Field
key={c.counter_token}
label={`${asset} in ${unit}`}
value={`${fmtPrice(c.vwap)} ${unit}`}
note={`${fmtHeight(c.trades)} trade${c.trades === 1 ? '' : 's'} · last ${fmtPrice(c.last_price)} ${unit}, ${ago(c.last_at)}`}
title={c.counter_token}
/>
)
})}
{listingFields}
</div>
<p className="panel-note">
Each figure is volume-weighted over the latest {fmtHeight(data.window_trades)} trades
against that token at most — the ratio of the summed amounts, so a dust trade at an odd
price moves it by its size — and is in that token, not in dollars.{' '}
{thin && (
<strong>
Some of these rest on fewer than five trades; a figure from one trade is one trade.{' '}
</strong>
)}
Token names are each token&apos;s own description of itself; the contract behind a figure is
in its tooltip.
</p>
<p className="panel-note">What is read: {data.reads}</p>
</section>
)
}
+3
View File
@@ -22,6 +22,7 @@ import { RequestFailed, fetchNetwork } from '../api/rest'
import { height as fmtHeight, tokens } from '../lib/format'
import { href } from '../lib/routes'
import { DistributionPanel } from './DistributionPanel'
import { IntentsPanel } from './IntentsPanel'
import { Sparkline, type SparkPoint } from './Sparkline'
import { Field } from './Field'
@@ -180,6 +181,8 @@ export function NetworkPanel({
</p>
</section>
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} />
{conc !== null && conc.complete && (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
+14
View File
@@ -245,3 +245,17 @@ export function tooFewBlocks(relativeError: number | null | undefined): boolean
// useful, and a range says more than a midpoint does.
return relativeError !== null && relativeError !== undefined && relativeError > 0.5
}
/**
* A price, as a ratio of two tokens.
*
* Significant figures rather than fixed places: a ZEC priced in USDT is in the
* thousands and a QTC priced in BTC may be in the millionths, and either one at
* two decimal places is wrong. Four figures is about what a handful of trades
* can support; the trade count printed beside it says how much fewer.
*/
export function price(value: number | null | undefined): string {
if (value === null || value === undefined || !Number.isFinite(value) || value <= 0) return '—'
if (value >= 1000) return value.toLocaleString('en-US', { maximumFractionDigits: 0 })
return Number(value.toPrecision(4)).toLocaleString('en-US', { maximumSignificantDigits: 4 })
}