diff --git a/.sqlx/query-65eb7abb616a4cb3f3a406eff1eb34fba2de19ad571f3025dade848472dc88de.json b/.sqlx/query-65eb7abb616a4cb3f3a406eff1eb34fba2de19ad571f3025dade848472dc88de.json new file mode 100644 index 0000000..9ddf6a6 --- /dev/null +++ b/.sqlx/query-65eb7abb616a4cb3f3a406eff1eb34fba2de19ad571f3025dade848472dc88de.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "delete from quote_probe_raw where at < now() - interval '24 hours'", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "65eb7abb616a4cb3f3a406eff1eb34fba2de19ad571f3025dade848472dc88de" +} diff --git a/.sqlx/query-8a168960fc8a555ebc4da05f18157d3a123c455c82e7d739642bd11a0b5a0163.json b/.sqlx/query-8a168960fc8a555ebc4da05f18157d3a123c455c82e7d739642bd11a0b5a0163.json new file mode 100644 index 0000000..dd4d2ac --- /dev/null +++ b/.sqlx/query-8a168960fc8a555ebc4da05f18157d3a123c455c82e7d739642bd11a0b5a0163.json @@ -0,0 +1,90 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select at, direction, counter, size::text as \"size!\", outcome, message, latency_ms,\n pool_out::text as pool_out, markup_bps, best_solver,\n best_held::text as best_held, solvers_able\n from quote_probe\n where chain = $1 and asset = $2 and at >= $3\n order by at, direction, counter, size\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "direction", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "counter", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "size!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "outcome", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "message", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "latency_ms", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "pool_out", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "markup_bps", + "type_info": "Int4" + }, + { + "ordinal": 9, + "name": "best_solver", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "best_held", + "type_info": "Text" + }, + { + "ordinal": 11, + "name": "solvers_able", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + null, + false, + true, + true, + null, + true, + true, + null, + true + ] + }, + "hash": "8a168960fc8a555ebc4da05f18157d3a123c455c82e7d739642bd11a0b5a0163" +} diff --git a/.sqlx/query-ac30c5141f8be42eea2f378b47d32dd264601d30f35dad9a2d2c1195035d0ee6.json b/.sqlx/query-ac30c5141f8be42eea2f378b47d32dd264601d30f35dad9a2d2c1195035d0ee6.json new file mode 100644 index 0000000..c67560a --- /dev/null +++ b/.sqlx/query-ac30c5141f8be42eea2f378b47d32dd264601d30f35dad9a2d2c1195035d0ee6.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into quote_probe_raw (at, direction, counter, size, status, body)\n values ($1, $2, $3, $4::text::numeric, $5, $6)\n on conflict do nothing\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Text", + "Text", + "Text", + "Int4", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "ac30c5141f8be42eea2f378b47d32dd264601d30f35dad9a2d2c1195035d0ee6" +} diff --git a/.sqlx/query-dac25395a974ee74c8cf16f6367d867677ff356c53ce37aa72951353e320f56a.json b/.sqlx/query-dac25395a974ee74c8cf16f6367d867677ff356c53ce37aa72951353e320f56a.json new file mode 100644 index 0000000..8970fd6 --- /dev/null +++ b/.sqlx/query-dac25395a974ee74c8cf16f6367d867677ff356c53ce37aa72951353e320f56a.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "\n insert into quote_probe\n (chain, asset, at, direction, counter, size, outcome, message, latency_ms,\n pool_out, markup_bps, best_solver, best_held, solvers_able)\n values ($1, $2, $3, $4, $5, $6::text::numeric, $7, $8, $9,\n $10::text::numeric, $11, $12, $13::text::numeric, $14)\n on conflict do nothing\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Timestamptz", + "Text", + "Text", + "Text", + "Text", + "Text", + "Int4", + "Text", + "Int4", + "Text", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "dac25395a974ee74c8cf16f6367d867677ff356c53ce37aa72951353e320f56a" +} diff --git a/CLAUDE.md b/CLAUDE.md index 78b591e..5b44368 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -594,6 +594,21 @@ final, stored raw and decoded on read. `height_included` is the check; without it a quiet shard's trades are counted once per block until it produces again. +**A refusal to quote is recorded against what could have filled it** (#45). +`quoteprobe` asks 1Click for keyless dry quotes on QTC pairs only, the +smallest size each way every minute and every size each quarter hour, and +beside each answer reads, from our own nodes, the pool's `quote` for the same +input and every solver's balance of the output token. "Able" means one solver +alone held at least the pool's output, because solvers do not pool inventory. +Two rules hold it up. **A probe that got no answer is `failed`, never +`no_liquidity`**, so a block on us cannot read as a solver pause; `classify` +matches "No liquidity available" exactly and treats anything reworded as a +refusal until someone looks. And **no 1Click data is kept past 24 hours**: the +1ClickSwap API Terms §4.1(e) forbid it. A quote becomes `markup_bps` on arrival, +and `quote_probe_raw` is pruned hourly. The page records and does not accuse: +inventory existing while no quote was given has benign explanations, and the +note says so. + **A token's symbol is not its identity.** `tron-d28a…omft.near` calls itself `USDT` — it is USDT on Tron — and the issue that started this read the contract name and called it TRON. Anyone can deploy a token called USDC, so a stablecoin diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl index f0eae16..8250f80 100644 --- a/asset/config/config.toml.tmpl +++ b/asset/config/config.toml.tmpl @@ -158,6 +158,25 @@ pool_rpc_urls = ["http://gargravarr.hanzalova.internal:24403"] # pools are listed there, a few thousand-pool pages every ten minutes, rather # than on a rate-limited public endpoint (blackbeard/observer#39). ref_v1_rpc_urls = ["http://quadbrat.hanzalova.internal:24403"] +# Ask 1Click whether it will quote QTC, both ways, keyless and dry, and set the +# answer beside what the pool and the solvers could have filled +# (blackbeard/observer#45). Only pairs with QTC on one side are ever probed. The +# smallest size goes every minute and the rest every fifteen: about four +# requests a minute against the 1,200 a minute 1Click publishes for keyless +# use (x-ratelimit-limit-unauth, 2026-10-07). +# +# Native USDC on NEAR is the pool's own counter token. USDC bridged from +# Ethereum is the payout most users choose; it has no QTC pool of its own, so +# it is compared against native USDC's: same issuer, same value, same six +# decimals. +[[chains.intents.probe]] +counter = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" +buy_sizes = [100, 1000, 10000, 100000] + +[[chains.intents.probe]] +counter = "nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near" +buy_sizes = [100, 1000, 10000, 100000] +reference = "nep141:17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1" # The QTC bridge to NEAR, read from this chain's side (blackbeard/observer#39). # Roles, not guesses, so the evidence is written down: diff --git a/crates/blackbeard-api/src/config.rs b/crates/blackbeard-api/src/config.rs index 3bd50c5..742c405 100644 --- a/crates/blackbeard-api/src/config.rs +++ b/crates/blackbeard-api/src/config.rs @@ -312,6 +312,38 @@ pub struct IntentsConfig { /// at the tip: a full listing is nine calls of a thousand pools each. #[serde(default)] pub ref_v1_rpc_urls: Vec, + /// Pairs to ask 1Click to quote, keyless and dry, against what the pool and + /// the solvers could have filled (blackbeard/observer#45). Every pair is the + /// asset against one counter token; nothing else is ever probed. Empty + /// turns the probe off. + #[serde(default)] + pub probe: Vec, + /// Sizes to probe selling the asset at, in whole units of it, smallest + /// first: the smallest is probed every minute, the rest every + /// [`FULL_EVERY_MINUTES`](crate::quoteprobe::FULL_EVERY_MINUTES) minutes. + #[serde(default = "default_probe_sell_sizes")] + pub probe_sell_sizes: Vec, +} + +/// One counter token to probe the asset against. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ProbeConfig { + /// The counter token, as a multi-token id. + pub counter: String, + /// Sizes to probe buying the asset with, in whole units of the counter, + /// smallest first. + pub buy_sizes: Vec, + /// A token whose pool stands in for this one's when this one has none of + /// its own — USDC bridged from Ethereum priced against native USDC's pool. + /// Only sound between tokens of the same value and decimals, which the + /// probe checks; say why beside it. + #[serde(default)] + pub reference: Option, +} + +fn default_probe_sell_sizes() -> Vec { + vec![1, 10, 100, 1_000] } impl IntentsConfig { diff --git a/crates/blackbeard-api/src/intents.rs b/crates/blackbeard-api/src/intents.rs index 180d048..b31b35b 100644 --- a/crates/blackbeard-api/src/intents.rs +++ b/crates/blackbeard-api/src/intents.rs @@ -127,6 +127,7 @@ pub fn spawn(chain: Arc, store: Store) { crate::intentsflow::spawn(Arc::clone(&chain), store.clone(), config.clone()); crate::refv1::spawn(Arc::clone(&chain), config.clone()); crate::bridgescan::spawn(Arc::clone(&chain), store.clone()); + crate::quoteprobe::spawn(Arc::clone(&chain), store.clone(), config.clone()); tokio::spawn(scan(chain, store, near, config)); } diff --git a/crates/blackbeard-api/src/main.rs b/crates/blackbeard-api/src/main.rs index f1b25f1..718611b 100644 --- a/crates/blackbeard-api/src/main.rs +++ b/crates/blackbeard-api/src/main.rs @@ -15,6 +15,7 @@ mod intents; mod intentsflow; mod neartx; mod poolscan; +mod quoteprobe; mod refdcl; mod refv1; mod registry; diff --git a/crates/blackbeard-api/src/quoteprobe.rs b/crates/blackbeard-api/src/quoteprobe.rs new file mode 100644 index 0000000..67b347a --- /dev/null +++ b/crates/blackbeard-api/src/quoteprobe.rs @@ -0,0 +1,371 @@ +//! Ask 1Click whether it will quote the asset, and record what could have +//! filled it when it would not (blackbeard/observer#45). +//! +//! Every minute, for every configured counter token, one keyless dry quote in +//! each direction at the smallest size; every [`FULL_EVERY_MINUTES`], every +//! size. Beside each answer, read at the same moment from our own nodes: +//! +//! - **what the pool would pay** for the same input, from its `quote` view; +//! - **what each solver held** of the output token inside `intents.near`, one +//! holder at a time, since solvers do not pool their inventory. +//! +//! So a "No liquidity available" is recorded together with whether any solver +//! could have settled the trade, which is the question this exists to answer. +//! Only QTC pairs are probed: every request has the asset on one side. +//! +//! Only our own observations are kept long term. The 1ClickSwap API Terms +//! (§4.1(e)) forbid storing API data for more than 24 hours, so a quote's +//! amount becomes a markup against the pool on arrival and the raw response +//! is deleted after a day. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use blackbeard_core::quote_probe::{self as core, Outcome}; +use blackbeard_data::near::NearClient; +use blackbeard_data::store::{QuoteProbeRow, Store}; +use chrono::Utc; +use serde_json::json; + +use crate::config::IntentsConfig; +use crate::state::ChainRuntime; + +/// How often every size is probed, in minutes; between, only the smallest. +pub const FULL_EVERY_MINUTES: u64 = 15; + +/// How long one 1Click request may take. Its answers have taken about three +/// seconds. +const QUOTE_TIMEOUT: Duration = Duration::from_secs(20); + +/// How long one call to our own nodes may take. +const NEAR_TIMEOUT: Duration = Duration::from_secs(10); + +/// Between two 1Click requests: unhurried, well inside the published limit. +const SPACING: Duration = Duration::from_millis(500); + +/// How far ahead a dry quote's deadline is set. Nothing is committed either +/// way; the field is required. +const DEADLINE_MINUTES: i64 = 10; + +/// Start the probe, when the chain configures one. +pub fn spawn(chain: Arc, store: Store, config: IntentsConfig) { + if config.probe.is_empty() { + return; + } + let near = match NearClient::new( + config.rpc_urls.clone(), + config.archive_urls.clone(), + config.view_urls.clone(), + NEAR_TIMEOUT, + ) + .and_then(|n| n.with_own_views(config.pool_rpc_urls.clone(), NEAR_TIMEOUT)) + { + Ok(n) => n, + Err(e) => { + tracing::error!(chain = %chain.id(), error = %e, "quote probe not started"); + return; + } + }; + tracing::info!(chain = %chain.id(), pairs = config.probe.len(), "probing 1Click quotes"); + tokio::spawn(run(chain, store, near, config)); +} + +/// One trade to probe. +struct Probe { + direction: &'static str, + counter: String, + /// The token given and the token received. + origin: String, + destination: String, + /// Input, base units. + amount: u128, + /// The pool to compare with, and the tokens as it names them, when one + /// can be asked. + pool: Option, +} + +#[derive(Clone)] +struct PoolRef { + contract: String, + pool_id: String, + input: String, + output: String, +} + +async fn run(chain: Arc, store: Store, near: NearClient, config: IntentsConfig) { + let id = chain.id(); + // The last outcome per direction and counter, so a change is logged once. + let mut last: HashMap<(String, String), &'static str> = HashMap::new(); + loop { + // On the minute, so probes line up across restarts and directions. + let now = Utc::now(); + let wait = 60 - (now.timestamp().rem_euclid(60)) as u64; + tokio::time::sleep(Duration::from_secs(wait)).await; + let minute = (Utc::now().timestamp() / 60) as u64; + + let probes = plan(&chain, &store, &near, &config, minute).await; + if probes.is_empty() { + continue; + } + let holdings = holdings(&chain, &store, &near, &config).await; + let mut rows = Vec::new(); + for p in probes { + let row = probe(&store, &near, &config, &holdings, &p).await; + let key = (p.direction.to_owned(), p.counter.clone()); + let outcome = match row.outcome.as_str() { + "quoted" => "quoted", + "no_liquidity" => "no_liquidity", + "refused" => "refused", + _ => "failed", + }; + if last.get(&key) != Some(&outcome) { + tracing::info!(chain = %id, direction = p.direction, counter = %p.counter, + outcome, message = ?row.message, solvers_able = ?row.solvers_able, + "1Click quote availability changed"); + last.insert(key, outcome); + } + rows.push(row); + tokio::time::sleep(SPACING).await; + } + if let Err(e) = store.record_quote_probes(&id, &config.asset, &rows).await { + tracing::warn!(chain = %id, error = %e, "quote probes not recorded"); + } + if minute % 60 == 0 { + match store.prune_quote_raw().await { + Ok(n) => tracing::debug!(chain = %id, deleted = n, "raw quote responses pruned"), + Err(e) => tracing::warn!(chain = %id, error = %e, "raw quote responses not pruned"), + } + } + } +} + +/// This minute's probes. +async fn plan( + chain: &ChainRuntime, + store: &Store, + near: &NearClient, + config: &IntentsConfig, + minute: u64, +) -> Vec { + let asset = config.asset.clone(); + let Some(asset_decimals) = decimals(near, store, &asset).await else { + tracing::warn!(%asset, "asset decimals unknown; probe skipped this minute"); + return Vec::new(); + }; + let pools = chain + .ref_pools + .read() + .unwrap_or_else(|p| p.into_inner()) + .as_ref() + .map(|p| p.pools.clone()) + .unwrap_or_default(); + let contract_of = |t: &str| t.strip_prefix("nep141:").unwrap_or(t).to_owned(); + let mut out = Vec::new(); + for pair in &config.probe { + let Some(counter_decimals) = decimals(near, store, &pair.counter).await else { + tracing::warn!(counter = %pair.counter, "counter decimals unknown; pair skipped"); + continue; + }; + // The pool to compare with: the counter's own, or its stated + // reference's when the two have the same decimals. Different decimals + // would make the comparison meaningless, so there is then none. + let reference = pair.reference.as_deref().unwrap_or(&pair.counter); + let reference_ok = match pair.reference.as_deref() { + None => true, + Some(r) => decimals(near, store, r).await == Some(counter_decimals), + }; + let pool = pools + .iter() + .filter(|p| p.counter_token == reference && p.state == "Running") + .max_by_key(|p| p.counter_held.0.parse::().unwrap_or(0)) + .filter(|_| reference_ok); + + let sells: Vec = config + .probe_sell_sizes + .iter() + .map(|s| u128::from(*s) * 10u128.pow(u32::from(asset_decimals))) + .collect(); + let buys: Vec = pair + .buy_sizes + .iter() + .map(|s| u128::from(*s) * 10u128.pow(u32::from(counter_decimals))) + .collect(); + for &amount in core::sizes_due(minute, &sells, FULL_EVERY_MINUTES) { + out.push(Probe { + direction: "sell", + counter: pair.counter.clone(), + origin: asset.clone(), + destination: pair.counter.clone(), + amount, + pool: pool.map(|p| PoolRef { + contract: p.contract.clone(), + pool_id: p.pool_id.clone(), + input: contract_of(&asset), + output: contract_of(reference), + }), + }); + } + for &amount in core::sizes_due(minute, &buys, FULL_EVERY_MINUTES) { + out.push(Probe { + direction: "buy", + counter: pair.counter.clone(), + origin: pair.counter.clone(), + destination: asset.clone(), + amount, + pool: pool.map(|p| PoolRef { + contract: p.contract.clone(), + pool_id: p.pool_id.clone(), + input: contract_of(reference), + output: contract_of(&asset), + }), + }); + } + } + out +} + +/// What every solver holds inside the Intents contract of the asset and each +/// counter, read now from our own node. Token id to `(solver, amount)`. +async fn holdings( + chain: &Arc, + store: &Store, + near: &NearClient, + config: &IntentsConfig, +) -> HashMap> { + let mut tokens = vec![config.asset.clone()]; + for p in &config.probe { + if !tokens.contains(&p.counter) { + tokens.push(p.counter.clone()); + } + } + let mut out: HashMap> = HashMap::new(); + for solver in crate::intents::solver_set(chain, store).await { + let read = near + .view_live( + &config.contract, + "mt_batch_balance_of", + json!({"account_id": solver, "token_ids": tokens}), + ) + .await; + let balances: Vec = match read.map(serde_json::from_value) { + Ok(Ok(b)) => b, + // One unreadable solver leaves it out of this minute's capacity + // rather than counting it as holding nothing. + Ok(Err(e)) => { + tracing::debug!(%solver, error = %e, "solver balances did not parse"); + continue; + } + Err(e) => { + tracing::debug!(%solver, error = %e, "solver balances unreadable"); + continue; + } + }; + for (token, raw) in tokens.iter().zip(balances) { + let amount: u128 = raw.parse().unwrap_or(0); + if amount > 0 { + out.entry(token.clone()) + .or_default() + .push((solver.clone(), amount)); + } + } + } + out +} + +/// Probe one trade and say what could have filled it. +async fn probe( + store: &Store, + near: &NearClient, + config: &IntentsConfig, + holdings: &HashMap>, + p: &Probe, +) -> QuoteProbeRow { + // The pool first: it is our own node, and it is the yardstick. + let pool_out: Option = match &p.pool { + Some(pool) => near + .view_anywhere( + &pool.contract, + "quote", + json!({"pool_ids": [pool.pool_id], "input_token": pool.input, + "output_token": pool.output, "input_amount": p.amount.to_string(), + "tag": null}), + ) + .await + .ok() + .and_then(|q| q["amount"].as_str().and_then(|a| a.parse().ok())) + .filter(|out| *out > 0), + None => None, + }; + + let at = Utc::now(); + let deadline = (at + chrono::Duration::minutes(DEADLINE_MINUTES)) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let request = core::request(&p.origin, &p.destination, p.amount, &deadline); + let answer = + blackbeard_data::near::oneclick_quote(&config.oneclick_url, &request, QUOTE_TIMEOUT).await; + let (outcome, latency, status, body) = match &answer { + Ok(a) => ( + core::classify(Some(a.status), &a.body), + Some(a.latency), + Some(i32::from(a.status)), + a.body.clone(), + ), + Err(e) => ( + Outcome::Failed(format!("not answered: {e}")), + None, + None, + json!({"error": e.to_string()}), + ), + }; + if let Err(e) = store + .record_quote_raw( + at, + p.direction, + &p.counter, + &p.amount.to_string(), + status, + &body, + ) + .await + { + tracing::debug!(error = %e, "raw quote response not kept"); + } + + let (best, able) = match (holdings.get(&p.destination), pool_out) { + (Some(h), Some(needed)) => { + let (best, able) = core::capacity(needed, h); + (best, Some(able)) + } + (Some(h), None) => (core::capacity(0, h).0, None), + (None, Some(_)) => (None, Some(0)), + (None, None) => (None, None), + }; + let markup = match (&outcome, pool_out) { + (Outcome::Quoted { amount_out }, Some(pool)) => core::markup_bps(pool, *amount_out), + _ => None, + }; + QuoteProbeRow { + at, + direction: p.direction.to_owned(), + counter: p.counter.clone(), + size: p.amount.to_string(), + outcome: outcome.name().to_owned(), + message: outcome.message().map(str::to_owned), + latency_ms: latency.and_then(|l| i32::try_from(l.as_millis()).ok()), + pool_out: pool_out.map(|o| o.to_string()), + markup_bps: markup, + best_solver: best.as_ref().map(|b| b.0.clone()), + best_held: best.map(|b| b.1.to_string()), + solvers_able: able.map(|a| a as i32), + } +} + +/// A token's decimals, cached in the store after the first read. +async fn decimals(near: &NearClient, store: &Store, token: &str) -> Option { + if let Ok(Some((_, d))) = store.near_token(token).await { + return Some(d); + } + crate::intents::remember_token(near, store, token).await; + store.near_token(token).await.ok().flatten().map(|(_, d)| d) +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index e1df90e..c6200a0 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -76,6 +76,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/ref/bids", get(ref_bids)) .route("/v1/chains/{chain}/intents/flows", get(intents_flows)) .route("/v1/chains/{chain}/intents/fills", get(intents_fills)) + .route("/v1/chains/{chain}/intents/quotes", get(intents_quotes)) .route("/v1/chains/{chain}/ref/v1", get(ref_v1)) .route("/v1/chains/{chain}/bridge", get(bridge)) .route("/v1/chains/{chain}/bridge/deposits", get(bridge_deposits)) @@ -5144,6 +5145,102 @@ async fn intents_account( Ok(Json(found)) } +#[derive(Debug, Deserialize)] +struct QuotesQuery { + /// Hours of history, default a day, at most three. + hours: Option, +} + +/// Whether 1Click would quote the asset, against what could have filled it +/// (blackbeard/observer#45). +async fn intents_quotes( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let config = runtime + .spec + .intents + .clone() + .ok_or_else(|| no_intents(&chain))?; + let id = runtime.id(); + // Three days at most: about six thousand probes a day is the ceiling of + // what one response should carry. + let hours = query.hours.unwrap_or(24).clamp(1, 72); + let since = chrono::Utc::now() - chrono::Duration::hours(i64::from(hours)); + let rows = state + .store + .quote_probes(&id, &config.asset, since) + .await + .map_err(database_unavailable)?; + let big = |s: &Option| s.clone().map(BigUintDec); + let probes: Vec = rows + .into_iter() + .map(|r| blackbeard_entities::QuoteProbe { + pool_out: big(&r.pool_out), + best_held: big(&r.best_held), + at: r.at, + direction: r.direction, + counter: r.counter, + size: BigUintDec(r.size), + outcome: r.outcome, + message: r.message, + latency_ms: r.latency_ms.and_then(|l| u32::try_from(l).ok()), + markup_bps: r.markup_bps, + best_solver: r.best_solver, + solvers_able: r.solvers_able.and_then(|a| u32::try_from(a).ok()), + }) + .collect(); + + let mut availability: Vec = Vec::new(); + for pair in &config.probe { + for direction in ["sell", "buy"] { + let these: Vec<&blackbeard_entities::QuoteProbe> = probes + .iter() + .filter(|p| p.direction == direction && p.counter == pair.counter) + .collect(); + let count = |o: &str| these.iter().filter(|p| p.outcome == o).count() as u32; + let mut markups: Vec = these.iter().filter_map(|p| p.markup_bps).collect(); + markups.sort_unstable(); + availability.push(blackbeard_entities::QuoteAvailability { + direction: direction.to_owned(), + counter: pair.counter.clone(), + answered: these.iter().filter(|p| p.outcome != "failed").count() as u32, + quoted: count("quoted"), + no_liquidity: count("no_liquidity"), + no_liquidity_while_able: these + .iter() + .filter(|p| p.outcome == "no_liquidity" && p.solvers_able.unwrap_or(0) > 0) + .count() as u32, + refused: count("refused"), + failed: count("failed"), + median_markup_bps: markups.get(markups.len() / 2).copied(), + }); + } + } + + let mut tokens = Vec::new(); + for token in std::iter::once(&config.asset).chain(config.probe.iter().map(|p| &p.counter)) { + if let Ok(Some((symbol, decimals))) = state.store.near_token(token).await { + tokens.push(blackbeard_entities::NearToken { + token: token.clone(), + symbol, + decimals, + }); + } + } + Ok(Json(blackbeard_entities::QuoteProbes { + chain: id, + asset: config.asset.clone(), + hours, + probing: !config.probe.is_empty(), + probes, + availability, + tokens, + })) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/blackbeard-core/src/lib.rs b/crates/blackbeard-core/src/lib.rs index 854e5b2..b51c9c4 100644 --- a/crates/blackbeard-core/src/lib.rs +++ b/crates/blackbeard-core/src/lib.rs @@ -24,6 +24,7 @@ pub mod intents; pub mod near_tx; pub mod near_words; pub mod pallet; +pub mod quote_probe; pub mod runtime; pub mod scale; pub mod series; diff --git a/crates/blackbeard-core/src/quote_probe.rs b/crates/blackbeard-core/src/quote_probe.rs new file mode 100644 index 0000000..8c2d247 --- /dev/null +++ b/crates/blackbeard-core/src/quote_probe.rs @@ -0,0 +1,247 @@ +//! Whether 1Click would quote the tracked asset, and whether it could have +//! (blackbeard/observer#45). +//! +//! On NEAR Intents a price exists only when a solver chooses to quote one, and +//! nothing on-chain records that it did not. "No liquidity available" is what +//! a user sees either way. So the observer asks, keyless and `dry`, a few times +//! a minute, and sets each answer beside two things it can see for itself: +//! what the pool would pay for the same trade on-chain, and what the solvers +//! held of the output token inside the Intents contract at that moment. +//! +//! Only this observer's own observations are kept long term. The 1ClickSwap +//! API Terms (§4.1(e)) forbid storing API data for more than a day, so a +//! quoted amount is reduced to a markup against the pool when it arrives and +//! the raw response goes after 24 hours. + +use serde_json::{Value, json}; + +/// What one probe found. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Outcome { + /// A quote came back; its output in base units of the output token. + Quoted { + /// `quote.amountOut`. + amount_out: u128, + }, + /// The service's own "No liquidity available": nobody offered. + NoLiquidity, + /// The service answered with some other refusal, in its own words: an + /// amount too small, a pair it does not list. + Refused(String), + /// The service could not be asked: a transport failure, a timeout, a rate + /// limit or a server error. A statement about the probe, never about the + /// solvers, and the page must keep the two apart. + Failed(String), +} + +impl Outcome { + /// The name stored for it. + pub fn name(&self) -> &'static str { + match self { + Outcome::Quoted { .. } => "quoted", + Outcome::NoLiquidity => "no_liquidity", + Outcome::Refused(_) => "refused", + Outcome::Failed(_) => "failed", + } + } + + /// Its words, where it has any. + pub fn message(&self) -> Option<&str> { + match self { + Outcome::Refused(m) | Outcome::Failed(m) => Some(m), + Outcome::NoLiquidity => Some(NO_LIQUIDITY), + Outcome::Quoted { .. } => None, + } + } +} + +/// The service's words for "no solver offered". +pub const NO_LIQUIDITY: &str = "No liquidity available"; + +/// Read an answer. `None` for `status` means the request never got one. +pub fn classify(status: Option, body: &Value) -> Outcome { + let Some(status) = status else { + return Outcome::Failed("no answer".into()); + }; + let message = body["message"] + .as_str() + .map(str::to_owned) + .or_else(|| body.as_str().map(|s| s.chars().take(200).collect())); + match status { + 200 | 201 => match body["quote"]["amountOut"] + .as_str() + .and_then(|a| a.parse().ok()) + { + Some(amount_out) => Outcome::Quoted { amount_out }, + None => Outcome::Failed(format!("HTTP {status} without a quote amount")), + }, + // Matched exactly. A reworded message is a change worth noticing, and + // reading it as a refusal keeps it out of the no-liquidity count + // rather than guessing it means the same. + 400 if message.as_deref() == Some(NO_LIQUIDITY) => Outcome::NoLiquidity, + 429 => Outcome::Failed("rate limited (HTTP 429)".into()), + s if s >= 500 => Outcome::Failed(format!("HTTP {s}")), + s => Outcome::Refused(message.unwrap_or_else(|| format!("HTTP {s}"))), + } +} + +/// The dry quote request: exact input, both sides inside the Intents +/// contract, nothing committed. `deadline` is an RFC 3339 instant, passed in +/// because this module has no clock. +pub fn request(origin: &str, destination: &str, amount: u128, deadline: &str) -> Value { + json!({ + "dry": true, + "swapType": "EXACT_INPUT", + "slippageTolerance": 100, + "originAsset": origin, + "depositType": "INTENTS", + "destinationAsset": destination, + "amount": amount.to_string(), + "refundTo": "near", + "refundType": "INTENTS", + "recipient": "near", + "recipientType": "INTENTS", + "deadline": deadline, + }) +} + +/// How much less than the pool a quote pays, in basis points of the pool's +/// output: positive when the quote is worse, negative when it beats the pool. +/// `None` when the pool would pay nothing, which leaves nothing to compare. +pub fn markup_bps(pool_out: u128, quoted_out: u128) -> Option { + if pool_out == 0 { + return None; + } + let diff = pool_out as f64 - quoted_out as f64; + let bps = (diff / pool_out as f64 * 10_000.0).round(); + (bps.abs() < f64::from(i32::MAX)).then_some(bps as i32) +} + +/// Who could have filled a trade needing `needed` of the output token: the +/// single largest holder among `holdings`, and how many each held enough. +/// +/// One holder at a time, deliberately. Solvers do not pool their inventory, +/// so ten solvers holding a tenth each is not one that could fill. +pub fn capacity(needed: u128, holdings: &[(String, u128)]) -> (Option<(String, u128)>, u32) { + let best = holdings + .iter() + .filter(|(_, h)| *h > 0) + .max_by_key(|(_, h)| *h) + .cloned(); + let able = holdings + .iter() + .filter(|(_, h)| needed > 0 && *h >= needed) + .count() as u32; + (best, able) +} + +/// Which sizes to probe on a given minute: the smallest every minute, the +/// whole grid every `full_every` minutes. That keeps a pause visible to the +/// minute at a fraction of the load. +pub fn sizes_due(minute: u64, sizes: &[u128], full_every: u64) -> &[u128] { + if full_every > 0 && minute % full_every == 0 { + sizes + } else { + &sizes[..sizes.len().min(1)] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn answers_are_read_as_the_live_service_gave_them() { + // Captured 2026-10-07 10:24 UTC: both QTC directions, then a control. + let none = json!({"message": "No liquidity available", "correlationId": "7937dbb1", + "timestamp": "2026-10-07T10:24:34.989Z", "path": "/v0/quote"}); + assert_eq!(classify(Some(400), &none), Outcome::NoLiquidity); + let quoted = json!({"quote": {"amountIn": "100000000", "amountOut": "19683582272330652667436545", + "minAmountOut": "19486746449607346140762179"}}); + assert_eq!( + classify(Some(201), "ed), + Outcome::Quoted { + amount_out: 19_683_582_272_330_652_667_436_545 + } + ); + } + + #[test] + fn a_probe_that_could_not_ask_is_never_counted_as_no_liquidity() { + assert!(matches!(classify(None, &Value::Null), Outcome::Failed(_))); + assert!(matches!( + classify(Some(429), &json!({"message": NO_LIQUIDITY})), + Outcome::Failed(_) + )); + assert!(matches!( + classify(Some(503), &json!("upstream down")), + Outcome::Failed(_) + )); + assert!(matches!( + classify(Some(201), &json!({})), + Outcome::Failed(_) + )); + } + + #[test] + fn other_refusals_keep_their_words() { + let low = json!({"message": "Amount is too low for bridge, try at least 1000000"}); + assert_eq!( + classify(Some(400), &low), + Outcome::Refused("Amount is too low for bridge, try at least 1000000".into()) + ); + // A reworded no-liquidity message is a refusal until someone looks. + assert!(matches!( + classify(Some(400), &json!({"message": "no liquidity"})), + Outcome::Refused(_) + )); + } + + #[test] + fn markup_is_how_much_worse_than_the_pool() { + assert_eq!(markup_bps(1_000_000, 990_000), Some(100)); + assert_eq!(markup_bps(1_000_000, 1_010_000), Some(-100)); + assert_eq!(markup_bps(0, 5), None); + } + + #[test] + fn capacity_is_one_holder_at_a_time() { + let holdings = vec![ + ("a.near".to_owned(), 40), + ("b.near".to_owned(), 70), + ("c.near".to_owned(), 0), + ]; + assert_eq!( + capacity(60, &holdings), + (Some(("b.near".to_owned(), 70)), 1) + ); + // Together they hold 110, but no one of them holds 100. + assert_eq!( + capacity(100, &holdings), + (Some(("b.near".to_owned(), 70)), 0) + ); + assert_eq!(capacity(10, &[]), (None, 0)); + } + + #[test] + fn the_full_grid_runs_only_on_its_minute() { + let sizes = [1, 10, 100, 1000]; + assert_eq!(sizes_due(0, &sizes, 15), &sizes); + assert_eq!(sizes_due(7, &sizes, 15), &[1]); + assert_eq!(sizes_due(30, &sizes, 15), &sizes); + } + + #[test] + fn the_request_is_dry_and_keeps_everything_inside_intents() { + let r = request( + "nep141:qtc.omft.near", + "nep141:usdc", + 5, + "2026-10-07T10:34:00Z", + ); + assert_eq!(r["dry"], true); + assert_eq!(r["depositType"], "INTENTS"); + assert_eq!(r["recipientType"], "INTENTS"); + assert_eq!(r["amount"], "5"); + } +} diff --git a/crates/blackbeard-data/migrations/0021_quote_probe.sql b/crates/blackbeard-data/migrations/0021_quote_probe.sql new file mode 100644 index 0000000..f2cef2c --- /dev/null +++ b/crates/blackbeard-data/migrations/0021_quote_probe.sql @@ -0,0 +1,53 @@ +-- Whether 1Click would quote the tracked asset, against whether it could have. +-- blackbeard/observer#45. +-- +-- One row per probe: a keyless dry quote for one direction, size and counter +-- token, beside what this observer read for itself at the same moment, which +-- is what the pool would pay on-chain for the same trade and what the solvers +-- held of the output token inside intents.near. +-- +-- Only our own observations are kept here. The 1ClickSwap API Terms (§4.1(e)) +-- forbid storing API data for more than 24 hours, so a quoted amount is +-- reduced to `markup_bps` against the pool when it arrives, and the response +-- itself goes to `quote_probe_raw`, which is emptied after a day. +create table quote_probe ( + chain text not null references chain (id) on delete cascade, + asset text not null, + at timestamptz not null, + -- `sell` gives the asset for the counter token; `buy` the reverse. + direction text not null check (direction in ('sell', 'buy')), + counter text not null, + -- The input, base units of whichever token is given. + size numeric not null, + -- `failed` is the probe not getting an answer (transport, 429, 5xx): a + -- fact about the probe, never about the solvers. + outcome text not null + check (outcome in ('quoted', 'no_liquidity', 'refused', 'failed')), + message text, + latency_ms integer, + -- What the reference pool's own `quote` view gives for the same input, read + -- from our node at the same moment. Null when no pool could be asked. + pool_out numeric, + -- For a quote: how much less than `pool_out` it paid, in basis points. + markup_bps integer, + -- The solver holding the most of the output token inside intents.near, + -- and how much. Solvers do not pool inventory, so one holder is the test. + best_solver text, + best_held numeric, + -- How many solvers each held at least `pool_out` of the output token. + solvers_able integer, + primary key (chain, asset, at, direction, counter, size) +); +create index quote_probe_recent on quote_probe (chain, asset, at desc); + +-- The responses themselves, for a day, for debugging the probe. Deleted by +-- the probe loop; never read by the API. +create table quote_probe_raw ( + at timestamptz not null, + direction text not null, + counter text not null, + size numeric not null, + status integer, + body jsonb not null, + primary key (at, direction, counter, size) +); diff --git a/crates/blackbeard-data/src/near.rs b/crates/blackbeard-data/src/near.rs index 0fa05d5..e617985 100644 --- a/crates/blackbeard-data/src/near.rs +++ b/crates/blackbeard-data/src/near.rs @@ -710,6 +710,57 @@ pub async fn oneclick_lists( .any(|t| t.get("assetId").and_then(Value::as_str) == Some(asset))) } +/// What a 1Click quote request came back with: the HTTP status and the body. +/// +/// Not interpreted here. Whether a 400 means "no liquidity" or "bad request" +/// is in the body's words, and `blackbeard_core::quote_probe::classify` is +/// where those are read, with tests (blackbeard/observer#45). +#[derive(Debug, Clone)] +pub struct OneClickAnswer { + /// HTTP status. + pub status: u16, + /// The body as JSON, or as a string when it was not JSON. + pub body: Value, + /// How long the request took. + pub latency: Duration, +} + +/// The User-Agent every probe sends, so the service can see who is asking +/// and why, and reach us if it objects. +pub const PROBE_USER_AGENT: &str = "blackbeard.observer quote-availability probe (https://git.lair.cafe/blackbeard/observer/issues/45)"; + +/// Ask 1Click for a quote, with no key. +/// +/// The caller sets `dry: true` in the request: a dry quote commits nothing and +/// creates no deposit address. A transport failure or timeout is an `Err`; any +/// HTTP answer, error statuses included, is `Ok`, because "the service said +/// no" and "the service could not be asked" are different facts and the +/// record must never mistake one for the other. +pub async fn oneclick_quote( + base_url: &str, + request: &Value, + timeout: Duration, +) -> Result { + let url = format!("{}/v0/quote", base_url.trim_end_matches('/')); + let started = std::time::Instant::now(); + let response = reqwest::Client::builder() + .timeout(timeout) + .user_agent(PROBE_USER_AGENT) + .build()? + .post(&url) + .json(request) + .send() + .await?; + let status = response.status().as_u16(); + let text = response.text().await?; + let body = serde_json::from_str(&text).unwrap_or(Value::String(text)); + Ok(OneClickAnswer { + status, + body, + latency: started.elapsed(), + }) +} + impl NearClient { /// A whole transaction from the public endpoints, which track every shard: /// the view endpoint first, then the archive when it no longer holds it. diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index b2f8be3..b75f8cd 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -4429,3 +4429,151 @@ impl Store { .collect()) } } + +/// One quote probe and what was seen beside it (blackbeard/observer#45). +#[derive(Debug, Clone)] +pub struct QuoteProbeRow { + /// When. + pub at: DateTime, + /// `sell` or `buy`. + pub direction: String, + /// The counter token. + pub counter: String, + /// Input, base units. + pub size: String, + /// `quoted`, `no_liquidity`, `refused` or `failed`. + pub outcome: String, + /// The service's words, or why the probe failed. + pub message: Option, + /// How long the service took. + pub latency_ms: Option, + /// The pool's on-chain output for the same input. + pub pool_out: Option, + /// How much less than the pool a quote paid. + pub markup_bps: Option, + /// The largest single solver holding of the output token. + pub best_solver: Option, + /// What it held. + pub best_held: Option, + /// Solvers each holding at least `pool_out`. + pub solvers_able: Option, +} + +impl Store { + /// Record one round of probes. + pub async fn record_quote_probes( + &self, + chain: &ChainId, + asset: &str, + rows: &[QuoteProbeRow], + ) -> Result<(), DataError> { + for r in rows { + sqlx::query!( + r#" + insert into quote_probe + (chain, asset, at, direction, counter, size, outcome, message, latency_ms, + pool_out, markup_bps, best_solver, best_held, solvers_able) + values ($1, $2, $3, $4, $5, $6::text::numeric, $7, $8, $9, + $10::text::numeric, $11, $12, $13::text::numeric, $14) + on conflict do nothing + "#, + chain.as_str(), + asset, + r.at, + r.direction, + r.counter, + r.size, + r.outcome, + r.message, + r.latency_ms, + r.pool_out, + r.markup_bps, + r.best_solver, + r.best_held, + r.solvers_able, + ) + .execute(&self.pool) + .await?; + } + Ok(()) + } + + /// Keep a raw response for a day. + pub async fn record_quote_raw( + &self, + at: DateTime, + direction: &str, + counter: &str, + size: &str, + status: Option, + body: &serde_json::Value, + ) -> Result<(), DataError> { + sqlx::query!( + r#" + insert into quote_probe_raw (at, direction, counter, size, status, body) + values ($1, $2, $3, $4::text::numeric, $5, $6) + on conflict do nothing + "#, + at, + direction, + counter, + size, + status, + body, + ) + .execute(&self.pool) + .await?; + Ok(()) + } + + /// Delete raw responses older than a day: the 1ClickSwap API Terms + /// (§4.1(e)) allow no longer. + pub async fn prune_quote_raw(&self) -> Result { + let done = + sqlx::query!("delete from quote_probe_raw where at < now() - interval '24 hours'") + .execute(&self.pool) + .await?; + Ok(done.rows_affected()) + } + + /// Every probe since `since`, oldest first. + pub async fn quote_probes( + &self, + chain: &ChainId, + asset: &str, + since: DateTime, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select at, direction, counter, size::text as "size!", outcome, message, latency_ms, + pool_out::text as pool_out, markup_bps, best_solver, + best_held::text as best_held, solvers_able + from quote_probe + where chain = $1 and asset = $2 and at >= $3 + order by at, direction, counter, size + "#, + chain.as_str(), + asset, + since, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| QuoteProbeRow { + at: r.at, + direction: r.direction, + counter: r.counter, + size: r.size, + outcome: r.outcome, + message: r.message, + latency_ms: r.latency_ms, + pool_out: r.pool_out, + markup_bps: r.markup_bps, + best_solver: r.best_solver, + best_held: r.best_held, + solvers_able: r.solvers_able, + }) + .collect()) + } +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 7a26bbe..88ef545 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -58,6 +58,7 @@ pub use miner::{ pub use near::{ IntentsAccount, IntentsAccountEvent, IntentsBalance, NearAction, NearAmount, NearEvent, NearMemo, NearPart, NearReceipt, NearToken, NearTx, NearTxSource, QuantusTxRef, + QuoteAvailability, QuoteProbe, QuoteProbes, }; pub use reversible::{PendingTransfer, ReversibleState}; pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource}; diff --git a/crates/blackbeard-entities/src/near.rs b/crates/blackbeard-entities/src/near.rs index d803c1c..e387fd0 100644 --- a/crates/blackbeard-entities/src/near.rs +++ b/crates/blackbeard-entities/src/near.rs @@ -339,3 +339,81 @@ pub struct IntentsAccountEvent { #[ts(type = "number | null")] pub quantus_height: Option, } + +/// One probe: whether 1Click would quote one trade, and what this observer saw +/// that could have filled it (blackbeard/observer#45). +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "QuoteProbe.ts")] +pub struct QuoteProbe { + /// When. + pub at: DateTime, + /// `sell` gives the asset for the counter token; `buy` the reverse. + pub direction: String, + /// The counter token. + pub counter: String, + /// The input, base units of the token given. + pub size: BigUintDec, + /// `quoted`, `no_liquidity`, `refused`, or `failed` — the last meaning the + /// probe got no answer, which says nothing about the solvers. + pub outcome: String, + /// The service's own words, or why the probe failed. + pub message: Option, + /// How long the service took, in milliseconds. + pub latency_ms: Option, + /// What the pool's own `quote` view gave for the same input, read from our + /// node at the same moment: what an on-chain fill would have paid. + pub pool_out: Option, + /// For a quote, how much less than the pool it paid, in basis points. + pub markup_bps: Option, + /// The solver holding the most of the output token inside intents.near. + pub best_solver: Option, + /// What it held, base units of the output token. + pub best_held: Option, + /// How many solvers each held at least `pool_out` of the output token. + pub solvers_able: Option, +} + +/// One direction and counter token, summed over the window. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "QuoteAvailability.ts")] +pub struct QuoteAvailability { + /// `sell` or `buy`. + pub direction: String, + /// The counter token. + pub counter: String, + /// Probes that got an answer: every outcome but `failed`. + pub answered: u32, + /// Of those, quoted. + pub quoted: u32, + /// Of those, refused as having no liquidity. + pub no_liquidity: u32, + /// Of the no-liquidity answers, those given while at least one solver held + /// enough of the output token to fill the trade on its own. + pub no_liquidity_while_able: u32, + /// Answered with some other refusal. + pub refused: u32, + /// Probes that got no answer at all. + pub failed: u32, + /// The median markup of the quotes, in basis points. + pub median_markup_bps: Option, +} + +/// The probe's record over a window. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "QuoteProbes.ts")] +pub struct QuoteProbes { + /// Which chain's token. + pub chain: ChainId, + /// The token, as NEAR Intents names it. + pub asset: String, + /// The window, in hours. + pub hours: u32, + /// Whether the probe is configured to run at all. + pub probing: bool, + /// Every probe in the window, oldest first. + pub probes: Vec, + /// Per direction and counter, over the window. + pub availability: Vec, + /// Display facts for the asset and every counter. + pub tokens: Vec, +} diff --git a/web/src/api/generated/QuoteAvailability.ts b/web/src/api/generated/QuoteAvailability.ts new file mode 100644 index 0000000..d8e24cf --- /dev/null +++ b/web/src/api/generated/QuoteAvailability.ts @@ -0,0 +1,43 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * One direction and counter token, summed over the window. + */ +export type QuoteAvailability = { +/** + * `sell` or `buy`. + */ +direction: string, +/** + * The counter token. + */ +counter: string, +/** + * Probes that got an answer: every outcome but `failed`. + */ +answered: number, +/** + * Of those, quoted. + */ +quoted: number, +/** + * Of those, refused as having no liquidity. + */ +no_liquidity: number, +/** + * Of the no-liquidity answers, those given while at least one solver held + * enough of the output token to fill the trade on its own. + */ +no_liquidity_while_able: number, +/** + * Answered with some other refusal. + */ +refused: number, +/** + * Probes that got no answer at all. + */ +failed: number, +/** + * The median markup of the quotes, in basis points. + */ +median_markup_bps: number | null, }; diff --git a/web/src/api/generated/QuoteProbe.ts b/web/src/api/generated/QuoteProbe.ts new file mode 100644 index 0000000..a3930f3 --- /dev/null +++ b/web/src/api/generated/QuoteProbe.ts @@ -0,0 +1,58 @@ +// 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 probe: whether 1Click would quote one trade, and what this observer saw + * that could have filled it (blackbeard/observer#45). + */ +export type QuoteProbe = { +/** + * When. + */ +at: string, +/** + * `sell` gives the asset for the counter token; `buy` the reverse. + */ +direction: string, +/** + * The counter token. + */ +counter: string, +/** + * The input, base units of the token given. + */ +size: BigUintDec, +/** + * `quoted`, `no_liquidity`, `refused`, or `failed` — the last meaning the + * probe got no answer, which says nothing about the solvers. + */ +outcome: string, +/** + * The service's own words, or why the probe failed. + */ +message: string | null, +/** + * How long the service took, in milliseconds. + */ +latency_ms: number | null, +/** + * What the pool's own `quote` view gave for the same input, read from our + * node at the same moment: what an on-chain fill would have paid. + */ +pool_out: BigUintDec | null, +/** + * For a quote, how much less than the pool it paid, in basis points. + */ +markup_bps: number | null, +/** + * The solver holding the most of the output token inside intents.near. + */ +best_solver: string | null, +/** + * What it held, base units of the output token. + */ +best_held: BigUintDec | null, +/** + * How many solvers each held at least `pool_out` of the output token. + */ +solvers_able: number | null, }; diff --git a/web/src/api/generated/QuoteProbes.ts b/web/src/api/generated/QuoteProbes.ts new file mode 100644 index 0000000..6d45e3a --- /dev/null +++ b/web/src/api/generated/QuoteProbes.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 { ChainId } from "./ChainId"; +import type { NearToken } from "./NearToken"; +import type { QuoteAvailability } from "./QuoteAvailability"; +import type { QuoteProbe } from "./QuoteProbe"; + +/** + * The probe's record over a window. + */ +export type QuoteProbes = { +/** + * Which chain's token. + */ +chain: ChainId, +/** + * The token, as NEAR Intents names it. + */ +asset: string, +/** + * The window, in hours. + */ +hours: number, +/** + * Whether the probe is configured to run at all. + */ +probing: boolean, +/** + * Every probe in the window, oldest first. + */ +probes: Array, +/** + * Per direction and counter, over the window. + */ +availability: Array, +/** + * Display facts for the asset and every counter. + */ +tokens: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index e417662..f02e508 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -31,6 +31,7 @@ import type { IntentsSwaps } from './generated/IntentsSwaps' import type { IntentsAccount } from './generated/IntentsAccount' import type { Markets } from './generated/Markets' import type { NearTx } from './generated/NearTx' +import type { QuoteProbes } from './generated/QuoteProbes' import type { RefPools } from './generated/RefPools' import type { RefSwaps } from './generated/RefSwaps' import type { ReversibleState } from './generated/ReversibleState' @@ -455,3 +456,12 @@ export async function fetchIntentsAccount( ): Promise { return get(`/chains/${chain}/near/intents/${encodeURIComponent(account)}`, signal) } + +/** Whether 1Click would quote the asset, against what could have filled it. */ +export async function fetchQuoteProbes( + chain: string, + hours: number, + signal?: AbortSignal, +): Promise { + return get(`/chains/${chain}/intents/quotes?hours=${hours}`, signal) +} diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx index d32c9bd..514e49c 100644 --- a/web/src/components/MarketPage.tsx +++ b/web/src/components/MarketPage.tsx @@ -27,6 +27,7 @@ import { FillsPanel } from './FillsPanel' import { IntentsPanel } from './IntentsPanel' import { InventoryPanel } from './InventoryPanel' import { PriceChart } from './PriceChart' +import { QuotePanel } from './QuotePanel' import { PoolPriceChart } from './RefDclMarket' import { RefDclMarket } from './RefDclMarket' import { SwapHead, SwapRow } from './SwapRow' @@ -161,6 +162,8 @@ function NearIntents({ + + diff --git a/web/src/components/QuotePanel.tsx b/web/src/components/QuotePanel.tsx new file mode 100644 index 0000000..748ace5 --- /dev/null +++ b/web/src/components/QuotePanel.tsx @@ -0,0 +1,437 @@ +/** + * Whether 1Click would quote the token, set against whether anyone could have + * filled it (blackbeard/observer#45). + * + * On NEAR Intents a price exists only when a solver chooses to quote, and + * "No liquidity available" is all a user is told when none does. The chain + * records trades, never refusals. So the observer asks — keyless, `dry`, once + * a minute each way — and beside every answer reads two things it can see for + * itself: what the pool would pay for the same trade, and what each solver held + * of the token the trade would need, one solver at a time. + * + * The strip is an emphasis chart, not a categorical one: its story is one + * state, "no quote while a solver held enough to fill it", and that state alone + * takes the accent. Everything else is the quiet ink, told apart by height + * rather than hue — a quote, a refusal nobody could have filled, a probe that + * got no answer. `--mark-quiet` is validated against `--accent` in both themes + * (index.css). + * + * It records and does not accuse. A refusal while inventory existed has + * benign explanations — risk limits, inventory reserved for other flows, a + * pause during an audit — and the page says so beside the figures. + */ + +import { useEffect, useMemo, useState } from 'react' + +import type { QuoteAvailability } from '../api/generated/QuoteAvailability' +import type { QuoteProbe } from '../api/generated/QuoteProbe' +import type { QuoteProbes } from '../api/generated/QuoteProbes' +import { RequestFailed, fetchQuoteProbes } from '../api/rest' +import { tokens as fmtTokens, utc, when, zone } from '../lib/format' +import { shortNear } from '../lib/near' +import { tokenLabel } from '../lib/tokens' + +const SPANS: [number, string][] = [ + [6, '6 h'], + [24, '24 h'], + [72, '3 d'], +] + +const REFRESH_MS = 60_000 + +/** Cells per strip, whatever the span: each is one bucket of minutes. */ +const BUCKETS = 144 + +/** Strip height, in the SVG's units and in pixels. */ +const STRIP = 16 + +/** What a bucket shows, most significant first. */ +type State = 'withheld' | 'quoted' | 'unquoted' | 'failed' | 'none' + +/** A probe that came back "no liquidity" while a solver held enough. */ +function withheld(p: QuoteProbe): boolean { + return p.outcome === 'no_liquidity' && (p.solvers_able ?? 0) > 0 +} + +function stateOf(p: QuoteProbe): State { + if (withheld(p)) return 'withheld' + if (p.outcome === 'quoted') return 'quoted' + if (p.outcome === 'failed') return 'failed' + return 'unquoted' +} + +const RANK: Record = { withheld: 4, quoted: 3, unquoted: 2, failed: 1, none: 0 } + +const LEGEND: { state: State; label: string }[] = [ + { state: 'withheld', label: 'no quote, while a solver held enough to fill it' }, + { state: 'quoted', label: 'quoted' }, + { state: 'unquoted', label: 'no quote, and no solver held enough' }, + { state: 'failed', label: 'the probe got no answer' }, +] + +export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symbol: string }) { + const [hours, setHours] = useState(24) + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [readout, setReadout] = useState(null) + + useEffect(() => { + const controller = new AbortController() + const load = () => + fetchQuoteProbes(chain, hours, controller.signal) + .then((d) => { + setData(d) + 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, hours]) + + const meta = (token: string) => data?.tokens.find((t) => t.token === token) + const label = (token: string) => tokenLabel(token, meta(token)?.symbol) + // The chain's own symbol when it has been read, else the token's on NEAR. + const symbol = chainSymbol || (data ? label(data.asset) : '') + const amount = (raw: string | null, token: string) => { + const m = meta(token) + return raw && m ? `${fmtTokens(raw, m.decimals)} ${label(token)}` : '—' + } + /** The token a probe's trade gives, and the one it would receive. */ + const sides = (p: { direction: string; counter: string }) => + p.direction === 'sell' + ? { given: data?.asset ?? '', received: p.counter } + : { given: p.counter, received: data?.asset ?? '' } + + const rows = useMemo(() => strips(data), [data]) + const incidents = useMemo( + () => (data ? data.probes.filter(withheld).slice(-12).reverse() : []), + [data], + ) + + const describe = (p: QuoteProbe) => { + const { given, received } = sides(p) + const trade = `${p.direction === 'sell' ? 'selling' : 'buying with'} ${amount(p.size, given)}` + const pool = p.pool_out ? `; the pool would have paid ${amount(p.pool_out, received)}` : '' + const best = p.best_solver + ? `; ${shortNear(p.best_solver)} held ${amount(p.best_held, received)}` + : '' + const verdict = + p.outcome === 'quoted' + ? `quoted${p.markup_bps !== null ? `, ${(p.markup_bps / 100).toFixed(2)}% below the pool` : ''}` + : p.outcome === 'failed' + ? `no answer (${p.message ?? 'probe failed'})` + : `“${p.message ?? p.outcome}”` + return `${when(p.at)}, ${trade}: ${verdict}${pool}${best}` + } + + return ( +
+
+

