feat: on-chain context for each pause in quoting, and pool settings kept
ci / gate (push) Successful in 4m32s
deploy / validate (push) Skipped
deploy / build (push) Successful in 9m51s
deploy / deploy-web (push) Successful in 6s
deploy / deploy-api (push) Successful in 17s

Each of the newest pauses now says what happened on-chain around QTC
while it ran, so a reader can tell "not quoting but still trading" from
"offline":

- pool swaps by known solvers, with the QTC they sold into and bought
  out of the pool;
- direct pool swaps by anyone else;
- 1Click fills, which a pause should have none of;
- bridge deposits arriving on NEAR;
- the largest solver QTC holding at the pause's start and end, from the
  probe's own readings;
- changes to the pool's state or whitelist.

That last needs data that was not kept: the pool's state and whitelist
were read every minute and held only in memory. ref_pool_settings
(migration 0023) records them when either changes, and only a row with
a predecessor counts as a change. The first row is how the pool was
first seen.

Context is computed for the 12 newest pauses, a few aggregate queries
each. It is counts and amounts only, and the page says it does not
explain why quotes stopped.

Refs #45

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
This commit is contained in:
2026-10-07 17:43:29 +03:00
co-authored by Claude Opus 5.5
parent ab2b59d178
commit 8892f0a399
16 changed files with 714 additions and 32 deletions
@@ -0,0 +1,44 @@
{
"db_name": "PostgreSQL",
"query": "\n select trader = any($5) as \"solver!\", side,\n count(*) as \"n!\",\n coalesce(sum(case when side = 'sell' then amount_in else amount_out end), 0)::text\n as \"asset!\"\n from ref_swap\n where chain = $1 and asset = $2 and at between $3 and $4\n group by 1, 2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "solver!",
"type_info": "Bool"
},
{
"ordinal": 1,
"name": "side",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "n!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "asset!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"TextArray"
]
},
"nullable": [
null,
false,
null,
null
]
},
"hash": "12be2703a83db80a597fdc663118cec20c949efcb11c9bd66caeb796d0e1dee4"
}
@@ -0,0 +1,30 @@
{
"db_name": "PostgreSQL",
"query": "\n select count(*) as \"n!\", coalesce(sum(amount), 0)::text as \"total!\"\n from bridge_mint where chain = $1 and at between $2 and $3\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n!",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "total!",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
null,
null
]
},
"hash": "221f8d14ab56268c724b145bba2e7d7cc0d123f3eed1377d066cb7809b3717ba"
}
@@ -0,0 +1,18 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into ref_pool_settings (chain, pool_id, at, state, whitelist)\n select $1, $2, $3, $4, $5\n where not exists (\n select 1 from (\n select state, whitelist from ref_pool_settings\n where chain = $1 and pool_id = $2\n order by at desc limit 1\n ) last\n where last.state = $4 and last.whitelist = $5\n )\n on conflict do nothing\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Text",
"Jsonb"
]
},
"nullable": []
},
"hash": "c8100168daaac6edbe235ed6b0bbb2d8f37cb64203a870913ecaa6066f9766e3"
}
@@ -0,0 +1,26 @@
{
"db_name": "PostgreSQL",
"query": "\n select count(*) as \"n!\" from intents_transfer\n where chain = $1 and asset = $2 and at between $3 and $4\n and (from_account = any($5) or to_account = any($5))\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "n!",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Timestamptz",
"Timestamptz",
"TextArray"
]
},
"nullable": [
null
]
},
"hash": "ea0415a16bad88feb5dceb8acd3b92fa05e13ee2cc8d289d88279fc01bfc4a13"
}
@@ -0,0 +1,54 @@
{
"db_name": "PostgreSQL",
"query": "\n select at as \"at!\", pool_id as \"pool_id!\", state as \"state!\",\n whitelist as \"whitelist!\", prev_state as \"prev_state!\",\n prev_whitelist as \"prev_whitelist!\"\n from (\n select *, lag(state) over w as prev_state, lag(whitelist) over w as prev_whitelist\n from ref_pool_settings\n where chain = $1\n window w as (partition by pool_id order by at)\n ) s\n where at between $2 and $3 and prev_state is not null\n order by at\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "at!",
"type_info": "Timestamptz"
},
{
"ordinal": 1,
"name": "pool_id!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "state!",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "whitelist!",
"type_info": "Jsonb"
},
{
"ordinal": 4,
"name": "prev_state!",
"type_info": "Text"
},
{
"ordinal": 5,
"name": "prev_whitelist!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Timestamptz",
"Timestamptz"
]
},
"nullable": [
false,
false,
false,
false,
null,
null
]
},
"hash": "f4517ce12291b0574331092fbd3459a9037cf365f877324bc22f80087fa62e9a"
}
+18
View File
@@ -371,6 +371,24 @@ async fn record_history(
if let Err(e) = store.record_pool_snapshot(&snapshot).await {
tracing::warn!(%pool_id, error = %e, "pool snapshot not recorded");
}
// Its state and whitelist, kept only when they change (#45): whether the
// pool was running and who could swap in it is the context a pause in
// quoting needs, and it was held in memory alone.
let mut whitelist: Vec<String> =
serde_json::from_value(state["whitelist"].clone()).unwrap_or_default();
whitelist.sort();
if let Err(e) = store
.record_pool_settings(
chain,
pool_id,
at,
state["state"].as_str().unwrap_or_default(),
&serde_json::json!(whitelist),
)
.await
{
tracing::warn!(%pool_id, error = %e, "pool settings not recorded");
}
// Sorted by point so equal books compare equal in the store.
let mut levels: Vec<Value> = order_book
.as_object()
+71 -1
View File
@@ -5181,7 +5181,8 @@ async fn intents_quotes(
.quote_probes(&id, &config.asset, lookback)
.await
.map_err(database_unavailable)?;
let pauses = quote_pauses(&state, &config, &all_rows, since).await;
let solvers = crate::intents::solver_set(&runtime, &state.store).await;
let pauses = quote_pauses(&state, &id, &config, &solvers, &all_rows, since).await;
let rows: Vec<blackbeard_data::store::QuoteProbeRow> =
all_rows.into_iter().filter(|r| r.at >= since).collect();
let days = state
@@ -5277,7 +5278,9 @@ async fn intents_quotes(
/// depend on which sizes happened to be asked.
async fn quote_pauses(
state: &AppState,
chain: &blackbeard_entities::ChainId,
config: &crate::config::IntentsConfig,
solvers: &[String],
rows: &[blackbeard_data::store::QuoteProbeRow],
since: chrono::DateTime<chrono::Utc>,
) -> Vec<blackbeard_entities::QuotePause> {
@@ -5377,14 +5380,81 @@ async fn quote_pauses(
price_end: nearest(&price, end_ms, NEAR_MS),
price_1h: nearest(&price, end_ms + 3_600_000, NEAR_MS),
price_4h: nearest(&price, end_ms + 4 * 3_600_000, 2 * NEAR_MS),
context: None,
});
}
}
}
out.sort_by_key(|p| std::cmp::Reverse(p.start));
// Context for the newest: a handful of aggregate queries each, which a
// page of a dozen pauses can afford and a page of hundreds cannot.
for p in out.iter_mut().take(PAUSE_CONTEXTS) {
let to = p.resumed_at.unwrap_or(p.last);
let Ok(c) = state
.store
.pause_context(chain, &config.asset, solvers, p.start, to)
.await
else {
continue;
};
// The largest holder of the asset, from the probes' own readings: the
// buy probes, whose output token is the asset.
let held: Vec<&blackbeard_data::store::QuoteProbeRow> = rows
.iter()
.filter(|r| r.direction == "buy" && r.at >= p.start && r.at <= to)
.filter(|r| r.best_held.is_some())
.collect();
let first = held.first();
let last = held.last();
p.context = Some(blackbeard_entities::QuotePauseContext {
solver_swaps: c.solver_swaps.max(0) as u32,
solver_sold: BigUintDec(c.solver_sold),
solver_bought: BigUintDec(c.solver_bought),
other_swaps: c.other_swaps.max(0) as u32,
other_sold: BigUintDec(c.other_sold),
other_bought: BigUintDec(c.other_bought),
fills: c.fills.max(0) as u32,
minted_count: c.minted_count.max(0) as u32,
minted: BigUintDec(c.minted),
holder_start: first.and_then(|r| r.best_solver.clone()),
held_start: first.and_then(|r| r.best_held.clone()).map(BigUintDec),
holder_end: last.and_then(|r| r.best_solver.clone()),
held_end: last.and_then(|r| r.best_held.clone()).map(BigUintDec),
pool_changes: c
.settings
.into_iter()
.map(|ch| {
let list = |v: &serde_json::Value| -> Vec<String> {
serde_json::from_value(v.clone()).unwrap_or_default()
};
let (before, after) = (list(&ch.whitelist.0), list(&ch.whitelist.1));
let changed = ch.state.0 != ch.state.1;
blackbeard_entities::PoolChange {
at: ch.at,
pool_id: ch.pool_id,
state_from: changed.then(|| ch.state.0.clone()),
state_to: changed.then(|| ch.state.1.clone()),
whitelisted: after
.iter()
.filter(|a| !before.contains(a))
.cloned()
.collect(),
delisted: before
.iter()
.filter(|a| !after.contains(a))
.cloned()
.collect(),
}
})
.collect(),
});
}
out
}
/// Pauses given on-chain context, newest first.
const PAUSE_CONTEXTS: usize = 12;
/// `GET /v1/chains/{chain}/tx/{hash}`: one extrinsic by its hash
/// (blackbeard/observer#48).
///
@@ -0,0 +1,19 @@
-- A Ref DCL pool's state and swap whitelist, kept when they change.
-- blackbeard/observer#45.
--
-- Both are read every minute (refdcl::pools) and were only ever held in memory,
-- so a pool paused, or a solver dropped from its whitelist, during a stretch
-- with no quotes would leave no trace afterwards. That is exactly the context
-- a pause needs: "the pool stopped" and "the quotes stopped while the pool ran"
-- are different events. A row is written only when either differs from the
-- latest one for the pool, so the table is a list of changes, and the first
-- row is the state as first seen.
create table ref_pool_settings (
chain text not null references chain (id) on delete cascade,
pool_id text not null,
at timestamptz not null,
state text not null,
-- Sorted, so equal whitelists compare equal.
whitelist jsonb not null,
primary key (chain, pool_id, at)
);
+177
View File
@@ -4746,3 +4746,180 @@ impl Store {
.collect())
}
}
/// One change to a pool's settings.
#[derive(Debug, Clone)]
pub struct SettingsChange {
/// When it was first seen.
pub at: DateTime<Utc>,
/// Which pool.
pub pool_id: String,
/// State before and after.
pub state: (String, String),
/// Whitelist before and after.
pub whitelist: (serde_json::Value, serde_json::Value),
}
/// What happened on-chain around the asset during one stretch of time, for
/// the context of a pause in quoting (blackbeard/observer#45).
#[derive(Debug, Clone, Default)]
pub struct PauseContextRow {
/// Pool swaps by known solvers: count, asset sold into the pool, asset
/// bought out of it (base units).
pub solver_swaps: i64,
/// Asset solvers sold into the pool.
pub solver_sold: String,
/// Asset solvers bought out of it.
pub solver_bought: String,
/// Pool swaps by anyone else, calling the pool directly.
pub other_swaps: i64,
/// Asset others sold into the pool.
pub other_sold: String,
/// Asset others bought out of it.
pub other_bought: String,
/// Transfers of the asset inside intents.near with a solver on one side:
/// 1Click fills.
pub fills: i64,
/// Bridge mints: QTC arriving on NEAR.
pub minted_count: i64,
/// How much.
pub minted: String,
/// Pool settings that changed in the stretch: when, which pool, and the
/// state and whitelist before and after. A pool's first row is how it was
/// first seen, not a change, and is never one of these.
pub settings: Vec<SettingsChange>,
}
impl Store {
/// Keep a pool's state and whitelist if either changed since the last row.
pub async fn record_pool_settings(
&self,
chain: &ChainId,
pool_id: &str,
at: DateTime<Utc>,
state: &str,
whitelist: &serde_json::Value,
) -> Result<(), DataError> {
sqlx::query!(
r#"
insert into ref_pool_settings (chain, pool_id, at, state, whitelist)
select $1, $2, $3, $4, $5
where not exists (
select 1 from (
select state, whitelist from ref_pool_settings
where chain = $1 and pool_id = $2
order by at desc limit 1
) last
where last.state = $4 and last.whitelist = $5
)
on conflict do nothing
"#,
chain.as_str(),
pool_id,
at,
state,
whitelist,
)
.execute(&self.pool)
.await?;
Ok(())
}
/// What happened around the asset between `from` and `to`.
pub async fn pause_context(
&self,
chain: &ChainId,
asset: &str,
solvers: &[String],
from: DateTime<Utc>,
to: DateTime<Utc>,
) -> Result<PauseContextRow, DataError> {
let mut out = PauseContextRow::default();
let swaps = sqlx::query!(
r#"
select trader = any($5) as "solver!", side,
count(*) as "n!",
coalesce(sum(case when side = 'sell' then amount_in else amount_out end), 0)::text
as "asset!"
from ref_swap
where chain = $1 and asset = $2 and at between $3 and $4
group by 1, 2
"#,
chain.as_str(),
asset,
from,
to,
solvers,
)
.fetch_all(&self.pool)
.await?;
out.solver_sold = "0".into();
out.solver_bought = "0".into();
out.other_sold = "0".into();
out.other_bought = "0".into();
for r in swaps {
match (r.solver, r.side.as_str()) {
(true, "sell") => (out.solver_swaps += r.n, out.solver_sold = r.asset),
(true, _) => (out.solver_swaps += r.n, out.solver_bought = r.asset),
(false, "sell") => (out.other_swaps += r.n, out.other_sold = r.asset),
(false, _) => (out.other_swaps += r.n, out.other_bought = r.asset),
};
}
out.fills = sqlx::query_scalar!(
r#"
select count(*) as "n!" from intents_transfer
where chain = $1 and asset = $2 and at between $3 and $4
and (from_account = any($5) or to_account = any($5))
"#,
chain.as_str(),
asset,
from,
to,
solvers,
)
.fetch_one(&self.pool)
.await?;
let minted = sqlx::query!(
r#"
select count(*) as "n!", coalesce(sum(amount), 0)::text as "total!"
from bridge_mint where chain = $1 and at between $2 and $3
"#,
chain.as_str(),
from,
to,
)
.fetch_one(&self.pool)
.await?;
out.minted_count = minted.n;
out.minted = minted.total;
out.settings = sqlx::query!(
r#"
select at as "at!", pool_id as "pool_id!", state as "state!",
whitelist as "whitelist!", prev_state as "prev_state!",
prev_whitelist as "prev_whitelist!"
from (
select *, lag(state) over w as prev_state, lag(whitelist) over w as prev_whitelist
from ref_pool_settings
where chain = $1
window w as (partition by pool_id order by at)
) s
where at between $2 and $3 and prev_state is not null
order by at
"#,
chain.as_str(),
from,
to,
)
.fetch_all(&self.pool)
.await?
.into_iter()
.map(|r| SettingsChange {
at: r.at,
pool_id: r.pool_id,
state: (r.prev_state, r.state),
whitelist: (r.prev_whitelist, r.whitelist),
})
.collect();
Ok(out)
}
}
+2 -2
View File
@@ -57,8 +57,8 @@ pub use miner::{
};
pub use near::{
IntentsAccount, IntentsAccountEvent, IntentsBalance, NearAction, NearAmount, NearEvent,
NearMemo, NearPart, NearReceipt, NearToken, NearTx, NearTxSource, QuantusTxRef,
QuoteAvailability, QuoteDay, QuotePause, QuoteProbe, QuoteProbes,
NearMemo, NearPart, NearReceipt, NearToken, NearTx, NearTxSource, PoolChange, QuantusTxRef,
QuoteAvailability, QuoteDay, QuotePause, QuotePauseContext, QuoteProbe, QuoteProbes,
};
pub use reversible::{PendingTransfer, ReversibleState};
pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource};
+60
View File
@@ -462,6 +462,66 @@ pub struct QuotePause {
pub price_1h: Option<f64>,
/// Four hours after the end.
pub price_4h: Option<f64>,
/// What happened on-chain around the asset during it. Computed for the
/// newest pauses only.
pub context: Option<QuotePauseContext>,
}
/// What happened on-chain around the asset during a pause in quoting: whether
/// solvers were still trading, whether QTC kept arriving to sell, whether the
/// pool itself changed. None of it says why quotes stopped; all of it is what
/// a reader needs before guessing.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "QuotePauseContext.ts")]
pub struct QuotePauseContext {
/// Pool swaps by known solvers.
pub solver_swaps: u32,
/// Asset solvers sold into the pool, base units.
pub solver_sold: BigUintDec,
/// Asset solvers bought out of the pool.
pub solver_bought: BigUintDec,
/// Pool swaps by anyone else, calling the pool directly.
pub other_swaps: u32,
/// Asset they sold into it.
pub other_sold: BigUintDec,
/// Asset they bought out of it.
pub other_bought: BigUintDec,
/// Transfers of the asset inside intents.near with a solver on one side:
/// fills, which a pause in quoting should have none of.
pub fills: u32,
/// Bridge mints in the pause: QTC arriving on NEAR.
pub minted_count: u32,
/// How much, base units.
pub minted: BigUintDec,
/// The solver holding the most of the asset at the pause's first probe,
/// and how much.
pub holder_start: Option<String>,
/// What it held then.
pub held_start: Option<BigUintDec>,
/// The largest holder at the pause's last probe.
pub holder_end: Option<String>,
/// What it held then.
pub held_end: Option<BigUintDec>,
/// Changes to the pool's state or whitelist during it.
pub pool_changes: Vec<PoolChange>,
}
/// One change to a pool's settings.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "PoolChange.ts")]
pub struct PoolChange {
/// When it was first seen.
pub at: DateTime<Utc>,
/// Which pool.
pub pool_id: String,
/// The state before, when it changed.
pub state_from: Option<String>,
/// The state after, when it changed.
pub state_to: Option<String>,
/// Accounts added to the swap whitelist.
pub whitelisted: Vec<String>,
/// Accounts removed from it.
pub delisted: Vec<String>,
}
/// One UTC day of probes for one direction and counter.
+30
View File
@@ -0,0 +1,30 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* One change to a pool's settings.
*/
export type PoolChange = {
/**
* When it was first seen.
*/
at: string,
/**
* Which pool.
*/
pool_id: string,
/**
* The state before, when it changed.
*/
state_from: string | null,
/**
* The state after, when it changed.
*/
state_to: string | null,
/**
* Accounts added to the swap whitelist.
*/
whitelisted: Array<string>,
/**
* Accounts removed from it.
*/
delisted: Array<string>, };
+7 -1
View File
@@ -1,4 +1,5 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { QuotePauseContext } from "./QuotePauseContext";
/**
* A run of consecutive unquoted minutes in one direction and pair.
@@ -67,4 +68,9 @@ price_1h: number | null,
/**
* Four hours after the end.
*/
price_4h: number | null, };
price_4h: number | null,
/**
* What happened on-chain around the asset during it. Computed for the
* newest pauses only.
*/
context: QuotePauseContext | null, };
@@ -0,0 +1,69 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
import type { PoolChange } from "./PoolChange";
/**
* What happened on-chain around the asset during a pause in quoting: whether
* solvers were still trading, whether QTC kept arriving to sell, whether the
* pool itself changed. None of it says why quotes stopped; all of it is what
* a reader needs before guessing.
*/
export type QuotePauseContext = {
/**
* Pool swaps by known solvers.
*/
solver_swaps: number,
/**
* Asset solvers sold into the pool, base units.
*/
solver_sold: BigUintDec,
/**
* Asset solvers bought out of the pool.
*/
solver_bought: BigUintDec,
/**
* Pool swaps by anyone else, calling the pool directly.
*/
other_swaps: number,
/**
* Asset they sold into it.
*/
other_sold: BigUintDec,
/**
* Asset they bought out of it.
*/
other_bought: BigUintDec,
/**
* Transfers of the asset inside intents.near with a solver on one side:
* fills, which a pause in quoting should have none of.
*/
fills: number,
/**
* Bridge mints in the pause: QTC arriving on NEAR.
*/
minted_count: number,
/**
* How much, base units.
*/
minted: BigUintDec,
/**
* The solver holding the most of the asset at the pause's first probe,
* and how much.
*/
holder_start: string | null,
/**
* What it held then.
*/
held_start: BigUintDec | null,
/**
* The largest holder at the pause's last probe.
*/
holder_end: string | null,
/**
* What it held then.
*/
held_end: BigUintDec | null,
/**
* Changes to the pool's state or whitelist during it.
*/
pool_changes: Array<PoolChange>, };
+81 -28
View File
@@ -299,11 +299,12 @@ export function QuotePanel({ chain, symbol: chainSymbol }: { chain: string; symb
</thead>
<tbody>
{data.pauses.slice(0, 12).map((p) => (
<PauseRow
<PauseRows
key={`${p.direction}-${p.counter}-${p.start}`}
p={p}
label={label}
symbol={symbol}
decimals={meta(data.asset)?.decimals ?? 12}
/>
))}
</tbody>
@@ -532,14 +533,16 @@ function span(p: QuotePause): string {
return `${h} h ${minutes % 60} min`
}
function PauseRow({
function PauseRows({
p,
label,
symbol,
decimals,
}: {
p: QuotePause
label: (token: string) => string
symbol: string
decimals: number
}) {
const until =
p.end === 'resumed' && p.resumed_at
@@ -551,35 +554,85 @@ function PauseRow({
.map((v) => (v === null ? '—' : fmtPrice(v)))
.join(' → ')
return (
<tr>
<td className="left">
{p.direction === 'sell' ? 'selling' : 'buying'} {symbol} · {label(p.counter)}
</td>
<td className="left numeral event-when" title={utc(p.start)}>
{p.began_before ? 'by ' : ''}
{when(p.start)}
</td>
<td
className="left numeral event-when"
title={p.resumed_at ? utc(p.resumed_at) : utc(p.last)}
>
{until}
</td>
<td className="numeral">
{p.began_before ? '≥ ' : ''}
{span(p)}
</td>
<td className="numeral">
{p.solver_able} of {p.unquoted} min
{p.unanswered > 0 && <span className="receipt-meta"> · {p.unanswered} unanswered</span>}
</td>
<td className="numeral" title="at the start → at the end → an hour later → four hours later">
{prices}
</td>
</tr>
<>
<tr>
<td className="left">
{p.direction === 'sell' ? 'selling' : 'buying'} {symbol} · {label(p.counter)}
</td>
<td className="left numeral event-when" title={utc(p.start)}>
{p.began_before ? 'by ' : ''}
{when(p.start)}
</td>
<td
className="left numeral event-when"
title={p.resumed_at ? utc(p.resumed_at) : utc(p.last)}
>
{until}
</td>
<td className="numeral">
{p.began_before ? '≥ ' : ''}
{span(p)}
</td>
<td className="numeral">
{p.solver_able} of {p.unquoted} min
{p.unanswered > 0 && <span className="receipt-meta"> · {p.unanswered} unanswered</span>}
</td>
<td
className="numeral"
title="at the start → at the end → an hour later → four hours later"
>
{prices}
</td>
</tr>
{p.context && (
<tr className="pause-context">
<td colSpan={6} className="left receipt-meta">
{contextText(p, symbol, decimals)}
</td>
</tr>
)}
</>
)
}
/**
* What happened on-chain during a pause, in a sentence. Counts and amounts
* only: none of it says why the quotes stopped.
*/
function contextText(p: QuotePause, symbol: string, decimals: number): string {
const c = p.context
if (!c) return ''
const q = (raw: string) => `${fmtTokens(raw, decimals)} ${symbol}`
const parts: string[] = []
parts.push(
c.solver_swaps > 0
? `solvers swapped in the pool ${c.solver_swaps} times (sold ${q(c.solver_sold)}, bought ${q(c.solver_bought)})`
: 'no solver swapped in the pool',
)
if (c.other_swaps > 0)
parts.push(
`others swapped with the pool directly ${c.other_swaps} times (sold ${q(c.other_sold)}, bought ${q(c.other_bought)})`,
)
parts.push(c.fills > 0 ? `${c.fills} fills through 1Click` : 'no fills through 1Click')
if (c.minted_count > 0)
parts.push(`${c.minted_count} bridge deposits brought ${q(c.minted)} to NEAR`)
if (c.holder_start && c.held_start && c.held_end)
parts.push(
`the largest ${symbol} holder among solvers, ${shortNear(c.holder_start)}, held ${q(c.held_start)}${
c.holder_end === c.holder_start ? ` → ${q(c.held_end)}` : ''
}`,
)
for (const ch of c.pool_changes) {
const bits = [
ch.state_from && ch.state_to ? `state ${ch.state_from} → ${ch.state_to}` : null,
ch.whitelisted.length ? `whitelisted ${ch.whitelisted.map(shortNear).join(', ')}` : null,
ch.delisted.length ? `removed from whitelist ${ch.delisted.map(shortNear).join(', ')}` : null,
].filter(Boolean)
if (bits.length) parts.push(`pool ${when(ch.at)}: ${bits.join('; ')}`)
}
return `During it: ${parts.join('; ')}.`
}
/**
* Buy availability against sell availability, per counter: the gap the
* launch evening made plain, when buying was quoted and selling never was.
+8
View File
@@ -2282,3 +2282,11 @@ tr.mine .share-bar > i {
order: 10;
}
}
/* A pause's on-chain context, under its row: part of the same record, so no
rule between them. */
.pause-context td {
border-top: 0;
padding-top: 0;
white-space: normal;
}