From 2d80325c3bbffe4a673ebba32f7d122f90a1a10c Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Sat, 3 Oct 2026 23:12:32 +0300 Subject: [PATCH] feat(api): solver inventory per side of each pair, read from our node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NEAR Intents has no order book: solvers quote privately per request and nothing on chain carries a price level. What is on chain is the inventory they fill from, inside intents.near — and intents.near is on the shard our node tracks, so mt_batch_balance_of is answered locally in about 40 ms. The scan now records every solver it sees filling any pair — a named account with no referral, which the #30 survey found to be solvers without exception — in intents_solver (migration 0011), so the list survives restarts and is there before QTC has a trade of its own. Once a minute the observer asks intents.near what each holds of the asset and every counter traded against it, and serves it at /intents/inventory: the asset as the side that serves buyers, each counter as the side that serves sellers, with how many solvers hold it and the largest holder. The market page shows it as a table, the asset and the ten counters most solvers hold with the rest folded, under a note that says what it is not: no price levels, shared across pairs so the rows do not add up, and not a ceiling. Checked live against our node with wNEAR configured: 11 solvers, 74 tokens read in one pass. Before QTC is listed the panel's job is its one row — when solvers start stocking QTC, it shows here before the first trade. Closes #34 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj --- ...e11a6aae975d77a8b8289f95a6bd96c3b5f21.json | 32 ++++ ...fa9e1a0dd73ba2836d98474927a9b3193de16.json | 16 ++ ...f62fbe39893021d118be560c1986501ae0231.json | 23 +++ crates/blackbeard-api/src/intents.rs | 140 ++++++++++++++- crates/blackbeard-api/src/routes.rs | 24 +++ crates/blackbeard-api/src/state.rs | 3 + crates/blackbeard-core/src/intents.rs | 49 +++++ .../migrations/0011_intents_solver.sql | 15 ++ crates/blackbeard-data/src/near.rs | 33 ++++ crates/blackbeard-data/src/store.rs | 65 +++++++ crates/blackbeard-entities/src/intents.rs | 50 ++++++ crates/blackbeard-entities/src/lib.rs | 5 +- web/src/api/generated/IntentsInventory.ts | 25 +++ web/src/api/generated/InventoryHolder.ts | 15 ++ web/src/api/generated/InventoryToken.ts | 38 ++++ web/src/api/rest.ts | 9 + web/src/components/InventoryPanel.tsx | 167 ++++++++++++++++++ web/src/components/MarketPage.tsx | 3 + web/src/lib/tokens.ts | 7 +- 19 files changed, 710 insertions(+), 9 deletions(-) create mode 100644 .sqlx/query-1f83a928a4758bea96d635769ede11a6aae975d77a8b8289f95a6bd96c3b5f21.json create mode 100644 .sqlx/query-3f3344e1b8b27450f527aaefc5ffa9e1a0dd73ba2836d98474927a9b3193de16.json create mode 100644 .sqlx/query-8753c8ef359a70cdb2954dca520f62fbe39893021d118be560c1986501ae0231.json create mode 100644 crates/blackbeard-data/migrations/0011_intents_solver.sql create mode 100644 web/src/api/generated/IntentsInventory.ts create mode 100644 web/src/api/generated/InventoryHolder.ts create mode 100644 web/src/api/generated/InventoryToken.ts create mode 100644 web/src/components/InventoryPanel.tsx diff --git a/.sqlx/query-1f83a928a4758bea96d635769ede11a6aae975d77a8b8289f95a6bd96c3b5f21.json b/.sqlx/query-1f83a928a4758bea96d635769ede11a6aae975d77a8b8289f95a6bd96c3b5f21.json new file mode 100644 index 0000000..5b0ca3f --- /dev/null +++ b/.sqlx/query-1f83a928a4758bea96d635769ede11a6aae975d77a8b8289f95a6bd96c3b5f21.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "select account, fills, last_seen_height from intents_solver order by fills desc", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "fills", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "last_seen_height", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "1f83a928a4758bea96d635769ede11a6aae975d77a8b8289f95a6bd96c3b5f21" +} diff --git a/.sqlx/query-3f3344e1b8b27450f527aaefc5ffa9e1a0dd73ba2836d98474927a9b3193de16.json b/.sqlx/query-3f3344e1b8b27450f527aaefc5ffa9e1a0dd73ba2836d98474927a9b3193de16.json new file mode 100644 index 0000000..4740c61 --- /dev/null +++ b/.sqlx/query-3f3344e1b8b27450f527aaefc5ffa9e1a0dd73ba2836d98474927a9b3193de16.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into intents_solver (account, first_seen_height, last_seen_height, fills)\n select account, $3, $3, fills from unnest($1::text[], $2::bigint[]) as t(account, fills)\n on conflict (account) do update set\n first_seen_height = least(intents_solver.first_seen_height, excluded.first_seen_height),\n last_seen_height = greatest(intents_solver.last_seen_height, excluded.last_seen_height),\n fills = intents_solver.fills + excluded.fills,\n updated_at = now()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "TextArray", + "Int8Array", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3f3344e1b8b27450f527aaefc5ffa9e1a0dd73ba2836d98474927a9b3193de16" +} diff --git a/.sqlx/query-8753c8ef359a70cdb2954dca520f62fbe39893021d118be560c1986501ae0231.json b/.sqlx/query-8753c8ef359a70cdb2954dca520f62fbe39893021d118be560c1986501ae0231.json new file mode 100644 index 0000000..fd9d57e --- /dev/null +++ b/.sqlx/query-8753c8ef359a70cdb2954dca520f62fbe39893021d118be560c1986501ae0231.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "select distinct counter_token from intents_trade where chain = $1 and asset = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "counter_token", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8753c8ef359a70cdb2954dca520f62fbe39893021d118be560c1986501ae0231" +} diff --git a/crates/blackbeard-api/src/intents.rs b/crates/blackbeard-api/src/intents.rs index 005ad5b..0d3117d 100644 --- a/crates/blackbeard-api/src/intents.rs +++ b/crates/blackbeard-api/src/intents.rs @@ -18,6 +18,7 @@ //! stated in every price response rather than left to be discovered. //! blackbeard/observer#27. +use std::collections::BTreeMap; use std::sync::Arc; use std::time::Duration; @@ -65,6 +66,18 @@ const RETRY: Duration = Duration::from_secs(15); /// How often supply and listing are re-read. const LISTING_INTERVAL: Duration = Duration::from_secs(300); +/// How often solver inventory is re-read. One batch call per solver against +/// our own node, so this is cheap; a minute is fresh enough for a figure that +/// is capacity rather than a price. +const INVENTORY_INTERVAL: Duration = Duration::from_secs(60); + +/// Most solvers asked, by fills. The list is a few dozen; this bounds it if a +/// change to the scan ever starts admitting accounts that are not solvers. +const INVENTORY_SOLVERS: usize = 200; + +/// Largest holders reported per token. +const INVENTORY_TOP: usize = 5; + /// Every NEAR call's timeout. The archive is the slow one. const NEAR_TIMEOUT: Duration = Duration::from_secs(20); @@ -92,6 +105,12 @@ pub fn spawn(chain: Arc, store: Store) { near.clone(), config.clone(), )); + tokio::spawn(inventory( + Arc::clone(&chain), + store.clone(), + near.clone(), + config.clone(), + )); tokio::spawn(scan(chain, store, near, config)); } @@ -188,7 +207,14 @@ async fn scan(chain: Arc, store: Store, near: NearClient, config: let mut reached = None; for &height in &heights { match read_block(&near, &store, &config, height, shard, live_from).await { - Ok(trades) => { + Ok((trades, solvers)) => { + if !solvers.is_empty() { + let solvers: Vec<(String, u32)> = solvers.into_iter().collect(); + if let Err(e) = store.record_solvers(height, &solvers).await { + tracing::warn!(chain = %id, near_height = height, error = %e, + "solvers not recorded"); + } + } if !trades.is_empty() { let rows: Vec = trades .into_iter() @@ -248,12 +274,16 @@ async fn read_block( height: u64, shard: u64, live_from: u64, -) -> Result, blackbeard_data::DataError> { +) -> Result<(Vec, BTreeMap), blackbeard_data::DataError> { let Some(chunk) = near.chunk(height, shard, live_from).await? else { - return Ok(Vec::new()); + return Ok((Vec::new(), BTreeMap::new())); }; let mut out = Vec::new(); + // Every solver in every executed outcome, not only the tracked asset's: + // before the asset trades, the solvers of other pairs are the ones whose + // inventory says when it starts to be stocked (blackbeard/observer#34). + let mut solvers: BTreeMap = BTreeMap::new(); for tx in chunk .transactions .iter() @@ -265,6 +295,9 @@ async fn read_block( continue; } let (legs, malformed) = core::executed_legs(&outcome.logs); + for (account, n) in core::solver_fills(&legs) { + *solvers.entry(account).or_insert(0) += n; + } if malformed > 0 { tracing::warn!(tx = %tx.hash, malformed, "token_diff entries that did not parse; the event format may have changed"); @@ -301,7 +334,7 @@ async fn read_block( } } } - Ok(out) + Ok((out, solvers)) } /// The legs as the contract logged them, amounts as strings. @@ -465,3 +498,102 @@ pub fn to_swap( intent_hashes: r.intent_hashes, } } + +/// Read what solvers hold inside intents.near of the asset and every counter +/// it has traded against, every minute. blackbeard/observer#34. +async fn inventory( + chain: Arc, + store: Store, + near: NearClient, + config: IntentsConfig, +) { + loop { + match read_inventory(&chain.id(), &store, &near, &config).await { + Ok(inv) => { + *chain + .intents_inventory + .write() + .unwrap_or_else(|p| p.into_inner()) = Some(inv); + } + Err(e) => { + tracing::warn!(chain = %chain.id(), error = %e, "solver inventory unreadable") + } + } + tokio::time::sleep(INVENTORY_INTERVAL).await; + } +} + +async fn read_inventory( + chain: &blackbeard_entities::ChainId, + store: &Store, + near: &NearClient, + config: &IntentsConfig, +) -> Result { + let solvers: Vec = store + .intents_solvers() + .await? + .into_iter() + .take(INVENTORY_SOLVERS) + .map(|(account, _, _)| account) + .collect(); + let mut tokens = vec![config.asset.clone()]; + for c in store.intent_counters(chain, &config.asset).await? { + if !tokens.contains(&c) { + tokens.push(c); + } + } + + // token -> (sum, holders) + let mut held: Vec<(u128, Vec<(String, u128)>)> = vec![(0, Vec::new()); tokens.len()]; + for solver in &solvers { + let balances = near + .view_live( + &config.contract, + "mt_batch_balance_of", + json!({"account_id": solver, "token_ids": tokens}), + ) + .await?; + let balances: Vec = serde_json::from_value(balances)?; + for (i, raw) in balances.iter().enumerate().take(tokens.len()) { + let amount: u128 = raw.parse().unwrap_or(0); + if amount > 0 { + held[i].0 = held[i].0.saturating_add(amount); + held[i].1.push((solver.clone(), amount)); + } + } + } + + let meta = token_meta(store, tokens.clone()).await; + let mut out: Vec = tokens + .iter() + .zip(held) + .enumerate() + .map(|(i, (token, (sum, mut holders)))| { + holders.sort_by_key(|h| std::cmp::Reverse(h.1)); + blackbeard_entities::InventoryToken { + symbol: meta.get(token).map(|m| m.0.clone()), + decimals: meta.get(token).map(|m| m.1), + side: if i == 0 { "sell" } else { "buy" }.to_owned(), + held: BigUintDec(sum.to_string()), + holders: holders.len() as u32, + top: holders + .into_iter() + .take(INVENTORY_TOP) + .map(|(account, amount)| blackbeard_entities::InventoryHolder { + account, + held: BigUintDec(amount.to_string()), + }) + .collect(), + token: token.clone(), + } + }) + .collect(); + // The asset first; counters by how many solvers hold them, since amounts + // in different tokens do not compare. + out[1..].sort_by(|a, b| b.holders.cmp(&a.holders)); + Ok(blackbeard_entities::IntentsInventory { + checked_at: chrono::Utc::now(), + solvers: solvers.len() as u32, + tokens: out, + }) +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index ed39202..15ad895 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -66,6 +66,10 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/intents/swaps", get(intents_swaps)) .route("/v1/chains/{chain}/intents/price", get(intents_price)) .route("/v1/chains/{chain}/intents/series", get(intents_series)) + .route( + "/v1/chains/{chain}/intents/inventory", + get(intents_inventory), + ) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -2010,6 +2014,26 @@ async fn intents_price( })) } +/// What solvers hold inside intents.near of each side of the asset's pairs. +/// +/// Inventory, not an order book — see `IntentsInventory`. `null` until the first +/// read, which happens a few seconds after start. blackbeard/observer#34. +async fn intents_inventory( + State(state): State, + Path(chain): Path, +) -> Result>, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + if runtime.spec.intents.is_none() { + return Err(no_intents(&chain)); + } + let inventory = runtime + .intents_inventory + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + Ok(Json(inventory)) +} + /// Trades a price series is taken over, by default and at most. const PRICE_SERIES_TRADES: i64 = 500; const PRICE_SERIES_TRADES_MAX: i64 = 5000; diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 1eea822..b7a2794 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -182,6 +182,8 @@ pub struct ChainRuntime { /// held across an await: the reader clones it out and the writer swaps a /// whole value in. pub intents_listing: RwLock>, + /// Solver inventory inside intents.near, as last read. Same lock rules. + pub intents_inventory: RwLock>, /// Blocks awaiting their telemetry attribution. pub pending_attributions: std::sync::Mutex>, /// Live subscriber count per window, so the recompute timer can skip a @@ -228,6 +230,7 @@ impl ChainRuntime { concentration: tokio::sync::RwLock::new(None), vesting: tokio::sync::RwLock::new(None), intents_listing: RwLock::new(None), + intents_inventory: RwLock::new(None), pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()), subscribers: Default::default(), } diff --git a/crates/blackbeard-core/src/intents.rs b/crates/blackbeard-core/src/intents.rs index fe1a026..920ba3a 100644 --- a/crates/blackbeard-core/src/intents.rs +++ b/crates/blackbeard-core/src/intents.rs @@ -342,6 +342,32 @@ pub fn vwap(trades: &[(u128, u128)], asset_decimals: u8, counter_decimals: u8) - (asset > 0.0).then(|| counter / asset) } +/// Whether an account is an implicit one: 64 hex characters (NEAR's) or `0x` +/// and 40 (an Ethereum-derived one). Those are users' per-quote and wallet +/// accounts; solvers run named ones. +pub fn is_implicit(account: &str) -> bool { + let hex = |s: &str, n: usize| s.len() == n && s.bytes().all(|b| b.is_ascii_hexdigit()); + hex(account, 64) || account.strip_prefix("0x").is_some_and(|rest| hex(rest, 40)) +} + +/// The solvers in one outcome's executed legs, with how many legs each filled. +/// +/// A solver is a leg with no `referral` on a named account. Measured in the +/// #30 survey: 589 such legs over 350 outcomes, every one a known solver, and +/// none of 407 referral legs on a solver. Implicit accounts without a referral +/// are left out rather than guessed at — they are as likely to be users who +/// came in without a front end. +pub fn solver_fills(legs: &[Leg]) -> BTreeMap { + let mut out = BTreeMap::new(); + for leg in legs + .iter() + .filter(|l| l.referral.is_none() && !is_implicit(&l.account_id)) + { + *out.entry(leg.account_id.clone()).or_insert(0) += 1; + } + out +} + /// One trade, as the price series reads it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct SeriesTrade { @@ -767,4 +793,27 @@ mod tests { let (_, b) = bucket_prices(&[trade(0, 702_585_188, 9_036_300_000)], 8, 6, 48, 60_000); assert!((b[0].vwap - 1286.15).abs() < 0.01, "{}", b[0].vwap); } + #[test] + fn solvers_are_named_legs_without_a_referral() { + let (legs, _) = executed_legs(&round_trip()); + let solvers = solver_fills(&legs); + assert_eq!(solvers.get("solver-priv-liq.near"), Some(&2)); + assert_eq!(solvers.get("solver-multichain-asset.near"), Some(&1)); + assert_eq!(solvers.get("solver-multichain-asset-escrow.near"), Some(&1)); + // The taker is implicit and carries a referral: never a solver. + assert!(solvers.keys().all(|a| !is_implicit(a))); + assert_eq!(solvers.values().sum::(), 4); + } + + #[test] + fn implicit_accounts_are_recognised_both_ways() { + assert!(is_implicit( + "42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171" + )); + assert!(is_implicit("0x94845333028b1204fbe14e1278fd4adde46b22ce")); + assert!(!is_implicit( + "0x94845333028b1204fbe14e1278fd4adde46b22ce.near" + )); + assert!(!is_implicit("blockzen-1-main.near")); + } } diff --git a/crates/blackbeard-data/migrations/0011_intents_solver.sql b/crates/blackbeard-data/migrations/0011_intents_solver.sql new file mode 100644 index 0000000..8716b2b --- /dev/null +++ b/crates/blackbeard-data/migrations/0011_intents_solver.sql @@ -0,0 +1,15 @@ +-- Accounts seen filling trades on NEAR Intents. blackbeard/observer#34. +-- +-- Recorded by the intents scan from every executed outcome it reads, not only +-- the tracked asset's: a leg with no referral, on a named account, is a solver +-- (the #30 survey found 589 such legs and every one was a solver). The list is +-- what the inventory poll asks intents.near about, so it has to survive a +-- restart — and it is ours: a solver that only ever fills by a route this +-- indexer does not read is not in it. +create table intents_solver ( + account text primary key, + first_seen_height bigint not null, + last_seen_height bigint not null, + fills bigint not null default 0, + updated_at timestamptz not null default now() +); diff --git a/crates/blackbeard-data/src/near.rs b/crates/blackbeard-data/src/near.rs index e1a99b6..998ea52 100644 --- a/crates/blackbeard-data/src/near.rs +++ b/crates/blackbeard-data/src/near.rs @@ -315,6 +315,39 @@ impl NearClient { } } +impl NearClient { + /// Call a view method on the **live** endpoints rather than the view ones. + /// + /// For a contract on the shard the live node tracks — `intents.near`, + /// whose balances our own node answers in tens of milliseconds — where + /// `view` would send the same question to a rate-limited public endpoint + /// for no reason. A contract on any other shard must go through `view`: + /// a one-shard node answers it UNAVAILABLE_SHARD. + pub async fn view_live( + &self, + contract: &str, + method: &str, + args: Value, + ) -> Result { + use base64::Engine as _; + let result = self + .live + .call( + "query", + json!({ + "request_type": "call_function", + "finality": "final", + "account_id": contract, + "method_name": method, + "args_base64": base64::engine::general_purpose::STANDARD.encode(args.to_string()), + }), + ) + .await?; + let bytes: Vec = serde_json::from_value(result["result"].clone())?; + Ok(serde_json::from_slice(&bytes)?) + } +} + /// A NEP-141 token's display facts. #[derive(Debug, Clone, PartialEq, Eq)] pub struct TokenMetadata { diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 1cadbdc..409e5ab 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -3040,6 +3040,71 @@ impl Store { .collect()) } + /// Record solver fills seen at a NEAR height: `(account, fills)`. + pub async fn record_solvers( + &self, + near_height: u64, + solvers: &[(String, u32)], + ) -> Result<(), DataError> { + if solvers.is_empty() { + return Ok(()); + } + let accounts: Vec = solvers.iter().map(|s| s.0.clone()).collect(); + let fills: Vec = solvers.iter().map(|s| i64::from(s.1)).collect(); + sqlx::query!( + r#" + insert into intents_solver (account, first_seen_height, last_seen_height, fills) + select account, $3, $3, fills from unnest($1::text[], $2::bigint[]) as t(account, fills) + on conflict (account) do update set + first_seen_height = least(intents_solver.first_seen_height, excluded.first_seen_height), + last_seen_height = greatest(intents_solver.last_seen_height, excluded.last_seen_height), + fills = intents_solver.fills + excluded.fills, + updated_at = now() + "#, + &accounts, + &fills, + near_height as i64, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Every solver seen, most fills first: `(account, fills, last_seen_height)`. + pub async fn intents_solvers(&self) -> Result, DataError> { + let rows = sqlx::query!( + "select account, fills, last_seen_height from intents_solver order by fills desc" + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| { + ( + r.account, + r.fills.max(0) as u64, + r.last_seen_height.max(0) as u64, + ) + }) + .collect()) + } + + /// Every counter token ever traded against an asset. + pub async fn intent_counters( + &self, + chain: &ChainId, + asset: &str, + ) -> Result, DataError> { + let rows = sqlx::query!( + "select distinct counter_token from intents_trade where chain = $1 and asset = $2", + chain.as_str(), + asset + ) + .fetch_all(&self.pool) + .await?; + Ok(rows.into_iter().map(|r| r.counter_token).collect()) + } + /// A NEP-141 token's cached `ft_metadata`, `(symbol, decimals)`. pub async fn near_token(&self, token_id: &str) -> Result, DataError> { let row = sqlx::query!( diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index def7458..7d59607 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -254,3 +254,53 @@ pub struct IntentsSeries { #[ts(type = "number")] pub indexed_to: Option, } + +/// One solver's holding of one token inside `intents.near`. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "InventoryHolder.ts")] +pub struct InventoryHolder { + /// The solver's account, as named on NEAR. + pub account: String, + /// What it holds, base units. + pub held: BigUintDec, +} + +/// What solvers hold of one token inside `intents.near`. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "InventoryToken.ts")] +pub struct InventoryToken { + /// The token, as a multi-token id. + pub token: String, + /// Its own symbol, when read. Display only. + pub symbol: Option, + /// Its decimals, when read. + pub decimals: Option, + /// `sell` for the tracked asset — what a buyer of it could be filled from — + /// or `buy` for a counter token — what a seller could be paid in. + pub side: String, + /// Summed over every solver, base units. **Not** a figure to add across + /// counter tokens or pairs: one solver's USDT backs every pair it quotes. + pub held: BigUintDec, + /// Solvers holding any of it. + pub holders: u32, + /// The largest holders, most first. + pub top: Vec, +} + +/// Solver inventory inside `intents.near` for each side of the asset's pairs. +/// +/// **Inventory, not an order book.** NEAR Intents has none: solvers quote +/// privately per request, and nothing on chain carries a price level. This is +/// what solvers hold where they fill from — capacity without a price, shared +/// across every pair they quote, and not a ceiling, since a solver can deposit +/// just in time or route through another token. blackbeard/observer#34. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "IntentsInventory.ts")] +pub struct IntentsInventory { + /// When the balances were read. + pub checked_at: DateTime, + /// Solvers asked: accounts this observer has seen filling trades. + pub solvers: u32, + /// The tracked asset first, then its counter tokens by amount held. + pub tokens: Vec, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 349f8ca..c95ce87 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -40,8 +40,9 @@ pub use chain::{ }; pub use error::{ApiError, EntityError}; pub use intents::{ - CounterPrice, IntentsListing, IntentsPrice, IntentsSeries, IntentsSeriesPoint, IntentsSwap, - IntentsSwaps, MarketSummary, Markets, + CounterPrice, IntentsInventory, IntentsListing, IntentsPrice, IntentsSeries, + IntentsSeriesPoint, IntentsSwap, IntentsSwaps, InventoryHolder, InventoryToken, MarketSummary, + Markets, }; pub use miner::{ AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity, diff --git a/web/src/api/generated/IntentsInventory.ts b/web/src/api/generated/IntentsInventory.ts new file mode 100644 index 0000000..4805f37 --- /dev/null +++ b/web/src/api/generated/IntentsInventory.ts @@ -0,0 +1,25 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { InventoryToken } from "./InventoryToken"; + +/** + * Solver inventory inside `intents.near` for each side of the asset's pairs. + * + * **Inventory, not an order book.** NEAR Intents has none: solvers quote + * privately per request, and nothing on chain carries a price level. This is + * what solvers hold where they fill from — capacity without a price, shared + * across every pair they quote, and not a ceiling, since a solver can deposit + * just in time or route through another token. blackbeard/observer#34. + */ +export type IntentsInventory = { +/** + * When the balances were read. + */ +checked_at: string, +/** + * Solvers asked: accounts this observer has seen filling trades. + */ +solvers: number, +/** + * The tracked asset first, then its counter tokens by amount held. + */ +tokens: Array, }; diff --git a/web/src/api/generated/InventoryHolder.ts b/web/src/api/generated/InventoryHolder.ts new file mode 100644 index 0000000..9c1c7b1 --- /dev/null +++ b/web/src/api/generated/InventoryHolder.ts @@ -0,0 +1,15 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; + +/** + * One solver's holding of one token inside `intents.near`. + */ +export type InventoryHolder = { +/** + * The solver's account, as named on NEAR. + */ +account: string, +/** + * What it holds, base units. + */ +held: BigUintDec, }; diff --git a/web/src/api/generated/InventoryToken.ts b/web/src/api/generated/InventoryToken.ts new file mode 100644 index 0000000..99f999b --- /dev/null +++ b/web/src/api/generated/InventoryToken.ts @@ -0,0 +1,38 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; +import type { InventoryHolder } from "./InventoryHolder"; + +/** + * What solvers hold of one token inside `intents.near`. + */ +export type InventoryToken = { +/** + * The token, as a multi-token id. + */ +token: string, +/** + * Its own symbol, when read. Display only. + */ +symbol: string | null, +/** + * Its decimals, when read. + */ +decimals: number | null, +/** + * `sell` for the tracked asset — what a buyer of it could be filled from — + * or `buy` for a counter token — what a seller could be paid in. + */ +side: string, +/** + * Summed over every solver, base units. **Not** a figure to add across + * counter tokens or pairs: one solver's USDT backs every pair it quotes. + */ +held: BigUintDec, +/** + * Solvers holding any of it. + */ +holders: number, +/** + * The largest holders, most first. + */ +top: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index d9ad92d..858c0c7 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 { IntentsInventory } from './generated/IntentsInventory' import type { IntentsPrice } from './generated/IntentsPrice' import type { IntentsSeries } from './generated/IntentsSeries' import type { IntentsSwaps } from './generated/IntentsSwaps' @@ -323,3 +324,11 @@ export async function fetchIntentsSeries( signal, ) } + +/** Solver inventory inside intents.near; null until first read. */ +export async function fetchIntentsInventory( + chain: string, + signal?: AbortSignal, +): Promise { + return get(`/chains/${chain}/intents/inventory`, signal) +} diff --git a/web/src/components/InventoryPanel.tsx b/web/src/components/InventoryPanel.tsx new file mode 100644 index 0000000..ef27c4c --- /dev/null +++ b/web/src/components/InventoryPanel.tsx @@ -0,0 +1,167 @@ +/** + * What solvers hold inside intents.near, for each side of the asset's pairs. + * + * Not an order book, and the panel says so before it shows a number: NEAR + * Intents has none. Solvers quote privately per request and nothing on chain + * carries a price level. What is on chain is the inventory they fill from — + * read from our own node, which tracks intents.near's shard, once a minute. + * + * A table rather than a chart. Amounts in different tokens share no scale, and + * the figures do not add up across rows: one solver's USDT backs every pair it + * quotes. blackbeard/observer#34. + */ + +import { useEffect, useState } from 'react' + +import type { IntentsInventory } from '../api/generated/IntentsInventory' +import { RequestFailed, fetchIntentsInventory } from '../api/rest' +import { ago, height as fmtHeight, tokens } from '../lib/format' +import { tokenLabel } from '../lib/tokens' + +/** Counter tokens shown before the rest fold away, by solvers holding them. */ +const SHOWN = 10 + +/** How often the page re-asks; the backend reads once a minute. */ +const REFRESH_MS = 60_000 + +export function InventoryPanel({ chain, symbol }: { chain: string; symbol: string }) { + const [inventory, setInventory] = useState(undefined) + const [error, setError] = useState(null) + + useEffect(() => { + let controller = new AbortController() + const load = () => { + controller = new AbortController() + fetchIntentsInventory(chain, controller.signal) + .then((inv) => { + setInventory(inv) + setError(null) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + } + load() + const id = window.setInterval(load, REFRESH_MS) + return () => { + window.clearInterval(id) + controller.abort() + } + }, [chain]) + + const asset = inventory?.tokens[0]?.symbol ?? symbol + const amount = (raw: string, decimals: number | null) => + decimals === null ? `${raw} base units` : tokens(raw, decimals) + // The asset, then the counters most solvers hold. A busy token has seventy + // counters; the rest fold, so the panel stays a panel. + const shown = inventory?.tokens.slice(0, SHOWN + 1) ?? [] + const folded = inventory?.tokens.slice(SHOWN + 1) ?? [] + const rows = (list: NonNullable['tokens']) => + list.map((t) => { + const top = t.top[0] + return ( + + + {tokenLabel(t.token, t.symbol)} + + + {t.side === 'sell' ? `buyers of ${asset}` : `sellers of ${asset}`} + + + {amount(t.held, t.decimals)} + + {fmtHeight(t.holders)} + + {top ? top.account : '—'} + + + ) + }) + const head = ( + + + + Token + + + Serves + + Held by solvers + Solvers holding + + Largest holder + + + + ) + + return ( +
+
+