Will anyone quote {symbol}?

+ + 1Click, asked every minute, against what could have filled it + +
+
+ {SPANS.map(([h, l]) => ( + + ))} +
+ + {error &&

{error}

} + {!error && !data &&

Reading the probe record…

} + {data && !data.probing &&

The quote probe is not configured here.

} + {data && data.probing && data.probes.length === 0 && ( +

No probes yet in this window: the first runs on the next minute.

+ )} + + {data && rows.length > 0 && ( + <> +
+ {rows.map((row) => ( +
+
+ {row.direction === 'sell' + ? `Selling ${symbol} for ${label(row.counter)}` + : `Buying ${symbol} with ${label(row.counter)}`} + + {' '} + · {amount(row.size, sides(row).given)} each minute + +
+ setReadout(null)} + > + + {row.buckets.map((b, i) => ( + + setReadout( + b.last + ? `${b.count} probe${b.count === 1 ? '' : 's'} — latest ${describe(b.last)}` + : `${when(new Date(b.from).toISOString())}: no probe`, + ) + } + > + {/* The hit area is the whole column, wider than any mark. */} + + + + ))} + +
+ ))} +
+
+ {rows[0] ? when(new Date(rows[0].from).toISOString()) : ''} + times in {zone()} + now +
+

+ {readout ?? 'Hover the strip for any moment’s probes.'} +

