diff --git a/.sqlx/query-a78483d3063e6f5bfb47a2b406d6e121eaeea0e1fd1b2242cc3978983f333589.json b/.sqlx/query-a78483d3063e6f5bfb47a2b406d6e121eaeea0e1fd1b2242cc3978983f333589.json new file mode 100644 index 0000000..a07a4df --- /dev/null +++ b/.sqlx/query-a78483d3063e6f5bfb47a2b406d6e121eaeea0e1fd1b2242cc3978983f333589.json @@ -0,0 +1,55 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select height, event_index,\n fields->>'from' as \"from!\", fields->>'to' as \"to!\",\n (fields->>'amount')::numeric::text as \"amount!\", at\n from chain_event\n where chain = $1\n and (accounts @> array[$2] or accounts @> array[$3])\n and ( (pallet = 'Balances' and variant = 'Transfer')\n or (pallet = 'Wormhole' and variant = 'NativeTransferred'\n and fields->>'from' = $4))\n and (fields->>'to' = $2 or fields->>'from' = $2 or fields->>'from' = $3)\n order by height, event_index\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "event_index", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "from!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "to!", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "amount!", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + null, + null, + null, + true + ] + }, + "hash": "a78483d3063e6f5bfb47a2b406d6e121eaeea0e1fd1b2242cc3978983f333589" +} diff --git a/CLAUDE.md b/CLAUDE.md index 3a9510b..74b43f7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -779,6 +779,38 @@ read zero for every miner and looked like evidence. The site now has somewhere that says how much of the chain is unobservable and by which route, so that zero can be read correctly. +## The bridge is read from this chain's side, and its roles are configured + +`/:chain/bridge` (blackbeard/observer#39) sets the bridge's **custody** account +against `qtc.omft.near`'s supply on NEAR. Both of its accounts, custody and the +**fee payer**, are named in `[chains.bridge]` with the evidence beside them, +never inferred: a classifier that called "receives a lot" custody would be one +guess from labelling somebody's wallet a bridge. + +**A deposit is told by *who* and *when*, never by how much.** The fee payer funds +the depositing address, and the address sweeps into custody, one block later on +every mainnet deposit so far. The funding is usually 0.0010166025 QTC but was +0.0010169025 before the 150,000 sweep and 0.0010168025 before the 5,990: match +on the amount and the two largest deposits on the chain disappear. Deposit +addresses are **not** one-time either, which #39 assumed: `qzkz3nB…` deposited +three times. `blackbeard_core::bridge::classify` is the rule, tested against +every real transfer. + +**Locked is custody's balance less what was never a deposit.** The first 5 QTC +custody held came from an address the fee payer never funded, so nothing was +minted against it. That is derived, not configured: anything reaching custody +from an unfunded sender is an `other_inflow`. Measured 2026-10-06 at block +~176,178: custody 155,999.701, locked 155,994.701, minted 155,994.698. Deposits +less withdrawals came to 155,994.704; custody's own transaction fees account for +the 0.003 between that and locked. + +**Every ordinary transfer is two events.** `Balances::Transfer` and +`Wormhole::NativeTransferred` are both emitted, with the same sender, recipient +and amount. Custody's seventeen transfers are seventeen of each. `bridge_transfers` +takes `Balances::Transfer`, plus `NativeTransferred` only where the sender is the +sentinel (an exit, which has no `Balances::Transfer`). Anything that sums both +doubles. The account route's `flows` does sum both; see the note on #39. + ## The network page mixes two kinds of certainty, and says which `/:chain/network` puts chain state and this observer's index on one page, and diff --git a/asset/config/config.toml.tmpl b/asset/config/config.toml.tmpl index ec9af23..ad3427b 100644 --- a/asset/config/config.toml.tmpl +++ b/asset/config/config.toml.tmpl @@ -153,6 +153,19 @@ rpc_urls = ["https://near.internal"] # alone by a firewalld rich rule, as frankie's RPC is to the proxy. pool_rpc_urls = ["http://gargravarr.hanzalova.internal:24403"] +# The QTC bridge to NEAR, read from this chain's side (blackbeard/observer#39). +# Roles, not guesses, so the evidence is written down: +# +# custody: every QTC the bridge has minted on NEAR is swept here. Its deposits +# less withdrawals reconcile to `qtc.omft.near`'s ft_total_supply to within +# 0.006 QTC (155,994.704 against 155,994.698, both read 2026-10-06 near block +# 176,178), and its balance less the 5 QTC seed to within 0.003. +# fee_payer: funded the depositor's address exactly one block before every one +# of the 13 sweeps into custody to that date, and funded nobody else. +[chains.bridge] +custody = "qzmzw3Bf2sPHaqSZJnUmNJAYiD5DZosZ4xw1kZduqGGKePTDk" +fee_payer = "qznFJTshpdkU6bFpiT7xbi4cLyS7wkLKa1gCWs8m3xwi3bUvB" + [[chains]] id = "heisenberg" display_name = "Heisenberg" diff --git a/crates/blackbeard-api/src/config.rs b/crates/blackbeard-api/src/config.rs index 405f8f2..d2f30e0 100644 --- a/crates/blackbeard-api/src/config.rs +++ b/crates/blackbeard-api/src/config.rs @@ -191,6 +191,46 @@ pub struct ChainConfig { /// See blackbeard/observer#27. #[serde(default)] pub intents: Option, + /// The bridge that mints this chain's token on NEAR, read from this chain's + /// side: its custody account and the fee payer that funds each deposit. + /// Absent for a chain with no bridge. blackbeard/observer#39. + #[serde(default)] + pub bridge: Option, +} + +/// The bridge's two accounts on this chain, as SS58 addresses. +/// +/// Configuration rather than inference: these are roles, and a role named from +/// an address's behaviour — "it receives a lot, so it must be custody" — is one +/// bad guess from describing a person's wallet as a bridge. The evidence for +/// each belongs in a comment beside it in the config. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct BridgeConfig { + /// Where deposits are swept and withdrawals paid from. + pub custody: String, + /// The account that funds each deposit address just before its sweep. + pub fee_payer: String, + /// How long after the fee payer funds an address its sweep may land and + /// still count as a deposit, in blocks. Mainnet has only ever shown one. + #[serde(default = "default_max_sweep_lag")] + pub max_sweep_lag: u64, +} + +fn default_max_sweep_lag() -> u64 { + 100 +} + +impl BridgeConfig { + /// Custody's account id, as our tables spell it. + pub fn custody_hex(&self) -> Option { + blackbeard_core::wormhole::account_id(&self.custody) + } + + /// The fee payer's account id, as our tables spell it. + pub fn fee_payer_hex(&self) -> Option { + blackbeard_core::wormhole::account_id(&self.fee_payer) + } } /// Following one token's settled swaps on NEAR Intents. @@ -396,6 +436,22 @@ impl Config { ); } } + if let Some(bridge) = &chain.bridge { + if bridge.custody_hex().is_none() || bridge.fee_payer_hex().is_none() { + anyhow::bail!( + "chain `{}` names a bridge account that is not a valid address on this \ + network; a typo here would classify nothing and look like a bridge with \ + no deposits", + chain.id + ); + } + if bridge.custody == bridge.fee_payer { + anyhow::bail!( + "chain `{}` names one account as both bridge custody and fee payer", + chain.id + ); + } + } if chain.target_block_time_seconds <= 0.0 { anyhow::bail!( "chain `{}` has a target block time of {}; hashrate is difficulty divided by it", @@ -439,6 +495,7 @@ mod tests { warm_start_blocks: 10, max_gap_fill_blocks: 10, intents: None, + bridge: None, } } @@ -466,6 +523,16 @@ mod tests { .find_map(|c| c.intents.as_ref()) .expect("a chain tracks NEAR Intents"); assert_eq!(intents.asset, "nep141:qtc.omft.near"); + let bridge = config + .chains + .iter() + .find_map(|c| c.bridge.as_ref()) + .expect("a chain names its bridge"); + assert!(bridge.custody_hex().is_some(), "custody is a valid address"); + assert!( + bridge.fee_payer_hex().is_some(), + "fee payer is a valid address" + ); } /// A config naming one endpoint is still a valid config. Every existing diff --git a/crates/blackbeard-api/src/registry.rs b/crates/blackbeard-api/src/registry.rs index bc0dcc3..3d4861f 100644 --- a/crates/blackbeard-api/src/registry.rs +++ b/crates/blackbeard-api/src/registry.rs @@ -55,6 +55,8 @@ pub struct ChainSpec { pub max_gap_fill_blocks: u64, /// NEAR Intents tracking for this chain's token, if configured. pub intents: Option, + /// The bridge minting this chain's token on NEAR, if configured. + pub bridge: Option, } impl ChainSpec { @@ -75,6 +77,7 @@ impl ChainSpec { warm_start_blocks: chain.warm_start_blocks, max_gap_fill_blocks: chain.max_gap_fill_blocks, intents: chain.intents.clone(), + bridge: chain.bridge.clone(), } } } diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 20093f8..3141842 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -73,6 +73,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/ref/pools", get(ref_pools)) .route("/v1/chains/{chain}/ref/swaps", get(ref_swaps)) .route("/v1/chains/{chain}/ref/series", get(ref_series)) + .route("/v1/chains/{chain}/bridge", get(bridge)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -2097,6 +2098,177 @@ async fn ref_pools( Ok(Json(pools)) } +#[derive(Debug, Deserialize)] +struct BridgeQuery { + /// `height:index`, exclusive; newest first. + before: Option, + limit: Option, +} + +/// The bridge to NEAR from this chain's side: custody against what is minted, +/// and every movement through custody (blackbeard/observer#39). +/// +/// The bridge's transfers are a few dozen rows, so all of them are classified +/// on each request and the page is cut from the result: a deposit is told from +/// any other inflow by the fee payer's funding, which can be on an earlier page. +async fn bridge( + 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.bridge.clone().ok_or_else(|| { + Failure( + StatusCode::NOT_FOUND, + ApiError::new( + "no_bridge", + format!("chain `{chain}` has no bridge configured"), + ), + ) + })?; + let (Some(custody), Some(fee_payer)) = (config.custody_hex(), config.fee_payer_hex()) else { + // Validated at startup; unreachable unless the config changed under us. + return Err(Failure( + StatusCode::INTERNAL_SERVER_ERROR, + ApiError::new("bad_bridge", "the configured bridge accounts do not parse"), + )); + }; + let before = match query.before.as_deref() { + None => None, + Some(raw) => Some( + raw.split_once(':') + .and_then(|(h, i)| Some((h.parse::().ok()?, i.parse::().ok()?))) + .ok_or_else(|| { + Failure( + StatusCode::BAD_REQUEST, + ApiError::new( + "malformed_cursor", + format!("`{raw}` is not a `height:index` cursor"), + ), + ) + })?, + ), + }; + let limit = query.limit.unwrap_or(50).clamp(1, 500); + let id = runtime.id(); + + let rows = state + .store + .bridge_transfers(&id, &custody, &fee_payer) + .await + .map_err(database_unavailable)?; + let scan = state + .store + .event_scan(&id) + .await + .map_err(database_unavailable)?; + let at: std::collections::HashMap<(u64, u32), chrono::DateTime> = rows + .iter() + .filter_map(|r| r.at.map(|at| ((r.height, r.event_index), at))) + .collect(); + let transfers: Vec = rows + .iter() + .filter_map(|r| { + Some(blackbeard_core::bridge::Transfer { + height: r.height, + index: r.event_index, + from: r.from.clone(), + to: r.to.clone(), + amount: r.amount.parse().ok()?, + }) + }) + .collect(); + let movements = + blackbeard_core::bridge::classify(&transfers, &custody, &fee_payer, config.max_sweep_lag); + let totals = blackbeard_core::bridge::totals(&movements); + + let (custody_balance, _) = read_balance(&runtime, &custody).await; + let locked = custody_balance + .as_ref() + .and_then(|b| b.0.parse::().ok()) + .map(|b| b.saturating_sub(totals.other_inflows)); + + // Minted is the NEAR token's supply as the intents listing last read it, + // rescaled from the NEAR token's decimals to this chain's. + let decimals = runtime.read().token_decimals; + let listing = runtime + .intents_listing + .read() + .unwrap_or_else(|p| p.into_inner()) + .clone(); + let near_decimals = match runtime.spec.intents.as_ref() { + Some(intents) => state + .store + .near_token(&intents.asset) + .await + .ok() + .flatten() + .map(|(_, d)| d), + None => None, + }; + let minted = match ( + listing + .as_ref() + .and_then(|l| l.total_supply.as_ref()) + .and_then(|s| s.0.parse::().ok()), + near_decimals, + decimals, + ) { + (Some(supply), Some(from), Some(to)) => blackbeard_core::intents::rescale(supply, from, to), + _ => None, + }; + let locked_less_minted = locked + .zip(minted) + .map(|(l, m)| (l as i128 - m as i128).to_string()); + + let ss58 = + |hex: &str| blackbeard_core::wormhole::ss58_of(hex).unwrap_or_else(|| hex.to_owned()); + let mut newest_first: Vec<&blackbeard_core::bridge::Movement> = movements + .iter() + .rev() + .filter(|m| before.is_none_or(|b| (m.height, m.index) < b)) + .collect(); + let next = (newest_first.len() > limit).then(|| { + let last = newest_first[limit - 1]; + format!("{}:{}", last.height, last.index) + }); + newest_first.truncate(limit); + + Ok(Json(blackbeard_entities::BridgeSummary { + chain: id, + custody: config.custody.clone(), + fee_payer: config.fee_payer.clone(), + decimals, + custody_balance, + other_inflows: BigUintDec(totals.other_inflows.to_string()), + locked: locked.map(|l| BigUintDec(l.to_string())), + deposited: BigUintDec(totals.deposited.to_string()), + withdrawn: BigUintDec(totals.withdrawn.to_string()), + net_deposited: totals.net_deposited().to_string(), + deposits: totals.deposits, + withdrawals: totals.withdrawals, + depositors: totals.depositors, + minted: minted.map(|m| BigUintDec(m.to_string())), + minted_checked_at: listing.map(|l| l.checked_at), + locked_less_minted, + movements: newest_first + .into_iter() + .map(|m| blackbeard_entities::BridgeMovement { + height: m.height, + index: m.index, + kind: m.kind.as_str().to_owned(), + counterparty: ss58(&m.counterparty), + amount: BigUintDec(m.amount.to_string()), + funded_at: m.funded_at, + at: at.get(&(m.height, m.index)).copied(), + }) + .collect(), + next, + indexed_from: scan.map(|s| s.0), + indexed_to: scan.map(|s| s.1), + })) +} + #[derive(Debug, Deserialize)] struct RefSwapsQuery { /// `near_height:tx_hash`, exclusive; newest first. diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 564dd1a..a254f7e 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -533,6 +533,7 @@ mod tests { warm_start_blocks: 100, max_gap_fill_blocks: 100, intents: None, + bridge: None, }, RpcClient::new("http://127.0.0.1:9944", Duration::from_secs(5)).unwrap(), )) diff --git a/crates/blackbeard-core/src/bridge.rs b/crates/blackbeard-core/src/bridge.rs new file mode 100644 index 0000000..be1224d --- /dev/null +++ b/crates/blackbeard-core/src/bridge.rs @@ -0,0 +1,511 @@ +//! The QTC bridge to NEAR, read from the Quantus side (blackbeard/observer#39). +//! +//! The bridge keeps QTC it has minted on NEAR in one **custody** account on +//! Quantus. Every movement through it is an ordinary, visible transfer there: +//! +//! - **A deposit** is a transfer into custody from a depositor's address that a +//! **fee payer** funded moments before — enough to pay for the sweep, so the +//! depositor needs no QTC of their own to cover the fee. On mainnet the +//! funding lands exactly one block before the sweep, every time. +//! - **A withdrawal** is a transfer out of custody: QTC released on Quantus +//! against QTC burned on NEAR. +//! - **Anything else into custody** is not a deposit. The first QTC custody +//! ever held, 5 QTC at block 169,168, came from an address the fee payer never +//! funded — it *funded* the fee payer, eight blocks earlier — and nothing was +//! minted against it. +//! +//! Two things #39 believed that the data does not bear out, and which this +//! module is written against instead: +//! +//! - **Deposit addresses are not one-time.** `qzkz3nB…` deposited three times +//! (0.2, 1 and 5,990 QTC), funded each time. A rule that expected one sweep +//! per address would have dropped two of them. +//! - **The funding amount is not fixed.** It is usually 0.0010166025 QTC but was +//! 0.0010169025 before the 150,000 QTC sweep and 0.0010168025 before the +//! 5,990 — the sweep's own fee, presumably, which depends on the transaction. +//! Matching on the amount would have missed the two largest deposits on the +//! chain. The rule matches on *who* and *when*, never on how much. +//! +//! Roles come from config, with the evidence beside them, never from an +//! address's shape or behaviour: a classifier that inferred "custody" from +//! large inflows would be one bad guess from describing someone's wallet as a +//! bridge. +//! +//! Pure: the caller supplies transfers counted **once each**. Every ordinary +//! transfer on this chain emits both `Balances::Transfer` and +//! `Wormhole::NativeTransferred`, so reading both doubles every figure. + +use std::collections::{BTreeMap, BTreeSet}; + +/// One transfer, as the chain recorded it. Accounts are opaque strings — the +/// caller's spelling (hex in our tables), compared for equality only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Transfer { + /// Block it is in. + pub height: u64, + /// Its event's index within the block, to order and page by. + pub index: u32, + /// Sender. + pub from: String, + /// Recipient. + pub to: String, + /// Amount, in the chain's base units. + pub amount: u128, +} + +/// What a movement through custody was. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// Into custody from an address the fee payer had just funded. + Deposit, + /// Out of custody. + Withdrawal, + /// Into custody from anyone else: not minted against. + OtherInflow, +} + +impl Kind { + /// The name the API carries. + pub fn as_str(self) -> &'static str { + match self { + Kind::Deposit => "deposit", + Kind::Withdrawal => "withdrawal", + Kind::OtherInflow => "other_inflow", + } + } +} + +/// One movement through custody. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Movement { + /// Block. + pub height: u64, + /// Event index within it. + pub index: u32, + /// What it was. + pub kind: Kind, + /// The other side: the depositor's address for a deposit, the recipient for + /// a withdrawal, the sender for anything else. + pub counterparty: String, + /// Amount, base units. + pub amount: u128, + /// For a deposit, the block the fee payer funded the depositor in. + pub funded_at: Option, +} + +/// Totals over a set of movements. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct Totals { + /// Summed deposits. + pub deposited: u128, + /// Summed withdrawals. + pub withdrawn: u128, + /// Summed other inflows. + pub other_inflows: u128, + /// How many deposits. + pub deposits: u32, + /// How many withdrawals. + pub withdrawals: u32, + /// How many distinct addresses deposited. + pub depositors: u32, +} + +impl Totals { + /// Deposits less withdrawals: what the flows say should be minted, signed + /// because a range read from the middle of history can show more out than in. + pub fn net_deposited(&self) -> i128 { + self.deposited as i128 - self.withdrawn as i128 + } +} + +/// Classify every transfer touching `custody`, oldest first. +/// +/// `transfers` must include the fee payer's outgoing transfers as well as +/// custody's own, since those are what identify a depositor. A deposit is a +/// transfer into custody whose sender the fee payer funded at most `max_lag` +/// blocks earlier, at or before the sweep's own block. Transfers neither into +/// nor out of custody are only evidence and are not returned. +pub fn classify( + transfers: &[Transfer], + custody: &str, + fee_payer: &str, + max_lag: u64, +) -> Vec { + // Every block each address was funded in by the fee payer. + let mut funded: BTreeMap<&str, Vec> = BTreeMap::new(); + for t in transfers.iter().filter(|t| t.from == fee_payer) { + funded.entry(t.to.as_str()).or_default().push(t.height); + } + + let mut ordered: Vec<&Transfer> = transfers.iter().collect(); + ordered.sort_by_key(|t| (t.height, t.index)); + + let mut out = Vec::new(); + for t in ordered { + if t.from == custody && t.to == custody { + continue; + } + if t.from == custody { + out.push(Movement { + height: t.height, + index: t.index, + kind: Kind::Withdrawal, + counterparty: t.to.clone(), + amount: t.amount, + funded_at: None, + }); + } else if t.to == custody { + // The latest funding at or before the sweep, if it is recent enough. + let funded_at = (t.from != fee_payer) + .then(|| funded.get(t.from.as_str())) + .flatten() + .and_then(|hs| hs.iter().copied().filter(|h| *h <= t.height).max()) + .filter(|h| t.height - h <= max_lag); + out.push(Movement { + height: t.height, + index: t.index, + kind: if funded_at.is_some() { + Kind::Deposit + } else { + Kind::OtherInflow + }, + counterparty: t.from.clone(), + amount: t.amount, + funded_at, + }); + } + } + out +} + +/// Sum a set of movements. +pub fn totals(movements: &[Movement]) -> Totals { + let mut t = Totals::default(); + let mut depositors = BTreeSet::new(); + for m in movements { + match m.kind { + Kind::Deposit => { + t.deposited += m.amount; + t.deposits += 1; + depositors.insert(m.counterparty.as_str()); + } + Kind::Withdrawal => { + t.withdrawn += m.amount; + t.withdrawals += 1; + } + Kind::OtherInflow => t.other_inflows += m.amount, + } + } + t.depositors = depositors.len() as u32; + t +} + +#[cfg(test)] +mod tests { + use super::*; + + const CUSTODY: &str = "qzmzw3Bf2sPHaqSZJnUmNJAYiD5DZosZ4xw1kZduqGGKePTDk"; + const FEE_PAYER: &str = "qznFJTshpdkU6bFpiT7xbi4cLyS7wkLKa1gCWs8m3xwi3bUvB"; + const QTC: u128 = 1_000_000_000_000; + + fn t(height: u64, from: &str, to: &str, amount: u128) -> Transfer { + Transfer { + height, + index: 0, + from: from.into(), + to: to.into(), + amount, + } + } + + /// Every transfer into or out of custody and every one out of the fee + /// payer on mainnet up to block 176,178, read from the production index on + /// 2026-10-06 (`Balances::Transfer` only, so each counted once). + fn mainnet() -> Vec { + let c = CUSTODY; + let f = FEE_PAYER; + let fund = 1_016_602_500; // 0.0010166025 QTC + vec![ + // The team's holder seeds the fee payer, then custody: 5 QTC each. + t( + 169_160, + "qzozZqZWovEQRxWp5BQvp6UNkzbQpF2H814asZuWboY2jFEAc", + f, + 5 * QTC, + ), + t( + 169_168, + "qzozZqZWovEQRxWp5BQvp6UNkzbQpF2H814asZuWboY2jFEAc", + c, + 5 * QTC, + ), + t( + 169_642, + f, + "qzouzBtX7LpDMSeY1Dtw4yxZJ2kspHYvkKVBhXBxfnkAEvFeV", + fund, + ), + t( + 169_643, + "qzouzBtX7LpDMSeY1Dtw4yxZJ2kspHYvkKVBhXBxfnkAEvFeV", + c, + QTC, + ), + t( + 169_829, + f, + "qzmm8XfEMWhADDxMUB3wRbLYdFsCaXXNz2Mx6anoqVGTAkAhX", + fund, + ), + t( + 169_830, + "qzmm8XfEMWhADDxMUB3wRbLYdFsCaXXNz2Mx6anoqVGTAkAhX", + c, + QTC, + ), + t( + 171_220, + c, + "qznH1ZTHWydSowRVwrDHaonQ7oY44rpVA8eA56oaCPvM4ZsGF", + 996_000_000_000, + ), + t( + 171_234, + f, + "qznH1ZTHWydSowRVwrDHaonQ7oY44rpVA8eA56oaCPvM4ZsGF", + fund, + ), + t( + 171_235, + "qznH1ZTHWydSowRVwrDHaonQ7oY44rpVA8eA56oaCPvM4ZsGF", + c, + 996_000_000_000, + ), + t( + 171_258, + f, + "qznGu2FZi7kBMPMGqRZXd5zEQs1w9NZuPk6NefBQmWGV28Zpm", + fund, + ), + t( + 171_259, + "qznGu2FZi7kBMPMGqRZXd5zEQs1w9NZuPk6NefBQmWGV28Zpm", + c, + QTC, + ), + t( + 172_665, + f, + "qzpakQiEvDdGtffw6BWTQwT45WQ5w41EQcvmwNe7vw1AoMPxn", + fund, + ), + t( + 172_666, + "qzpakQiEvDdGtffw6BWTQwT45WQ5w41EQcvmwNe7vw1AoMPxn", + c, + QTC, + ), + t( + 172_677, + f, + "qzkBFDFbmKkGZJjfq7Yq38SKTuwGcwhz9WqfvBbb1SgEPfvUA", + fund, + ), + t( + 172_678, + "qzkBFDFbmKkGZJjfq7Yq38SKTuwGcwhz9WqfvBbb1SgEPfvUA", + c, + QTC / 10, + ), + t( + 172_709, + c, + "qzmmiJ9hCYgVFeqnFYC5Ya9XAMcukoQCgCjhHGKmewYjRhmBG", + 1_096_000_000_000, + ), + t( + 172_873, + f, + "qzpMizXTsZyAqBuWKSohP2KVreQKUSbgXYCc8pnGF4tJ5SNuS", + fund, + ), + t( + 172_874, + "qzpMizXTsZyAqBuWKSohP2KVreQKUSbgXYCc8pnGF4tJ5SNuS", + c, + QTC / 10, + ), + t( + 173_144, + f, + "qzqEhYzcehQWwLH2DT2n8y7PjxQX8QxDpByeLfqvhUJP7qWTN", + fund, + ), + t( + 173_145, + "qzqEhYzcehQWwLH2DT2n8y7PjxQX8QxDpByeLfqvhUJP7qWTN", + c, + QTC / 10, + ), + // The funding before the largest sweep is not the usual amount. + t( + 173_222, + f, + "qzpb7tK1wjZUfZDXju1WBLkvPA236yxAwikpSNWkiEiWq7Xys", + 1_016_902_500, + ), + t( + 173_223, + "qzpb7tK1wjZUfZDXju1WBLkvPA236yxAwikpSNWkiEiWq7Xys", + c, + 150_000 * QTC, + ), + t( + 174_266, + f, + "qzpxTRzWQj5SPoLY9nhDrpD2EosjMpF1h3HoRhPtpwNnRsLS3", + fund, + ), + t( + 174_267, + "qzpxTRzWQj5SPoLY9nhDrpD2EosjMpF1h3HoRhPtpwNnRsLS3", + c, + QTC / 2, + ), + t( + 174_717, + c, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + QTC / 5, + ), + // One address, three deposits, funded before each. + t( + 174_730, + f, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + fund, + ), + t( + 174_731, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + c, + QTC / 5, + ), + t( + 174_759, + f, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + fund, + ), + t( + 174_760, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + c, + QTC, + ), + t( + 174_828, + f, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + 1_016_802_500, + ), + t( + 174_829, + "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i", + c, + 5_990 * QTC, + ), + ] + } + + #[test] + fn mainnet_reconciles_to_what_custody_holds() { + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + let t = totals(&moves); + assert_eq!(t.deposits, 13); + assert_eq!(t.withdrawals, 3); + assert_eq!(t.depositors, 11); // qzkz3nB… deposited three times + assert_eq!(t.deposited, 155_996_996_000_000_000); // 155,996.996 QTC + assert_eq!(t.withdrawn, 2_292_000_000_000); // 2.292 QTC + assert_eq!(t.other_inflows, 5 * QTC); // the seed, and only the seed + // 155,994.704 QTC: against ft_total_supply 155,994.698 on NEAR at the + // time, and custody's balance of 155,999.701 less the 5 QTC seed. + assert_eq!(t.net_deposited(), 155_994_704_000_000_000); + } + + #[test] + fn the_seed_is_not_a_deposit_because_its_sender_was_never_funded() { + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + let seed = moves.iter().find(|m| m.height == 169_168).unwrap(); + assert_eq!(seed.kind, Kind::OtherInflow); + assert_eq!(seed.funded_at, None); + } + + #[test] + fn a_deposit_is_recognised_whatever_the_funding_amount() { + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + for (height, funded) in [(173_223, 173_222), (174_829, 174_828)] { + let m = moves.iter().find(|m| m.height == height).unwrap(); + assert_eq!(m.kind, Kind::Deposit, "block {height}"); + assert_eq!(m.funded_at, Some(funded)); + } + } + + #[test] + fn an_address_that_deposits_again_is_matched_to_its_latest_funding() { + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + let fundings: Vec> = moves + .iter() + .filter(|m| { + m.kind == Kind::Deposit + && m.counterparty == "qzkz3nBAawgVa7oYWMcsx5GorCn4CtcFKvjyViQQFMWW9AS7i" + }) + .map(|m| m.funded_at) + .collect(); + assert_eq!(fundings, vec![Some(174_730), Some(174_759), Some(174_828)]); + } + + #[test] + fn a_withdrawal_and_a_redeposit_are_both_real_movements() { + // 0.996 QTC out at 171,220 and back in at 171,235: a release on Quantus + // and a fresh mint on NEAR, which net to zero but are two events. + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + let out = moves.iter().find(|m| m.height == 171_220).unwrap(); + let back = moves.iter().find(|m| m.height == 171_235).unwrap(); + assert_eq!((out.kind, back.kind), (Kind::Withdrawal, Kind::Deposit)); + assert_eq!(out.amount, back.amount); + } + + #[test] + fn funding_that_is_too_old_or_comes_after_the_sweep_does_not_make_a_deposit() { + let x = "qzexample"; + let late = [t(500, x, CUSTODY, QTC), t(501, FEE_PAYER, x, 1)]; + assert_eq!( + classify(&late, CUSTODY, FEE_PAYER, 100)[0].kind, + Kind::OtherInflow + ); + let stale = [t(100, FEE_PAYER, x, 1), t(201, x, CUSTODY, QTC)]; + assert_eq!( + classify(&stale, CUSTODY, FEE_PAYER, 100)[0].kind, + Kind::OtherInflow + ); + let fresh = [t(100, FEE_PAYER, x, 1), t(200, x, CUSTODY, QTC)]; + assert_eq!( + classify(&fresh, CUSTODY, FEE_PAYER, 100)[0].kind, + Kind::Deposit + ); + } + + #[test] + fn the_fee_payer_paying_custody_is_not_a_deposit() { + let moves = classify(&[t(10, FEE_PAYER, CUSTODY, QTC)], CUSTODY, FEE_PAYER, 100); + assert_eq!(moves[0].kind, Kind::OtherInflow); + } + + #[test] + fn transfers_that_do_not_touch_custody_are_evidence_only() { + let moves = classify(&mainnet(), CUSTODY, FEE_PAYER, 100); + assert_eq!(moves.len(), 17); + assert!(moves.iter().all(|m| m.counterparty != CUSTODY)); + } +} diff --git a/crates/blackbeard-core/src/lib.rs b/crates/blackbeard-core/src/lib.rs index 99ca09f..b6feb43 100644 --- a/crates/blackbeard-core/src/lib.rs +++ b/crates/blackbeard-core/src/lib.rs @@ -14,6 +14,7 @@ #![deny(missing_docs)] pub mod attribution; +pub mod bridge; pub mod concentration; pub mod dcl; pub mod digest; diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index d42e909..d3efc43 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -461,6 +461,23 @@ pub struct StoredEvent { pub fields: serde_json::Value, } +/// One transfer touching the bridge's accounts, counted once. +#[derive(Debug, Clone)] +pub struct BridgeTransferRow { + /// Block. + pub height: u64, + /// Event index within it. + pub event_index: u32, + /// Sender, as `0x` hex. + pub from: String, + /// Recipient, as `0x` hex. + pub to: String, + /// Amount in base units, as a decimal string. + pub amount: String, + /// When the block was authored, if recorded. + pub at: Option>, +} + /// One row of an account's merged history. #[derive(Debug, Clone)] pub struct ActivityRow { @@ -2239,6 +2256,56 @@ impl Store { }) } + /// Every transfer into or out of the bridge's custody account and every one + /// out of its fee payer, oldest first (blackbeard/observer#39). + /// + /// **Each transfer once.** An ordinary transfer emits both + /// `Balances::Transfer` and `Wormhole::NativeTransferred` with the same + /// sender, recipient and amount — custody's seventeen transfers are seventeen + /// of each — so reading both doubles every figure. `Balances::Transfer` is + /// the one taken; a `NativeTransferred` is added only where its sender is the + /// hidden sentinel, which is a wormhole exit with no `Balances::Transfer` + /// beside it. + pub async fn bridge_transfers( + &self, + chain: &ChainId, + custody: &str, + fee_payer: &str, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select height, event_index, + fields->>'from' as "from!", fields->>'to' as "to!", + (fields->>'amount')::numeric::text as "amount!", at + from chain_event + where chain = $1 + and (accounts @> array[$2] or accounts @> array[$3]) + and ( (pallet = 'Balances' and variant = 'Transfer') + or (pallet = 'Wormhole' and variant = 'NativeTransferred' + and fields->>'from' = $4)) + and (fields->>'to' = $2 or fields->>'from' = $2 or fields->>'from' = $3) + order by height, event_index + "#, + chain.as_str(), + custody, + fee_payer, + SENTINEL_SENDER, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| BridgeTransferRow { + height: r.height.max(0) as u64, + event_index: r.event_index.max(0) as u32, + from: r.from, + to: r.to, + amount: r.amount, + at: r.at, + }) + .collect()) + } + /// Every wormhole transfer, grouped by how it was produced. /// /// The join that makes the chain's privacy legible: an event alone cannot diff --git a/crates/blackbeard-entities/src/bridge.rs b/crates/blackbeard-entities/src/bridge.rs new file mode 100644 index 0000000..51c01bb --- /dev/null +++ b/crates/blackbeard-entities/src/bridge.rs @@ -0,0 +1,92 @@ +//! The bridge that mints this chain's token on NEAR, read from this chain's +//! side. blackbeard/observer#39. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{BigUintDec, ChainId}; + +/// What the bridge holds here against what it has minted on NEAR, and every +/// movement through its custody account. +/// +/// Two kinds of figure, measured differently, and the page says which is which: +/// `custody_balance` and `minted` are state, read now; `deposited`, `withdrawn` +/// and `other_inflows` are summed over this observer's index of the chain, and +/// are only complete if that index covers the bridge's whole life. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BridgeSummary.ts")] +pub struct BridgeSummary { + /// Which chain. + pub chain: ChainId, + /// The custody account, SS58. Named in config, with evidence, never inferred. + pub custody: String, + /// The fee payer that funds each deposit, SS58. Named in config. + pub fee_payer: String, + /// The chain token's decimals, for every amount here. + pub decimals: Option, + /// Custody's balance now, from chain state. + pub custody_balance: Option, + /// Everything that reached custody other than a deposit — the 5 QTC seed the + /// first transfer put there, and anything sent to it directly since. Nothing + /// was minted against it. + pub other_inflows: BigUintDec, + /// `custody_balance` less `other_inflows`: what custody holds against + /// minted tokens. Absent when the balance could not be read. + pub locked: Option, + /// Summed deposits over the indexed range. + pub deposited: BigUintDec, + /// Summed withdrawals over the indexed range. + pub withdrawn: BigUintDec, + /// `deposited` less `withdrawn`, as a signed decimal string: what the flows + /// say should be minted. + pub net_deposited: String, + /// How many deposits. + pub deposits: u32, + /// How many withdrawals. + pub withdrawals: u32, + /// How many distinct addresses have deposited. An address can deposit more + /// than once; one person can also use several. + pub depositors: u32, + /// The token's `ft_total_supply` on NEAR, rescaled to this chain's decimals. + /// Read by the NEAR Intents listing, not here; absent until it has been. + pub minted: Option, + /// When `minted` was read. + pub minted_checked_at: Option>, + /// `locked` less `minted`, signed. Negative would mean more minted on NEAR + /// than held here — the figure to watch. + pub locked_less_minted: Option, + /// Movements, newest first: one page of them. + pub movements: Vec, + /// Cursor for the next page (`height:index`), when there is one. + pub next: Option, + /// Lowest block of this chain's event index. + #[ts(type = "number")] + pub indexed_from: Option, + /// Highest block of this chain's event index. + #[ts(type = "number")] + pub indexed_to: Option, +} + +/// One movement through custody. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "BridgeMovement.ts")] +pub struct BridgeMovement { + /// Block. + #[ts(type = "number")] + pub height: u64, + /// Event index within it. + pub index: u32, + /// `deposit`, `withdrawal` or `other_inflow`. + pub kind: String, + /// The other side, SS58: the depositing address, the withdrawal's + /// recipient, or the sender of anything else. An address, not a person. + pub counterparty: String, + /// Amount, base units. + pub amount: BigUintDec, + /// For a deposit, the block the fee payer funded the depositing address in. + #[ts(type = "number | null")] + pub funded_at: Option, + /// When the block was authored. + pub at: Option>, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 811f7c2..6832970 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -17,6 +17,7 @@ mod account; mod block; +mod bridge; mod call; mod chain; mod error; @@ -31,6 +32,7 @@ mod ws; pub use account::{AccountDetail, AccountEvent, AccountRow, ActivitySource, RewardSummary}; pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, RecentBlock}; +pub use bridge::{BridgeMovement, BridgeSummary}; pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Concentration, diff --git a/web/src/api/generated/BridgeMovement.ts b/web/src/api/generated/BridgeMovement.ts new file mode 100644 index 0000000..5458b3a --- /dev/null +++ b/web/src/api/generated/BridgeMovement.ts @@ -0,0 +1,36 @@ +// 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 movement through custody. + */ +export type BridgeMovement = { +/** + * Block. + */ +height: number, +/** + * Event index within it. + */ +index: number, +/** + * `deposit`, `withdrawal` or `other_inflow`. + */ +kind: string, +/** + * The other side, SS58: the depositing address, the withdrawal's + * recipient, or the sender of anything else. An address, not a person. + */ +counterparty: string, +/** + * Amount, base units. + */ +amount: BigUintDec, +/** + * For a deposit, the block the fee payer funded the depositing address in. + */ +funded_at: number | null, +/** + * When the block was authored. + */ +at: string | null, }; diff --git a/web/src/api/generated/BridgeSummary.ts b/web/src/api/generated/BridgeSummary.ts new file mode 100644 index 0000000..4f032cc --- /dev/null +++ b/web/src/api/generated/BridgeSummary.ts @@ -0,0 +1,102 @@ +// 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 { BridgeMovement } from "./BridgeMovement"; +import type { ChainId } from "./ChainId"; + +/** + * What the bridge holds here against what it has minted on NEAR, and every + * movement through its custody account. + * + * Two kinds of figure, measured differently, and the page says which is which: + * `custody_balance` and `minted` are state, read now; `deposited`, `withdrawn` + * and `other_inflows` are summed over this observer's index of the chain, and + * are only complete if that index covers the bridge's whole life. + */ +export type BridgeSummary = { +/** + * Which chain. + */ +chain: ChainId, +/** + * The custody account, SS58. Named in config, with evidence, never inferred. + */ +custody: string, +/** + * The fee payer that funds each deposit, SS58. Named in config. + */ +fee_payer: string, +/** + * The chain token's decimals, for every amount here. + */ +decimals: number | null, +/** + * Custody's balance now, from chain state. + */ +custody_balance: BigUintDec | null, +/** + * Everything that reached custody other than a deposit — the 5 QTC seed the + * first transfer put there, and anything sent to it directly since. Nothing + * was minted against it. + */ +other_inflows: BigUintDec, +/** + * `custody_balance` less `other_inflows`: what custody holds against + * minted tokens. Absent when the balance could not be read. + */ +locked: BigUintDec | null, +/** + * Summed deposits over the indexed range. + */ +deposited: BigUintDec, +/** + * Summed withdrawals over the indexed range. + */ +withdrawn: BigUintDec, +/** + * `deposited` less `withdrawn`, as a signed decimal string: what the flows + * say should be minted. + */ +net_deposited: string, +/** + * How many deposits. + */ +deposits: number, +/** + * How many withdrawals. + */ +withdrawals: number, +/** + * How many distinct addresses have deposited. An address can deposit more + * than once; one person can also use several. + */ +depositors: number, +/** + * The token's `ft_total_supply` on NEAR, rescaled to this chain's decimals. + * Read by the NEAR Intents listing, not here; absent until it has been. + */ +minted: BigUintDec | null, +/** + * When `minted` was read. + */ +minted_checked_at: string | null, +/** + * `locked` less `minted`, signed. Negative would mean more minted on NEAR + * than held here — the figure to watch. + */ +locked_less_minted: string | null, +/** + * Movements, newest first: one page of them. + */ +movements: Array, +/** + * Cursor for the next page (`height:index`), when there is one. + */ +next: string | null, +/** + * Lowest block of this chain's event index. + */ +indexed_from: number, +/** + * Highest block of this chain's event index. + */ +indexed_to: number, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index d18617b..31a5c56 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -11,6 +11,7 @@ import type { AccountEvent } from './generated/AccountEvent' import type { AccountRow } from './generated/AccountRow' import type { ApiError } from './generated/ApiError' import type { BlockDetail } from './generated/BlockDetail' +import type { BridgeSummary } from './generated/BridgeSummary' import type { BlockExtrinsic } from './generated/BlockExtrinsic' import type { CallIndex } from './generated/CallIndex' import type { ChainSeries } from './generated/ChainSeries' @@ -352,6 +353,16 @@ export async function fetchRefPools(chain: string, signal?: AbortSignal): Promis return get(`/chains/${chain}/ref/pools`, signal) } +/** The bridge to NEAR from this chain's side, with one page of movements. */ +export async function fetchBridge( + chain: string, + before: string | null, + signal?: AbortSignal, +): Promise { + const cursor = before ? `?before=${encodeURIComponent(before)}` : '' + return get(`/chains/${chain}/bridge${cursor}`, signal) +} + /** Swaps in the asset's Ref DCL pools, newest first. */ export async function fetchRefSwaps( chain: string, diff --git a/web/src/components/BridgePanel.tsx b/web/src/components/BridgePanel.tsx new file mode 100644 index 0000000..ebb3ace --- /dev/null +++ b/web/src/components/BridgePanel.tsx @@ -0,0 +1,253 @@ +/** + * The bridge to NEAR, read from this chain's side (blackbeard/observer#39). + * + * Beside the NEAR Intents listing because it answers the other half of the + * same question. The listing says how much of the token exists on NEAR and the + * most there could be (#36); this says what is actually held here against it, + * and who moved it across — by address, never by person. + * + * Two kinds of figure, and the panel keeps them apart: what custody holds and + * what NEAR has minted are state, read now; deposits and withdrawals are summed + * over this observer's index, whose range is printed beside them. The flows + * table reports what moved and does not judge it: a depositor is an address the + * bridge's fee payer funded, which says nothing about who controls it or why + * they bridged. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { BridgeMovement } from '../api/generated/BridgeMovement' +import type { BridgeSummary } from '../api/generated/BridgeSummary' +import { RequestFailed, fetchBridge } from '../api/rest' +import { height as fmtHeight, shortAddress, tokens, utc, when } from '../lib/format' +import { href } from '../lib/routes' +import { Field } from './Field' + +const REFRESH_MS = 60_000 + +/** How each movement reads in the table. */ +const KIND: Record = { + deposit: 'deposited', + withdrawal: 'withdrawn', + other_inflow: 'sent to custody', +} + +export function BridgePanel({ + chain, + decimals, + symbol, +}: { + chain: string + decimals: number + symbol: string +}) { + // `undefined` while loading, `null` when this chain has no bridge configured. + const [data, setData] = useState(undefined) + const [older, setOlder] = useState([]) + const [cursor, setCursor] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + let controller = new AbortController() + const load = () => { + controller = new AbortController() + fetchBridge(chain, null, controller.signal) + .then((d) => { + setData(d) + setError(null) + }) + .catch((e: unknown) => { + if (controller.signal.aborted) return + if (e instanceof RequestFailed && e.status === 404) { + setData(null) + return + } + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + } + setOlder([]) + setCursor(null) + load() + const id = window.setInterval(load, REFRESH_MS) + return () => { + window.clearInterval(id) + controller.abort() + } + }, [chain]) + + if (data === null) return null + + const d = data?.decimals ?? decimals + const amount = (raw: string | null | undefined) => (raw ? `${tokens(raw, d)} ${symbol}` : '—') + const signed = (raw: string | null | undefined) => { + if (!raw) return '—' + const negative = raw.startsWith('-') + return `${negative ? '−' : '+'}${tokens(negative ? raw.slice(1) : raw, d)} ${symbol}` + } + const next = older.length > 0 ? cursor : (data?.next ?? null) + const loadOlder = () => { + if (!next) return + fetchBridge(chain, next) + .then((page) => { + setOlder((rows) => [...rows, ...page.movements]) + setCursor(page.next) + }) + .catch(() => {}) + } + const rows = data ? [...data.movements, ...older] : [] + const short = data?.locked_less_minted?.startsWith('-') === true + const range = + data && data.indexed_from !== null && data.indexed_to !== null + ? `blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}` + : 'nothing indexed yet' + + return ( +
+
+