Solver inventory

+ + {inventory + ? `${fmtHeight(inventory.solvers)} solvers · read ${ago(inventory.checked_at)}` + : 'inside intents.near'} + +
+ + {error &&

{error}

} + {!error && inventory === undefined &&

Reading…

} + {!error && inventory === null && ( +

+ Not read yet — the first read is a few seconds after the observer starts. +

+ )} + {inventory && ( +
+ + + {head} + {rows(shown)} +
+ What solvers hold inside intents.near, by token +
+
+ )} + {folded.length > 0 && ( +
+ + {fmtHeight(folded.length)} more counter token{folded.length === 1 ? '' : 's'} + +
+ + + {head} + {rows(folded)} +
The remaining counter tokens
+
+
+ )} + + {inventory && inventory.tokens.length === 1 && ( +

+ Only {asset} is listed because nothing has traded against it yet: the counter tokens + appear here once there are trades to name them. +

+ )} +

+ This is inventory, not an order book. NEAR Intents has no book: solvers + quote privately for each request, and nothing on chain carries a price. What is on chain is + what solvers hold where they fill from — read from intents.near itself every minute. It has + no price levels; it is shared across every pair a solver quotes, so the rows do not add up; + and it is not a ceiling, since a solver can deposit just in time or route through another + token. A zero says nothing is held there now, not that a trade cannot be filled. +

