feat: link QTC bridge deposits and withdrawals across both chains
ci / gate (push) Successful in 4m32s
deploy / validate (push) Skipped
deploy / build (push) Successful in 10m22s
deploy / deploy-web (push) Successful in 12s
deploy / deploy-api (push) Successful in 30s

#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:
2026-10-07 09:10:33 +03:00
co-authored by Claude Opus 5.5
parent 69050a45c1
commit 084fd5ba2c
30 changed files with 2228 additions and 32 deletions
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
@@ -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"
}
+15
View File
@@ -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.
+513
View File
@@ -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");
}
}
}
}
+20
View File
@@ -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.
+1
View File
@@ -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,
+25
View File
@@ -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.
+1
View File
@@ -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;
+265
View File
@@ -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.
+269
View File
@@ -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';
+14
View File
@@ -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 _;
+239 -5
View File
@@ -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,
&times 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
+117
View File
@@ -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,
}
+4 -1
View File
@@ -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,
+46
View File
@@ -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, };
+24
View File
@@ -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, };
+28
View File
@@ -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, };
+23
View File
@@ -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, };
+18
View File
@@ -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,
+221
View File
@@ -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&apos;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&apos;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>
)
}
+3
View File
@@ -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} />