+
    + {LEGEND.map((l) => ( +
  • + + {l.label} +
  • + ))} +
+ +
+ + + + + + + + + + + + + + + {data.availability.map((a) => ( + + + + + + + + + + ))} + +
+ Probe outcomes over the last {data.hours} hours, by direction and counter token +
+ Trade + AnsweredQuotedNo quote…while a solver could fillNo answerMedian markup
+ {a.direction === 'sell' ? 'sell' : 'buy'} {symbol} · {label(a.counter)} + {a.answered}{share(a.quoted, a.answered)}{share(a.no_liquidity, a.answered)} + {share(a.no_liquidity_while_able, a.answered)} + {a.failed} + {a.median_markup_bps === null + ? '—' + : `${(a.median_markup_bps / 100).toFixed(2)}%`} +
+
+ + {incidents.length > 0 && ( +
+ + + + + + + + + + + + + {incidents.map((p) => { + const { given, received } = sides(p) + return ( + + + + + + + + ) + })} + +
+ The latest probes answered “no liquidity” while a solver held enough +
+ When + + Trade asked for + Pool would pay + Largest solver holding + Solvers able
+ {when(p.at)} + + {p.direction} {amount(p.size, given)} + {amount(p.pool_out, received)} + {p.best_solver + ? `${shortNear(p.best_solver)}: ${amount(p.best_held, received)}` + : '—'} + {p.solvers_able ?? '—'}
+
+ )} + + )} + +