+

+ The solvers are the accounts this observer has seen filling trades on any pair — a named + account with no referral. One that only fills by a route the indexer does not read is not + among them. +

+
+ ) +} diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx index 33d1ffa..5cf480f 100644 --- a/web/src/components/MarketPage.tsx +++ b/web/src/components/MarketPage.tsx @@ -21,6 +21,7 @@ import { height as fmtHeight } from '../lib/format' import { useObserverInstance } from '../lib/observer-context' import { href } from '../lib/routes' import { IntentsPanel } from './IntentsPanel' +import { InventoryPanel } from './InventoryPanel' import { PriceChart } from './PriceChart' import { SwapHead, SwapRow } from './SwapRow' @@ -136,6 +137,8 @@ function NearIntents({ + +

Settling now

diff --git a/web/src/lib/tokens.ts b/web/src/lib/tokens.ts index e0f9792..d382f39 100644 --- a/web/src/lib/tokens.ts +++ b/web/src/lib/tokens.ts @@ -44,7 +44,8 @@ export function shortContract(tokenId: string): string { 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 + // A chain's own coin needs no qualifier: `SOL · sol` and `wNEAR · near` say + // nothing the symbol does not. Only a token that could be confused with + // another gets one. + return origin && !symbol.toLowerCase().endsWith(origin) ? `${symbol} · ${origin}` : symbol }