Bridge

+ custody on this chain against {symbol} minted on NEAR +
+ + {error &&

{error}

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

Reading the bridge…

} + + {data && short && ( +

+ + More {symbol} is minted on NEAR than custody holds against it, by{' '} + {signed(data.locked_less_minted).slice(1)}. + {' '} + Either some of it is unbacked, or one of the two figures was read at a moment the other + had not caught up with — the NEAR supply is read every few minutes, custody on each + request. +

+ )} + + {data && ( + <> +
+ + + + + + +
+ + {rows.length === 0 ? ( +

Nothing has moved through custody in the blocks indexed.

+ ) : ( +
+ + + + + + + + + + + + + {rows.map((m) => ( + + + + + + + + ))} + +
+ Movements through the bridge's custody account, newest first +
+ When + + Movement + + Address + {symbol} + Block +
+ {when(m.at)} + + {KIND[m.kind] ?? m.kind} + + + {shortAddress(m.counterparty)} + + {tokens(m.amount, d)} + + {fmtHeight(m.height)} + +
+
+ )} + {next && ( +
+ +
+ )} + +

+ A deposit is a transfer into custody from an address the bridge's + fee payer funded just before, so the depositor pays no fee; a{' '} + withdrawal is a transfer out, released against {symbol} burned on NEAR. + Anything else sent to custody — {amount(data.other_inflows)} so far, the first of it the + seed custody started with — is not minted against, which is why it is left out of what + is locked. Both accounts are named in this observer's configuration with the + evidence for each; neither is inferred. +

+

+ What this cannot say: an address is not a person. One holder can use several deposit + addresses and one address can deposit many times, and a depositor may be a miner, an + exchange or anyone else — bridging says nothing about why. Custody and NEAR's + supply are read now; deposits and withdrawals are summed over the blocks this observer + has indexed ({range}), and are only the whole history if that range covers it. +

+ + )} +
+ ) +} diff --git a/web/src/components/MarketPage.tsx b/web/src/components/MarketPage.tsx index 996f349..1324c21 100644 --- a/web/src/components/MarketPage.tsx +++ b/web/src/components/MarketPage.tsx @@ -20,6 +20,7 @@ import { RequestFailed, fetchMarkets, fetchSwapsNewest } from '../api/rest' import { height as fmtHeight } from '../lib/format' import { useObserverInstance } from '../lib/observer-context' import { href } from '../lib/routes' +import { BridgePanel } from './BridgePanel' import { IntentsPanel } from './IntentsPanel' import { InventoryPanel } from './InventoryPanel' import { PriceChart } from './PriceChart' @@ -156,6 +157,8 @@ function NearIntents({ + +