feat: link QTC bridge deposits and withdrawals across both chains
#44 part 3. A deposit is linked exactly: the NEAR mint is bridge-mng.near calling omft.near.ft_deposit, whose arguments carry the Intents account and BRIDGED_FROM's txHash — Blake2-256 of the depositor's Quantus transfer into their deposit address, length prefix included (checked: extrinsic 1 of block 180,029 hashes to 0xc4444afc…, the hash in mint piPTXyoq…). `chain_extrinsic.hash` is stored from now on and filled for the bridge's deposit transfers. `bridgescan` reads mints from gargravarr (bridge-mng.near is on shard 8), each confirmed executed before it is recorded. History before our nodes is found by bisecting the token's supply — 16 changes before gargravarr's oldest block, 204 archive calls — and a rise is accepted as mints only when the mints around it sum to it exactly. Withdrawals (ft_withdraw with WITHDRAW_TO) come from the intents scan, rewound to frankie's oldest block (migration 0018); custody's payout is matched by a strict, stated rule, as an inference, with the fee it implies — every pre-node withdrawal paid exactly 0.002 QTC less. `/bridge/deposits` and `/bridge/withdrawals`; a linked-transactions panel on the NEAR Intents page. Refs #44 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
+65
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select e.height, e.extrinsic_index, e.event_index,\n e.fields->>'from' as \"from!\", e.fields->>'to' as \"to!\",\n (e.fields->>'amount')::numeric::text as \"amount!\", e.at, x.hash\n from chain_event e\n left join chain_extrinsic x\n on x.chain = e.chain and x.height = e.height and x.extrinsic_index = e.extrinsic_index\n where e.chain = $1\n and e.pallet = 'Balances' and e.variant = 'Transfer'\n and e.fields->>'to' = any($2)\n order by e.height, e.event_index\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "extrinsic_index",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "event_index",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "from!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "to!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "amount!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "hash",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
true,
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "22b4f760af8f1d529266bffd3127a1e04dd4634d59448f78e5ce2b8e7f5c6a79"
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "update chain_extrinsic set hash = $4 where chain = $1 and height = $2 and extrinsic_index = $3",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Int4",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "631de8790be6b9a0257382baa9808cabebf4933ed6e86cfcd4c0beb55f249e86"
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into chain_extrinsic\n (chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success, accounts, at, hash)\n select chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success,\n array(select jsonb_array_elements_text(accounts)), at, hash\n from unnest(\n $1::text[], $2::bigint[], $3::int[], $4::text[], $5::text[], $6::text[],\n $7::text[], $8::int[], $9::jsonb[], $10::jsonb[], $11::bool[], $12::jsonb[],\n $13::timestamptz[], $14::text[]\n ) as t(chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success, accounts, at, hash)\n on conflict (chain, height, extrinsic_index) do update set\n pallet = excluded.pallet,\n call = excluded.call,\n signer = excluded.signer,\n signature = excluded.signature,\n signature_bytes = excluded.signature_bytes,\n args = excluded.args,\n extra = excluded.extra,\n success = coalesce(excluded.success, chain_extrinsic.success),\n accounts = excluded.accounts,\n at = coalesce(excluded.at, chain_extrinsic.at),\n hash = coalesce(excluded.hash, chain_extrinsic.hash)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Int8Array",
|
||||
"Int4Array",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Int4Array",
|
||||
"JsonbArray",
|
||||
"JsonbArray",
|
||||
"BoolArray",
|
||||
"JsonbArray",
|
||||
"TimestamptzArray",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "79816e82d765cdb43f1b27ed1340e8ee0e6e72c199b540788c3c756cefd23078"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into bridge_mint (chain, near_height, tx_hash, at, account, amount, quantus_tx)\n values ($1, $2, $3, $4, $5, $6::text::numeric, $7)\n on conflict (chain, tx_hash) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "a94a6b768deecd017eabc7871aaa9377b3b405857c82e9db56a81d60e0fb070d"
|
||||
}
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into chain_extrinsic\n (chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success, accounts, at)\n select chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success,\n array(select jsonb_array_elements_text(accounts)), at\n from unnest(\n $1::text[], $2::bigint[], $3::int[], $4::text[], $5::text[], $6::text[],\n $7::text[], $8::int[], $9::jsonb[], $10::jsonb[], $11::bool[], $12::jsonb[],\n $13::timestamptz[]\n ) as t(chain, height, extrinsic_index, pallet, call, signer, signature,\n signature_bytes, args, extra, success, accounts, at)\n on conflict (chain, height, extrinsic_index) do update set\n pallet = excluded.pallet,\n call = excluded.call,\n signer = excluded.signer,\n signature = excluded.signature,\n signature_bytes = excluded.signature_bytes,\n args = excluded.args,\n extra = excluded.extra,\n success = coalesce(excluded.success, chain_extrinsic.success),\n accounts = excluded.accounts,\n at = coalesce(excluded.at, chain_extrinsic.at)\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"TextArray",
|
||||
"Int8Array",
|
||||
"Int4Array",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"TextArray",
|
||||
"Int4Array",
|
||||
"JsonbArray",
|
||||
"JsonbArray",
|
||||
"BoolArray",
|
||||
"JsonbArray",
|
||||
"TimestamptzArray"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "c0bc24a0ac524e0ef577158d8d3159c6aadddc4cfb3b076fc417faf51d642c5d"
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into bridge_withdrawal\n (chain, near_height, tx_hash, intent_hash, at, account, destination, amount)\n values ($1, $2, $3, $4, $5, $6, $7, $8::text::numeric)\n on conflict (chain, tx_hash, intent_hash) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd47ee8b3db65e3ca8ceec14ceb43489123e7cfd4939792d9c7f24265599d130"
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select near_height, tx_hash, intent_hash, at, account, destination,\n amount::text as \"amount!\"\n from bridge_withdrawal where chain = $1 order by near_height\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "near_height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tx_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "intent_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "account",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "destination",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "amount!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "ec56841f812f415c544ddadbac99d0e85931f272fa56526fc1003fa036ac2bcc"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select near_height, tx_hash, at, account, amount::text as \"amount!\", quantus_tx\n from bridge_mint where chain = $1 order by near_height\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "near_height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tx_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "account",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "amount!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "quantus_tx",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "f85d5b62de5c5f4a088a4f9fed5f2703cf1fbe03b1f7b3741c4fad0307119911"
|
||||
}
|
||||
@@ -549,6 +549,21 @@ restock from the pool; adding them counts one demand twice. DCL amounts are
|
||||
**discrete sums over points**, not Uniswap v3's continuous formula, and
|
||||
`total_x` includes uncollected LP fees that sit in no range (`blackbeard_core::dcl`).
|
||||
|
||||
**A bridge deposit is linked by a hash the bridge writes itself.** The NEAR
|
||||
mint is `bridge-mng.near` calling `omft.near.ft_deposit`, and its arguments
|
||||
carry the Intents account (`msg.receiver_id`) and `BRIDGED_FROM:{…"txHash"}`:
|
||||
the depositor's transfer *into their deposit address*, not the sweep into
|
||||
custody. A Quantus tx hash is Blake2-256 of the encoded extrinsic **length
|
||||
prefix included** — exactly the hex `chain_getBlock` returns — and
|
||||
`chain_extrinsic.hash` stores it from 0018 on. `bridge-mng.near` is on shard 8,
|
||||
so mints are read from gargravarr's chunks with no receipt fetch. Withdrawals
|
||||
are `dip4` `ft_withdraw` with `WITHDRAW_TO:<address>` in the relay's outcomes;
|
||||
custody's payout names no NEAR tx, so that match is inferred and labelled so.
|
||||
Every pre-node withdrawal paid exactly 0.002 QTC less (0.998→0.996,
|
||||
1.098→1.096, 0.202→0.200). History before our nodes (minting began at
|
||||
#218,636,504) is found by bisecting `ft_total_supply`: 16 changes, 204 archive
|
||||
calls, against a day of block-by-block reads.
|
||||
|
||||
**A chunk call for a block with no new chunk returns the previous one.**
|
||||
`height_included` is the check; without it a quiet shard's trades are counted
|
||||
once per block until it produces again.
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
//! The QTC bridge's transactions on NEAR, linked to Quantus
|
||||
//! (blackbeard/observer#44).
|
||||
//!
|
||||
//! Three jobs, each resumable, because the process is restarted several times
|
||||
//! a day:
|
||||
//!
|
||||
//! - **Mints, live.** Every mint is a transaction `bridge-mng.near` signs on its
|
||||
//! own shard (8), which gargravarr tracks. Its `ft_deposit` arguments carry
|
||||
//! the Intents account, the amount and the depositor's Quantus transaction
|
||||
//! hash, so a mint is recorded from the chunk alone, once its receipt is
|
||||
//! confirmed executed.
|
||||
//! - **History before our nodes.** Minting began at NEAR #218,636,504, before
|
||||
//! gargravarr's oldest block (and withdrawals before frankie's). Scanning that
|
||||
//! stretch block by block would be a day of public archive calls. Instead the
|
||||
//! token's `ft_total_supply` is bisected to the exact blocks where it changed,
|
||||
//! and only the chunks just before each are read. A rise is accepted as mints
|
||||
//! only when the mints found there sum to it exactly, which is proof they
|
||||
//! executed without a further call. One limit, stated rather than hidden: a
|
||||
//! mint and a burn that cancel exactly inside one bisected span are not seen;
|
||||
//! their Quantus deposit still lists, marked unlinked.
|
||||
//! - **Quantus hashes.** The mint names the depositor's transfer into their
|
||||
//! deposit address by its hash. Extrinsics indexed before hashes were kept
|
||||
//! have none, so those transfers' blocks are read once and hashed.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use blackbeard_core::intents as core;
|
||||
use blackbeard_data::near::NearClient;
|
||||
use blackbeard_data::store::{BridgeMintRow, BridgeWithdrawalRow, Store};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::config::{BridgeConfig, IntentsConfig};
|
||||
use crate::state::ChainRuntime;
|
||||
|
||||
/// NEAR heights read per pass.
|
||||
const BATCH: u64 = 64;
|
||||
|
||||
/// Wait at the node's final block.
|
||||
const TIP_WAIT: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Wait after a failure.
|
||||
const RETRY: Duration = Duration::from_secs(15);
|
||||
|
||||
/// How long one NEAR call may take.
|
||||
const NEAR_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Between two calls to the public archive: under FastNEAR's sustained rate.
|
||||
const ARCHIVE_SPACING: Duration = Duration::from_millis(800);
|
||||
|
||||
/// How often missing Quantus hashes are looked for.
|
||||
const HASH_INTERVAL: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Chunks read before a supply change, looking for what caused it: the mint is
|
||||
/// signed on one shard and executes on another a block or two later.
|
||||
const LOOKBACK: u64 = 4;
|
||||
|
||||
fn mint_cursor(asset: &str) -> String {
|
||||
format!("bridge-mints:{asset}")
|
||||
}
|
||||
|
||||
fn history_cursor(asset: &str) -> String {
|
||||
format!("bridge-history:{asset}")
|
||||
}
|
||||
|
||||
/// Start the three jobs when the chain has a bridge and a node for its shards.
|
||||
pub fn spawn(chain: Arc<ChainRuntime>, store: Store) {
|
||||
let (Some(bridge), Some(intents)) = (chain.spec.bridge.clone(), chain.spec.intents.clone())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if intents.pool_rpc_urls.is_empty() {
|
||||
return;
|
||||
}
|
||||
let live = match NearClient::new(
|
||||
intents.pool_rpc_urls.clone(),
|
||||
intents.pool_rpc_urls.clone(),
|
||||
intents.view_urls.clone(),
|
||||
NEAR_TIMEOUT,
|
||||
) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(chain = %chain.id(), error = %e, "bridge scan not started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let archive = match NearClient::new(
|
||||
intents.archive_urls.clone(),
|
||||
intents.archive_urls.clone(),
|
||||
intents.archive_urls.clone(),
|
||||
NEAR_TIMEOUT,
|
||||
) {
|
||||
Ok(n) => n,
|
||||
Err(e) => {
|
||||
tracing::error!(chain = %chain.id(), error = %e, "bridge history not started");
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::spawn(mints(
|
||||
Arc::clone(&chain),
|
||||
store.clone(),
|
||||
live.clone(),
|
||||
bridge.clone(),
|
||||
intents.clone(),
|
||||
));
|
||||
tokio::spawn(history(
|
||||
Arc::clone(&chain),
|
||||
store.clone(),
|
||||
live,
|
||||
archive,
|
||||
bridge.clone(),
|
||||
intents,
|
||||
));
|
||||
tokio::spawn(hashes(chain, store, bridge));
|
||||
}
|
||||
|
||||
/// The mints in one chunk of the minter's shard, confirmed by `confirm`.
|
||||
fn mints_in(
|
||||
chunk: &blackbeard_data::near::Chunk,
|
||||
bridge: &BridgeConfig,
|
||||
) -> Vec<(String, blackbeard_core::bridge::NearMint)> {
|
||||
chunk
|
||||
.transactions
|
||||
.iter()
|
||||
.filter(|t| {
|
||||
t.signer_id == bridge.near_minter && t.receiver_id == bridge.near_minter_contract
|
||||
})
|
||||
.filter_map(|t| {
|
||||
let args = t.call_args("ft_deposit")?;
|
||||
let mint = blackbeard_core::bridge::near_mint(&args, &bridge.near_token)?;
|
||||
Some((t.hash.clone(), mint))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Live mints from the node's oldest block forward.
|
||||
async fn mints(
|
||||
chain: Arc<ChainRuntime>,
|
||||
store: Store,
|
||||
near: NearClient,
|
||||
bridge: BridgeConfig,
|
||||
intents: IntentsConfig,
|
||||
) {
|
||||
let id = chain.id();
|
||||
let key = mint_cursor(&intents.asset);
|
||||
loop {
|
||||
if chain.read().current_runtime().is_none() {
|
||||
tokio::time::sleep(RETRY).await;
|
||||
continue;
|
||||
}
|
||||
let (tip, oldest) = match near.heights().await {
|
||||
Ok(h) => h,
|
||||
Err(e) => {
|
||||
tracing::debug!(chain = %id, error = %e, "bridge mint node not answering");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let cursor = match store.intents_scan(&id, &key).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(chain = %id, error = %e, "bridge mint cursor unreadable");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let (low, high) = cursor.unwrap_or((tip, tip.saturating_sub(1)));
|
||||
let (heights, descending): (Vec<u64>, bool) = if high < tip {
|
||||
((high + 1..=tip.min(high + BATCH)).collect(), false)
|
||||
} else if low > oldest {
|
||||
(
|
||||
(low.saturating_sub(BATCH).max(oldest)..low).rev().collect(),
|
||||
true,
|
||||
)
|
||||
} else {
|
||||
tokio::time::sleep(TIP_WAIT).await;
|
||||
continue;
|
||||
};
|
||||
let Ok(layout) = near.shard_layout(heights[0]).await else {
|
||||
tokio::time::sleep(RETRY).await;
|
||||
continue;
|
||||
};
|
||||
let Some(shard) = core::shard_for(
|
||||
&bridge.near_minter,
|
||||
&layout.boundary_accounts,
|
||||
&layout.shard_ids,
|
||||
) else {
|
||||
tracing::error!(chain = %id, minter = %bridge.near_minter, "minter maps to no shard");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut reached = None;
|
||||
'heights: for &height in &heights {
|
||||
let chunk = match near.chunk(height, shard, oldest).await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(chain = %id, near_height = height, error = %e,
|
||||
"bridge mint chunk unreadable; will retry");
|
||||
break;
|
||||
}
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
for (tx_hash, mint) in chunk
|
||||
.as_ref()
|
||||
.map(|c| mints_in(c, &bridge))
|
||||
.unwrap_or_default()
|
||||
{
|
||||
// Recorded only once its receipt on the minter contract has
|
||||
// executed successfully: a failed mint minted nothing.
|
||||
let outcomes = match near.tx_outcomes(&tx_hash, &bridge.near_minter).await {
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
tracing::warn!(chain = %id, tx = %tx_hash, error = %e, "mint outcome unreadable");
|
||||
break 'heights;
|
||||
}
|
||||
};
|
||||
let Some(executed) = outcomes
|
||||
.iter()
|
||||
.find(|o| o.executor_id == bridge.near_minter_contract)
|
||||
else {
|
||||
// Not executed yet: stop here and come back.
|
||||
break 'heights;
|
||||
};
|
||||
if !executed.succeeded {
|
||||
tracing::info!(chain = %id, tx = %tx_hash, "bridge mint failed on NEAR; not recorded");
|
||||
continue;
|
||||
}
|
||||
let Ok((_, at)) = near.block_at(json!(height)).await else {
|
||||
break 'heights;
|
||||
};
|
||||
rows.push(BridgeMintRow {
|
||||
near_height: height,
|
||||
tx_hash,
|
||||
at,
|
||||
account: mint.account,
|
||||
amount: mint.amount.to_string(),
|
||||
quantus_tx: mint.quantus_tx,
|
||||
});
|
||||
}
|
||||
if !rows.is_empty() {
|
||||
if let Err(e) = store.record_bridge_mints(&id, &rows).await {
|
||||
tracing::warn!(chain = %id, error = %e, "bridge mints not recorded");
|
||||
break;
|
||||
}
|
||||
for r in &rows {
|
||||
tracing::info!(chain = %id, near_height = r.near_height, quantus_tx = %r.quantus_tx,
|
||||
account = %r.account, "QTC minted on NEAR");
|
||||
}
|
||||
}
|
||||
reached = Some(height);
|
||||
}
|
||||
if let Some(reached) = reached {
|
||||
let (new_low, new_high) = if descending {
|
||||
(reached, high)
|
||||
} else {
|
||||
(low.min(heights[0]), reached)
|
||||
};
|
||||
if let Err(e) = store.set_intents_scan(&id, &key, new_low, new_high).await {
|
||||
tracing::warn!(chain = %id, error = %e, "bridge mint cursor not saved");
|
||||
}
|
||||
} else {
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `ft_total_supply` at `height` on the archive, paced and retried. A height
|
||||
/// never produced reads as the block before it.
|
||||
async fn supply_at(near: &NearClient, token: &str, mut height: u64) -> u128 {
|
||||
loop {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
match near
|
||||
.view_at(token, "ft_total_supply", json!({}), height)
|
||||
.await
|
||||
{
|
||||
Ok(Some(v)) => {
|
||||
if let Some(n) = v.as_str().and_then(|s| s.parse().ok()) {
|
||||
return n;
|
||||
}
|
||||
tracing::warn!(%token, height, "supply did not parse; retrying");
|
||||
}
|
||||
Ok(None) => height -= 1,
|
||||
Err(e) => {
|
||||
tracing::debug!(%token, height, error = %e, "archive supply read failed; retrying");
|
||||
tokio::time::sleep(RETRY).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every height in `(a, b]` where the supply changed, with the change.
|
||||
async fn supply_changes(near: &NearClient, token: &str, a: u64, b: u64) -> Vec<(u64, i128)> {
|
||||
let mut out = Vec::new();
|
||||
// Explicit stack rather than recursion: async recursion needs boxing, and
|
||||
// the depth is the log of the span anyway.
|
||||
let mut todo = vec![(
|
||||
a,
|
||||
supply_at(near, token, a).await,
|
||||
b,
|
||||
supply_at(near, token, b).await,
|
||||
)];
|
||||
while let Some((lo, s_lo, hi, s_hi)) = todo.pop() {
|
||||
if s_lo == s_hi {
|
||||
continue;
|
||||
}
|
||||
if hi - lo == 1 {
|
||||
out.push((hi, s_hi as i128 - s_lo as i128));
|
||||
continue;
|
||||
}
|
||||
let mid = lo + (hi - lo) / 2;
|
||||
let s_mid = supply_at(near, token, mid).await;
|
||||
todo.push((mid, s_mid, hi, s_hi));
|
||||
todo.push((lo, s_lo, mid, s_mid));
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Mints and withdrawals from before our nodes' history, once.
|
||||
async fn history(
|
||||
chain: Arc<ChainRuntime>,
|
||||
store: Store,
|
||||
live: NearClient,
|
||||
archive: NearClient,
|
||||
bridge: BridgeConfig,
|
||||
intents: IntentsConfig,
|
||||
) {
|
||||
let id = chain.id();
|
||||
let key = history_cursor(&intents.asset);
|
||||
let Some(token) = intents.token_contract().map(str::to_owned) else {
|
||||
return;
|
||||
};
|
||||
// Done already: the cursor records the span it covered.
|
||||
if let Ok(Some(_)) = store.intents_scan(&id, &key).await {
|
||||
return;
|
||||
}
|
||||
// The horizon is gargravarr's oldest block: the mint scan covers from
|
||||
// there. Withdrawals older than frankie's are read here too; the intents
|
||||
// scan covers frankie's span.
|
||||
let horizon = loop {
|
||||
match live.heights().await {
|
||||
Ok((_, oldest)) => break oldest,
|
||||
Err(_) => tokio::time::sleep(RETRY).await,
|
||||
}
|
||||
};
|
||||
let from = intents.start_height;
|
||||
tracing::info!(chain = %id, from, horizon, "reading the bridge's history before our nodes");
|
||||
let changes = supply_changes(&archive, &token, from, horizon).await;
|
||||
tracing::info!(chain = %id, changes = changes.len(), "supply changes found before our nodes");
|
||||
|
||||
for (height, delta) in changes {
|
||||
let layout = loop {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
match archive.shard_layout(height).await {
|
||||
Ok(l) => break l,
|
||||
Err(_) => tokio::time::sleep(RETRY).await,
|
||||
}
|
||||
};
|
||||
let shard_of = |a: &str| core::shard_for(a, &layout.boundary_accounts, &layout.shard_ids);
|
||||
let window = height.saturating_sub(LOOKBACK)..=height;
|
||||
if delta > 0 {
|
||||
let Some(shard) = shard_of(&bridge.near_minter) else {
|
||||
continue;
|
||||
};
|
||||
let mut found: Vec<(u64, String, blackbeard_core::bridge::NearMint)> = Vec::new();
|
||||
for h in window {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
if let Ok(Some(chunk)) = archive.chunk(h, shard, u64::MAX).await {
|
||||
found.extend(
|
||||
mints_in(&chunk, &bridge)
|
||||
.into_iter()
|
||||
.map(|(t, m)| (h, t, m)),
|
||||
);
|
||||
}
|
||||
}
|
||||
let total: u128 = found.iter().map(|(_, _, m)| m.amount).sum();
|
||||
if total as i128 != delta {
|
||||
tracing::warn!(chain = %id, near_height = height, delta, found = total,
|
||||
"mints around a supply rise do not sum to it; not linked");
|
||||
continue;
|
||||
}
|
||||
let mut rows = Vec::new();
|
||||
for (h, tx_hash, m) in found {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
let Ok((_, at)) = archive.block_at(json!(h)).await else {
|
||||
continue;
|
||||
};
|
||||
rows.push(BridgeMintRow {
|
||||
near_height: h,
|
||||
tx_hash,
|
||||
at,
|
||||
account: m.account,
|
||||
amount: m.amount.to_string(),
|
||||
quantus_tx: m.quantus_tx,
|
||||
});
|
||||
}
|
||||
if let Err(e) = store.record_bridge_mints(&id, &rows).await {
|
||||
tracing::warn!(chain = %id, error = %e, "historical mints not recorded");
|
||||
}
|
||||
} else {
|
||||
let Some(shard) = shard_of(&intents.contract) else {
|
||||
continue;
|
||||
};
|
||||
let mut rows = Vec::new();
|
||||
for h in window {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
let Ok(Some(chunk)) = archive.chunk(h, shard, u64::MAX).await else {
|
||||
continue;
|
||||
};
|
||||
for tx in chunk
|
||||
.transactions
|
||||
.iter()
|
||||
.filter(|t| t.receiver_id == intents.contract && t.calls("execute_intents"))
|
||||
{
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
let Ok(outcomes) = archive.tx_outcomes(&tx.hash, &tx.signer_id).await else {
|
||||
continue;
|
||||
};
|
||||
for o in outcomes
|
||||
.iter()
|
||||
.filter(|o| o.executor_id == intents.contract && o.succeeded)
|
||||
{
|
||||
for w in blackbeard_core::bridge::near_withdrawals(&o.logs, &token) {
|
||||
tokio::time::sleep(ARCHIVE_SPACING).await;
|
||||
let Ok((_, at)) = archive.block_at(json!(o.block_hash)).await else {
|
||||
continue;
|
||||
};
|
||||
rows.push(BridgeWithdrawalRow {
|
||||
near_height: h,
|
||||
tx_hash: tx.hash.clone(),
|
||||
intent_hash: w.intent_hash,
|
||||
at,
|
||||
account: w.account,
|
||||
destination: w.destination,
|
||||
amount: w.amount.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = store.record_bridge_withdrawals(&id, &rows).await {
|
||||
tracing::warn!(chain = %id, error = %e, "historical withdrawals not recorded");
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(e) = store.set_intents_scan(&id, &key, from, horizon).await {
|
||||
tracing::warn!(chain = %id, error = %e, "bridge history cursor not saved");
|
||||
}
|
||||
tracing::info!(chain = %id, "bridge history before our nodes read");
|
||||
}
|
||||
|
||||
/// Hash the transfers into deposit addresses that were indexed without one.
|
||||
async fn hashes(chain: Arc<ChainRuntime>, store: Store, bridge: BridgeConfig) {
|
||||
let id = chain.id();
|
||||
let (Some(custody), Some(fee_payer)) = (bridge.custody_hex(), bridge.fee_payer_hex()) else {
|
||||
return;
|
||||
};
|
||||
loop {
|
||||
tokio::time::sleep(HASH_INTERVAL).await;
|
||||
let Ok(rows) = store.bridge_transfers(&id, &custody, &fee_payer).await else {
|
||||
continue;
|
||||
};
|
||||
let transfers: Vec<blackbeard_core::bridge::Transfer> = 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 mut addresses: Vec<String> = blackbeard_core::bridge::classify(
|
||||
&transfers,
|
||||
&custody,
|
||||
&fee_payer,
|
||||
bridge.max_sweep_lag,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|m| m.kind == blackbeard_core::bridge::Kind::Deposit)
|
||||
.map(|m| m.counterparty)
|
||||
.collect();
|
||||
addresses.sort();
|
||||
addresses.dedup();
|
||||
let Ok(into) = store.transfers_into(&id, &addresses).await else {
|
||||
continue;
|
||||
};
|
||||
for t in into.iter().filter(|t| t.hash.is_none()) {
|
||||
let Some(index) = t.extrinsic_index else {
|
||||
continue;
|
||||
};
|
||||
let body = match chain.rpc.block_hash(t.height).await {
|
||||
Ok(Some(h)) => chain.rpc.body(&h).await,
|
||||
Ok(None) => continue,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
let Ok(Some(body)) = body else { continue };
|
||||
let Some(raw) = body.extrinsics.get(index as usize) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bytes) = hex::decode(raw.trim_start_matches("0x")) else {
|
||||
continue;
|
||||
};
|
||||
let hash = blackbeard_core::bridge::extrinsic_hash(&bytes);
|
||||
if let Err(e) = store.set_extrinsic_hash(&id, t.height, index, &hash).await {
|
||||
tracing::warn!(chain = %id, height = t.height, error = %e, "extrinsic hash not stored");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,11 +215,31 @@ pub struct BridgeConfig {
|
||||
/// still count as a deposit, in blocks. Mainnet has only ever shown one.
|
||||
#[serde(default = "default_max_sweep_lag")]
|
||||
pub max_sweep_lag: u64,
|
||||
/// The NEAR account that signs every mint (blackbeard/observer#44). Mint
|
||||
/// `piPTXyoq…` (2026-10-07) is `bridge-mng.near` calling
|
||||
/// `omft.near.ft_deposit` with `token: "qtc"`.
|
||||
#[serde(default = "default_near_minter")]
|
||||
pub near_minter: String,
|
||||
/// The contract it calls.
|
||||
#[serde(default = "default_near_minter_contract")]
|
||||
pub near_minter_contract: String,
|
||||
/// The token's name in that call.
|
||||
#[serde(default = "default_near_token")]
|
||||
pub near_token: String,
|
||||
}
|
||||
|
||||
fn default_max_sweep_lag() -> u64 {
|
||||
100
|
||||
}
|
||||
fn default_near_minter() -> String {
|
||||
"bridge-mng.near".into()
|
||||
}
|
||||
fn default_near_minter_contract() -> String {
|
||||
"omft.near".into()
|
||||
}
|
||||
fn default_near_token() -> String {
|
||||
"qtc".into()
|
||||
}
|
||||
|
||||
impl BridgeConfig {
|
||||
/// Custody's account id, as our tables spell it.
|
||||
|
||||
@@ -1212,6 +1212,7 @@ async fn index_extrinsics(
|
||||
signature_bytes: x.signature_bytes,
|
||||
args: x.args,
|
||||
extra: x.extra,
|
||||
hash: Some(blackbeard_core::bridge::extrinsic_hash(&bytes)),
|
||||
success: outcomes.get(&index).copied(),
|
||||
accounts: x.accounts,
|
||||
at,
|
||||
|
||||
@@ -126,6 +126,7 @@ pub fn spawn(chain: Arc<ChainRuntime>, store: Store) {
|
||||
crate::poolscan::spawn(Arc::clone(&chain), store.clone(), config.clone());
|
||||
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());
|
||||
tokio::spawn(scan(chain, store, near, config));
|
||||
}
|
||||
|
||||
@@ -227,7 +228,15 @@ async fn scan(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config:
|
||||
solvers,
|
||||
pool_swaps,
|
||||
transfers,
|
||||
withdrawals,
|
||||
}) => {
|
||||
if !withdrawals.is_empty() {
|
||||
if let Err(e) = store.record_bridge_withdrawals(&id, &withdrawals).await {
|
||||
tracing::warn!(chain = %id, near_height = height, error = %e,
|
||||
"bridge withdrawals not recorded");
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !transfers.is_empty() {
|
||||
if let Err(e) = store
|
||||
.record_intent_transfers(&id, &config.asset, &transfers)
|
||||
@@ -334,6 +343,7 @@ async fn read_block(
|
||||
// asset: `(tx_hash, signer, trader)`, fetched whole later by `refdcl`.
|
||||
let mut pool_swaps: Vec<(String, String, String)> = Vec::new();
|
||||
let mut transfers: Vec<blackbeard_data::store::IntentTransferRecord> = Vec::new();
|
||||
let mut withdrawals: Vec<blackbeard_data::store::BridgeWithdrawalRow> = Vec::new();
|
||||
let pools = crate::refdcl::contracts(config);
|
||||
let asset_contract = config.token_contract().unwrap_or_default();
|
||||
for tx in chunk
|
||||
@@ -368,6 +378,19 @@ async fn read_block(
|
||||
});
|
||||
}
|
||||
}
|
||||
// QTC withdrawn to Quantus (blackbeard/observer#44).
|
||||
for w in blackbeard_core::bridge::near_withdrawals(&outcome.logs, asset_contract) {
|
||||
let (_, at) = near.block_at(json!(outcome.block_hash)).await?;
|
||||
withdrawals.push(blackbeard_data::store::BridgeWithdrawalRow {
|
||||
near_height: height,
|
||||
tx_hash: tx.hash.clone(),
|
||||
intent_hash: w.intent_hash,
|
||||
at,
|
||||
account: w.account,
|
||||
destination: w.destination,
|
||||
amount: w.amount.to_string(),
|
||||
});
|
||||
}
|
||||
let (legs, malformed) = core::executed_legs(&outcome.logs);
|
||||
for (account, n) in core::solver_fills(&legs) {
|
||||
*solvers.entry(account).or_insert(0) += n;
|
||||
@@ -419,6 +442,7 @@ async fn read_block(
|
||||
solvers,
|
||||
pool_swaps,
|
||||
transfers,
|
||||
withdrawals,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -429,6 +453,7 @@ struct BlockRead {
|
||||
solvers: BTreeMap<String, u32>,
|
||||
pool_swaps: Vec<(String, String, String)>,
|
||||
transfers: Vec<blackbeard_data::store::IntentTransferRecord>,
|
||||
withdrawals: Vec<blackbeard_data::store::BridgeWithdrawalRow>,
|
||||
}
|
||||
|
||||
/// The legs as the contract logged them, amounts as strings.
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
//! §8). It does not daemonise, logs to stdout for journald, and exits 0 on
|
||||
//! `SIGTERM` after draining in-flight requests.
|
||||
|
||||
mod bridgescan;
|
||||
mod config;
|
||||
mod ingest;
|
||||
mod intents;
|
||||
|
||||
@@ -78,6 +78,11 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
|
||||
.route("/v1/chains/{chain}/intents/fills", get(intents_fills))
|
||||
.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))
|
||||
.route(
|
||||
"/v1/chains/{chain}/bridge/withdrawals",
|
||||
get(bridge_withdrawals),
|
||||
)
|
||||
.route("/v1/chains/{chain}/nodes", get(nodes))
|
||||
.route("/v1/chains/{chain}/nodes/{peer}", get(node))
|
||||
.route("/v1/chains/{chain}/accounts", get(accounts))
|
||||
@@ -2102,6 +2107,266 @@ async fn ref_pools(
|
||||
Ok(Json(pools))
|
||||
}
|
||||
|
||||
/// Withdrawn less paid that a payout may differ by and still match: the
|
||||
/// bridge's fee. Every withdrawal before our nodes' history paid exactly 0.002
|
||||
/// QTC (0.998 → 0.996, 1.098 → 1.096, 0.202 → 0.200); the allowance is wider so
|
||||
/// a fee change shows as a different implied fee, not as a run of misses.
|
||||
const BRIDGE_MAX_FEE_UNITS: u32 = 10; // in thousandths of one token: 0.01
|
||||
/// How long after a withdrawal custody's payout may land and still match.
|
||||
const BRIDGE_MAX_PAYOUT_DELAY_MS: i64 = 2 * 60 * 60 * 1000;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BridgeLinksQuery {
|
||||
/// An Intents account.
|
||||
account: Option<String>,
|
||||
/// A Quantus address, SS58: a depositor, a deposit address or a destination.
|
||||
address: Option<String>,
|
||||
/// A Quantus transaction hash.
|
||||
quantus_tx: Option<String>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// The bridge's Quantus-side movements, classified, with their times.
|
||||
async fn bridge_movements(
|
||||
state: &AppState,
|
||||
runtime: &std::sync::Arc<crate::state::ChainRuntime>,
|
||||
) -> Result<
|
||||
(
|
||||
crate::config::BridgeConfig,
|
||||
Vec<blackbeard_core::bridge::Movement>,
|
||||
std::collections::HashMap<(u64, u32), chrono::DateTime<chrono::Utc>>,
|
||||
),
|
||||
Failure,
|
||||
> {
|
||||
let config = runtime.spec.bridge.clone().ok_or_else(|| {
|
||||
Failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
ApiError::new("no_bridge", "this chain has no bridge configured"),
|
||||
)
|
||||
})?;
|
||||
let (Some(custody), Some(fee_payer)) = (config.custody_hex(), config.fee_payer_hex()) else {
|
||||
return Err(Failure(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
ApiError::new("bad_bridge", "the configured bridge accounts do not parse"),
|
||||
));
|
||||
};
|
||||
let rows = state
|
||||
.store
|
||||
.bridge_transfers(&runtime.id(), &custody, &fee_payer)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let at = rows
|
||||
.iter()
|
||||
.filter_map(|r| r.at.map(|t| ((r.height, r.event_index), t)))
|
||||
.collect();
|
||||
let transfers: Vec<blackbeard_core::bridge::Transfer> = 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);
|
||||
Ok((config, movements, at))
|
||||
}
|
||||
|
||||
/// Deposits, linked across both chains (blackbeard/observer#44): each
|
||||
/// depositor's transfer into their deposit address on Quantus, joined to the
|
||||
/// NEAR mint by the transfer's hash, which the bridge writes into the mint.
|
||||
async fn bridge_deposits(
|
||||
State(state): State<AppState>,
|
||||
Path(chain): Path<String>,
|
||||
Query(query): Query<BridgeLinksQuery>,
|
||||
) -> Result<Json<blackbeard_entities::BridgeDeposits>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
let id = runtime.id();
|
||||
let (_, movements, _) = bridge_movements(&state, &runtime).await?;
|
||||
let sweeps: Vec<&blackbeard_core::bridge::Movement> = movements
|
||||
.iter()
|
||||
.filter(|m| m.kind == blackbeard_core::bridge::Kind::Deposit)
|
||||
.collect();
|
||||
let mut addresses: Vec<String> = sweeps.iter().map(|m| m.counterparty.clone()).collect();
|
||||
addresses.sort();
|
||||
addresses.dedup();
|
||||
let into = state
|
||||
.store
|
||||
.transfers_into(&id, &addresses)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let mints: std::collections::HashMap<String, blackbeard_data::store::BridgeMintRow> = state
|
||||
.store
|
||||
.bridge_mints(&id)
|
||||
.await
|
||||
.map_err(database_unavailable)?
|
||||
.into_iter()
|
||||
.map(|m| (m.quantus_tx.clone(), m))
|
||||
.collect();
|
||||
let ss58 =
|
||||
|hex: &str| blackbeard_core::wormhole::ss58_of(hex).unwrap_or_else(|| hex.to_owned());
|
||||
let wanted_address = query
|
||||
.address
|
||||
.as_deref()
|
||||
.and_then(blackbeard_core::wormhole::account_id);
|
||||
let mut deposits: Vec<blackbeard_entities::BridgeDeposit> = into
|
||||
.iter()
|
||||
.filter(|t| t.from != t.to)
|
||||
.map(|t| {
|
||||
let mint = t.hash.as_ref().and_then(|h| mints.get(h));
|
||||
let swept = sweeps
|
||||
.iter()
|
||||
.filter(|m| m.counterparty == t.to && m.height >= t.height)
|
||||
.map(|m| m.height)
|
||||
.min();
|
||||
blackbeard_entities::BridgeDeposit {
|
||||
quantus_tx: t.hash.clone(),
|
||||
height: t.height,
|
||||
at: t.at,
|
||||
from: ss58(&t.from),
|
||||
deposit_address: ss58(&t.to),
|
||||
amount: BigUintDec(t.amount.clone()),
|
||||
swept_at_height: swept,
|
||||
seconds_to_mint: mint.zip(t.at).map(|(m, sent)| (m.at - sent).num_seconds()),
|
||||
mint: mint.map(|m| blackbeard_entities::BridgeMint {
|
||||
near_tx: m.tx_hash.clone(),
|
||||
near_height: m.near_height,
|
||||
at: m.at,
|
||||
account: m.account.clone(),
|
||||
amount: BigUintDec(m.amount.clone()),
|
||||
}),
|
||||
}
|
||||
})
|
||||
.filter(|d| {
|
||||
query
|
||||
.account
|
||||
.as_ref()
|
||||
.is_none_or(|a| d.mint.as_ref().is_some_and(|m| &m.account == a))
|
||||
&& query
|
||||
.quantus_tx
|
||||
.as_ref()
|
||||
.is_none_or(|h| d.quantus_tx.as_deref() == Some(&h.to_ascii_lowercase()))
|
||||
&& wanted_address.as_ref().is_none_or(|a| {
|
||||
let hex = |s: &str| blackbeard_core::wormhole::account_id(s);
|
||||
hex(&d.from).as_ref() == Some(a) || hex(&d.deposit_address).as_ref() == Some(a)
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
deposits.reverse();
|
||||
let linked = deposits.iter().filter(|d| d.mint.is_some()).count() as u64;
|
||||
deposits.truncate(query.limit.unwrap_or(200).clamp(1, 1000));
|
||||
Ok(Json(blackbeard_entities::BridgeDeposits {
|
||||
chain: id,
|
||||
decimals: runtime.read().token_decimals,
|
||||
deposits,
|
||||
linked,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Withdrawals (blackbeard/observer#44): exact on NEAR, and matched to
|
||||
/// custody's payout on Quantus by a stated rule.
|
||||
async fn bridge_withdrawals(
|
||||
State(state): State<AppState>,
|
||||
Path(chain): Path<String>,
|
||||
Query(query): Query<BridgeLinksQuery>,
|
||||
) -> Result<Json<blackbeard_entities::BridgeWithdrawals>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
let id = runtime.id();
|
||||
let decimals = runtime.read().token_decimals;
|
||||
let (_, movements, at) = bridge_movements(&state, &runtime).await?;
|
||||
let payouts: Vec<&blackbeard_core::bridge::Movement> = movements
|
||||
.iter()
|
||||
.filter(|m| m.kind == blackbeard_core::bridge::Kind::Withdrawal)
|
||||
.collect();
|
||||
let rows = state
|
||||
.store
|
||||
.bridge_withdrawals(&id)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let dest_hex: Vec<String> = rows
|
||||
.iter()
|
||||
.map(|r| blackbeard_core::wormhole::account_id(&r.destination).unwrap_or_default())
|
||||
.collect();
|
||||
let w_legs: Vec<blackbeard_core::bridge::Leg<'_>> = rows
|
||||
.iter()
|
||||
.zip(&dest_hex)
|
||||
.map(|(r, d)| blackbeard_core::bridge::Leg {
|
||||
account: d,
|
||||
amount: r.amount.parse().unwrap_or(0),
|
||||
at_ms: r.at.timestamp_millis(),
|
||||
})
|
||||
.collect();
|
||||
let p_legs: Vec<blackbeard_core::bridge::Leg<'_>> = payouts
|
||||
.iter()
|
||||
.map(|m| blackbeard_core::bridge::Leg {
|
||||
account: &m.counterparty,
|
||||
amount: m.amount,
|
||||
at_ms: at
|
||||
.get(&(m.height, m.index))
|
||||
.map(|t| t.timestamp_millis())
|
||||
.unwrap_or(i64::MIN),
|
||||
})
|
||||
.collect();
|
||||
let unit = 10u128.pow(u32::from(decimals.unwrap_or(12)));
|
||||
let max_fee = unit * u128::from(BRIDGE_MAX_FEE_UNITS) / 1000;
|
||||
let matched = blackbeard_core::bridge::match_payouts(
|
||||
&w_legs,
|
||||
&p_legs,
|
||||
max_fee,
|
||||
BRIDGE_MAX_PAYOUT_DELAY_MS,
|
||||
);
|
||||
let mut out: Vec<blackbeard_entities::BridgeWithdrawalLink> = rows
|
||||
.iter()
|
||||
.zip(matched)
|
||||
.map(|(r, m)| {
|
||||
let amount: u128 = r.amount.parse().unwrap_or(0);
|
||||
blackbeard_entities::BridgeWithdrawalLink {
|
||||
near_tx: r.tx_hash.clone(),
|
||||
near_height: r.near_height,
|
||||
at: r.at,
|
||||
account: r.account.clone(),
|
||||
destination: r.destination.clone(),
|
||||
amount: BigUintDec(r.amount.clone()),
|
||||
payout: m.map(|j| {
|
||||
let p = payouts[j];
|
||||
blackbeard_entities::BridgePayout {
|
||||
height: p.height,
|
||||
at: at.get(&(p.height, p.index)).copied(),
|
||||
amount: BigUintDec(p.amount.to_string()),
|
||||
fee: BigUintDec(amount.saturating_sub(p.amount).to_string()),
|
||||
}
|
||||
}),
|
||||
}
|
||||
})
|
||||
.filter(|w| {
|
||||
query.account.as_ref().is_none_or(|a| &w.account == a)
|
||||
&& query.address.as_ref().is_none_or(|a| &w.destination == a)
|
||||
})
|
||||
.collect();
|
||||
out.reverse();
|
||||
let matched = out.iter().filter(|w| w.payout.is_some()).count() as u64;
|
||||
out.truncate(query.limit.unwrap_or(200).clamp(1, 1000));
|
||||
Ok(Json(blackbeard_entities::BridgeWithdrawals {
|
||||
chain: id,
|
||||
decimals,
|
||||
rule: format!(
|
||||
"Inferred: custody's payout carries no NEAR hash. A payout matches a withdrawal \
|
||||
when it goes to the WITHDRAW_TO address, lands within {} hours after it, and pays \
|
||||
no more than was withdrawn and at most {} less (the bridge's fee, 0.002 QTC on \
|
||||
every withdrawal seen so far). Withdrawals are matched oldest first to the earliest \
|
||||
payout that fits; one that fits none is left unmatched, never forced.",
|
||||
BRIDGE_MAX_PAYOUT_DELAY_MS / 3_600_000,
|
||||
f64::from(BRIDGE_MAX_FEE_UNITS) / 1000.0
|
||||
),
|
||||
withdrawals: out,
|
||||
matched,
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct BridgeQuery {
|
||||
/// `height:index`, exclusive; newest first.
|
||||
|
||||
@@ -200,10 +200,279 @@ pub fn totals(movements: &[Movement]) -> Totals {
|
||||
t
|
||||
}
|
||||
|
||||
/// A Quantus transaction hash: Blake2-256 of the encoded extrinsic, length
|
||||
/// prefix included — exactly the bytes `chain_getBlock` returns for it.
|
||||
/// Verified against block 180,029, whose transfer hashes to the `txHash` the
|
||||
/// bridge wrote into the NEAR mint `piPTXyoq…` (blackbeard/observer#44).
|
||||
pub fn extrinsic_hash(encoded: &[u8]) -> String {
|
||||
use blake2::digest::consts::U32;
|
||||
use blake2::{Blake2b, Digest};
|
||||
let digest = Blake2b::<U32>::digest(encoded);
|
||||
format!("0x{}", hex::encode(digest))
|
||||
}
|
||||
|
||||
/// One QTC mint on NEAR, read from the bridge's own `omft.near.ft_deposit`
|
||||
/// call (blackbeard/observer#44).
|
||||
///
|
||||
/// The call's arguments carry the whole link: the Intents account the QTC is
|
||||
/// credited to (`msg.receiver_id`), the amount, and the Quantus transaction
|
||||
/// that paid the deposit address (`memo` = `BRIDGED_FROM:{…,"txHash":"0x…"}`).
|
||||
/// That hash is Blake2-256 of the encoded Quantus extrinsic — the depositor's
|
||||
/// transfer *into* their deposit address, not the sweep into custody.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NearMint {
|
||||
/// The Intents account credited.
|
||||
pub account: String,
|
||||
/// Amount, base units of the NEAR token.
|
||||
pub amount: u128,
|
||||
/// The Quantus transaction hash, `0x`-prefixed lowercase hex.
|
||||
pub quantus_tx: String,
|
||||
}
|
||||
|
||||
/// A bridge mint, from an `ft_deposit` call's decoded arguments, when it mints
|
||||
/// `token` (the bridge's short name for it, e.g. `qtc`) and says where from.
|
||||
pub fn near_mint(args: &serde_json::Value, token: &str) -> Option<NearMint> {
|
||||
if args.get("token")?.as_str()? != token {
|
||||
return None;
|
||||
}
|
||||
let msg: serde_json::Value = serde_json::from_str(args.get("msg")?.as_str()?).ok()?;
|
||||
let memo = args.get("memo")?.as_str()?.strip_prefix("BRIDGED_FROM:")?;
|
||||
let from: serde_json::Value = serde_json::from_str(memo).ok()?;
|
||||
let hash = from.get("txHash")?.as_str()?.to_ascii_lowercase();
|
||||
if hash.len() != 66 || !hash.starts_with("0x") {
|
||||
return None;
|
||||
}
|
||||
Some(NearMint {
|
||||
account: msg.get("receiver_id")?.as_str()?.to_owned(),
|
||||
amount: args.get("amount")?.as_str()?.parse().ok()?,
|
||||
quantus_tx: hash,
|
||||
})
|
||||
}
|
||||
|
||||
/// One QTC withdrawal requested inside NEAR Intents: a `dip4` `ft_withdraw`
|
||||
/// of the token to its own contract with `memo` = `WITHDRAW_TO:<address>`
|
||||
/// (blackbeard/observer#44). The QTC is burned on NEAR and custody pays the
|
||||
/// address on Quantus, net of the bridge's fee.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NearWithdrawal {
|
||||
/// The Intents account withdrawing.
|
||||
pub account: String,
|
||||
/// The Quantus address to be paid.
|
||||
pub destination: String,
|
||||
/// Amount withdrawn, base units of the NEAR token.
|
||||
pub amount: u128,
|
||||
/// The intent.
|
||||
pub intent_hash: String,
|
||||
}
|
||||
|
||||
/// Every executed `WITHDRAW_TO` withdrawal of `token_contract` in one
|
||||
/// outcome's logs. Only intents in `intents_executed` count.
|
||||
pub fn near_withdrawals(logs: &[String], token_contract: &str) -> Vec<NearWithdrawal> {
|
||||
use std::collections::BTreeSet;
|
||||
let events: Vec<serde_json::Value> = logs
|
||||
.iter()
|
||||
.filter_map(|l| l.strip_prefix(crate::intents::EVENT_PREFIX))
|
||||
.filter_map(|j| serde_json::from_str(j).ok())
|
||||
.filter(|e: &serde_json::Value| e.get("standard").and_then(|s| s.as_str()) == Some("dip4"))
|
||||
.collect();
|
||||
let executed: BTreeSet<String> = events
|
||||
.iter()
|
||||
.filter(|e| e.get("event").and_then(|s| s.as_str()) == Some("intents_executed"))
|
||||
.flat_map(|e| {
|
||||
e.get("data")
|
||||
.and_then(|d| d.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.filter_map(|d| d.get("intent_hash")?.as_str().map(str::to_owned))
|
||||
.collect();
|
||||
events
|
||||
.iter()
|
||||
.filter(|e| e.get("event").and_then(|s| s.as_str()) == Some("ft_withdraw"))
|
||||
.flat_map(|e| {
|
||||
e.get("data")
|
||||
.and_then(|d| d.as_array())
|
||||
.cloned()
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.filter_map(|d| {
|
||||
if d.get("token")?.as_str()? != token_contract {
|
||||
return None;
|
||||
}
|
||||
let destination = d
|
||||
.get("memo")?
|
||||
.as_str()?
|
||||
.strip_prefix("WITHDRAW_TO:")?
|
||||
.to_owned();
|
||||
Some(NearWithdrawal {
|
||||
account: d.get("account_id")?.as_str()?.to_owned(),
|
||||
destination,
|
||||
amount: d.get("amount")?.as_str()?.parse().ok()?,
|
||||
intent_hash: d.get("intent_hash")?.as_str()?.to_owned(),
|
||||
})
|
||||
})
|
||||
.filter(|w| executed.contains(&w.intent_hash))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// One side of a withdrawal match: who, how much, when (milliseconds).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Leg<'a> {
|
||||
/// The Quantus account, `0x` hex.
|
||||
pub account: &'a str,
|
||||
/// Amount, base units.
|
||||
pub amount: u128,
|
||||
/// When, milliseconds since the epoch.
|
||||
pub at_ms: i64,
|
||||
}
|
||||
|
||||
/// Match each NEAR withdrawal to custody's payout on Quantus
|
||||
/// (blackbeard/observer#44). The payout carries no NEAR hash, so this is an
|
||||
/// inference, and it is strict: same destination, paid after the withdrawal
|
||||
/// and within `max_delay_ms` of it, no more than was withdrawn and no more
|
||||
/// than `max_fee` less. Withdrawals are taken oldest first, each to the
|
||||
/// earliest unused payout that fits; one that fits none is left unmatched,
|
||||
/// never forced. Returns, per withdrawal, the index of its payout.
|
||||
pub fn match_payouts(
|
||||
withdrawals: &[Leg<'_>],
|
||||
payouts: &[Leg<'_>],
|
||||
max_fee: u128,
|
||||
max_delay_ms: i64,
|
||||
) -> Vec<Option<usize>> {
|
||||
let mut order: Vec<usize> = (0..withdrawals.len()).collect();
|
||||
order.sort_by_key(|&i| withdrawals[i].at_ms);
|
||||
let mut used = vec![false; payouts.len()];
|
||||
let mut out = vec![None; withdrawals.len()];
|
||||
for i in order {
|
||||
let w = withdrawals[i];
|
||||
let best = payouts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(j, p)| {
|
||||
!used[*j]
|
||||
&& p.account == w.account
|
||||
&& p.at_ms >= w.at_ms
|
||||
&& p.at_ms - w.at_ms <= max_delay_ms
|
||||
&& p.amount <= w.amount
|
||||
&& w.amount - p.amount <= max_fee
|
||||
})
|
||||
.min_by_key(|(_, p)| p.at_ms)
|
||||
.map(|(j, _)| j);
|
||||
if let Some(j) = best {
|
||||
used[j] = true;
|
||||
out[i] = Some(j);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn leg(account: &str, amount: u128, at_ms: i64) -> Leg<'_> {
|
||||
Leg {
|
||||
account,
|
||||
amount,
|
||||
at_ms,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_payout_matches_the_withdrawal_it_settles_and_no_other() {
|
||||
let fee = 2_000_000_000; // 0.002 QTC
|
||||
let w = [
|
||||
leg("0xa", 1_098_000_000_000, 1_000),
|
||||
leg("0xb", 5_000_000_000_000, 2_000),
|
||||
];
|
||||
let p = [
|
||||
leg("0xb", 4_998_000_000_000, 60_000), // b, less the fee
|
||||
leg("0xa", 1_096_000_000_000, 30_000), // a, less the fee
|
||||
leg("0xa", 1_096_000_000_000, 500), // before a's withdrawal: not it
|
||||
];
|
||||
assert_eq!(
|
||||
match_payouts(&w, &p, fee, 3_600_000),
|
||||
vec![Some(1), Some(0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nothing_is_forced_when_no_payout_fits() {
|
||||
let w = [leg("0xa", 1_000_000_000_000, 0)];
|
||||
// Too small a payout (fee far above the allowance), too late, too much.
|
||||
let p = [
|
||||
leg("0xa", 900_000_000_000, 10),
|
||||
leg("0xa", 999_000_000_000, 7_200_001),
|
||||
leg("0xa", 1_000_000_000_001, 10),
|
||||
];
|
||||
assert_eq!(match_payouts(&w, &p, 10_000_000_000, 7_200_000), vec![None]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_equal_withdrawals_take_two_payouts_in_order() {
|
||||
let w = [leg("0xa", 10, 0), leg("0xa", 10, 100)];
|
||||
let p = [leg("0xa", 9, 200), leg("0xa", 9, 50)];
|
||||
assert_eq!(match_payouts(&w, &p, 1, 1_000), vec![Some(1), Some(0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quantus_tx_hash_is_blake2_256_of_the_encoded_extrinsic() {
|
||||
// Block 180,029's timestamp inherent, exactly as chain_getBlock
|
||||
// returns it; its sibling transfer hashes, the same way, to the
|
||||
// txHash in NEAR mint piPTXyoq….
|
||||
let raw = hex::decode("280501000bd875d814a101").unwrap();
|
||||
assert_eq!(
|
||||
extrinsic_hash(&raw),
|
||||
"0x23448ad4acafb1f37de587a03ed237bcacd72c35b15a5f4a3da492f3654e0219"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bridge_mint_links_its_quantus_tx_and_intents_account() {
|
||||
// `omft.near.ft_deposit` from mint piPTXyoq…, as gargravarr served it
|
||||
// on 2026-10-07.
|
||||
let args = serde_json::json!({
|
||||
"owner_id": "intents.near", "token": "qtc", "amount": "50000000000",
|
||||
"msg": "{\"receiver_id\":\"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978\"}",
|
||||
"memo": "BRIDGED_FROM:{\"networkType\":\"qtc\",\"chainId\":\"mainnet\",\"txHash\":\"0xc4444afc1d43ddccad1421cb1989a9159516411db085710317ead1adeda3bf8d\"}"
|
||||
});
|
||||
let m = near_mint(&args, "qtc").unwrap();
|
||||
assert_eq!(
|
||||
m.account,
|
||||
"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978"
|
||||
);
|
||||
assert_eq!(m.amount, 50_000_000_000);
|
||||
assert_eq!(
|
||||
m.quantus_tx,
|
||||
"0xc4444afc1d43ddccad1421cb1989a9159516411db085710317ead1adeda3bf8d"
|
||||
);
|
||||
assert!(
|
||||
near_mint(&args, "eth").is_none(),
|
||||
"another token is not a QTC mint"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_withdraw_to_counts_only_when_its_intent_executed() {
|
||||
let w = r#"EVENT_JSON:{"standard":"dip4","version":"0.3.0","event":"ft_withdraw","data":[{"intent_hash":"H1","account_id":"acct.near","token":"qtc.omft.near","receiver_id":"qtc.omft.near","amount":"1096000000000","memo":"WITHDRAW_TO:qzDest"}]}"#.to_owned();
|
||||
let other = r#"EVENT_JSON:{"standard":"dip4","version":"0.3.0","event":"ft_withdraw","data":[{"intent_hash":"H2","account_id":"solver.near","token":"17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1","receiver_id":"dclv2.ref-labs.near","amount":"1","memo":"rebalance"}]}"#.to_owned();
|
||||
let ok = r#"EVENT_JSON:{"standard":"dip4","version":"0.3.0","event":"intents_executed","data":[{"intent_hash":"H1","account_id":"acct.near","nonce":"n"},{"intent_hash":"H2","account_id":"solver.near","nonce":"m"}]}"#.to_owned();
|
||||
let found = near_withdrawals(&[w.clone(), other, ok], "qtc.omft.near");
|
||||
assert_eq!(
|
||||
found,
|
||||
vec![NearWithdrawal {
|
||||
account: "acct.near".into(),
|
||||
destination: "qzDest".into(),
|
||||
amount: 1_096_000_000_000,
|
||||
intent_hash: "H1".into(),
|
||||
}]
|
||||
);
|
||||
assert!(
|
||||
near_withdrawals(&[w], "qtc.omft.near").is_empty(),
|
||||
"not executed: no withdrawal"
|
||||
);
|
||||
}
|
||||
|
||||
const CUSTODY: &str = "qzmzw3Bf2sPHaqSZJnUmNJAYiD5DZosZ4xw1kZduqGGKePTDk";
|
||||
const FEE_PAYER: &str = "qznFJTshpdkU6bFpiT7xbi4cLyS7wkLKa1gCWs8m3xwi3bUvB";
|
||||
const QTC: u128 = 1_000_000_000_000;
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
-- QTC bridge transactions linked across both chains.
|
||||
-- blackbeard/observer#44.
|
||||
--
|
||||
-- A deposit is exact end to end: the depositor's Quantus transfer into their
|
||||
-- deposit address has a hash (Blake2-256 of the encoded extrinsic), and the
|
||||
-- bridge writes that hash into the NEAR mint's `ft_deposit` memo
|
||||
-- (`BRIDGED_FROM:{…"txHash"}`) beside the Intents account it credits. A
|
||||
-- withdrawal is exact on NEAR (`ft_withdraw` with `WITHDRAW_TO:<address>`)
|
||||
-- and only inferred on Quantus, where custody's payout carries no NEAR hash.
|
||||
|
||||
-- Every QTC mint on NEAR, from the bridge's own call.
|
||||
create table bridge_mint (
|
||||
chain text not null references chain (id) on delete cascade,
|
||||
near_height bigint not null,
|
||||
tx_hash text not null,
|
||||
at timestamptz not null,
|
||||
-- The Intents account credited (`msg.receiver_id`).
|
||||
account text not null,
|
||||
amount numeric not null,
|
||||
-- The depositor's Quantus transaction, `0x` lowercase hex.
|
||||
quantus_tx text not null,
|
||||
primary key (chain, tx_hash)
|
||||
);
|
||||
create index bridge_mint_quantus_tx on bridge_mint (chain, quantus_tx);
|
||||
create index bridge_mint_account on bridge_mint (chain, account);
|
||||
|
||||
-- Every QTC withdrawal requested inside NEAR Intents.
|
||||
create table bridge_withdrawal (
|
||||
chain text not null references chain (id) on delete cascade,
|
||||
near_height bigint not null,
|
||||
tx_hash text not null,
|
||||
intent_hash text not null,
|
||||
at timestamptz not null,
|
||||
account text not null,
|
||||
-- The Quantus address `WITHDRAW_TO` names.
|
||||
destination text not null,
|
||||
amount numeric not null,
|
||||
primary key (chain, tx_hash, intent_hash)
|
||||
);
|
||||
create index bridge_withdrawal_destination on bridge_withdrawal (chain, destination);
|
||||
create index bridge_withdrawal_account on bridge_withdrawal (chain, account);
|
||||
|
||||
-- The extrinsic's hash, as wallets and explorers name a transaction. Written
|
||||
-- for every extrinsic indexed from now on; filled for older ones only where a
|
||||
-- link needs it (the bridge's deposit transfers), never by re-reading history.
|
||||
alter table chain_extrinsic add column hash text;
|
||||
create index chain_extrinsic_hash on chain_extrinsic (chain, hash) where hash is not null;
|
||||
|
||||
-- The intents scan must read QTC's withdrawals, which it has passed. Pull its
|
||||
-- high end back to frankie's oldest block (#218,692,333 on 2026-10-07), not
|
||||
-- further: the forward re-read is unpaced, and anything older than our node
|
||||
-- would go to a rate-limited archive at full speed. Withdrawals before it are
|
||||
-- found by bisecting the token's supply instead.
|
||||
update intents_scan
|
||||
set high = least(high, 218692400)
|
||||
where asset = 'nep141:qtc.omft.near';
|
||||
@@ -48,6 +48,20 @@ impl ChunkTransaction {
|
||||
})
|
||||
}
|
||||
|
||||
/// The decoded JSON arguments of the first call to `method`.
|
||||
pub fn call_args(&self, method: &str) -> Option<Value> {
|
||||
use base64::Engine as _;
|
||||
let f = self
|
||||
.actions
|
||||
.iter()
|
||||
.filter_map(|a| a.get("FunctionCall"))
|
||||
.find(|f| f.get("method_name").and_then(Value::as_str) == Some(method))?;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(f.get("args")?.as_str()?)
|
||||
.ok()?;
|
||||
serde_json::from_slice(&bytes).ok()
|
||||
}
|
||||
|
||||
/// Whether any function call's decoded arguments contain `needle`.
|
||||
pub fn args_mention(&self, needle: &str) -> bool {
|
||||
use base64::Engine as _;
|
||||
|
||||
@@ -338,6 +338,63 @@ pub struct IntentTransferRecord {
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// A transfer into a bridge deposit address (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DepositTransferRow {
|
||||
/// Quantus block.
|
||||
pub height: u64,
|
||||
/// The extrinsic that made it, when the event has one.
|
||||
pub extrinsic_index: Option<u32>,
|
||||
/// The event's position in the block.
|
||||
pub event_index: u32,
|
||||
/// Sender, `0x` hex.
|
||||
pub from: String,
|
||||
/// The deposit address, `0x` hex.
|
||||
pub to: String,
|
||||
/// Amount, base units.
|
||||
pub amount: String,
|
||||
/// The block's time.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
/// The extrinsic's hash, when stored.
|
||||
pub hash: Option<String>,
|
||||
}
|
||||
|
||||
/// A QTC mint on NEAR (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeMintRow {
|
||||
/// NEAR block.
|
||||
pub near_height: u64,
|
||||
/// The mint transaction.
|
||||
pub tx_hash: String,
|
||||
/// The block's time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The Intents account credited.
|
||||
pub account: String,
|
||||
/// Amount, base units of the NEAR token.
|
||||
pub amount: String,
|
||||
/// The depositor's Quantus transaction.
|
||||
pub quantus_tx: String,
|
||||
}
|
||||
|
||||
/// A QTC withdrawal requested in NEAR Intents (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BridgeWithdrawalRow {
|
||||
/// NEAR block.
|
||||
pub near_height: u64,
|
||||
/// The relay transaction.
|
||||
pub tx_hash: String,
|
||||
/// The intent.
|
||||
pub intent_hash: String,
|
||||
/// The block's time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The Intents account withdrawing.
|
||||
pub account: String,
|
||||
/// The Quantus address to be paid.
|
||||
pub destination: String,
|
||||
/// Amount, base units of the NEAR token.
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// One read of a Ref DCL pool's state, base units as decimal strings
|
||||
/// (blackbeard/observer#41).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -639,6 +696,9 @@ pub struct ExtrinsicRecord {
|
||||
pub args: serde_json::Value,
|
||||
/// Signed extensions as submitted.
|
||||
pub extra: serde_json::Value,
|
||||
/// Blake2-256 of the encoded extrinsic, `0x` hex: the hash wallets and the
|
||||
/// bridge name it by (blackbeard/observer#44).
|
||||
pub hash: Option<String>,
|
||||
/// Whether the dispatch succeeded, when the block's events said.
|
||||
pub success: Option<bool>,
|
||||
/// Every account it mentions, nested calls included.
|
||||
@@ -1639,21 +1699,22 @@ impl Store {
|
||||
.map(|e| serde_json::Value::from(e.accounts.clone()))
|
||||
.collect();
|
||||
let times: Vec<Option<DateTime<Utc>>> = extrinsics.iter().map(|e| e.at).collect();
|
||||
let hashes: Vec<Option<String>> = extrinsics.iter().map(|e| e.hash.clone()).collect();
|
||||
|
||||
let result = sqlx::query!(
|
||||
r#"
|
||||
insert into chain_extrinsic
|
||||
(chain, height, extrinsic_index, pallet, call, signer, signature,
|
||||
signature_bytes, args, extra, success, accounts, at)
|
||||
signature_bytes, args, extra, success, accounts, at, hash)
|
||||
select chain, height, extrinsic_index, pallet, call, signer, signature,
|
||||
signature_bytes, args, extra, success,
|
||||
array(select jsonb_array_elements_text(accounts)), at
|
||||
array(select jsonb_array_elements_text(accounts)), at, hash
|
||||
from unnest(
|
||||
$1::text[], $2::bigint[], $3::int[], $4::text[], $5::text[], $6::text[],
|
||||
$7::text[], $8::int[], $9::jsonb[], $10::jsonb[], $11::bool[], $12::jsonb[],
|
||||
$13::timestamptz[]
|
||||
$13::timestamptz[], $14::text[]
|
||||
) as t(chain, height, extrinsic_index, pallet, call, signer, signature,
|
||||
signature_bytes, args, extra, success, accounts, at)
|
||||
signature_bytes, args, extra, success, accounts, at, hash)
|
||||
on conflict (chain, height, extrinsic_index) do update set
|
||||
pallet = excluded.pallet,
|
||||
call = excluded.call,
|
||||
@@ -1664,7 +1725,8 @@ impl Store {
|
||||
extra = excluded.extra,
|
||||
success = coalesce(excluded.success, chain_extrinsic.success),
|
||||
accounts = excluded.accounts,
|
||||
at = coalesce(excluded.at, chain_extrinsic.at)
|
||||
at = coalesce(excluded.at, chain_extrinsic.at),
|
||||
hash = coalesce(excluded.hash, chain_extrinsic.hash)
|
||||
"#,
|
||||
&chains,
|
||||
&heights,
|
||||
@@ -1679,6 +1741,7 @@ impl Store {
|
||||
&successes as &[Option<bool>],
|
||||
&accounts,
|
||||
× as &[Option<DateTime<Utc>>],
|
||||
&hashes as &[Option<String>],
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
@@ -2392,6 +2455,177 @@ impl Store {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Transfers into any of `addresses` (the bridge's deposit addresses),
|
||||
/// with the hash of the extrinsic that made each, when known
|
||||
/// (blackbeard/observer#44): `(height, extrinsic_index, from, to, amount,
|
||||
/// at, hash)`.
|
||||
pub async fn transfers_into(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
addresses: &[String],
|
||||
) -> Result<Vec<DepositTransferRow>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select e.height, e.extrinsic_index, e.event_index,
|
||||
e.fields->>'from' as "from!", e.fields->>'to' as "to!",
|
||||
(e.fields->>'amount')::numeric::text as "amount!", e.at, x.hash
|
||||
from chain_event e
|
||||
left join chain_extrinsic x
|
||||
on x.chain = e.chain and x.height = e.height and x.extrinsic_index = e.extrinsic_index
|
||||
where e.chain = $1
|
||||
and e.pallet = 'Balances' and e.variant = 'Transfer'
|
||||
and e.fields->>'to' = any($2)
|
||||
order by e.height, e.event_index
|
||||
"#,
|
||||
chain.as_str(),
|
||||
addresses,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| DepositTransferRow {
|
||||
height: r.height.max(0) as u64,
|
||||
extrinsic_index: r.extrinsic_index.map(|i| i.max(0) as u32),
|
||||
event_index: r.event_index.max(0) as u32,
|
||||
from: r.from,
|
||||
to: r.to,
|
||||
amount: r.amount,
|
||||
at: r.at,
|
||||
hash: r.hash,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Store an extrinsic's hash, for one indexed before hashes were kept.
|
||||
pub async fn set_extrinsic_hash(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
height: u64,
|
||||
extrinsic_index: u32,
|
||||
hash: &str,
|
||||
) -> Result<(), DataError> {
|
||||
sqlx::query!(
|
||||
"update chain_extrinsic set hash = $4 where chain = $1 and height = $2 and extrinsic_index = $3",
|
||||
chain.as_str(),
|
||||
height as i64,
|
||||
extrinsic_index as i32,
|
||||
hash,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record QTC mints on NEAR, idempotently.
|
||||
pub async fn record_bridge_mints(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
rows: &[BridgeMintRow],
|
||||
) -> Result<(), DataError> {
|
||||
for r in rows {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into bridge_mint (chain, near_height, tx_hash, at, account, amount, quantus_tx)
|
||||
values ($1, $2, $3, $4, $5, $6::text::numeric, $7)
|
||||
on conflict (chain, tx_hash) do nothing
|
||||
"#,
|
||||
chain.as_str(),
|
||||
r.near_height as i64,
|
||||
r.tx_hash,
|
||||
r.at,
|
||||
r.account,
|
||||
r.amount,
|
||||
r.quantus_tx,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record QTC withdrawals requested in NEAR Intents, idempotently.
|
||||
pub async fn record_bridge_withdrawals(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
rows: &[BridgeWithdrawalRow],
|
||||
) -> Result<(), DataError> {
|
||||
for r in rows {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into bridge_withdrawal
|
||||
(chain, near_height, tx_hash, intent_hash, at, account, destination, amount)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8::text::numeric)
|
||||
on conflict (chain, tx_hash, intent_hash) do nothing
|
||||
"#,
|
||||
chain.as_str(),
|
||||
r.near_height as i64,
|
||||
r.tx_hash,
|
||||
r.intent_hash,
|
||||
r.at,
|
||||
r.account,
|
||||
r.destination,
|
||||
r.amount,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every recorded QTC mint, oldest first.
|
||||
pub async fn bridge_mints(&self, chain: &ChainId) -> Result<Vec<BridgeMintRow>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select near_height, tx_hash, at, account, amount::text as "amount!", quantus_tx
|
||||
from bridge_mint where chain = $1 order by near_height
|
||||
"#,
|
||||
chain.as_str(),
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| BridgeMintRow {
|
||||
near_height: r.near_height.max(0) as u64,
|
||||
tx_hash: r.tx_hash,
|
||||
at: r.at,
|
||||
account: r.account,
|
||||
amount: r.amount,
|
||||
quantus_tx: r.quantus_tx,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every recorded QTC withdrawal, oldest first.
|
||||
pub async fn bridge_withdrawals(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
) -> Result<Vec<BridgeWithdrawalRow>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select near_height, tx_hash, intent_hash, at, account, destination,
|
||||
amount::text as "amount!"
|
||||
from bridge_withdrawal where chain = $1 order by near_height
|
||||
"#,
|
||||
chain.as_str(),
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| BridgeWithdrawalRow {
|
||||
near_height: r.near_height.max(0) as u64,
|
||||
tx_hash: r.tx_hash,
|
||||
intent_hash: r.intent_hash,
|
||||
at: r.at,
|
||||
account: r.account,
|
||||
destination: r.destination,
|
||||
amount: r.amount,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Every wormhole transfer, grouped by how it was produced.
|
||||
///
|
||||
/// The join that makes the chain's privacy legible: an event alone cannot
|
||||
|
||||
@@ -98,3 +98,120 @@ pub struct BridgeMovement {
|
||||
/// When the block was authored.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// The NEAR side of a deposit: the mint the bridge made for it
|
||||
/// (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgeMint.ts")]
|
||||
pub struct BridgeMint {
|
||||
/// The mint transaction on NEAR.
|
||||
pub near_tx: String,
|
||||
/// Its block.
|
||||
#[ts(type = "number")]
|
||||
pub near_height: u64,
|
||||
/// Its time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The Intents account credited.
|
||||
pub account: String,
|
||||
/// Amount minted, base units of the NEAR token.
|
||||
pub amount: BigUintDec,
|
||||
}
|
||||
|
||||
/// One deposit, linked across both chains: the depositor's transfer into their
|
||||
/// deposit address on Quantus, and — matched on that transfer's hash, which the
|
||||
/// bridge writes into the mint — the mint on NEAR. Exact, never inferred.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgeDeposit.ts")]
|
||||
pub struct BridgeDeposit {
|
||||
/// The depositor's Quantus transaction, `0x` hex, when its hash is known.
|
||||
pub quantus_tx: Option<String>,
|
||||
/// Its block.
|
||||
#[ts(type = "number")]
|
||||
pub height: u64,
|
||||
/// Its time.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
/// The depositor, SS58.
|
||||
pub from: String,
|
||||
/// Their deposit address, SS58. Permanent per Intents account.
|
||||
pub deposit_address: String,
|
||||
/// Amount sent, base units.
|
||||
pub amount: BigUintDec,
|
||||
/// The block its sweep into custody landed in, when it has.
|
||||
#[ts(type = "number")]
|
||||
pub swept_at_height: Option<u64>,
|
||||
/// The NEAR mint, when found.
|
||||
pub mint: Option<BridgeMint>,
|
||||
/// Seconds from the Quantus transfer to the NEAR mint.
|
||||
#[ts(type = "number")]
|
||||
pub seconds_to_mint: Option<i64>,
|
||||
}
|
||||
|
||||
/// The Quantus side of a withdrawal: custody's payout, matched by rule.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgePayout.ts")]
|
||||
pub struct BridgePayout {
|
||||
/// Its block.
|
||||
#[ts(type = "number")]
|
||||
pub height: u64,
|
||||
/// Its time.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
/// Amount paid, base units.
|
||||
pub amount: BigUintDec,
|
||||
/// Withdrawn less paid: the bridge's fee, as this match implies it.
|
||||
pub fee: BigUintDec,
|
||||
}
|
||||
|
||||
/// One withdrawal: QTC withdrawn from NEAR Intents to a Quantus address, and
|
||||
/// custody's payout to it. The NEAR half is exact; the payout carries no NEAR
|
||||
/// hash, so it is matched by `BridgeWithdrawals::rule` and is an inference.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgeWithdrawalLink.ts")]
|
||||
pub struct BridgeWithdrawalLink {
|
||||
/// The NEAR transaction.
|
||||
pub near_tx: String,
|
||||
/// Its block.
|
||||
#[ts(type = "number")]
|
||||
pub near_height: u64,
|
||||
/// Its time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The Intents account withdrawing.
|
||||
pub account: String,
|
||||
/// The Quantus address `WITHDRAW_TO` names.
|
||||
pub destination: String,
|
||||
/// Amount withdrawn, base units.
|
||||
pub amount: BigUintDec,
|
||||
/// The matched payout, when one fits the rule.
|
||||
pub payout: Option<BridgePayout>,
|
||||
}
|
||||
|
||||
/// Linked deposits, newest first.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgeDeposits.ts")]
|
||||
pub struct BridgeDeposits {
|
||||
/// Which chain.
|
||||
pub chain: ChainId,
|
||||
/// The chain's decimals.
|
||||
pub decimals: Option<u8>,
|
||||
/// The deposits.
|
||||
pub deposits: Vec<BridgeDeposit>,
|
||||
/// How many have a NEAR mint linked.
|
||||
#[ts(type = "number")]
|
||||
pub linked: u64,
|
||||
}
|
||||
|
||||
/// Withdrawals, newest first.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "BridgeWithdrawals.ts")]
|
||||
pub struct BridgeWithdrawals {
|
||||
/// Which chain.
|
||||
pub chain: ChainId,
|
||||
/// The chain's decimals.
|
||||
pub decimals: Option<u8>,
|
||||
/// How a payout is matched to a withdrawal, in words.
|
||||
pub rule: String,
|
||||
/// The withdrawals.
|
||||
pub withdrawals: Vec<BridgeWithdrawalLink>,
|
||||
/// How many have a payout matched.
|
||||
#[ts(type = "number")]
|
||||
pub matched: u64,
|
||||
}
|
||||
|
||||
@@ -32,7 +32,10 @@ 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 bridge::{
|
||||
BridgeDeposit, BridgeDeposits, BridgeMint, BridgeMovement, BridgePayout, BridgeSummary,
|
||||
BridgeWithdrawalLink, BridgeWithdrawals,
|
||||
};
|
||||
pub use call::{CallIndex, CallSummary, EventSummary};
|
||||
pub use chain::{
|
||||
AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Concentration,
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// 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 { BridgeMint } from "./BridgeMint";
|
||||
|
||||
/**
|
||||
* One deposit, linked across both chains: the depositor's transfer into their
|
||||
* deposit address on Quantus, and — matched on that transfer's hash, which the
|
||||
* bridge writes into the mint — the mint on NEAR. Exact, never inferred.
|
||||
*/
|
||||
export type BridgeDeposit = {
|
||||
/**
|
||||
* The depositor's Quantus transaction, `0x` hex, when its hash is known.
|
||||
*/
|
||||
quantus_tx: string | null,
|
||||
/**
|
||||
* Its block.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* Its time.
|
||||
*/
|
||||
at: string | null,
|
||||
/**
|
||||
* The depositor, SS58.
|
||||
*/
|
||||
from: string,
|
||||
/**
|
||||
* Their deposit address, SS58. Permanent per Intents account.
|
||||
*/
|
||||
deposit_address: string,
|
||||
/**
|
||||
* Amount sent, base units.
|
||||
*/
|
||||
amount: BigUintDec,
|
||||
/**
|
||||
* The block its sweep into custody landed in, when it has.
|
||||
*/
|
||||
swept_at_height: number,
|
||||
/**
|
||||
* The NEAR mint, when found.
|
||||
*/
|
||||
mint: BridgeMint | null,
|
||||
/**
|
||||
* Seconds from the Quantus transfer to the NEAR mint.
|
||||
*/
|
||||
seconds_to_mint: number, };
|
||||
@@ -0,0 +1,24 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BridgeDeposit } from "./BridgeDeposit";
|
||||
import type { ChainId } from "./ChainId";
|
||||
|
||||
/**
|
||||
* Linked deposits, newest first.
|
||||
*/
|
||||
export type BridgeDeposits = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The chain's decimals.
|
||||
*/
|
||||
decimals: number | null,
|
||||
/**
|
||||
* The deposits.
|
||||
*/
|
||||
deposits: Array<BridgeDeposit>,
|
||||
/**
|
||||
* How many have a NEAR mint linked.
|
||||
*/
|
||||
linked: number, };
|
||||
@@ -0,0 +1,28 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* The NEAR side of a deposit: the mint the bridge made for it
|
||||
* (blackbeard/observer#44).
|
||||
*/
|
||||
export type BridgeMint = {
|
||||
/**
|
||||
* The mint transaction on NEAR.
|
||||
*/
|
||||
near_tx: string,
|
||||
/**
|
||||
* Its block.
|
||||
*/
|
||||
near_height: number,
|
||||
/**
|
||||
* Its time.
|
||||
*/
|
||||
at: string,
|
||||
/**
|
||||
* The Intents account credited.
|
||||
*/
|
||||
account: string,
|
||||
/**
|
||||
* Amount minted, base units of the NEAR token.
|
||||
*/
|
||||
amount: BigUintDec, };
|
||||
@@ -0,0 +1,23 @@
|
||||
// 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";
|
||||
|
||||
/**
|
||||
* The Quantus side of a withdrawal: custody's payout, matched by rule.
|
||||
*/
|
||||
export type BridgePayout = {
|
||||
/**
|
||||
* Its block.
|
||||
*/
|
||||
height: number,
|
||||
/**
|
||||
* Its time.
|
||||
*/
|
||||
at: string | null,
|
||||
/**
|
||||
* Amount paid, base units.
|
||||
*/
|
||||
amount: BigUintDec,
|
||||
/**
|
||||
* Withdrawn less paid: the bridge's fee, as this match implies it.
|
||||
*/
|
||||
fee: BigUintDec, };
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
import type { BridgePayout } from "./BridgePayout";
|
||||
|
||||
/**
|
||||
* One withdrawal: QTC withdrawn from NEAR Intents to a Quantus address, and
|
||||
* custody's payout to it. The NEAR half is exact; the payout carries no NEAR
|
||||
* hash, so it is matched by `BridgeWithdrawals::rule` and is an inference.
|
||||
*/
|
||||
export type BridgeWithdrawalLink = {
|
||||
/**
|
||||
* The NEAR transaction.
|
||||
*/
|
||||
near_tx: string,
|
||||
/**
|
||||
* Its block.
|
||||
*/
|
||||
near_height: number,
|
||||
/**
|
||||
* Its time.
|
||||
*/
|
||||
at: string,
|
||||
/**
|
||||
* The Intents account withdrawing.
|
||||
*/
|
||||
account: string,
|
||||
/**
|
||||
* The Quantus address `WITHDRAW_TO` names.
|
||||
*/
|
||||
destination: string,
|
||||
/**
|
||||
* Amount withdrawn, base units.
|
||||
*/
|
||||
amount: BigUintDec,
|
||||
/**
|
||||
* The matched payout, when one fits the rule.
|
||||
*/
|
||||
payout: BridgePayout | null, };
|
||||
@@ -0,0 +1,28 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BridgeWithdrawalLink } from "./BridgeWithdrawalLink";
|
||||
import type { ChainId } from "./ChainId";
|
||||
|
||||
/**
|
||||
* Withdrawals, newest first.
|
||||
*/
|
||||
export type BridgeWithdrawals = {
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The chain's decimals.
|
||||
*/
|
||||
decimals: number | null,
|
||||
/**
|
||||
* How a payout is matched to a withdrawal, in words.
|
||||
*/
|
||||
rule: string,
|
||||
/**
|
||||
* The withdrawals.
|
||||
*/
|
||||
withdrawals: Array<BridgeWithdrawalLink>,
|
||||
/**
|
||||
* How many have a payout matched.
|
||||
*/
|
||||
matched: number, };
|
||||
@@ -7,6 +7,8 @@
|
||||
*/
|
||||
|
||||
import type { BidSide } from './generated/BidSide'
|
||||
import type { BridgeDeposits } from './generated/BridgeDeposits'
|
||||
import type { BridgeWithdrawals } from './generated/BridgeWithdrawals'
|
||||
import type { IntentsFills } from './generated/IntentsFills'
|
||||
import type { IntentsFlows } from './generated/IntentsFlows'
|
||||
import type { RefV1Pools } from './generated/RefV1Pools'
|
||||
@@ -337,6 +339,22 @@ export async function fetchRefV1(chain: string, signal?: AbortSignal): Promise<R
|
||||
return get<RefV1Pools | null>(`/chains/${chain}/ref/v1`, signal)
|
||||
}
|
||||
|
||||
/** Bridge deposits linked across both chains (blackbeard/observer#44). */
|
||||
export async function fetchBridgeDeposits(
|
||||
chain: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BridgeDeposits> {
|
||||
return get<BridgeDeposits>(`/chains/${chain}/bridge/deposits`, signal)
|
||||
}
|
||||
|
||||
/** Bridge withdrawals, with custody payouts matched by rule. */
|
||||
export async function fetchBridgeWithdrawals(
|
||||
chain: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<BridgeWithdrawals> {
|
||||
return get<BridgeWithdrawals>(`/chains/${chain}/bridge/withdrawals`, signal)
|
||||
}
|
||||
|
||||
/** 1Click fills of the asset, newest first (blackbeard/observer#43). */
|
||||
export async function fetchIntentsFills(
|
||||
chain: string,
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* QTC bridge transactions, linked across both chains (blackbeard/observer#44).
|
||||
*
|
||||
* A deposit is exact end to end: the bridge writes the depositor's Quantus
|
||||
* transaction hash into the NEAR mint, beside the Intents account it credits.
|
||||
* A withdrawal is exact on NEAR — `WITHDRAW_TO` names the Quantus address —
|
||||
* but custody's payout carries no NEAR hash, so the match is an inference by
|
||||
* the rule the API states, shown with the fee it implies.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { BridgeDeposits } from '../api/generated/BridgeDeposits'
|
||||
import type { BridgeWithdrawals } from '../api/generated/BridgeWithdrawals'
|
||||
import { RequestFailed, fetchBridgeDeposits, fetchBridgeWithdrawals } from '../api/rest'
|
||||
import { height as fmtHeight, shortAddress, tokens, utc, when } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
|
||||
const NEAR_TX = 'https://nearblocks.io/txns/'
|
||||
const REFRESH_MS = 120_000
|
||||
const SHOWN = 25
|
||||
|
||||
function minutes(seconds: number | null): string {
|
||||
if (seconds === null) return '—'
|
||||
if (seconds < 120) return `${seconds} s`
|
||||
return `${Math.round(seconds / 60)} min`
|
||||
}
|
||||
|
||||
export function BridgeLinksPanel({ chain, symbol }: { chain: string; symbol: string }) {
|
||||
const [deposits, setDeposits] = useState<BridgeDeposits | null>(null)
|
||||
const [withdrawals, setWithdrawals] = useState<BridgeWithdrawals | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [all, setAll] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = () =>
|
||||
Promise.all([
|
||||
fetchBridgeDeposits(chain, controller.signal),
|
||||
fetchBridgeWithdrawals(chain, controller.signal),
|
||||
])
|
||||
.then(([d, w]) => {
|
||||
setDeposits(d)
|
||||
setWithdrawals(w)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
load()
|
||||
const id = window.setInterval(load, REFRESH_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain])
|
||||
|
||||
const d = deposits?.decimals ?? withdrawals?.decimals ?? 12
|
||||
const amount = (raw: string) => `${tokens(raw, d)} ${symbol}`
|
||||
const account = (a: string) => (a.length > 24 ? shortAddress(a) : a)
|
||||
const depositRows = deposits ? (all ? deposits.deposits : deposits.deposits.slice(0, SHOWN)) : []
|
||||
const withdrawalRows = withdrawals
|
||||
? all
|
||||
? withdrawals.withdrawals
|
||||
: withdrawals.withdrawals.slice(0, SHOWN)
|
||||
: []
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Bridge transactions</h2>
|
||||
<span className="eyebrow">
|
||||
{deposits
|
||||
? `${fmtHeight(deposits.linked)} of ${fmtHeight(deposits.deposits.length)} deposits linked to a NEAR mint`
|
||||
: 'Quantus ⇄ NEAR'}
|
||||
</span>
|
||||
</div>
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !deposits && <p className="empty">Reading…</p>}
|
||||
|
||||
{depositRows.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Deposits to NEAR, newest first</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Sent on Quantus
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
From
|
||||
</th>
|
||||
<th scope="col">{symbol}</th>
|
||||
<th scope="col" className="left">
|
||||
Minted on NEAR
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
To Intents account
|
||||
</th>
|
||||
<th scope="col">Took</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{depositRows.map((x) => (
|
||||
<tr key={`${x.height}-${x.quantus_tx ?? x.from}-${x.amount}`}>
|
||||
<td className="left numeral" title={x.quantus_tx ?? 'hash not yet computed'}>
|
||||
<Link to={href({ chain, block: String(x.height) })}>
|
||||
{x.at ? when(x.at) : `#${fmtHeight(x.height)}`}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="left" title={`${x.from} → ${x.deposit_address}`}>
|
||||
<Link to={href({ chain, account: x.from })}>{account(x.from)}</Link>
|
||||
</td>
|
||||
<td className="numeral">{amount(x.amount)}</td>
|
||||
<td className="left">
|
||||
{x.mint ? (
|
||||
<a
|
||||
href={`${NEAR_TX}${x.mint.near_tx}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={utc(x.mint.at)}
|
||||
>
|
||||
{x.mint.near_tx.slice(0, 8)}…
|
||||
</a>
|
||||
) : (
|
||||
<span className="panel-note" style={{ margin: 0 }}>
|
||||
not linked
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="left" title={x.mint?.account}>
|
||||
{x.mint ? account(x.mint.account) : '—'}
|
||||
</td>
|
||||
<td className="numeral">{minutes(x.seconds_to_mint)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{withdrawalRows.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Withdrawals to Quantus, newest first</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Withdrawn on NEAR
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
From Intents account
|
||||
</th>
|
||||
<th scope="col">{symbol}</th>
|
||||
<th scope="col" className="left">
|
||||
To Quantus address
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Paid (inferred)
|
||||
</th>
|
||||
<th scope="col">Fee</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{withdrawalRows.map((w) => (
|
||||
<tr key={`${w.near_tx}-${w.near_height}-${w.amount}`}>
|
||||
<td className="left numeral" title={utc(w.at)}>
|
||||
<a href={`${NEAR_TX}${w.near_tx}`} target="_blank" rel="noreferrer">
|
||||
{when(w.at)}
|
||||
</a>
|
||||
</td>
|
||||
<td className="left" title={w.account}>
|
||||
{account(w.account)}
|
||||
</td>
|
||||
<td className="numeral">{amount(w.amount)}</td>
|
||||
<td className="left" title={w.destination}>
|
||||
<Link to={href({ chain, account: w.destination })}>
|
||||
{account(w.destination)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="left">
|
||||
{w.payout ? (
|
||||
<Link to={href({ chain, block: String(w.payout.height) })}>
|
||||
#{fmtHeight(w.payout.height)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="panel-note" style={{ margin: 0 }}>
|
||||
no payout matched
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="numeral">{w.payout ? tokens(w.payout.fee, d) : '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{((deposits?.deposits.length ?? 0) > SHOWN ||
|
||||
(withdrawals?.withdrawals.length ?? 0) > SHOWN) &&
|
||||
!all && (
|
||||
<div className="pager">
|
||||
<button className="segmented panel-button" onClick={() => setAll(true)}>
|
||||
All →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="panel-note">
|
||||
A deposit is linked exactly: the bridge writes the depositor's Quantus transaction hash
|
||||
into the NEAR mint, beside the Intents account it credits, and a deposit address belongs to
|
||||
one account for good. That is what the bridge itself publishes on-chain; nothing here
|
||||
guesses who owns an address. A withdrawal is exact on NEAR, but custody's payout on
|
||||
Quantus names no NEAR transaction, so the payout shown is matched by rule —{' '}
|
||||
{withdrawals?.rule ?? 'see the API for the rule'}
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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 { BridgeLinksPanel } from './BridgeLinksPanel'
|
||||
import { BridgePanel } from './BridgePanel'
|
||||
import { IntentsFlowPanel } from './IntentsFlowPanel'
|
||||
import { FillsPanel } from './FillsPanel'
|
||||
@@ -162,6 +163,8 @@ function NearIntents({
|
||||
|
||||
<BridgePanel chain={chain} decimals={decimals} symbol={symbol} />
|
||||
|
||||
<BridgeLinksPanel chain={chain} symbol={symbol} />
|
||||
|
||||
<IntentsFlowPanel chain={chain} symbol={symbol} />
|
||||
|
||||
<FillsPanel chain={chain} symbol={symbol} />
|
||||
|
||||
Reference in New Issue
Block a user