+ Each minute the observer asks 1Click for a dry quote — nothing is committed — selling and + buying {symbol} at the smallest size, and every size each quarter hour, without a key and + inside the rate limit 1Click publishes. At the same moment it reads, from its own NEAR + nodes, what the Ref pool would pay for the same trade and what each known solver holds of + the token the trade would need. A solver counts as able only if it alone held at least what + the pool would pay: solvers do not pool inventory. “No liquidity available” while a solver + was able says inventory existed and no quote was given; it does not say why. Risk limits, + inventory reserved for other flows, or a pause during an audit would all look the same from + here, and which solvers 1Click asked, and what they answered, is private to them. 1Click’s + terms allow no API data to be kept beyond a day, so only these observations are kept: the + quote itself becomes a markup against the pool and is then discarded. A probe that got no + answer is shown as that, never as a refusal. +

+
+ ) +} + +function share(n: number, of: number): string { + if (of === 0) return '—' + return `${n} (${Math.round((n / of) * 100)}%)` +} + +function summaryText( + row: { direction: string; counter: string }, + availability: QuoteAvailability[], +): string { + const a = availability.find((x) => x.direction === row.direction && x.counter === row.counter) + if (!a) return 'no probes' + return `${a.answered} answered: ${a.quoted} quoted, ${a.no_liquidity} no quote, of which ${a.no_liquidity_while_able} while a solver could fill, ${a.failed} unanswered` +} + +/** One cell: height and fill say the state; only the one that matters is in the accent. */ +function Cell({ state, x, height = STRIP }: { state: State; x: number; height?: number }) { + // Thin marks with a wide surface gap: a day of refusals is a run of + // hairlines, not a slab. + const w = 2.5 + switch (state) { + case 'withheld': + return + case 'quoted': + return ( + + ) + case 'unquoted': + return ( + + ) + case 'failed': + return + default: + return null + } +} + +interface Bucket { + from: number + state: State + count: number + last: QuoteProbe | null +} + +interface Strip { + direction: string + counter: string + /** The every-minute size. */ + size: string + from: number + buckets: Bucket[] +} + +/** One strip per direction and counter, from the every-minute (smallest) probes. */ +function strips(data: QuoteProbes | null): Strip[] { + if (!data) return [] + const end = Date.now() + const start = end - data.hours * 3_600_000 + const width = (end - start) / BUCKETS + const out: Strip[] = [] + for (const a of data.availability) { + const own = data.probes.filter((p) => p.direction === a.direction && p.counter === a.counter) + if (own.length === 0) continue + // The every-minute size is the smallest one probed. + const size = own.reduce((m, p) => (BigInt(p.size) < BigInt(m) ? p.size : m), own[0]!.size) + const buckets: Bucket[] = Array.from({ length: BUCKETS }, (_, i) => ({ + from: start + i * width, + state: 'none' as State, + count: 0, + last: null, + })) + for (const p of own) { + if (p.size !== size) continue + const i = Math.min(BUCKETS - 1, Math.floor((Date.parse(p.at) - start) / width)) + const b = buckets[i] + if (!b) continue + b.count += 1 + const s = stateOf(p) + if (RANK[s] > RANK[b.state]) b.state = s + if (!b.last || Date.parse(p.at) > Date.parse(b.last.at)) b.last = p + } + out.push({ direction: a.direction, counter: a.counter, size, from: start, buckets }) + } + return out +} diff --git a/web/src/index.css b/web/src/index.css index 437a00e..2ba87f6 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -54,6 +54,11 @@ --text-primary: #f2ece0; --text-secondary: #a2937c; --text-muted: #7a6c59; + /* The quiet mark beside an accented one, where a chart's story is a single + state (emphasis, not categories): the quote strip (#45). Validated against + --accent on this surface: CVD ΔE 8.8 (deutan), normal 17.9. Low chroma is + the point — it is the gray the accent stands out from. */ + --mark-quiet: #a2937c; /* The single data hue: magnitude, share bars, the history area. */ --data: #bd8829; @@ -108,6 +113,9 @@ --text-primary: #1b1710; --text-secondary: #5b5142; --text-muted: #7c7263; + /* Darker than the dark theme's step: against this --accent the secondary ink + scores CVD ΔE 4.5 (protan), a hard fail. #3a3226 scores 14.7. */ + --mark-quiet: #3a3226; /* The bronze, re-stepped. `--data-bright` is *darker* than `--data`: it is the emphasis step, and emphasis on paper is weight, not glare. */ @@ -2111,3 +2119,78 @@ tr.mine .share-bar > i { font-family: var(--font-mono); font-size: 11px; } + +/* The quote strip (blackbeard/observer#45). An emphasis chart: one state in + the accent, the rest in the quiet ink, told apart by height — full for "no + quote while a solver could fill", 60% for a quote, a hollow 30% for a refusal + nobody could have filled, a baseline tick for a probe that got no answer. + Height carries the meaning in greyscale, print and forced-colors too. */ +.quote-strips { + display: grid; + gap: 10px; + padding: 12px 16px 0; +} + +.quote-strip-label { + font-size: 12px; + margin-bottom: 4px; +} + +.quote-cells { + display: block; + width: 100%; + height: 16px; +} + +.quote-baseline { + stroke: var(--border); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.quote-withheld { + fill: var(--accent); +} + +.quote-quoted { + fill: var(--mark-quiet); +} + +.quote-unquoted { + fill: none; + stroke: var(--mark-quiet); + stroke-width: 1; + vector-effect: non-scaling-stroke; +} + +.quote-failed { + fill: var(--text-muted); +} + +.quote-axis { + display: flex; + justify-content: space-between; + padding: 4px 16px 0; +} + +.quote-readout { + padding: 6px 16px 0; + min-height: 1.6em; +} + +.quote-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + list-style: none; + margin: 0; + padding: 8px 16px 12px; + font-size: 11px; + color: var(--text-secondary); +} + +.quote-legend li { + display: inline-flex; + align-items: center; + gap: 6px; +}