From 5d22137a4329162e0fda4c6375fa64d2fbe00cfe Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sat, 3 Oct 2026 22:04:59 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20a=20market=20route=20=E2=80=94=20/:?= =?UTF-8?q?chain/market=20and=20/:chain/market/near-intents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The market index lists the venues a chain's token is followed on, asked of a new /v1/chains/{chain}/markets so a chain followed nowhere says so and a second venue needs no frontend change. The near-intents page carries the price figures and listing evidence, swaps as they settle (from the IntentsSwap push), and the settled history, paged newest first through /intents/swaps?order=newest&before=… — the existing `after` stays for readers going forward, and mixing the two directions is a 400. `market` is a subject in lib/routes.ts like `node`: in parse, href and isStandings together. `markets` parses too and is rewritten to the singular every other kind uses. Rendered against 964 real wNEAR trades before it shipped, which is what showed the panel did not scale: seventy counter tokens became seventy tiles, NFT swaps sat in the grid as prices with no number, and "wNEAR in USDC" appeared six times for six different bridged tokens. Now only counters with decimals are prices, most traded first — four tiles on the network page, eight and a table of the rest on the market page — NFT and multi-token swaps are a count, and a bridged token is labelled by its origin (`USDC · base`), the contract it is, not the symbol it chose. Closes #32 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj --- ...890d97c1dbe593723932b017f66c2270bf5d6.json | 122 ++++++++++ crates/blackbeard-api/src/routes.rs | 106 ++++++++- crates/blackbeard-data/src/store.rs | 55 +++++ crates/blackbeard-entities/src/intents.rs | 37 ++- crates/blackbeard-entities/src/lib.rs | 4 +- web/src/App.tsx | 20 ++ web/src/api/generated/IntentsSwaps.ts | 14 +- web/src/api/generated/MarketSummary.ts | 26 +++ web/src/api/generated/Markets.ts | 17 ++ web/src/api/rest.ts | 20 ++ web/src/components/IntentsPanel.tsx | 94 +++++++- web/src/components/MarketPage.tsx | 215 ++++++++++++++++++ web/src/components/MarketsIndex.tsx | 87 +++++++ web/src/components/NetworkPanel.tsx | 2 +- web/src/components/SwapRow.tsx | 83 +++++++ web/src/index.css | 3 +- web/src/lib/routes.ts | 17 +- web/src/lib/tokens.ts | 50 ++++ 18 files changed, 940 insertions(+), 32 deletions(-) create mode 100644 .sqlx/query-eae0a7d8e24107d7c0f92ba154d890d97c1dbe593723932b017f66c2270bf5d6.json create mode 100644 web/src/api/generated/MarketSummary.ts create mode 100644 web/src/api/generated/Markets.ts create mode 100644 web/src/components/MarketPage.tsx create mode 100644 web/src/components/MarketsIndex.tsx create mode 100644 web/src/components/SwapRow.tsx create mode 100644 web/src/lib/tokens.ts diff --git a/.sqlx/query-eae0a7d8e24107d7c0f92ba154d890d97c1dbe593723932b017f66c2270bf5d6.json b/.sqlx/query-eae0a7d8e24107d7c0f92ba154d890d97c1dbe593723932b017f66c2270bf5d6.json new file mode 100644 index 0000000..55ab0d5 --- /dev/null +++ b/.sqlx/query-eae0a7d8e24107d7c0f92ba154d890d97c1dbe593723932b017f66c2270bf5d6.json @@ -0,0 +1,122 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select chain, asset, near_height, trade_index, at, tx_hash, receipt_id,\n counter_token, asset_amount::text as \"asset_amount!\",\n counter_amount::text as \"counter_amount!\", side, taker, referral,\n accounts, intent_hashes, legs, kind\n from intents_trade\n where chain = $1 and asset = $2\n and ($3::bigint is null or (near_height, trade_index) < ($3, $4::integer))\n order by near_height desc, trade_index desc\n limit $5\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "chain", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "asset", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "near_height", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "trade_index", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "tx_hash", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "receipt_id", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "counter_token", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "asset_amount!", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "counter_amount!", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "side", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "taker", + "type_info": "Text" + }, + { + "ordinal": 12, + "name": "referral", + "type_info": "Text" + }, + { + "ordinal": 13, + "name": "accounts", + "type_info": "TextArray" + }, + { + "ordinal": 14, + "name": "intent_hashes", + "type_info": "TextArray" + }, + { + "ordinal": 15, + "name": "legs", + "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "kind", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int4", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + null, + null, + true, + true, + true, + false, + false, + false, + false + ] + }, + "hash": "eae0a7d8e24107d7c0f92ba154d890d97c1dbe593723932b017f66c2270bf5d6" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 45ca238..f3f8f08 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -62,6 +62,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/vesting", get(vesting)) .route("/v1/chains/{chain}/distribution", get(distribution)) .route("/v1/chains/{chain}/wormhole", get(wormhole)) + .route("/v1/chains/{chain}/markets", get(markets)) .route("/v1/chains/{chain}/intents/swaps", get(intents_swaps)) .route("/v1/chains/{chain}/intents/price", get(intents_price)) .route("/v1/chains/{chain}/nodes", get(nodes)) @@ -1708,8 +1709,12 @@ const PRICE_TRADES_MAX: i64 = 1000; #[derive(Debug, Deserialize)] struct SwapsQuery { - /// `near_height:trade_index`, exclusive. + /// `oldest` (the default) or `newest`: which end to read from. + order: Option, + /// Reading oldest first: `near_height:trade_index`, exclusive. after: Option, + /// Reading newest first: `near_height:trade_index`, exclusive. + before: Option, limit: Option, } @@ -1764,7 +1769,41 @@ async fn near_tokens( Ok(out) } -/// Settled swaps of the chain's token on NEAR Intents, oldest first. +/// The venues this chain's token is followed on. +/// +/// One today, NEAR Intents, and only for a chain whose config tracks an asset +/// there; every other chain answers an empty list rather than a 404, because +/// "followed nowhere" is an answer. +async fn markets( + State(state): State, + Path(chain): Path, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let mut markets = Vec::new(); + if let Some(config) = runtime.spec.intents.clone() { + let symbol = state + .store + .near_token(&config.asset) + .await + .map_err(database_unavailable)? + .map(|(symbol, _)| symbol); + markets.push(blackbeard_entities::MarketSummary { + id: "near-intents".into(), + name: "NEAR Intents".into(), + asset: config.asset, + asset_symbol: symbol, + description: "Trades intents.near settled, read from the contract's own logs. There \ + is no pool or stored price: a price is the ratio inside a trade that executed." + .into(), + }); + } + Ok(Json(blackbeard_entities::Markets { + chain: runtime.id(), + markets, + })) +} + +/// Settled swaps of the chain's token on NEAR Intents, oldest or newest first. /// /// Trades only: every row is a `token_diff` pair that `intents.near` executed /// in a receipt that succeeded. blackbeard/observer#27. @@ -1780,17 +1819,59 @@ async fn intents_swaps( .clone() .ok_or_else(|| no_intents(&chain))?; let id = runtime.id(); - let after = match query.after.as_deref() { - Some(raw) => parse_trade_cursor(raw)?, - None => (0, 0), - }; let limit = query.limit.unwrap_or(SWAP_PAGE).clamp(1, SWAP_PAGE_MAX); + // Two directions, each with its own cursor, and never both: a cursor means + // "past this point in the direction I am reading", and naming the other + // direction's cursor would be a request that cannot be answered as asked. + let newest = match query.order.as_deref() { + None | Some("oldest") => false, + Some("newest") => true, + Some(other) => { + return Err(Failure( + StatusCode::BAD_REQUEST, + ApiError::new( + "bad_order", + format!("`{other}` is not `oldest` or `newest`"), + ), + )); + } + }; + let misplaced = if newest { + query.after.as_deref() + } else { + query.before.as_deref() + }; + if misplaced.is_some() { + return Err(Failure( + StatusCode::BAD_REQUEST, + ApiError::new( + "cursor_direction", + "use `after` when reading oldest first and `before` when reading newest first", + ), + )); + } - let rows = state - .store - .intent_trades(&id, &config.asset, after, limit) - .await - .map_err(database_unavailable)?; + let rows = if newest { + let before = query + .before + .as_deref() + .map(parse_trade_cursor) + .transpose()?; + state + .store + .intent_trades_before(&id, &config.asset, before, limit) + .await + } else { + let after = match query.after.as_deref() { + Some(raw) => parse_trade_cursor(raw)?, + None => (0, 0), + }; + state + .store + .intent_trades(&id, &config.asset, after, limit) + .await + } + .map_err(database_unavailable)?; let scan = state .store .intents_scan(&id, &config.asset) @@ -1801,6 +1882,7 @@ async fn intents_swaps( .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()); let full = rows.len() as i64 == limit; let next = rows @@ -1815,6 +1897,8 @@ async fn intents_swaps( Ok(Json(blackbeard_entities::IntentsSwaps { chain: id, asset: config.asset, + asset_symbol, + asset_decimals, swaps, next, indexed_from: scan.map(|(low, _)| low), diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index f655bcc..fae8020 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -2891,6 +2891,61 @@ impl Store { .collect()) } + /// Trades before a `(near_height, trade_index)` cursor, newest first — or + /// the newest trades, without one. What a history page reads from the top. + pub async fn intent_trades_before( + &self, + chain: &ChainId, + asset: &str, + before: Option<(u64, u32)>, + limit: i64, + ) -> Result, DataError> { + let (height, index) = + before.map_or((None, None), |(h, i)| (Some(h as i64), Some(i as i32))); + let rows = sqlx::query!( + r#" + select chain, asset, near_height, trade_index, at, tx_hash, receipt_id, + counter_token, asset_amount::text as "asset_amount!", + counter_amount::text as "counter_amount!", side, taker, referral, + accounts, intent_hashes, legs, kind + from intents_trade + where chain = $1 and asset = $2 + and ($3::bigint is null or (near_height, trade_index) < ($3, $4::integer)) + order by near_height desc, trade_index desc + limit $5 + "#, + chain.as_str(), + asset, + height, + index, + limit, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| IntentTrade { + chain: ChainId(r.chain), + asset: r.asset, + near_height: r.near_height.max(0) as u64, + trade_index: r.trade_index.max(0) as u32, + at: r.at, + tx_hash: r.tx_hash, + receipt_id: r.receipt_id, + counter_token: r.counter_token, + asset_amount: r.asset_amount, + counter_amount: r.counter_amount, + side: r.side, + taker: r.taker, + referral: r.referral, + accounts: r.accounts, + intent_hashes: r.intent_hashes, + legs: r.legs, + kind: r.kind, + }) + .collect()) + } + /// The latest `per_counter` trades against each counter token, newest /// first within each. /// diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index 5b0a546..2060f75 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -61,7 +61,7 @@ pub struct IntentsSwap { pub intent_hashes: Vec, } -/// A page of swaps, oldest first. +/// A page of swaps, in the order asked for. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "IntentsSwaps.ts")] pub struct IntentsSwaps { @@ -69,10 +69,16 @@ pub struct IntentsSwaps { pub chain: ChainId, /// The tracked asset, as a multi-token id. pub asset: String, + /// Its own `ft_metadata.symbol`, when read. Display only. + pub asset_symbol: Option, + /// Its decimals, when read — what every `asset_amount` below is scaled by. + pub asset_decimals: Option, /// The swaps. pub swaps: Vec, /// Cursor for the next page, `near_height:trade_index`, when this one was - /// full. Absent when there is nothing more *in the indexed range*. + /// full — to pass as `after` when reading oldest first, as `before` when + /// reading newest first. Absent when there is nothing more *in the indexed + /// range*. pub next: Option, /// Lowest NEAR height read. A swap below it is missing from this answer, /// not from the chain. @@ -164,3 +170,30 @@ pub struct IntentsPrice { #[ts(type = "number")] pub indexed_to: Option, } + +/// One venue a chain's token is followed on. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "MarketSummary.ts")] +pub struct MarketSummary { + /// The venue's path segment: `near-intents`. + pub id: String, + /// Its name, for display. + pub name: String, + /// The tracked asset, as the venue names it. + pub asset: String, + /// That asset's own symbol on the venue, when it has been read. + pub asset_symbol: Option, + /// How a price is arrived at there, in a sentence. + pub description: String, +} + +/// The venues a chain's token is followed on. Empty for a chain tracked on +/// none, which is every chain but the ones configured for it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "Markets.ts")] +pub struct Markets { + /// Which chain. + pub chain: ChainId, + /// Its venues. + pub markets: Vec, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index b343cea..f10f032 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -39,7 +39,9 @@ pub use chain::{ WormholeDeposit, WormholeDeposits, WormholeSummary, }; pub use error::{ApiError, EntityError}; -pub use intents::{CounterPrice, IntentsListing, IntentsPrice, IntentsSwap, IntentsSwaps}; +pub use intents::{ + CounterPrice, IntentsListing, IntentsPrice, IntentsSwap, IntentsSwaps, MarketSummary, Markets, +}; pub use miner::{ AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity, NodeIndex, NodeInfo, NodeRow, diff --git a/web/src/App.tsx b/web/src/App.tsx index 5c2fe98..934cbf6 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -27,6 +27,8 @@ import { NetworkPanel } from './components/NetworkPanel' import { WormholePanel } from './components/WormholePanel' import { NodeRoute } from './components/NodeRoute' import { NodesIndex } from './components/NodesIndex' +import { MarketPage } from './components/MarketPage' +import { MarketsIndex } from './components/MarketsIndex' import { RuntimesIndex } from './components/RuntimesIndex' import { ReversibleIndex } from './components/ReversibleIndex' import { SectionNav } from './components/SectionNav' @@ -88,6 +90,15 @@ export default function App() { } }, [chain, route, location.pathname, navigate]) + // `/:chain/markets` and `/:chain/markets/:market` parse, because that is what + // a person types for a list of them; the address bar is then put right, so + // the site only ever hands out the singular form every other kind uses. + useEffect(() => { + if (!chain || (route.market === null && route.index !== 'market')) return + const canonical = href({ chain, market: route.market, index: route.market ? null : 'market' }) + if (location.pathname !== canonical) navigate(canonical, { replace: true }) + }, [chain, route.market, route.index, location.pathname, navigate]) + useWatch(chain, route.window) /** @@ -322,6 +333,15 @@ export default function App() { symbol={info?.token_symbol ?? ''} /> )} + {route.index === 'market' && chain && } + {route.market && chain && ( + + )} {route.index === 'network' && chain && ( , /** * Cursor for the next page, `near_height:trade_index`, when this one was - * full. Absent when there is nothing more *in the indexed range*. + * full — to pass as `after` when reading oldest first, as `before` when + * reading newest first. Absent when there is nothing more *in the indexed + * range*. */ next: string | null, /** diff --git a/web/src/api/generated/MarketSummary.ts b/web/src/api/generated/MarketSummary.ts new file mode 100644 index 0000000..f1b3553 --- /dev/null +++ b/web/src/api/generated/MarketSummary.ts @@ -0,0 +1,26 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One venue a chain's token is followed on. + */ +export type MarketSummary = { +/** + * The venue's path segment: `near-intents`. + */ +id: string, +/** + * Its name, for display. + */ +name: string, +/** + * The tracked asset, as the venue names it. + */ +asset: string, +/** + * That asset's own symbol on the venue, when it has been read. + */ +asset_symbol: string | null, +/** + * How a price is arrived at there, in a sentence. + */ +description: string, }; diff --git a/web/src/api/generated/Markets.ts b/web/src/api/generated/Markets.ts new file mode 100644 index 0000000..0e9e1cb --- /dev/null +++ b/web/src/api/generated/Markets.ts @@ -0,0 +1,17 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { MarketSummary } from "./MarketSummary"; + +/** + * The venues a chain's token is followed on. Empty for a chain tracked on + * none, which is every chain but the ones configured for it. + */ +export type Markets = { +/** + * Which chain. + */ +chain: ChainId, +/** + * Its venues. + */ +markets: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index d53b62b..bd54762 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -18,6 +18,8 @@ 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 { IntentsSwaps } from './generated/IntentsSwaps' +import type { Markets } from './generated/Markets' import type { ReversibleState } from './generated/ReversibleState' import type { RecentBlock } from './generated/RecentBlock' import type { MinerDetail } from './generated/MinerDetail' @@ -290,3 +292,21 @@ export async function fetchIntentsPrice( ): Promise { return get(`/chains/${chain}/intents/price`, signal) } + +/** The venues a chain's token is followed on; empty for none. */ +export async function fetchMarkets(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${chain}/markets`, signal) +} + +/** + * Settled swaps, newest first: the latest page without a cursor, then each + * older page by passing the previous response's `next` as `before`. + */ +export async function fetchSwapsNewest( + chain: string, + before: string | null, + signal?: AbortSignal, +): Promise { + const cursor = before ? `&before=${encodeURIComponent(before)}` : '' + return get(`/chains/${chain}/intents/swaps?order=newest&limit=50${cursor}`, signal) +} diff --git a/web/src/components/IntentsPanel.tsx b/web/src/components/IntentsPanel.tsx index d15d656..55b070d 100644 --- a/web/src/components/IntentsPanel.tsx +++ b/web/src/components/IntentsPanel.tsx @@ -18,27 +18,34 @@ */ import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' 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 { useObserverInstance } from '../lib/observer-context' +import { href } from '../lib/routes' +import { tokenLabel } from '../lib/tokens' 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, + linkMarket = false, + tiles = 4, + table = false, }: { chain: string decimals: number symbol: string + /** Point at the full market page. Set where this panel is a summary of it. */ + linkMarket?: boolean + /** Counter tokens given a tile, most traded first. The rest are a table + * (`table`) or a count. A busy token trades against seventy. */ + tiles?: number + /** List every priced counter below the tiles. The market page's job. */ + table?: boolean }) { const [data, setData] = useState(null) const [absent, setAbsent] = useState(false) @@ -73,7 +80,9 @@ export function IntentsPanel({ const head = (eyebrow: string) => (
-

