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