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 })
+}