Market

+

+ {linkMarket ? Market : 'Market'} +

{eyebrow}
) @@ -105,7 +114,17 @@ export function IntentsPanel({ ? `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) + // Only a counter with known decimals has a price. NFTs and multi-tokens have + // none, and a "—" in a grid of prices reads as a price nobody could read + // rather than as what it is: a swap for something that is not a currency. + const priced = data.counters + .filter((c) => c.decimals !== null && c.vwap !== null) + .sort((a, b) => b.trades - a.trades) + const unpriced = data.counters.filter((c) => !priced.includes(c)) + const unpricedTrades = unpriced.reduce((n, c) => n + c.trades, 0) + const shown = priced.slice(0, tiles) + const rest = priced.slice(tiles) + const thin = shown.some((c) => c.trades < 5) const listingFields = ( <> @@ -157,22 +176,73 @@ export function IntentsPanel({
{head(`NEAR Intents · ${range}`)}
- {data.counters.map((c) => { - const unit = c.symbol ?? shortToken(c.counter_token) + {shown.map((c) => { + const unit = tokenLabel(c.counter_token, c.symbol) return ( ) })} {listingFields}
+ {table && rest.length > 0 && ( +
+ + + + + + + + + + + + + {rest.map((c) => ( + + + + + + + + ))} + +
Every other priced counter token
+ {asset} in + Volume-weightedLastTrades + Last traded +
+ {tokenLabel(c.counter_token, c.symbol)} + {fmtPrice(c.vwap)}{fmtPrice(c.last_price)}{fmtHeight(c.trades)}{ago(c.last_at)}
+
+ )} + {(!table && rest.length > 0) || unpriced.length > 0 ? ( +

+ {!table && rest.length > 0 && ( + <> + {fmtHeight(rest.length)} more counter token{rest.length === 1 ? '' : 's'} + {linkMarket && ( + <> + {' '} + — on the market page + + )} + .{' '} + + )} + {unpriced.length > 0 && + `${fmtHeight(unpricedTrades)} trade${unpricedTrades === 1 ? '' : 's'} against ${fmtHeight(unpriced.length)} token${unpriced.length === 1 ? '' : 's'} with no price — NFTs and multi-tokens, which have no decimals to divide by — are counted but not listed.`} +

+ ) : null}

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 diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx new file mode 100644 index 0000000..a3e83d7 --- /dev/null +++ b/web/src/components/MarketPage.tsx @@ -0,0 +1,215 @@ +/** + * One market: what NEAR Intents has settled for this chain's token. + * + * Three readings, top to bottom, in order of how settled they are. The price + * figures and the listing evidence first — the same panel the network page + * carries — then swaps as they settle, then the history they join. + * + * Empty is a state here, not an error. Until the token is bridged, the honest + * page is the evidence that nothing can have traded, and that is what the + * header shows; the feed and the history then say plainly that there is + * nothing in them yet. blackbeard/observer#32. + */ + +import { useEffect, useRef, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { IntentsSwap } from '../api/generated/IntentsSwap' +import type { IntentsSwaps } from '../api/generated/IntentsSwaps' +import { RequestFailed, fetchSwapsNewest } from '../api/rest' +import { height as fmtHeight } from '../lib/format' +import { useObserverInstance } from '../lib/observer-context' +import { href } from '../lib/routes' +import { IntentsPanel } from './IntentsPanel' +import { SwapHead, SwapRow } from './SwapRow' + +/** The venues this page knows how to show. */ +const KNOWN = new Set(['near-intents']) + +/** Lines the live feed keeps. Older ones are in the history below it. */ +const LIVE_LINES = 12 + +export function MarketPage({ + chain, + market, + decimals, + symbol, +}: { + chain: string + market: string + decimals: number + symbol: string +}) { + if (!KNOWN.has(market)) { + return ( +

+
+

No such market

+
+

+ This chain's token is not followed on a market called “{market}”.{' '} + The markets it is followed on. +

+
+ ) + } + return +} + +function NearIntents({ + chain, + decimals, + symbol, +}: { + chain: string + decimals: number + symbol: string +}) { + const [history, setHistory] = useState(null) + const [older, setOlder] = useState([]) + const [error, setError] = useState(null) + const [loadingOlder, setLoadingOlder] = useState(false) + const [live, setLive] = useState<{ key: number; swap: IntentsSwap }[]>([]) + // A monotonic key rather than height-and-index: the wormhole console's keys + // looked unique and were not, and React given duplicate keys renders + // duplicates and stops honouring the list's own length cap. + const nextKey = useRef(0) + const observer = useObserverInstance() + + useEffect(() => { + const controller = new AbortController() + setHistory(null) + setOlder([]) + setError(null) + setLive([]) + fetchSwapsNewest(chain, null, controller.signal) + .then(setHistory) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain]) + + useEffect( + () => + observer.onIntentsSwap((swap) => { + const key = nextKey.current++ + setLive((rows) => [{ key, swap }, ...rows].slice(0, LIVE_LINES)) + }), + [observer], + ) + + const asset = history?.asset_symbol ?? symbol + const assetDecimals = history?.asset_decimals ?? decimals + // The previous page's `next`, carried forward as each older page is read. + const cursor = history?.next ?? null + + const loadOlder = () => { + if (!cursor) return + setLoadingOlder(true) + fetchSwapsNewest(chain, cursor) + .then((page) => { + setOlder((rows) => [...rows, ...page.swaps]) + setHistory((h) => (h ? { ...h, next: page.next } : page)) + }) + .catch((e: unknown) => + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.'), + ) + .finally(() => setLoadingOlder(false)) + } + + const rows = history ? [...history.swaps, ...older] : [] + const range = + history && history.indexed_from !== null && history.indexed_to !== null + ? `NEAR blocks ${fmtHeight(history.indexed_from)}–${fmtHeight(history.indexed_to)}` + : 'nothing read yet' + + return ( + <> +

+ Markets › NEAR Intents +

+ + + +
+
+

Settling now

+ pushed as each block is read +
+ {live.length === 0 ? ( +

+ Waiting for the next {asset} swap to settle. Nothing appears here that has not. +

+ ) : ( +
+ + + + + {live.map(({ key, swap }) => ( + + ))} + +
Swaps as they settle, newest first
+
+ )} +
+ +
+
+

Settled

+ {range} +
+ {error &&

{error}

} + {!error && !history &&

Reading…

} + {history && rows.length === 0 && ( +

+ No {asset} swap has settled in the blocks read. That is a count over {range}, not a + guess. +

+ )} + {rows.length > 0 && ( +
+ + + + + {rows.map((swap) => ( + + ))} + +
Settled swaps, newest first
+
+ )} + {cursor && ( +
+ +
+ )} +

+ A row is one user's side of a swap, priced from what they gave and what they got. + “Between solvers” is market makers trading with each other: a real trade at a real price, + with no taker and so no side. “Via” is the front end the taker came through, as it named + itself. A solver's routing through other tokens is how a swap was filled, never a + trade of its own, and is not listed. +

+
+ + ) +} diff --git a/web/src/components/MarketsIndex.tsx b/web/src/components/MarketsIndex.tsx new file mode 100644 index 0000000..88b4ed1 --- /dev/null +++ b/web/src/components/MarketsIndex.tsx @@ -0,0 +1,87 @@ +/** + * The venues this chain's token is followed on. + * + * One today, NEAR Intents. Asked of the API rather than written here, so a + * chain followed nowhere says so instead of offering a link to an empty page, + * and a second venue appears without the frontend learning about it first. + * blackbeard/observer#32. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { Markets } from '../api/generated/Markets' +import { RequestFailed, fetchMarkets } from '../api/rest' +import { href } from '../lib/routes' + +export function MarketsIndex({ chain }: { chain: string }) { + const [markets, setMarkets] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + setMarkets(null) + setError(null) + fetchMarkets(chain, controller.signal) + .then(setMarkets) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain]) + + return ( +
+
+

Markets

+ where this chain's token is followed +
+ {error &&

{error}

} + {!error && !markets &&

Reading…

} + {markets && markets.markets.length === 0 && ( +

This chain's token is not followed on any market.

+ )} + {markets && markets.markets.length > 0 && ( +
+ + + + + + + + + + + {markets.markets.map((m) => ( + + + + + + ))} + +
+ Markets this chain's token is followed on +
+ Market + + Asset there + + How a price is read +
+ {m.name} + + {m.asset_symbol ?? m.asset} + {m.description}
+
+ )} +

+ A market here is a place where trades of this token settle and can be read back. A quote + from a front end, or a rate on a swap screen, is not one: it is an offer, and nothing + settled at it. +

+
+ ) +} diff --git a/web/src/components/NetworkPanel.tsx b/web/src/components/NetworkPanel.tsx index 22b936f..1fa8148 100644 --- a/web/src/components/NetworkPanel.tsx +++ b/web/src/components/NetworkPanel.tsx @@ -181,7 +181,7 @@ export function NetworkPanel({

- + {conc !== null && conc.complete && (
diff --git a/web/src/components/SwapRow.tsx b/web/src/components/SwapRow.tsx new file mode 100644 index 0000000..09425ea --- /dev/null +++ b/web/src/components/SwapRow.tsx @@ -0,0 +1,83 @@ +/** + * One settled swap, as a table row: shared by the live feed and the history on + * the market page, so the two cannot come to describe the same trade + * differently. + */ + +import type { IntentsSwap } from '../api/generated/IntentsSwap' +import { ago, price as fmtPrice, tokens, when } from '../lib/format' +import { tokenLabel } from '../lib/tokens' + +/** NearBlocks, for the transaction. A NEAR tx hash is all it needs. */ +const EXPLORER = 'https://nearblocks.io/txns/' + +export function SwapRow({ + swap, + asset, + decimals, + fresh = false, + relative = false, +}: { + swap: IntentsSwap + /** The tracked asset's own symbol. */ + asset: string + /** What `asset_amount` is scaled by. */ + decimals: number + /** Flash once on arrival, as the block ticker does. */ + fresh?: boolean + /** "3m ago" rather than a date — right for a live feed, wrong for history. */ + relative?: boolean +}) { + const counter = tokenLabel(swap.counter_token, swap.counter_symbol) + // A side only for a user's trade. Solvers trading with each other have no + // taker, so there is no side to name — said in words rather than left blank, + // because a blank reads as "unknown" and this is known. + const side = + swap.kind === 'unattributed' ? 'between solvers' : swap.side === 'buy' ? 'bought' : 'sold' + return ( + + + {relative ? ago(swap.at) : when(swap.at)} + + {side} + + {tokens(swap.asset_amount, decimals)} {asset} + + + {fmtPrice(swap.price)} {counter} + + + {swap.kind === 'unattributed' ? '—' : (swap.referral ?? '—')} + + + + {swap.tx_hash.slice(0, 8)}… + + + + ) +} + +/** The header row the swap tables share. */ +export function SwapHead({ asset }: { asset: string }) { + return ( + + + + Settled + + + Taker + + {asset} + Price per {asset} + + Via + + + Tx + + + + ) +} diff --git a/web/src/index.css b/web/src/index.css index ba06618..dd1c077 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -989,7 +989,8 @@ tr.mine .share-bar > i { } /* Newly arrived blocks flash once. Suppressed for reduced-motion, below. */ -.ticker-row.fresh { +.ticker-row.fresh, +.board tr.fresh { animation: arrive 1.4s ease-out; } diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 544b801..2888603 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -93,6 +93,8 @@ export interface Route { event: [string, string] | null /** A libp2p peer id, identifying one telemetry node. */ node: string | null + /** A venue the chain's token is followed on: `near-intents`. */ + market: string | null /** * A kind with no value: the index of everything of that kind. * @@ -117,6 +119,7 @@ export type SectionName = | 'network' | 'wormhole' | 'vesting' + | 'market' | 'about' /** @@ -145,6 +148,10 @@ export const SECTIONS: { id: SectionName; label: string; header?: boolean }[] = // is this economy doing — and because the two are read together: the // distribution table's blindness is explained here. { id: 'wormhole', label: 'Wormhole' }, + // With the economy pages, because a price is a question about the economy — + // but the only section whose subject is off this chain: what a venue + // elsewhere settled for its token (blackbeard/observer#32). + { id: 'market', label: 'Markets' }, // Last, and the only section that is about the future: every other page here // reports what the chain has already done. { id: 'vesting', label: 'Vesting' }, @@ -168,6 +175,7 @@ export const EMPTY: Route = { call: null, event: null, node: null, + market: null, index: null, } @@ -255,6 +263,9 @@ export function parse(pathname: string): Route { // A peer id is base58 and case-sensitive, so unlike a hash or an address it // is passed through exactly as written. if (kind === 'node') return { ...EMPTY, chain, node: value } + // `markets` is accepted as well as the canonical `market`: it is what a + // person naturally types for a list of them, and App rewrites it. + if (kind === 'market' || kind === 'markets') return { ...EMPTY, chain, market: value } // `/quantus/call/Balances/transfer_allow_death` — a pallet and a call, both // as the runtime names them, because those names are the identity and any // slug of ours would be a second vocabulary to keep in step. @@ -264,7 +275,7 @@ export function parse(pathname: string): Route { // A kind with nothing after it — `/quantus/account` or `/quantus/account/`, // which `filter(Boolean)` above makes the same path. The index of that kind. if (kind && !value) { - const named = section(kind) + const named = section(kind === 'markets' ? 'market' : kind) if (named) return { ...EMPTY, chain, index: named } // The standings are the miner index; there is no second page of them. if (kind === 'miner') return { ...EMPTY, chain } @@ -298,7 +309,8 @@ export function isStandings(route: Route): boolean { route.runtime === null && route.call === null && route.event === null && - route.node === null + route.node === null && + route.market === null ) } @@ -306,6 +318,7 @@ export function isStandings(route: Route): boolean { export function href(route: Partial): string { const chain = route.chain ?? '' if (route.node) return `/${chain}/node/${route.node}` + if (route.market) return `/${chain}/market/${route.market}` if (route.block) return `/${chain}/block/${route.block}` if (route.miner) { const window = windowPath(route.window ?? DEFAULT_WINDOW) diff --git a/web/src/lib/tokens.ts b/web/src/lib/tokens.ts new file mode 100644 index 0000000..e0f9792 --- /dev/null +++ b/web/src/lib/tokens.ts @@ -0,0 +1,50 @@ +/** + * How a NEAR Intents token is named on the page. + * + * A symbol alone does not identify a token there: USDC bridged from Ethereum, + * Solana, Base and Arbitrum are four different tokens that all call themselves + * USDC, and a market page listing "wNEAR in USDC" six times says nothing about + * which is which. The bridge's own contract name does, and it is the identity + * anyway — a symbol is self-reported, and anyone can deploy a token calling + * itself USDC (blackbeard/observer#27). + */ + +/** Native NEAR tokens worth naming by chain, because they share a symbol with a bridged one. */ +const NATIVE: Record = { + '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1': 'near', + 'usdt.tether-token.near': 'near', + 'wrap.near': 'near', +} + +/** The contract part of a multi-token id: `nep141:eth-0x….omft.near` → `eth-0x….omft.near`. */ +export function contractOf(tokenId: string): string { + return tokenId.replace(/^nep\d+:/, '') +} + +/** + * Where a token comes from, when its contract says: the chain prefix of a + * PoA (`*.omft.near`) or omni bridge token, `near` for a few native ones whose + * symbols collide with bridged tokens, and null otherwise. + */ +export function originOf(tokenId: string): string | null { + const contract = contractOf(tokenId) + if (NATIVE[contract]) return NATIVE[contract] + const bridged = /^([a-z0-9]+)(?:-[^.]*)?\.(?:omft|omdep)\.near$/.exec(contract) + if (bridged) return bridged[1] ?? null + return null +} + +/** A short contract name for a cell, keeping both ends. */ +export function shortContract(tokenId: string): string { + const contract = contractOf(tokenId) + return contract.length > 22 ? `${contract.slice(0, 9)}…${contract.slice(-9)}` : contract +} + +/** `USDC · eth`, or the symbol alone, or a short contract when there is no symbol. */ +export function tokenLabel(tokenId: string, symbol: string | null | undefined): string { + if (!symbol) return shortContract(tokenId) + const origin = originOf(tokenId) + // A chain's own coin needs no qualifier: `SOL · sol` says nothing `SOL` + // does not. Only a token that could be confused with another gets one. + return origin && origin !== symbol.toLowerCase() ? `${symbol} · ${origin}` : symbol +}