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:
@@ -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`.
|
||||
*/
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user