From 0d5ebd19ee6545fd340a69d0e05cb2cb8efa2b24 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sat, 3 Oct 2026 11:44:21 +0300 Subject: [PATCH] feat(ui): a market panel on the network page, from settled swaps only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj --- crates/blackbeard-api/src/routes.rs | 2 + crates/blackbeard-entities/src/intents.rs | 3 + web/src/api/generated/IntentsPrice.ts | 5 + web/src/api/rest.ts | 12 ++ web/src/components/IntentsPanel.tsx | 178 ++++++++++++++++++++++ web/src/components/NetworkPanel.tsx | 3 + web/src/lib/format.ts | 14 ++ 7 files changed, 217 insertions(+) create mode 100644 web/src/components/IntentsPanel.tsx diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index d9c2b1d..2f88001 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -1889,6 +1889,7 @@ async fn intents_price( .collect(); let tokens = near_tokens(&state.store, named).await?; let asset_decimals = tokens.get(&config.asset).map(|t| t.1); + let asset_symbol = tokens.get(&config.asset).map(|t| t.0.clone()); // Rows arrive grouped by counter, newest first within each. let mut counters: Vec = Vec::new(); @@ -1942,6 +1943,7 @@ async fn intents_price( Ok(Json(blackbeard_entities::IntentsPrice { chain: id, asset: config.asset, + asset_symbol, asset_decimals, window_trades: window as u32, counters, diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index 7296f3e..d07fc83 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -137,6 +137,9 @@ pub struct IntentsPrice { pub chain: ChainId, /// The tracked asset, as a multi-token id. pub 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. + pub asset_symbol: Option, /// Its decimals, from its own `ft_metadata`. pub asset_decimals: Option, /// Trades per counter token the figures are taken over, at most. diff --git a/web/src/api/generated/IntentsPrice.ts b/web/src/api/generated/IntentsPrice.ts index ef2ba9f..60b7c84 100644 --- a/web/src/api/generated/IntentsPrice.ts +++ b/web/src/api/generated/IntentsPrice.ts @@ -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`. */ diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 1b22457..d53b62b 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -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 { return get(`/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 { + return get(`/chains/${chain}/intents/price`, signal) +} diff --git a/web/src/components/IntentsPanel.tsx b/web/src/components/IntentsPanel.tsx new file mode 100644 index 0000000..07e4ec1 --- /dev/null +++ b/web/src/components/IntentsPanel.tsx @@ -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(null) + const [absent, setAbsent] = useState(false) + const [error, setError] = useState(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) => ( +
+

Market

+ {eyebrow} +
+ ) + + if (error) + return ( +
+ {head('NEAR Intents')} +

{error}

+
+ ) + if (!data) + return ( +
+ {head('NEAR Intents')} +

Reading NEAR…

+
+ ) + + 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 = ( + <> + + + + + ) + + if (data.counters.length === 0) + return ( +
+ {head(`NEAR Intents · ${range}`)} +
+ {listingFields} + +
+

+ There is no {asset} price yet. 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. +

+

What is read: {data.reads}

+
+ ) + + return ( +
+ {head(`NEAR Intents · ${range}`)} +
+ {data.counters.map((c) => { + const unit = c.symbol ?? shortToken(c.counter_token) + return ( + + ) + })} + {listingFields} +
+

+ 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 && ( + + Some of these rest on fewer than five trades; a figure from one trade is one trade.{' '} + + )} + Token names are each token's own description of itself; the contract behind a figure is + in its tooltip. +

+

What is read: {data.reads}

+
+ ) +} diff --git a/web/src/components/NetworkPanel.tsx b/web/src/components/NetworkPanel.tsx index 3858bf0..22b936f 100644 --- a/web/src/components/NetworkPanel.tsx +++ b/web/src/components/NetworkPanel.tsx @@ -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({

+ + {conc !== null && conc.complete && (
diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts index a686275..73c9332 100644 --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -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 }) +}