From 6b3bf76b7eeeda1f06dfabff1a46800af1dab5cd Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Wed, 7 Oct 2026 13:39:52 +0300 Subject: [PATCH] feat: probe whether 1Click will quote QTC, against what could have filled it On NEAR Intents a price exists only when a solver chooses to quote, and nothing on-chain records a refusal. Every minute the observer now asks 1Click for a keyless dry quote on QTC pairs only, both ways, against native USDC and Ethereum USDC: the smallest size each minute and the full grid each quarter hour, averaging about 4 requests a minute against a published limit of 1,200. At the same moment it reads two things from its own nodes: the Ref pool's quote for the same input, and every solver's balance of the output token inside intents.near. So each "No liquidity available" is stored with whether one solver alone held enough to settle the trade. A probe that got no answer is recorded as `failed`, never as a refusal. Only our own observations are kept: the 1ClickSwap API Terms (s4.1(e)) forbid keeping API data past 24 hours, so a quote becomes a markup against the pool on arrival and raw responses are pruned hourly. The first live round, 2026-10-07: "No liquidity available" both ways. Selling 1 QTC, the pool would have paid 101.53 USDC and three solvers each held enough. Buying with 100 USDC, solver-ref-2.near held 2,308 QTC. The near-intents market page shows a strip per direction and counter token. It is an emphasis chart: the accent marks only "no quote while a solver could fill", the other states are quiet ink told apart by height, and the neutral token is validated against the accent in both themes. Beside it are a summary table and a list of the latest refusals. The pause log and the price-move statistics in #45 come later, once there is history to support them. Refs #45 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC --- ...b34fba2de19ad571f3025dade848472dc88de.json | 12 + ...57d3a123c455c82e7d739642bd11a0b5a0163.json | 90 ++++ ...32dd264601d30f35dad9a2d2c1195035d0ee6.json | 19 + ...d867677ff356c53ce37aa72951353e320f56a.json | 27 ++ CLAUDE.md | 15 + asset/config/config.toml.tmpl | 19 + crates/blackbeard-api/src/config.rs | 32 ++ crates/blackbeard-api/src/intents.rs | 1 + crates/blackbeard-api/src/main.rs | 1 + crates/blackbeard-api/src/quoteprobe.rs | 371 +++++++++++++++ crates/blackbeard-api/src/routes.rs | 97 ++++ crates/blackbeard-core/src/lib.rs | 1 + crates/blackbeard-core/src/quote_probe.rs | 247 ++++++++++ .../migrations/0021_quote_probe.sql | 53 +++ crates/blackbeard-data/src/near.rs | 51 ++ crates/blackbeard-data/src/store.rs | 148 ++++++ crates/blackbeard-entities/src/lib.rs | 1 + crates/blackbeard-entities/src/near.rs | 78 ++++ web/src/api/generated/QuoteAvailability.ts | 43 ++ web/src/api/generated/QuoteProbe.ts | 58 +++ web/src/api/generated/QuoteProbes.ts | 38 ++ web/src/api/rest.ts | 10 + web/src/components/MarketPage.tsx | 3 + web/src/components/QuotePanel.tsx | 437 ++++++++++++++++++ web/src/index.css | 83 ++++ 25 files changed, 1935 insertions(+) create mode 100644 .sqlx/query-65eb7abb616a4cb3f3a406eff1eb34fba2de19ad571f3025dade848472dc88de.json create mode 100644 .sqlx/query-8a168960fc8a555ebc4da05f18157d3a123c455c82e7d739642bd11a0b5a0163.json create mode 100644 .sqlx/query-ac30c5141f8be42eea2f378b47d32dd264601d30f35dad9a2d2c1195035d0ee6.json create mode 100644 .sqlx/query-dac25395a974ee74c8cf16f6367d867677ff356c53ce37aa72951353e320f56a.json create mode 100644 crates/blackbeard-api/src/quoteprobe.rs create mode 100644 crates/blackbeard-core/src/quote_probe.rs create mode 100644 crates/blackbeard-data/migrations/0021_quote_probe.sql create mode 100644 web/src/api/generated/QuoteAvailability.ts create mode 100644 web/src/api/generated/QuoteProbe.ts create mode 100644 web/src/api/generated/QuoteProbes.ts create mode 100644 web/src/components/QuotePanel.tsx 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; +}