feat: NEAR transaction and Intents account pages
Adds /quantus/near/tx/<hash> and /quantus/near/intents/<account>, the last two parts of #44. A transaction is assembled from our own nodes where possible. The node tracking the signer's shard returns the transaction. Each receipt it lacks is fetched from the node tracking that receipt's receiver, using EXPERIMENTAL_receipt and a light client proof. A public node is used only when ours cannot serve all of it. The page says which source it used. Once a transaction is complete and every block it touched is final, it is stored in near_tx (migration 0020) as raw JSON and decoded on every read, so decoder fixes apply to stored transactions too. The decoder in blackbeard-core::near_tx orders the receipt tree, decodes function-call arguments, parses EVENT_JSON lines into amounts tied to their tokens, and reads the BRIDGED_FROM, WITHDRAW_TO and refund memos. It is tested against the bridge mint piPTXyoq and the failed swap with refund E4ysnVht. The account page shows balances in intents.near, read live from frankie, the account's intent signing keys, and the QTC activity this observer indexes. It says that activity is not the account's whole history. The bridge, fills and swap tables now link to these pages instead of nearblocks. Both pages still link out to nearblocks. Closes #44 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015tyzzRVjH9AdwGtYwsH5VC
This commit is contained in:
+67
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select kind as \"kind!\", tx_hash as \"tx_hash!\", near_height as \"near_height!\",\n at as \"at!\", amount, other, outgoing as \"outgoing!\", legs\n from (\n select 'bridge_deposit' as kind, tx_hash, near_height, at,\n amount::text as amount, quantus_tx as other, false as outgoing,\n null::jsonb as legs\n from bridge_mint where chain = $1 and account = $3\n union all\n select 'bridge_withdrawal', tx_hash, near_height, at,\n amount::text, destination, true, null::jsonb\n from bridge_withdrawal where chain = $1 and account = $3\n union all\n select 'transfer', tx_hash, near_height, at, amount::text,\n case when from_account = $3 then to_account else from_account end,\n from_account = $3, null::jsonb\n from intents_transfer\n where chain = $1 and asset = $2 and (from_account = $3 or to_account = $3)\n union all\n select 'trade', tx_hash, near_height, at, null, null, false, legs\n from intents_trade where chain = $1 and asset = $2 and $3 = any(accounts)\n ) history\n order by near_height desc\n limit $4\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tx_hash!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "near_height!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "at!",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "amount",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "other",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "outgoing!",
|
||||
"type_info": "Bool"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "legs",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "39a4f80c52a54ff85ed6e96415d6aa68f1a64e75c6fe08b432332c2a5ecdd0d4"
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into near_tx (hash, signer, source, status, blocks, shards)\n values ($1, $2, $3, $4, $5, $6)\n on conflict (hash) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Jsonb",
|
||||
"Jsonb",
|
||||
"Jsonb"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8b9e51fa995626a5e444d90fb8d66949f89bd5fcd7a884f00e32cb8c7c49ea0c"
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select hash as \"hash!\", height, extrinsic_index\n from chain_extrinsic where chain = $1 and hash = any($2)\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "extrinsic_index",
|
||||
"type_info": "Int4"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"TextArray"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
true,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d3f4797bdf3ad0a7eec61a909452add3fc91da92b2feef62114559bda5a5c77b"
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select hash, signer, source, status, blocks, shards from near_tx where hash = $1",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "signer",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "source",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "status",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "blocks",
|
||||
"type_info": "Jsonb"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "shards",
|
||||
"type_info": "Jsonb"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "d70b4dd8627c912ebdada8f2239945e8079afc80345e3817a95cbfa2c919c0bb"
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select kind as \"kind!\" from (\n select 'mint' as kind from bridge_mint where chain = $1 and tx_hash = $2\n union all\n select 'intents' from bridge_withdrawal where chain = $1 and tx_hash = $2\n union all\n select 'intents' from intents_transfer where chain = $1 and tx_hash = $2\n union all\n select 'intents' from intents_trade where chain = $1 and tx_hash = $2\n ) seen\n limit 1\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "kind!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "f37670f2a83d42e50ee2ab91288917570cd6b423adee15c59e19ebcd45733e40"
|
||||
}
|
||||
@@ -576,6 +576,20 @@ sums to it) or unread, and only unread is retried. The opposite mistake is just
|
||||
as bad: a retry with no bound and a `debug` log is a query that can hang
|
||||
forever while looking like progress.
|
||||
|
||||
**A whole NEAR transaction can be had from our own nodes, if asked the right
|
||||
way** (#44). `EXPERIMENTAL_tx_status` answers only on the node tracking the
|
||||
*signer's* shard, and only when `sender_account_id` is an account on that
|
||||
shard; any account there works, since it only routes. A wrong-shard sender gets
|
||||
`NONE` or `UNKNOWN_TRANSACTION` from the right node, which reads exactly like "no
|
||||
such transaction". What comes back omits receipts on shards that node does not
|
||||
track, with no sign of it but `near_tx::missing_receipts`: every id in a
|
||||
`receipt_ids` list must have an outcome. The missing ones come from the node
|
||||
tracking their receiver: `EXPERIMENTAL_receipt` for the receipt, and
|
||||
`EXPERIMENTAL_light_client_proof` (`type: receipt`) for its outcome. A public
|
||||
node finds a transaction with any sender, which is why it is the fallback and
|
||||
not the first resort. A transaction is kept in `near_tx` once complete and
|
||||
final, stored raw and decoded on read.
|
||||
|
||||
**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.
|
||||
|
||||
Generated
+1
@@ -284,6 +284,7 @@ dependencies = [
|
||||
name = "blackbeard-core"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"blackbeard-entities",
|
||||
"blake2",
|
||||
"chrono",
|
||||
|
||||
+2
-1
@@ -16,7 +16,8 @@ blackbeard-core = { path = "crates/blackbeard-core", version = "=0.1.0" }
|
||||
blackbeard-data = { path = "crates/blackbeard-data", version = "=0.1.0" }
|
||||
|
||||
anyhow = "1"
|
||||
# Only for NEAR view calls, whose arguments travel base64-encoded.
|
||||
# NEAR view-call arguments travel base64-encoded, and so do a transaction's
|
||||
# function-call arguments, which `blackbeard-core::near_tx` decodes.
|
||||
base64 = "0.22"
|
||||
axum = { version = "0.8", features = ["ws", "macros"] }
|
||||
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
|
||||
|
||||
@@ -13,6 +13,7 @@ mod config;
|
||||
mod ingest;
|
||||
mod intents;
|
||||
mod intentsflow;
|
||||
mod neartx;
|
||||
mod poolscan;
|
||||
mod refdcl;
|
||||
mod refv1;
|
||||
|
||||
@@ -0,0 +1,740 @@
|
||||
//! NEAR transactions and Intents accounts, read on request
|
||||
//! (blackbeard/observer#44).
|
||||
//!
|
||||
//! The only part of this API that calls NEAR while a browser waits, so it is
|
||||
//! built around not doing that twice:
|
||||
//!
|
||||
//! - **Our nodes first.** A transaction is held only by the node tracking its
|
||||
//! signer's shard, and only receipts on that node's shards come back with it.
|
||||
//! The rest are fetched one at a time — `EXPERIMENTAL_receipt` for the
|
||||
//! receipt, a light client proof for its outcome — from whichever of our
|
||||
//! nodes tracks the receiver. That costs nothing but our own nodes' time.
|
||||
//! - **A public node for what they cannot serve**: a transaction older than
|
||||
//! their history, or a receipt on a shard none of them tracks. One paced call,
|
||||
//! which returns the whole transaction.
|
||||
//! - **Kept forever once it cannot change**: complete, and every block it
|
||||
//! touched final. After that the page is one database read.
|
||||
//!
|
||||
//! Two fetches at a time at most, and a hash that was not found is not asked
|
||||
//! about again for a minute: a page anyone can load must not be a way to spend
|
||||
//! the public endpoint's rate limit, which the scans need.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use blackbeard_core::near_tx;
|
||||
use blackbeard_data::DataError;
|
||||
use blackbeard_data::near::{NearClient, NearNode};
|
||||
use blackbeard_data::store::{NearTxRow, Store};
|
||||
use blackbeard_entities::{
|
||||
BigUintDec, ChainId, IntentsAccount, IntentsAccountEvent, IntentsBalance, NearAmount,
|
||||
NearToken, NearTx, NearTxSource, QuantusTxRef,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::config::{BridgeConfig, IntentsConfig};
|
||||
|
||||
/// How long one call to one of our nodes may take.
|
||||
const OWN_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// How long one call to a public node may take.
|
||||
const PUBLIC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// Fetches running at once, across every page.
|
||||
const CONCURRENT_FETCHES: usize = 2;
|
||||
|
||||
/// How long a hash that was not found is answered from memory.
|
||||
const MISS_TTL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Receipts fetched one at a time before giving the transaction to a public
|
||||
/// node instead. A bridge mint has twelve; nothing seen here has had fifty.
|
||||
const MAX_ASSEMBLED_RECEIPTS: usize = 64;
|
||||
|
||||
/// Token metadata reads one page may start. Each is a paced public call.
|
||||
const MAX_TOKEN_READS: usize = 6;
|
||||
|
||||
/// History rows on an account page.
|
||||
const HISTORY_LIMIT: i64 = 500;
|
||||
|
||||
/// Balance pages read from the Intents contract, each of `BALANCE_PAGE`.
|
||||
const BALANCE_PAGES: usize = 10;
|
||||
const BALANCE_PAGE: usize = 100;
|
||||
|
||||
/// Why a read did not produce a page.
|
||||
#[derive(Debug)]
|
||||
pub enum ReadError {
|
||||
/// No node has it: a mistyped hash, or a transaction not yet included.
|
||||
NotFound,
|
||||
/// Something could not be asked.
|
||||
Unavailable(String),
|
||||
}
|
||||
|
||||
/// What reads transactions and accounts for one chain's NEAR side.
|
||||
#[derive(Debug)]
|
||||
pub struct NearReader {
|
||||
own: Vec<NearNode>,
|
||||
public: NearClient,
|
||||
/// frankie, whose live view calls answer for `intents.near`.
|
||||
intents_node: NearClient,
|
||||
intents: IntentsConfig,
|
||||
minter: Option<String>,
|
||||
fetching: tokio::sync::Semaphore,
|
||||
misses: std::sync::Mutex<HashMap<String, Instant>>,
|
||||
}
|
||||
|
||||
impl NearReader {
|
||||
/// A reader from the chain's config, or `None` when it follows nothing on
|
||||
/// NEAR.
|
||||
pub fn new(intents: &IntentsConfig, bridge: Option<&BridgeConfig>) -> Option<Self> {
|
||||
let mut urls: Vec<String> = Vec::new();
|
||||
for u in intents
|
||||
.rpc_urls
|
||||
.iter()
|
||||
.chain(&intents.pool_rpc_urls)
|
||||
.chain(&intents.ref_v1_rpc_urls)
|
||||
{
|
||||
if blackbeard_data::near::is_own_endpoint(u) && !urls.contains(u) {
|
||||
urls.push(u.clone());
|
||||
}
|
||||
}
|
||||
let own = urls
|
||||
.into_iter()
|
||||
.filter_map(|u| NearNode::new(u, OWN_TIMEOUT).ok())
|
||||
.collect();
|
||||
let public = NearClient::new(
|
||||
intents.rpc_urls.clone(),
|
||||
intents.archive_urls.clone(),
|
||||
intents.view_urls.clone(),
|
||||
PUBLIC_TIMEOUT,
|
||||
)
|
||||
.ok()?;
|
||||
let intents_node = NearClient::new(
|
||||
intents.rpc_urls.clone(),
|
||||
intents.archive_urls.clone(),
|
||||
intents.view_urls.clone(),
|
||||
OWN_TIMEOUT,
|
||||
)
|
||||
.ok()?;
|
||||
Some(Self {
|
||||
own,
|
||||
public,
|
||||
intents_node,
|
||||
intents: intents.clone(),
|
||||
minter: bridge.map(|b| b.near_minter.clone()),
|
||||
fetching: tokio::sync::Semaphore::new(CONCURRENT_FETCHES),
|
||||
misses: Default::default(),
|
||||
})
|
||||
}
|
||||
|
||||
/// A transaction, from the database when it is kept and from the nodes
|
||||
/// otherwise.
|
||||
pub async fn tx(
|
||||
&self,
|
||||
store: &Store,
|
||||
chain: &ChainId,
|
||||
hash: &str,
|
||||
) -> Result<NearTx, ReadError> {
|
||||
let unavailable = |e: DataError| ReadError::Unavailable(e.to_string());
|
||||
if let Some(row) = store.near_tx(hash).await.map_err(unavailable)? {
|
||||
return self.finish(store, chain, &row, true).await;
|
||||
}
|
||||
if self.missed_recently(hash) {
|
||||
return Err(ReadError::NotFound);
|
||||
}
|
||||
let _permit = self
|
||||
.fetching
|
||||
.acquire()
|
||||
.await
|
||||
.map_err(|e| ReadError::Unavailable(e.to_string()))?;
|
||||
// Another request may have kept it while this one waited.
|
||||
if let Some(row) = store.near_tx(hash).await.map_err(unavailable)? {
|
||||
return self.finish(store, chain, &row, true).await;
|
||||
}
|
||||
|
||||
// An account on the signer's shard, from where this observer saw it.
|
||||
let sender = match store.near_tx_seen(chain, hash).await.map_err(unavailable)? {
|
||||
Some(k) if k == "mint" => self.minter.clone(),
|
||||
Some(_) => Some(self.intents.contract.clone()),
|
||||
None => None,
|
||||
};
|
||||
let assembled = match &sender {
|
||||
Some(s) => self.assemble(hash, s).await,
|
||||
None => None,
|
||||
};
|
||||
let (mut status, source) = match assembled {
|
||||
Some(status) => (status, NearTxSource::Own),
|
||||
None => match self
|
||||
.public
|
||||
.tx_status_public(hash, sender.as_deref().unwrap_or("near"))
|
||||
.await
|
||||
{
|
||||
Ok(status) => (status, NearTxSource::Public),
|
||||
Err(e) if blackbeard_data::near::unknown(&e) => {
|
||||
self.remember_miss(hash);
|
||||
return Err(ReadError::NotFound);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(tx = hash, error = %e, "NEAR transaction unreadable");
|
||||
return Err(unavailable(e));
|
||||
}
|
||||
},
|
||||
};
|
||||
near_tx::strip(&mut status);
|
||||
|
||||
let (blocks, all_dated) = self.blocks(&status).await;
|
||||
let shards = self.shards(&status, &blocks).await;
|
||||
let row = NearTxRow {
|
||||
hash: hash.to_owned(),
|
||||
signer: near_tx::signer(&status).unwrap_or_default().to_owned(),
|
||||
source: match source {
|
||||
NearTxSource::Own => "own",
|
||||
NearTxSource::Public => "public",
|
||||
}
|
||||
.to_owned(),
|
||||
status,
|
||||
blocks: blocks_json(&blocks),
|
||||
shards: json!(shards),
|
||||
};
|
||||
let complete = near_tx::missing_receipts(&row.status).is_empty();
|
||||
let newest = blocks.values().map(|(h, _)| *h).max();
|
||||
let finality = self.final_height().await;
|
||||
let keep =
|
||||
complete && all_dated && matches!((newest, finality), (Some(n), Some(f)) if n <= f);
|
||||
if keep {
|
||||
if let Err(e) = store.record_near_tx(&row).await {
|
||||
tracing::warn!(tx = hash, error = %e, "NEAR transaction not kept");
|
||||
}
|
||||
}
|
||||
self.finish(store, chain, &row, keep).await
|
||||
}
|
||||
|
||||
/// The transaction from our own nodes, or `None` when they cannot give all
|
||||
/// of it.
|
||||
async fn assemble(&self, hash: &str, sender: &str) -> Option<Value> {
|
||||
let mut status = None;
|
||||
for node in &self.own {
|
||||
match node.tx_status(hash, sender).await {
|
||||
Ok(Some(s)) => {
|
||||
status = Some(s);
|
||||
break;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => tracing::debug!(node = %node.url, tx = hash, error = %e,
|
||||
"own node could not be asked for a transaction"),
|
||||
}
|
||||
}
|
||||
let mut status = status?;
|
||||
// Each node's own final head, for proofs against it: a node can only
|
||||
// prove an outcome against a block it has.
|
||||
let mut heads: HashMap<String, String> = HashMap::new();
|
||||
let mut fetched = 0;
|
||||
loop {
|
||||
let missing = near_tx::missing_receipts(&status);
|
||||
if missing.is_empty() {
|
||||
return Some(status);
|
||||
}
|
||||
for id in missing {
|
||||
fetched += 1;
|
||||
if fetched > MAX_ASSEMBLED_RECEIPTS {
|
||||
return None;
|
||||
}
|
||||
let mut found = false;
|
||||
for node in &self.own {
|
||||
let Ok(Some(receipt)) = node.receipt(&id).await else {
|
||||
continue;
|
||||
};
|
||||
let Some(receiver) = receipt["receiver_id"].as_str().map(str::to_owned) else {
|
||||
continue;
|
||||
};
|
||||
let head = match heads.get(&node.url) {
|
||||
Some(h) => h.clone(),
|
||||
None => match node.final_head().await {
|
||||
Ok((h, _)) => {
|
||||
heads.insert(node.url.clone(), h.clone());
|
||||
h
|
||||
}
|
||||
Err(_) => continue,
|
||||
},
|
||||
};
|
||||
if let Ok(Some(outcome)) = node.receipt_outcome(&id, &receiver, &head).await {
|
||||
near_tx::add_receipt(&mut status, receipt, outcome);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// A receipt on a shard none of ours tracks, or one older
|
||||
// than their history, or not executed yet.
|
||||
tracing::debug!(tx = hash, receipt = %id,
|
||||
"no node of ours holds a receipt; reading the transaction publicly");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Every block's height and time, and whether all of them were found.
|
||||
async fn blocks(&self, status: &Value) -> (HashMap<String, (u64, DateTime<Utc>)>, bool) {
|
||||
let mut out = HashMap::new();
|
||||
let mut all = true;
|
||||
'blocks: for hash in near_tx::block_hashes(status) {
|
||||
for node in &self.own {
|
||||
if let Ok(Some(b)) = node.block(&hash).await {
|
||||
out.insert(hash, b);
|
||||
continue 'blocks;
|
||||
}
|
||||
}
|
||||
match self.public.block_at(json!(hash)).await {
|
||||
Ok(b) => {
|
||||
out.insert(hash, b);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!(block = %hash, error = %e, "NEAR block unreadable");
|
||||
all = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
(out, all)
|
||||
}
|
||||
|
||||
/// Which shard each executing account was on, from the layout at the
|
||||
/// inclusion block. One layout for the whole transaction: a resharding
|
||||
/// inside one transaction's few blocks is possible and is not worth the
|
||||
/// calls it would take to notice.
|
||||
async fn shards(
|
||||
&self,
|
||||
status: &Value,
|
||||
blocks: &HashMap<String, (u64, DateTime<Utc>)>,
|
||||
) -> HashMap<String, u64> {
|
||||
let height = status["transaction_outcome"]["block_hash"]
|
||||
.as_str()
|
||||
.and_then(|b| blocks.get(b))
|
||||
.map(|b| b.0);
|
||||
let Some(height) = height else {
|
||||
return HashMap::new();
|
||||
};
|
||||
let Ok(layout) = self.public.shard_layout(height).await else {
|
||||
return HashMap::new();
|
||||
};
|
||||
near_tx::executors(status)
|
||||
.into_iter()
|
||||
.filter_map(|a| {
|
||||
let shard = blackbeard_core::intents::shard_for(
|
||||
&a,
|
||||
&layout.boundary_accounts,
|
||||
&layout.shard_ids,
|
||||
)?;
|
||||
Some((a, shard))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The newest final height any of our nodes reports.
|
||||
async fn final_height(&self) -> Option<u64> {
|
||||
for node in &self.own {
|
||||
if let Ok((_, h)) = node.final_head().await {
|
||||
return Some(h);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Decode a status and add what the database knows about it.
|
||||
async fn finish(
|
||||
&self,
|
||||
store: &Store,
|
||||
chain: &ChainId,
|
||||
row: &NearTxRow,
|
||||
stored: bool,
|
||||
) -> Result<NearTx, ReadError> {
|
||||
// Decoded in a scope of its own: the context borrows a closure, and
|
||||
// nothing borrowed from it may still be alive at the awaits below.
|
||||
let mut tx = {
|
||||
let blocks = blocks_from_json(&row.blocks);
|
||||
let shards: HashMap<String, u64> =
|
||||
serde_json::from_value(row.shards.clone()).unwrap_or_default();
|
||||
let shard_of = |a: &str| shards.get(a).copied();
|
||||
let ctx = near_tx::Context {
|
||||
blocks: &blocks,
|
||||
shard_of: &shard_of,
|
||||
intents_contract: &self.intents.contract,
|
||||
};
|
||||
let source = if row.source == "own" {
|
||||
NearTxSource::Own
|
||||
} else {
|
||||
NearTxSource::Public
|
||||
};
|
||||
near_tx::decode(&row.status, &ctx, source)
|
||||
.map_err(|e| ReadError::Unavailable(e.to_string()))?
|
||||
};
|
||||
tx.stored = stored;
|
||||
tx.tokens = self.tokens(store, near_tx::tokens(&tx)).await;
|
||||
let sources = near_tx::quantus_sources(&tx);
|
||||
if !sources.is_empty() {
|
||||
let found = store
|
||||
.extrinsics_by_hash(chain, &sources)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
tx.quantus_txs = sources
|
||||
.into_iter()
|
||||
.map(|hash| {
|
||||
let at = found.iter().find(|(h, _, _)| h == &hash);
|
||||
QuantusTxRef {
|
||||
chain: chain.clone(),
|
||||
height: at.map(|f| f.1),
|
||||
extrinsic_index: at.map(|f| f.2),
|
||||
hash,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
Ok(tx)
|
||||
}
|
||||
|
||||
/// Display facts for these tokens: cached ones from the database, and up
|
||||
/// to [`MAX_TOKEN_READS`] read and cached now. A token whose metadata
|
||||
/// cannot be had is left out, and its amounts show in base units.
|
||||
async fn tokens(&self, store: &Store, ids: Vec<String>) -> Vec<NearToken> {
|
||||
let mut out = Vec::new();
|
||||
let mut reads = 0;
|
||||
for id in ids {
|
||||
let mut cached = store.near_token(&id).await.ok().flatten();
|
||||
if cached.is_none() && reads < MAX_TOKEN_READS && id.starts_with("nep141:") {
|
||||
reads += 1;
|
||||
crate::intents::remember_token(&self.public, store, &id).await;
|
||||
cached = store.near_token(&id).await.ok().flatten();
|
||||
}
|
||||
if let Some((symbol, decimals)) = cached {
|
||||
out.push(NearToken {
|
||||
token: id,
|
||||
symbol,
|
||||
decimals,
|
||||
});
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn missed_recently(&self, hash: &str) -> bool {
|
||||
let mut misses = self.misses.lock().unwrap_or_else(|e| e.into_inner());
|
||||
misses.retain(|_, at| at.elapsed() < MISS_TTL);
|
||||
misses.contains_key(hash)
|
||||
}
|
||||
|
||||
fn remember_miss(&self, hash: &str) {
|
||||
let mut misses = self.misses.lock().unwrap_or_else(|e| e.into_inner());
|
||||
// Bounded: a crawler walking random hashes must not grow this forever.
|
||||
if misses.len() < 10_000 {
|
||||
misses.insert(hash.to_owned(), Instant::now());
|
||||
}
|
||||
}
|
||||
|
||||
/// An Intents account: its balances now, its keys, and this observer's
|
||||
/// index of what it did with the followed token.
|
||||
pub async fn account(
|
||||
&self,
|
||||
store: &Store,
|
||||
chain: &ChainId,
|
||||
account: &str,
|
||||
) -> Result<IntentsAccount, ReadError> {
|
||||
let contract = &self.intents.contract;
|
||||
let balances = self.balances(account).await;
|
||||
let public_keys: Vec<String> = self
|
||||
.intents_node
|
||||
.view_live(contract, "public_keys_of", json!({"account_id": account}))
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| serde_json::from_value(v).ok())
|
||||
.unwrap_or_default();
|
||||
let rows = store
|
||||
.intents_account_history(chain, &self.intents.asset, account, HISTORY_LIMIT)
|
||||
.await
|
||||
.map_err(|e| ReadError::Unavailable(e.to_string()))?;
|
||||
let asset = self.intents.asset.clone();
|
||||
let history: Vec<IntentsAccountEvent> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let mine = |signed: bool| {
|
||||
r.amount
|
||||
.clone()
|
||||
.map(|a| NearAmount {
|
||||
token: asset.clone(),
|
||||
amount: if signed { format!("-{a}") } else { a },
|
||||
account: None,
|
||||
})
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let mut event = IntentsAccountEvent {
|
||||
kind: r.kind.clone(),
|
||||
tx_hash: r.tx_hash.clone(),
|
||||
near_height: r.near_height,
|
||||
at: r.at,
|
||||
amounts: Vec::new(),
|
||||
counterparty: None,
|
||||
quantus_address: None,
|
||||
quantus_tx: None,
|
||||
quantus_height: None,
|
||||
};
|
||||
match r.kind.as_str() {
|
||||
"bridge_deposit" => {
|
||||
event.amounts = mine(false);
|
||||
event.quantus_tx = r.other;
|
||||
}
|
||||
"bridge_withdrawal" => {
|
||||
event.amounts = mine(true);
|
||||
event.quantus_address = r.other;
|
||||
}
|
||||
"transfer" => {
|
||||
event.kind = if r.outgoing {
|
||||
"transfer_out"
|
||||
} else {
|
||||
"transfer_in"
|
||||
}
|
||||
.into();
|
||||
event.amounts = mine(r.outgoing);
|
||||
event.counterparty = r.other;
|
||||
}
|
||||
_ => event.amounts = trade_amounts(r.legs.as_ref(), account),
|
||||
}
|
||||
event
|
||||
})
|
||||
.collect();
|
||||
// Where each deposit's Quantus transaction is, so it can link to its
|
||||
// block rather than stand as a bare hash.
|
||||
let mut history = history;
|
||||
let sources: Vec<String> = history
|
||||
.iter()
|
||||
.filter_map(|h| h.quantus_tx.clone())
|
||||
.collect();
|
||||
if !sources.is_empty() {
|
||||
let found = store
|
||||
.extrinsics_by_hash(chain, &sources)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
for h in &mut history {
|
||||
h.quantus_height = h
|
||||
.quantus_tx
|
||||
.as_ref()
|
||||
.and_then(|q| found.iter().find(|(f, _, _)| f == q))
|
||||
.map(|f| f.1);
|
||||
}
|
||||
}
|
||||
let mut ids: Vec<String> = vec![asset.clone()];
|
||||
for t in balances.iter().flatten().map(|b| &b.token).chain(
|
||||
history
|
||||
.iter()
|
||||
.flat_map(|h| h.amounts.iter().map(|a| &a.token)),
|
||||
) {
|
||||
if !ids.contains(t) {
|
||||
ids.push(t.clone());
|
||||
}
|
||||
}
|
||||
let tokens = self.tokens(store, ids).await;
|
||||
Ok(IntentsAccount {
|
||||
chain: chain.clone(),
|
||||
account: account.to_owned(),
|
||||
kind: near_tx::account_kind(account).to_owned(),
|
||||
public_keys,
|
||||
balances,
|
||||
balances_at: Utc::now(),
|
||||
tokens,
|
||||
history,
|
||||
asset,
|
||||
})
|
||||
}
|
||||
|
||||
/// Every non-zero balance, from our own node. `None` when the contract
|
||||
/// could not be asked — never an empty list standing in for "could not
|
||||
/// tell".
|
||||
async fn balances(&self, account: &str) -> Option<Vec<IntentsBalance>> {
|
||||
let contract = &self.intents.contract;
|
||||
let mut ids: Vec<String> = Vec::new();
|
||||
for page in 0..BALANCE_PAGES {
|
||||
let v = self
|
||||
.intents_node
|
||||
.view_live(
|
||||
contract,
|
||||
"mt_tokens_for_owner",
|
||||
json!({
|
||||
"account_id": account,
|
||||
"from_index": (page * BALANCE_PAGE).to_string(),
|
||||
"limit": BALANCE_PAGE,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
let list = v.as_array()?;
|
||||
ids.extend(
|
||||
list.iter()
|
||||
.filter_map(|t| t["token_id"].as_str().map(str::to_owned)),
|
||||
);
|
||||
if list.len() < BALANCE_PAGE {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ids.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let amounts: Vec<String> = self
|
||||
.intents_node
|
||||
.view_live(
|
||||
contract,
|
||||
"mt_batch_balance_of",
|
||||
json!({"account_id": account, "token_ids": ids}),
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| serde_json::from_value(v).ok())?;
|
||||
Some(
|
||||
ids.into_iter()
|
||||
.zip(amounts)
|
||||
.filter(|(_, a)| a != "0")
|
||||
.map(|(token, amount)| IntentsBalance {
|
||||
token,
|
||||
amount: BigUintDec(amount),
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a trade's legs moved for one account, signed.
|
||||
fn trade_amounts(legs: Option<&Value>, account: &str) -> Vec<NearAmount> {
|
||||
legs.and_then(Value::as_array)
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|l| l["account_id"].as_str() == Some(account))
|
||||
.filter_map(|l| l["diff"].as_object())
|
||||
.flatten()
|
||||
.map(|(token, amount)| NearAmount {
|
||||
token: token.clone(),
|
||||
amount: match amount {
|
||||
Value::String(s) => s.clone(),
|
||||
other => other.to_string(),
|
||||
},
|
||||
account: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn blocks_json(blocks: &HashMap<String, (u64, DateTime<Utc>)>) -> Value {
|
||||
Value::Object(
|
||||
blocks
|
||||
.iter()
|
||||
.map(|(k, (h, at))| (k.clone(), json!([h, at.timestamp_millis()])))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn blocks_from_json(v: &Value) -> HashMap<String, (u64, DateTime<Utc>)> {
|
||||
v.as_object()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(k, v)| {
|
||||
let h = v[0].as_u64()?;
|
||||
let at = DateTime::from_timestamp_millis(v[1].as_i64()?)?;
|
||||
Some((k.clone(), (h, at)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Whether a string is a NEAR account id: 2 to 64 characters of lower-case
|
||||
/// letters, digits and `_-.` separators, no separator at either end or two in
|
||||
/// a row. Checked before anything is asked, so a page cannot be made to send
|
||||
/// arbitrary text to a node.
|
||||
pub fn valid_account(id: &str) -> bool {
|
||||
let sep = |c: u8| matches!(c, b'_' | b'-' | b'.');
|
||||
let b = id.as_bytes();
|
||||
(2..=64).contains(&b.len())
|
||||
&& b.iter()
|
||||
.all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || sep(c))
|
||||
&& !sep(b[0])
|
||||
&& !sep(b[b.len() - 1])
|
||||
&& !b.windows(2).any(|w| sep(w[0]) && sep(w[1]))
|
||||
}
|
||||
|
||||
/// Whether a string is a base58 transaction hash: exactly 32 bytes once
|
||||
/// decoded. Checked before anything is asked, so a malformed hash is the
|
||||
/// caller's 400 rather than a node's parse error reported as an outage.
|
||||
pub fn valid_tx_hash(hash: &str) -> bool {
|
||||
(32..=44).contains(&hash.len())
|
||||
&& blackbeard_core::wormhole::base58_decode(hash).is_some_and(|b| b.len() == 32)
|
||||
}
|
||||
|
||||
/// The reader for a chain, built on first use.
|
||||
pub async fn reader(chain: &crate::state::ChainRuntime) -> Option<Arc<NearReader>> {
|
||||
chain
|
||||
.near
|
||||
.get_or_init(|| async {
|
||||
let intents = chain.spec.intents.as_ref()?;
|
||||
NearReader::new(intents, chain.spec.bridge.as_ref()).map(Arc::new)
|
||||
})
|
||||
.await
|
||||
.clone()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn account_ids_follow_near_rules() {
|
||||
for ok in [
|
||||
"intents.near",
|
||||
"solver-ref-2.near",
|
||||
"42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978",
|
||||
"0x30f4a0e03ea33439e081cdc3d93b6d2bd7ab3897",
|
||||
"ab",
|
||||
] {
|
||||
assert!(valid_account(ok), "{ok}");
|
||||
}
|
||||
for bad in [
|
||||
"a",
|
||||
"Intents.near",
|
||||
"a..b",
|
||||
".near",
|
||||
"near.",
|
||||
"a b",
|
||||
"",
|
||||
&"a".repeat(65),
|
||||
] {
|
||||
assert!(!valid_account(bad), "{bad}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tx_hashes_are_base58_of_32_bytes() {
|
||||
assert!(valid_tx_hash("piPTXyoqDZwSJsKk3DWGVvDSy8T7hWm6KW4LFxpTsLk"));
|
||||
assert!(valid_tx_hash(
|
||||
"E4ysnVhtKSxp1uEKrwYTXSCrP2Nenkgod1wVTQCyC4Pb"
|
||||
));
|
||||
assert!(!valid_tx_hash(
|
||||
"0xc4444afc1d43ddccad1421cb1989a9159516411db085710317ead1adeda3bf8d"
|
||||
));
|
||||
assert!(!valid_tx_hash(
|
||||
"piPTXyoqDZwSJsKk3DWGVvDSy8T7hWm6KW4LFxpTsL0"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_trade_is_read_from_the_account_s_own_legs() {
|
||||
let legs = json!([
|
||||
{"account_id": "a.near", "diff": {"nep141:qtc.omft.near": "-5", "nep141:usdc": "100"}},
|
||||
{"account_id": "b.near", "diff": {"nep141:qtc.omft.near": "5"}},
|
||||
]);
|
||||
let mut got = trade_amounts(Some(&legs), "a.near");
|
||||
got.sort_by(|x, y| x.token.cmp(&y.token));
|
||||
assert_eq!(got.len(), 2);
|
||||
assert_eq!(
|
||||
(got[0].token.as_str(), got[0].amount.as_str()),
|
||||
("nep141:qtc.omft.near", "-5")
|
||||
);
|
||||
assert_eq!(got[1].amount, "100");
|
||||
}
|
||||
}
|
||||
@@ -83,6 +83,11 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
|
||||
"/v1/chains/{chain}/bridge/withdrawals",
|
||||
get(bridge_withdrawals),
|
||||
)
|
||||
.route("/v1/chains/{chain}/near/tx/{hash}", get(near_tx))
|
||||
.route(
|
||||
"/v1/chains/{chain}/near/intents/{account}",
|
||||
get(intents_account),
|
||||
)
|
||||
.route("/v1/chains/{chain}/nodes", get(nodes))
|
||||
.route("/v1/chains/{chain}/nodes/{peer}", get(node))
|
||||
.route("/v1/chains/{chain}/accounts", get(accounts))
|
||||
@@ -5068,6 +5073,77 @@ fn database_unavailable(e: blackbeard_data::DataError) -> Failure {
|
||||
)
|
||||
}
|
||||
|
||||
/// The NEAR side of a chain, or 404 when it follows nothing on NEAR.
|
||||
async fn near_reader(
|
||||
runtime: &crate::state::ChainRuntime,
|
||||
) -> Result<std::sync::Arc<crate::neartx::NearReader>, Failure> {
|
||||
crate::neartx::reader(runtime).await.ok_or_else(|| {
|
||||
Failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
ApiError::new("no_near", "this chain follows nothing on NEAR"),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn near_failure(e: crate::neartx::ReadError) -> Failure {
|
||||
match e {
|
||||
crate::neartx::ReadError::NotFound => Failure(
|
||||
StatusCode::NOT_FOUND,
|
||||
ApiError::new(
|
||||
"not_found",
|
||||
"no NEAR node has this transaction: the hash is wrong, or it is not yet included",
|
||||
),
|
||||
),
|
||||
crate::neartx::ReadError::Unavailable(why) => Failure(
|
||||
StatusCode::SERVICE_UNAVAILABLE,
|
||||
ApiError::new("near_unavailable", format!("NEAR could not be read: {why}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// One NEAR transaction and every receipt it caused (blackbeard/observer#44).
|
||||
async fn near_tx(
|
||||
State(state): State<AppState>,
|
||||
Path((chain, hash)): Path<(String, String)>,
|
||||
) -> Result<Json<blackbeard_entities::NearTx>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
if !crate::neartx::valid_tx_hash(&hash) {
|
||||
return Err(Failure(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ApiError::new(
|
||||
"bad_hash",
|
||||
"a NEAR transaction hash is 43 or 44 base58 characters",
|
||||
),
|
||||
));
|
||||
}
|
||||
let reader = near_reader(&runtime).await?;
|
||||
let tx = reader
|
||||
.tx(&state.store, &runtime.id(), &hash)
|
||||
.await
|
||||
.map_err(near_failure)?;
|
||||
Ok(Json(tx))
|
||||
}
|
||||
|
||||
/// One NEAR Intents account (blackbeard/observer#44).
|
||||
async fn intents_account(
|
||||
State(state): State<AppState>,
|
||||
Path((chain, account)): Path<(String, String)>,
|
||||
) -> Result<Json<blackbeard_entities::IntentsAccount>, Failure> {
|
||||
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
|
||||
if !crate::neartx::valid_account(&account) {
|
||||
return Err(Failure(
|
||||
StatusCode::BAD_REQUEST,
|
||||
ApiError::new("bad_account", "not a NEAR account id"),
|
||||
));
|
||||
}
|
||||
let reader = near_reader(&runtime).await?;
|
||||
let found = reader
|
||||
.account(&state.store, &runtime.id(), &account)
|
||||
.await
|
||||
.map_err(near_failure)?;
|
||||
Ok(Json(found))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -188,6 +188,10 @@ pub struct ChainRuntime {
|
||||
pub ref_pools: RwLock<Option<blackbeard_entities::RefPools>>,
|
||||
/// Classic Ref pools holding the asset, when listed (blackbeard/observer#39).
|
||||
pub ref_v1_pools: RwLock<Option<blackbeard_entities::RefV1Pools>>,
|
||||
/// What reads NEAR transactions and Intents accounts for the pages that
|
||||
/// show them, built on first use (blackbeard/observer#44). `None` inside
|
||||
/// when the chain follows nothing on NEAR.
|
||||
pub near: tokio::sync::OnceCell<Option<Arc<crate::neartx::NearReader>>>,
|
||||
/// Blocks awaiting their telemetry attribution.
|
||||
pub pending_attributions: std::sync::Mutex<std::collections::VecDeque<PendingAttribution>>,
|
||||
/// Live subscriber count per window, so the recompute timer can skip a
|
||||
@@ -237,6 +241,7 @@ impl ChainRuntime {
|
||||
intents_inventory: RwLock::new(None),
|
||||
ref_pools: RwLock::new(None),
|
||||
ref_v1_pools: RwLock::new(None),
|
||||
near: tokio::sync::OnceCell::new(),
|
||||
pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()),
|
||||
subscribers: Default::default(),
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ authors.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
base64.workspace = true
|
||||
blackbeard-entities.workspace = true
|
||||
chrono.workspace = true
|
||||
blake2.workspace = true
|
||||
|
||||
@@ -21,6 +21,7 @@ pub mod digest;
|
||||
pub mod geo;
|
||||
pub mod hashrate;
|
||||
pub mod intents;
|
||||
pub mod near_tx;
|
||||
pub mod pallet;
|
||||
pub mod runtime;
|
||||
pub mod scale;
|
||||
|
||||
@@ -0,0 +1,768 @@
|
||||
//! A NEAR transaction's whole story, decoded from `EXPERIMENTAL_tx_status`
|
||||
//! (blackbeard/observer#44).
|
||||
//!
|
||||
//! The input is what the RPC returns — the transaction, every receipt, and every
|
||||
//! receipt's outcome — whether one public node returned it whole or it was
|
||||
//! assembled from our own nodes a receipt at a time. Nothing here does I/O: the
|
||||
//! blocks' heights and times, and which shard each account is on, are passed
|
||||
//! in, so the decoding can be tested against real transactions captured off
|
||||
//! the chain.
|
||||
//!
|
||||
//! What it adds to the raw form is only what a reader cannot do by eye: the
|
||||
//! receipt tree put in order, base64 arguments decoded, `EVENT_JSON:` lines
|
||||
//! parsed, the amount in each event tied to its token, and the bridge's memo
|
||||
//! formats read. Everything it read is kept beside what it made of it.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use base64::Engine as _;
|
||||
use blackbeard_entities::{
|
||||
BigUintDec, NearAction, NearAmount, NearEvent, NearMemo, NearReceipt, NearTx, NearTxSource,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde_json::Value;
|
||||
|
||||
/// The account the protocol sends gas refunds from.
|
||||
const SYSTEM: &str = "system";
|
||||
|
||||
/// What the decoder needs that the transaction does not say.
|
||||
pub struct Context<'a> {
|
||||
/// Each block's height and timestamp, by block hash. A block missing here
|
||||
/// leaves its receipts undated rather than failing the decode.
|
||||
pub blocks: &'a HashMap<String, (u64, DateTime<Utc>)>,
|
||||
/// Which shard an account is on.
|
||||
pub shard_of: &'a (dyn Fn(&str) -> Option<u64> + Sync),
|
||||
/// The NEAR Intents contract, whose events name Intents accounts.
|
||||
pub intents_contract: &'a str,
|
||||
}
|
||||
|
||||
/// Why a status did not decode.
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum TxError {
|
||||
/// A field every `tx_status` carries was missing.
|
||||
#[error("tx_status has no {0}")]
|
||||
Missing(&'static str),
|
||||
}
|
||||
|
||||
/// Receipt ids the transaction or an outcome spawned that have no outcome here.
|
||||
///
|
||||
/// Empty means the status is complete: every receipt that was ever created has
|
||||
/// executed and is accounted for. This is the whole completeness test — a node
|
||||
/// tracking some shards reports the outcomes it holds and silently omits the
|
||||
/// rest, so the only way to know is to check every reference resolves.
|
||||
pub fn missing_receipts(status: &Value) -> Vec<String> {
|
||||
let have: HashSet<&str> = outcomes(status)
|
||||
.iter()
|
||||
.filter_map(|o| o["id"].as_str())
|
||||
.collect();
|
||||
let mut missing = Vec::new();
|
||||
for spawned in std::iter::once(&status["transaction_outcome"])
|
||||
.chain(outcomes(status).iter())
|
||||
.filter_map(|o| o["outcome"]["receipt_ids"].as_array())
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
{
|
||||
if !have.contains(spawned) && !missing.iter().any(|m| m == spawned) {
|
||||
missing.push(spawned.to_owned());
|
||||
}
|
||||
}
|
||||
missing
|
||||
}
|
||||
|
||||
/// Every block the transaction and its receipts executed in, once each.
|
||||
pub fn block_hashes(status: &Value) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for b in std::iter::once(&status["transaction_outcome"])
|
||||
.chain(outcomes(status).iter())
|
||||
.filter_map(|o| o["block_hash"].as_str())
|
||||
{
|
||||
if !out.iter().any(|x| x == b) {
|
||||
out.push(b.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Add receipts fetched one at a time to a status, so it decodes as though one
|
||||
/// node had returned it whole. `receipt` is `EXPERIMENTAL_receipt`'s answer;
|
||||
/// `outcome` is the `outcome_proof` of a light client proof, which has the
|
||||
/// same shape as an entry of `receipts_outcome`.
|
||||
pub fn add_receipt(status: &mut Value, receipt: Value, outcome: Value) {
|
||||
if let Some(list) = status["receipts"].as_array_mut() {
|
||||
list.push(receipt);
|
||||
} else {
|
||||
status["receipts"] = Value::Array(vec![receipt]);
|
||||
}
|
||||
if let Some(list) = status["receipts_outcome"].as_array_mut() {
|
||||
list.push(outcome);
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop what is bulky and never shown: Merkle proofs and per-outcome gas
|
||||
/// profiles, which together are most of a stored transaction.
|
||||
pub fn strip(status: &mut Value) {
|
||||
let thin = |o: &mut Value| {
|
||||
if let Some(map) = o.as_object_mut() {
|
||||
map.remove("proof");
|
||||
}
|
||||
if let Some(map) = o["outcome"].as_object_mut() {
|
||||
map.remove("metadata");
|
||||
}
|
||||
};
|
||||
thin(&mut status["transaction_outcome"]);
|
||||
if let Some(list) = status["receipts_outcome"].as_array_mut() {
|
||||
list.iter_mut().for_each(thin);
|
||||
}
|
||||
}
|
||||
|
||||
/// The transaction's signer, from a status.
|
||||
pub fn signer(status: &Value) -> Option<&str> {
|
||||
status["transaction"]["signer_id"].as_str()
|
||||
}
|
||||
|
||||
/// Every account a receipt here was executed by, for working out shards.
|
||||
pub fn executors(status: &Value) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for e in outcomes(status)
|
||||
.iter()
|
||||
.filter_map(|o| o["outcome"]["executor_id"].as_str())
|
||||
{
|
||||
if !out.iter().any(|x| x == e) {
|
||||
out.push(e.to_owned());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn outcomes(status: &Value) -> &[Value] {
|
||||
status["receipts_outcome"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Decode a status into the page's shape.
|
||||
///
|
||||
/// `source`, `stored` and the Quantus links are the caller's to fill: they are
|
||||
/// facts about how the transaction was read, not about the transaction.
|
||||
pub fn decode(status: &Value, ctx: &Context<'_>, source: NearTxSource) -> Result<NearTx, TxError> {
|
||||
let tx = &status["transaction"];
|
||||
let hash = text(&tx["hash"]).ok_or(TxError::Missing("transaction.hash"))?;
|
||||
let signer = text(&tx["signer_id"]).ok_or(TxError::Missing("transaction.signer_id"))?;
|
||||
let receiver = text(&tx["receiver_id"]).ok_or(TxError::Missing("transaction.receiver_id"))?;
|
||||
let tx_outcome = &status["transaction_outcome"];
|
||||
let included = tx_outcome["block_hash"]
|
||||
.as_str()
|
||||
.and_then(|b| ctx.blocks.get(b));
|
||||
|
||||
// Receipts by id, for their predecessor and actions. The transaction's own
|
||||
// receipt is absent when it was signed and received by the same account —
|
||||
// the protocol executes that one locally without recording it — and its
|
||||
// actions are then the transaction's.
|
||||
let receipts: HashMap<&str, &Value> = status["receipts"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|r| Some((r["receipt_id"].as_str()?, r)))
|
||||
.collect();
|
||||
let by_id: HashMap<&str, &Value> = outcomes(status)
|
||||
.iter()
|
||||
.filter_map(|o| Some((o["id"].as_str()?, o)))
|
||||
.collect();
|
||||
let root = tx_outcome["outcome"]["receipt_ids"]
|
||||
.as_array()
|
||||
.and_then(|ids| ids.first())
|
||||
.and_then(Value::as_str);
|
||||
|
||||
// Depth first from the root, children in the order their parent spawned
|
||||
// them. Anything unreachable — which a well-formed status never has — is
|
||||
// appended rather than hidden.
|
||||
let mut order: Vec<(&str, Option<&str>, u32)> = Vec::new();
|
||||
let mut seen: HashSet<&str> = HashSet::new();
|
||||
let mut stack: Vec<(&str, Option<&str>, u32)> =
|
||||
root.map(|r| (r, None, 0)).into_iter().collect();
|
||||
while let Some((id, parent, depth)) = stack.pop() {
|
||||
if !seen.insert(id) {
|
||||
continue;
|
||||
}
|
||||
order.push((id, parent, depth));
|
||||
if let Some(children) = by_id
|
||||
.get(id)
|
||||
.and_then(|o| o["outcome"]["receipt_ids"].as_array())
|
||||
{
|
||||
for child in children.iter().rev().filter_map(Value::as_str) {
|
||||
stack.push((child, Some(id), depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
for id in by_id.keys() {
|
||||
if !seen.contains(id) {
|
||||
order.push((id, None, 0));
|
||||
}
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
for (id, parent, depth) in order {
|
||||
let Some(outcome) = by_id.get(id) else {
|
||||
continue;
|
||||
};
|
||||
let o = &outcome["outcome"];
|
||||
let executor = text(&o["executor_id"]).unwrap_or_default();
|
||||
let (predecessor, actions) = match receipts.get(id) {
|
||||
Some(r) => (
|
||||
text(&r["predecessor_id"]).unwrap_or_default(),
|
||||
r["receipt"]["Action"]["actions"].clone(),
|
||||
),
|
||||
None if Some(id) == root => (signer.clone(), tx["actions"].clone()),
|
||||
None => (String::new(), Value::Null),
|
||||
};
|
||||
let actions: Vec<NearAction> = actions
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.map(action)
|
||||
.collect();
|
||||
let (succeeded, failure) = result(&o["status"]);
|
||||
let block = outcome["block_hash"]
|
||||
.as_str()
|
||||
.and_then(|b| ctx.blocks.get(b));
|
||||
let mut events = Vec::new();
|
||||
let mut logs = Vec::new();
|
||||
for line in o["logs"].as_array().map(Vec::as_slice).unwrap_or_default() {
|
||||
let Some(line) = line.as_str() else { continue };
|
||||
match event(line, &executor, ctx.intents_contract) {
|
||||
Some(e) => events.push(e),
|
||||
None => logs.push(line.to_owned()),
|
||||
}
|
||||
}
|
||||
out.push(NearReceipt {
|
||||
id: id.to_owned(),
|
||||
parent: parent.map(str::to_owned),
|
||||
depth,
|
||||
gas_refund: predecessor == SYSTEM && actions.iter().all(|a| a.kind == "Transfer"),
|
||||
predecessor,
|
||||
shard: (ctx.shard_of)(&executor),
|
||||
receiver: executor,
|
||||
height: block.map(|b| b.0),
|
||||
at: block.map(|b| b.1),
|
||||
succeeded,
|
||||
failure,
|
||||
gas_burnt: o["gas_burnt"].as_u64().unwrap_or_default(),
|
||||
tokens_burnt: BigUintDec(text(&o["tokens_burnt"]).unwrap_or_else(|| "0".into())),
|
||||
actions,
|
||||
events,
|
||||
logs,
|
||||
});
|
||||
}
|
||||
|
||||
let (succeeded, failure) = result(&status["status"]);
|
||||
Ok(NearTx {
|
||||
hash,
|
||||
signer,
|
||||
receiver,
|
||||
height: included.map(|b| b.0),
|
||||
at: included.map(|b| b.1),
|
||||
source,
|
||||
complete: missing_receipts(status).is_empty(),
|
||||
stored: false,
|
||||
succeeded,
|
||||
failure,
|
||||
receipts: out,
|
||||
tokens: Vec::new(),
|
||||
quantus_txs: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
fn text(v: &Value) -> Option<String> {
|
||||
v.as_str().map(str::to_owned)
|
||||
}
|
||||
|
||||
/// Success, or the failure as the runtime words it.
|
||||
fn result(status: &Value) -> (bool, Option<String>) {
|
||||
match status.get("Failure") {
|
||||
None => (true, None),
|
||||
Some(f) => (false, Some(failure_text(f))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The innermost message in a failure: a contract's panic text where there is
|
||||
/// one, which is what anyone reading it wants, and the whole structure
|
||||
/// otherwise.
|
||||
fn failure_text(failure: &Value) -> String {
|
||||
fn find(v: &Value) -> Option<&str> {
|
||||
match v {
|
||||
Value::Object(m) => m
|
||||
.get("ExecutionError")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| m.values().find_map(find)),
|
||||
Value::Array(a) => a.iter().find_map(find),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
find(failure)
|
||||
.map(str::to_owned)
|
||||
.unwrap_or_else(|| failure.to_string())
|
||||
}
|
||||
|
||||
/// One action, as the RPC renders it: a bare string for the ones with no
|
||||
/// fields (`"CreateAccount"`), an object keyed by kind otherwise.
|
||||
fn action(raw: &Value) -> NearAction {
|
||||
let (kind, body) = match raw {
|
||||
Value::String(s) => (s.clone(), &Value::Null),
|
||||
Value::Object(m) => match m.iter().next() {
|
||||
Some((k, v)) => (k.clone(), v),
|
||||
None => (String::new(), &Value::Null),
|
||||
},
|
||||
_ => (String::new(), &Value::Null),
|
||||
};
|
||||
let bytes = body["args"]
|
||||
.as_str()
|
||||
.and_then(|b| base64::engine::general_purpose::STANDARD.decode(b).ok());
|
||||
let args: Option<Value> = bytes
|
||||
.as_deref()
|
||||
.and_then(|b| serde_json::from_slice(b).ok());
|
||||
NearAction {
|
||||
method: text(&body["method_name"]),
|
||||
args_bytes: match (&args, &bytes) {
|
||||
(None, Some(b)) => u32::try_from(b.len()).ok(),
|
||||
_ => None,
|
||||
},
|
||||
memos: args.as_ref().map(memos).unwrap_or_default(),
|
||||
args,
|
||||
deposit: text(&body["deposit"]).filter(|d| d != "0").map(BigUintDec),
|
||||
kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// An `EVENT_JSON:` line, or `None` for any other log line.
|
||||
fn event(line: &str, executor: &str, intents_contract: &str) -> Option<NearEvent> {
|
||||
let v: Value = serde_json::from_str(line.strip_prefix("EVENT_JSON:")?).ok()?;
|
||||
let standard = text(&v["standard"])?;
|
||||
let event = text(&v["event"])?;
|
||||
let data = v["data"].clone();
|
||||
let items: Vec<&Value> = match &data {
|
||||
Value::Array(a) => a.iter().collect(),
|
||||
Value::Null => Vec::new(),
|
||||
other => vec![other],
|
||||
};
|
||||
let amounts = items
|
||||
.iter()
|
||||
.flat_map(|item| amounts(&standard, &event, executor, item))
|
||||
.collect();
|
||||
let mut intents_accounts: Vec<String> = Vec::new();
|
||||
if executor == intents_contract {
|
||||
// `receiver_id` names an Intents account only in a transfer between
|
||||
// two of them; in a withdrawal it is the NEAR account paid out.
|
||||
let keys: &[&str] = if event == "transfer" {
|
||||
&[
|
||||
"account_id",
|
||||
"owner_id",
|
||||
"old_owner_id",
|
||||
"new_owner_id",
|
||||
"receiver_id",
|
||||
]
|
||||
} else {
|
||||
&["account_id", "owner_id", "old_owner_id", "new_owner_id"]
|
||||
};
|
||||
for item in &items {
|
||||
for key in keys {
|
||||
if let Some(a) = item[*key].as_str() {
|
||||
if a != intents_contract && !intents_accounts.iter().any(|x| x == a) {
|
||||
intents_accounts.push(a.to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(NearEvent {
|
||||
version: text(&v["version"]).unwrap_or_default(),
|
||||
memos: memos(&data),
|
||||
standard,
|
||||
event,
|
||||
data,
|
||||
amounts,
|
||||
intents_accounts,
|
||||
})
|
||||
}
|
||||
|
||||
/// A token id as NEAR Intents writes it. A NEP-141 contract named bare —
|
||||
/// `qtc.omft.near` in a `ft_withdraw` — gets its prefix, so one token has one
|
||||
/// name wherever it appears.
|
||||
fn token_id(t: &str) -> String {
|
||||
if t.contains(':') {
|
||||
t.to_owned()
|
||||
} else {
|
||||
format!("nep141:{t}")
|
||||
}
|
||||
}
|
||||
|
||||
/// The amounts one event entry names, each tied to its token.
|
||||
fn amounts(standard: &str, event: &str, executor: &str, item: &Value) -> Vec<NearAmount> {
|
||||
let one = |token: String, amount: &Value, account: &Value| {
|
||||
Some(NearAmount {
|
||||
token,
|
||||
amount: text(amount)?,
|
||||
account: text(account),
|
||||
})
|
||||
};
|
||||
let receiving = |item: &Value| {
|
||||
if item["new_owner_id"].is_string() {
|
||||
item["new_owner_id"].clone()
|
||||
} else {
|
||||
item["owner_id"].clone()
|
||||
}
|
||||
};
|
||||
match standard {
|
||||
// The emitting contract is the token.
|
||||
"nep141" => one(token_id(executor), &item["amount"], &receiving(item))
|
||||
.into_iter()
|
||||
.collect(),
|
||||
"nep245" => {
|
||||
let ids = item["token_ids"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
let amts = item["amounts"]
|
||||
.as_array()
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or_default();
|
||||
let owner = receiving(item);
|
||||
ids.iter()
|
||||
.zip(amts)
|
||||
.filter_map(|(t, a)| one(t.as_str()?.to_owned(), a, &owner))
|
||||
.collect()
|
||||
}
|
||||
"dip4" => match event {
|
||||
"token_diff" => item["diff"]
|
||||
.as_object()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(t, a)| one(token_id(t), a, &item["account_id"]))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
"transfer" => item["tokens"]
|
||||
.as_object()
|
||||
.map(|m| {
|
||||
m.iter()
|
||||
.filter_map(|(t, a)| one(token_id(t), a, &item["receiver_id"]))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
_ => match item["token"].as_str() {
|
||||
Some(t) => one(token_id(t), &item["amount"], &item["account_id"])
|
||||
.into_iter()
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
},
|
||||
"dcl.ref" if event == "swap" => {
|
||||
let negative = |v: &Value| v.as_str().map(|a| Value::String(format!("-{a}")));
|
||||
[
|
||||
item["token_in"].as_str().and_then(|t| {
|
||||
one(
|
||||
token_id(t),
|
||||
&negative(&item["amount_in"])?,
|
||||
&item["swapper"],
|
||||
)
|
||||
}),
|
||||
item["token_out"]
|
||||
.as_str()
|
||||
.and_then(|t| one(token_id(t), &item["amount_out"], &item["swapper"])),
|
||||
]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Memos in a known format anywhere in a value: in a `memo` field, or inside a
|
||||
/// string that is itself JSON — the Intents contract nests a signed payload as
|
||||
/// a string, and the withdrawal memo sits inside that.
|
||||
pub fn memos(v: &Value) -> Vec<NearMemo> {
|
||||
let mut out = Vec::new();
|
||||
walk(v, &mut out, 0);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(v: &Value, out: &mut Vec<NearMemo>, depth: u8) {
|
||||
// Bounded: every level of string-encoded JSON is a parse, and nothing on
|
||||
// this chain nests more than a few deep.
|
||||
if depth > 6 {
|
||||
return;
|
||||
}
|
||||
match v {
|
||||
Value::Object(m) => m.values().for_each(|x| walk(x, out, depth + 1)),
|
||||
Value::Array(a) => a.iter().for_each(|x| walk(x, out, depth + 1)),
|
||||
Value::String(s) => {
|
||||
if let Some(memo) = memo(s) {
|
||||
if !out.iter().any(|m| m.text == memo.text) {
|
||||
out.push(memo);
|
||||
}
|
||||
} else if s.starts_with('{') || s.starts_with('[') {
|
||||
if let Ok(inner) = serde_json::from_str::<Value>(s) {
|
||||
walk(&inner, out, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// One memo, when it is in a format this observer reads.
|
||||
fn memo(s: &str) -> Option<NearMemo> {
|
||||
let blank = |kind: &str| NearMemo {
|
||||
kind: kind.to_owned(),
|
||||
text: s.to_owned(),
|
||||
source_tx: None,
|
||||
source_network: None,
|
||||
address: None,
|
||||
};
|
||||
if let Some(rest) = s.strip_prefix("BRIDGED_FROM:") {
|
||||
let v: Value = serde_json::from_str(rest).ok()?;
|
||||
return Some(NearMemo {
|
||||
source_tx: text(&v["txHash"]).map(|h| h.to_ascii_lowercase()),
|
||||
source_network: text(&v["networkType"]),
|
||||
..blank("bridged_from")
|
||||
});
|
||||
}
|
||||
if let Some(address) = s.strip_prefix("WITHDRAW_TO:") {
|
||||
return Some(NearMemo {
|
||||
address: Some(address.to_owned()),
|
||||
..blank("withdraw_to")
|
||||
});
|
||||
}
|
||||
(s == "refund").then(|| blank("refund"))
|
||||
}
|
||||
|
||||
/// Every token an amount in the transaction names, once each.
|
||||
pub fn tokens(tx: &NearTx) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for a in tx
|
||||
.receipts
|
||||
.iter()
|
||||
.flat_map(|r| &r.events)
|
||||
.flat_map(|e| &e.amounts)
|
||||
{
|
||||
if !out.iter().any(|t| t == &a.token) {
|
||||
out.push(a.token.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Quantus transaction hashes the bridge's memos name, once each.
|
||||
pub fn quantus_sources(tx: &NearTx) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let memos = tx.receipts.iter().flat_map(|r| {
|
||||
r.actions
|
||||
.iter()
|
||||
.flat_map(|a| &a.memos)
|
||||
.chain(r.events.iter().flat_map(|e| &e.memos))
|
||||
});
|
||||
for m in memos {
|
||||
if m.source_network.as_deref() == Some("qtc") {
|
||||
if let Some(h) = &m.source_tx {
|
||||
if !out.contains(h) {
|
||||
out.push(h.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// What kind of account an id is, by NEAR's own rules: a 64-hex implicit
|
||||
/// account is an ed25519 public key, a `0x`+40-hex one an Ethereum address,
|
||||
/// and anything else was registered by name.
|
||||
pub fn account_kind(account: &str) -> &'static str {
|
||||
let hex = |s: &str| {
|
||||
s.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
|
||||
};
|
||||
if account.len() == 64 && hex(account) {
|
||||
"implicit_ed25519"
|
||||
} else if account.len() == 42 && account.starts_with("0x") && hex(&account[2..]) {
|
||||
"implicit_eth"
|
||||
} else {
|
||||
"named"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn fixture(name: &str) -> Value {
|
||||
let text = match name {
|
||||
"mint" => include_str!("../tests/near-tx-piPTXyoq.json"),
|
||||
_ => include_str!("../tests/near-tx-E4ysnVht.json"),
|
||||
};
|
||||
serde_json::from_str(text).unwrap()
|
||||
}
|
||||
|
||||
fn decoded(status: &Value) -> NearTx {
|
||||
let blocks = HashMap::new();
|
||||
let shard = |a: &str| (a == "intents.near").then_some(7);
|
||||
let ctx = Context {
|
||||
blocks: &blocks,
|
||||
shard_of: &shard,
|
||||
intents_contract: "intents.near",
|
||||
};
|
||||
decode(status, &ctx, NearTxSource::Public).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_whole_status_is_complete_and_one_with_a_receipt_removed_is_not() {
|
||||
let mut status = fixture("mint");
|
||||
assert!(missing_receipts(&status).is_empty());
|
||||
// The shape gargravarr returned for this transaction: everything but
|
||||
// the intents.near receipt on shard 7, which it does not track.
|
||||
let list = status["receipts_outcome"].as_array_mut().unwrap();
|
||||
let at = list
|
||||
.iter()
|
||||
.position(|o| o["outcome"]["executor_id"] == "intents.near")
|
||||
.unwrap();
|
||||
let gone = list.remove(at);
|
||||
assert_eq!(
|
||||
missing_receipts(&status),
|
||||
vec![gone["id"].as_str().unwrap().to_owned()]
|
||||
);
|
||||
assert!(!decoded(&status).complete);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_bridge_mint_names_its_quantus_source_and_the_intents_account_credited() {
|
||||
let tx = decoded(&fixture("mint"));
|
||||
assert_eq!(
|
||||
(tx.signer.as_str(), tx.receiver.as_str()),
|
||||
("bridge-mng.near", "omft.near")
|
||||
);
|
||||
assert!(tx.complete && tx.succeeded);
|
||||
// The transaction's own receipt comes first and carries the call.
|
||||
let first = &tx.receipts[0];
|
||||
assert_eq!((first.depth, first.parent.as_deref()), (0, None));
|
||||
assert_eq!(first.actions[0].method.as_deref(), Some("ft_deposit"));
|
||||
assert_eq!(
|
||||
first.actions[0].args.as_ref().unwrap()["amount"],
|
||||
"50000000000"
|
||||
);
|
||||
assert_eq!(
|
||||
quantus_sources(&tx),
|
||||
vec!["0xc4444afc1d43ddccad1421cb1989a9159516411db085710317ead1adeda3bf8d".to_owned()]
|
||||
);
|
||||
// Every parent precedes its children.
|
||||
for (i, r) in tx.receipts.iter().enumerate() {
|
||||
if let Some(p) = &r.parent {
|
||||
assert!(
|
||||
tx.receipts[..i].iter().any(|x| &x.id == p),
|
||||
"{} before its parent",
|
||||
r.id
|
||||
);
|
||||
}
|
||||
}
|
||||
let mint = tx
|
||||
.receipts
|
||||
.iter()
|
||||
.flat_map(|r| &r.events)
|
||||
.find(|e| e.event == "mt_mint")
|
||||
.unwrap();
|
||||
let account = "42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978";
|
||||
assert_eq!(mint.intents_accounts, vec![account.to_owned()]);
|
||||
assert_eq!(mint.amounts[0].token, "nep141:qtc.omft.near");
|
||||
assert_eq!(mint.amounts[0].amount, "50000000000");
|
||||
assert_eq!(mint.amounts[0].account.as_deref(), Some(account));
|
||||
// The token contract's own event names the token by its executor.
|
||||
let ft = tx
|
||||
.receipts
|
||||
.iter()
|
||||
.flat_map(|r| &r.events)
|
||||
.find(|e| e.event == "ft_mint")
|
||||
.unwrap();
|
||||
assert_eq!(ft.amounts[0].token, "nep141:qtc.omft.near");
|
||||
// Gas refunds are marked, and there is at least one.
|
||||
assert!(
|
||||
tx.receipts
|
||||
.iter()
|
||||
.any(|r| r.gas_refund && r.predecessor == "system")
|
||||
);
|
||||
assert_eq!(tx.receipts[2].shard, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failed_swap_shows_the_failure_and_the_refund_that_followed() {
|
||||
let tx = decoded(&fixture("refund"));
|
||||
// Signed and received by intents.near: its first receipt is not in
|
||||
// `receipts`, and its actions are the transaction's.
|
||||
assert_eq!(tx.receipts[0].predecessor, "intents.near");
|
||||
assert_eq!(
|
||||
tx.receipts[0].actions[0].method.as_deref(),
|
||||
Some("execute_intents")
|
||||
);
|
||||
assert_eq!(tx.receipts[0].shard, Some(7));
|
||||
// The transaction succeeded; one receipt inside it did not.
|
||||
assert!(tx.succeeded);
|
||||
let failed: Vec<&NearReceipt> = tx.receipts.iter().filter(|r| !r.succeeded).collect();
|
||||
assert_eq!(failed.len(), 1);
|
||||
assert_eq!(failed[0].receiver, "dclv2.ref-labs.near");
|
||||
assert_eq!(
|
||||
failed[0].failure.as_deref(),
|
||||
Some("Smart contract panicked: E407: account not in pool whitelist")
|
||||
);
|
||||
// The refund is read from the token's own transfer back.
|
||||
let refund = tx
|
||||
.receipts
|
||||
.iter()
|
||||
.flat_map(|r| &r.events)
|
||||
.find(|e| e.memos.iter().any(|m| m.kind == "refund"))
|
||||
.unwrap();
|
||||
assert_eq!(refund.event, "ft_transfer");
|
||||
assert_eq!(refund.amounts[0].account.as_deref(), Some("intents.near"));
|
||||
// The withdrawal's destination came from the dip4 event, and the
|
||||
// nested signed payload in the arguments yields nothing spurious.
|
||||
let withdraw = tx
|
||||
.receipts
|
||||
.iter()
|
||||
.flat_map(|r| &r.events)
|
||||
.find(|e| e.event == "ft_withdraw")
|
||||
.unwrap();
|
||||
assert_eq!(withdraw.amounts[0].token, "nep141:qtc.omft.near");
|
||||
assert_eq!(withdraw.intents_accounts.len(), 1);
|
||||
assert!(quantus_sources(&tx).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stripping_keeps_everything_decoded() {
|
||||
let mut status = fixture("refund");
|
||||
let before = serde_json::to_string(&decoded(&status).receipts.len()).unwrap();
|
||||
strip(&mut status);
|
||||
assert!(status["receipts_outcome"][0].get("proof").is_none());
|
||||
assert_eq!(
|
||||
serde_json::to_string(&decoded(&status).receipts.len()).unwrap(),
|
||||
before
|
||||
);
|
||||
assert!(status.to_string().len() < 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_kinds_follow_near_rules() {
|
||||
assert_eq!(
|
||||
account_kind("42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978"),
|
||||
"implicit_ed25519"
|
||||
);
|
||||
assert_eq!(
|
||||
account_kind("0x30f4a0e03ea33439e081cdc3d93b6d2bd7ab3897"),
|
||||
"implicit_eth"
|
||||
);
|
||||
assert_eq!(account_kind("solver-ref-2.near"), "named");
|
||||
// Upper-case hex is not an implicit account: NEAR ids are lower case.
|
||||
assert_eq!(
|
||||
account_kind("0x30F4A0E03EA33439E081CDC3D93B6D2BD7AB3897"),
|
||||
"named"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -155,7 +155,8 @@ pub fn ss58_of(account_hex: &str) -> Option<String> {
|
||||
}
|
||||
|
||||
/// Base58 decode: the inverse of [`base58`], and equally little arithmetic.
|
||||
fn base58_decode(input: &str) -> Option<Vec<u8>> {
|
||||
/// Bitcoin's alphabet, which NEAR's hashes use too.
|
||||
pub fn base58_decode(input: &str) -> Option<Vec<u8>> {
|
||||
let mut bytes: Vec<u8> = Vec::with_capacity(input.len());
|
||||
for c in input.chars() {
|
||||
let value = ALPHABET.iter().position(|a| *a as char == c)? as u32;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
-- Whole NEAR transactions, kept once nothing about them can change.
|
||||
-- blackbeard/observer#44.
|
||||
--
|
||||
-- A transaction page assembles a transaction from three nodes, a receipt at a
|
||||
-- time, or from a rate-limited public node, and our nodes keep about a day and
|
||||
-- a half of history. Neither is a cost to pay on every view, and the first
|
||||
-- stops being possible at all once the nodes prune. A transaction is written
|
||||
-- here only when it is complete (every receipt it spawned has an outcome) and
|
||||
-- every block it touched is final, so a row never needs revisiting.
|
||||
--
|
||||
-- Not chain-scoped: NEAR is one chain, and the same transaction is the same
|
||||
-- row whichever Quantus chain's page linked to it.
|
||||
create table near_tx (
|
||||
hash text primary key,
|
||||
signer text not null,
|
||||
-- `own` or `public`: which kind of node it was read from.
|
||||
source text not null check (source in ('own', 'public')),
|
||||
-- EXPERIMENTAL_tx_status, proofs and gas profiles stripped. Stored raw and
|
||||
-- decoded on every read, so a fix to the decoder applies to every
|
||||
-- transaction already kept.
|
||||
status jsonb not null,
|
||||
-- {"<block hash>": [height, unix milliseconds]} for every block it touched.
|
||||
blocks jsonb not null,
|
||||
-- {"<account>": shard} for every account a receipt executed on.
|
||||
shards jsonb not null,
|
||||
stored_at timestamptz not null default now()
|
||||
);
|
||||
@@ -222,6 +222,7 @@ pub fn unknown(e: &DataError) -> bool {
|
||||
if message.contains("UNKNOWN_BLOCK")
|
||||
|| message.contains("UNKNOWN_CHUNK")
|
||||
|| message.contains("UNKNOWN_TRANSACTION")
|
||||
|| message.contains("UNKNOWN_RECEIPT")
|
||||
|| message.contains("GARBAGE_COLLECTED_BLOCK"))
|
||||
}
|
||||
|
||||
@@ -709,6 +710,164 @@ pub async fn oneclick_lists(
|
||||
.any(|t| t.get("assetId").and_then(Value::as_str) == Some(asset)))
|
||||
}
|
||||
|
||||
impl NearClient {
|
||||
/// A whole transaction from the public endpoints, which track every shard:
|
||||
/// the view endpoint first, then the archive when it no longer holds it.
|
||||
///
|
||||
/// Any `sender_account_id` will do here. A node tracking every shard finds
|
||||
/// the transaction whichever shard the sender names; ours do not, which is
|
||||
/// why [`NearNode::tx_status`] needs one on the signer's shard.
|
||||
pub async fn tx_status_public(&self, hash: &str, sender: &str) -> Result<Value, DataError> {
|
||||
let params = json!({"tx_hash": hash, "sender_account_id": sender, "wait_until": "NONE"});
|
||||
if !self.views_are_own {
|
||||
pace_public_view().await;
|
||||
}
|
||||
let answer = match self
|
||||
.views
|
||||
.call("EXPERIMENTAL_tx_status", params.clone())
|
||||
.await
|
||||
{
|
||||
Err(e) if unknown(&e) => {
|
||||
if !self.archive_is_own {
|
||||
pace_public_view().await;
|
||||
}
|
||||
self.archive.call("EXPERIMENTAL_tx_status", params).await?
|
||||
}
|
||||
other => other?,
|
||||
};
|
||||
if answer["receipts_outcome"].is_array() {
|
||||
Ok(answer)
|
||||
} else {
|
||||
// `wait_until: NONE` on a transaction not yet included answers with
|
||||
// a status and nothing else.
|
||||
Err(DataError::Malformed {
|
||||
method: "EXPERIMENTAL_tx_status".into(),
|
||||
message: format!("{hash}: not yet included"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One NEAR node of ours, asked by name.
|
||||
///
|
||||
/// Not an [`RpcClient`] over all of them: they are not replicas. Each tracks
|
||||
/// different shards, so "try the next one" is how a transaction is assembled,
|
||||
/// not how a failure is survived — and which node answered matters to the
|
||||
/// caller, which is choosing them deliberately (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NearNode {
|
||||
/// Its endpoint, for logs.
|
||||
pub url: String,
|
||||
rpc: RpcClient,
|
||||
}
|
||||
|
||||
impl NearNode {
|
||||
/// A node at `url`.
|
||||
pub fn new(url: String, timeout: Duration) -> Result<Self, DataError> {
|
||||
Ok(Self {
|
||||
rpc: RpcClient::with_endpoints(vec![url.clone()], timeout)?,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
/// A transaction, as this node holds it. `Ok(None)` when it does not: a
|
||||
/// node answers only for a transaction on a shard it tracks, and only when
|
||||
/// `sender` is an account on that same shard — any account there will do,
|
||||
/// since the node uses it only to choose the shard. What comes back may be
|
||||
/// partial: receipts on shards this node does not track are left out with
|
||||
/// no sign of it but the completeness check in `blackbeard_core::near_tx`.
|
||||
pub async fn tx_status(&self, hash: &str, sender: &str) -> Result<Option<Value>, DataError> {
|
||||
let params = json!({"tx_hash": hash, "sender_account_id": sender, "wait_until": "NONE"});
|
||||
match self.rpc.call("EXPERIMENTAL_tx_status", params).await {
|
||||
Ok(v) if v["receipts_outcome"].is_array() => Ok(Some(v)),
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if unknown(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// One receipt — its predecessor, receiver and actions — when this node
|
||||
/// tracks its receiver's shard.
|
||||
pub async fn receipt(&self, id: &str) -> Result<Option<Value>, DataError> {
|
||||
match self
|
||||
.rpc
|
||||
.call("EXPERIMENTAL_receipt", json!({"receipt_id": id}))
|
||||
.await
|
||||
{
|
||||
Ok(v) => Ok(Some(v)),
|
||||
Err(e) if unknown(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// One receipt's outcome, by a light client proof against `head`: the only
|
||||
/// way to ask a node for an outcome without the transaction, which only
|
||||
/// the signer's shard holds. Returned in the shape of a `receipts_outcome`
|
||||
/// entry.
|
||||
pub async fn receipt_outcome(
|
||||
&self,
|
||||
id: &str,
|
||||
receiver: &str,
|
||||
head: &str,
|
||||
) -> Result<Option<Value>, DataError> {
|
||||
let params = json!({
|
||||
"type": "receipt",
|
||||
"receipt_id": id,
|
||||
"receiver_id": receiver,
|
||||
"light_client_head": head,
|
||||
});
|
||||
match self
|
||||
.rpc
|
||||
.call("EXPERIMENTAL_light_client_proof", params)
|
||||
.await
|
||||
{
|
||||
Ok(v) if v["outcome_proof"]["outcome"].is_object() => {
|
||||
Ok(Some(v["outcome_proof"].clone()))
|
||||
}
|
||||
Ok(_) => Ok(None),
|
||||
Err(e) if unknown(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// The node's latest final block: its hash and height.
|
||||
pub async fn final_head(&self) -> Result<(String, u64), DataError> {
|
||||
let block = self.rpc.call("block", json!({"finality": "final"})).await?;
|
||||
let header = &block["header"];
|
||||
match (header["hash"].as_str(), header["height"].as_u64()) {
|
||||
(Some(h), Some(n)) => Ok((h.to_owned(), n)),
|
||||
_ => Err(DataError::Malformed {
|
||||
method: "block".into(),
|
||||
message: "no header.hash or header.height".into(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A block's height and timestamp, by hash. Every node keeps every
|
||||
/// shard's block headers for as long as it keeps blocks at all, so any of
|
||||
/// ours answers for a recent one. `Ok(None)` for one it no longer holds.
|
||||
pub async fn block(&self, hash: &str) -> Result<Option<(u64, DateTime<Utc>)>, DataError> {
|
||||
match self.rpc.call("block", json!({"block_id": hash})).await {
|
||||
Ok(block) => Ok(block_time(&block)),
|
||||
Err(e) if unknown(&e) => Ok(None),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A block's height and timestamp from its JSON, when both are there.
|
||||
fn block_time(block: &Value) -> Option<(u64, DateTime<Utc>)> {
|
||||
let header = &block["header"];
|
||||
let nanos: i64 = header["timestamp_nanosec"]
|
||||
.as_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.or_else(|| header["timestamp"].as_i64())?;
|
||||
Some((
|
||||
header["height"].as_u64()?,
|
||||
DateTime::from_timestamp_nanos(nanos),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
|
||||
@@ -4231,3 +4231,201 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// A NEAR transaction kept whole (blackbeard/observer#44).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NearTxRow {
|
||||
/// Base58 hash.
|
||||
pub hash: String,
|
||||
/// Who signed it.
|
||||
pub signer: String,
|
||||
/// `own` or `public`.
|
||||
pub source: String,
|
||||
/// `EXPERIMENTAL_tx_status`, stripped.
|
||||
pub status: serde_json::Value,
|
||||
/// `{"<block hash>": [height, unix ms]}`.
|
||||
pub blocks: serde_json::Value,
|
||||
/// `{"<account>": shard}`.
|
||||
pub shards: serde_json::Value,
|
||||
}
|
||||
|
||||
/// One row of an Intents account's indexed history, before it is shaped for
|
||||
/// the page: which table it came from says what kind of thing it was.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntentsAccountRow {
|
||||
/// `bridge_deposit`, `bridge_withdrawal`, `trade` or `transfer`.
|
||||
pub kind: String,
|
||||
/// NEAR transaction.
|
||||
pub tx_hash: String,
|
||||
/// NEAR block.
|
||||
pub near_height: u64,
|
||||
/// When.
|
||||
pub at: DateTime<Utc>,
|
||||
/// Base units of the followed asset, unsigned; the kind says which way.
|
||||
pub amount: Option<String>,
|
||||
/// The other account of a transfer, or the withdrawal's destination, or
|
||||
/// the deposit's Quantus transaction.
|
||||
pub other: Option<String>,
|
||||
/// For a transfer, whether the account was the sender.
|
||||
pub outgoing: bool,
|
||||
/// For a trade, every leg as logged.
|
||||
pub legs: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// A kept NEAR transaction.
|
||||
pub async fn near_tx(&self, hash: &str) -> Result<Option<NearTxRow>, DataError> {
|
||||
let row = sqlx::query!(
|
||||
"select hash, signer, source, status, blocks, shards from near_tx where hash = $1",
|
||||
hash
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.map(|r| NearTxRow {
|
||||
hash: r.hash,
|
||||
signer: r.signer,
|
||||
source: r.source,
|
||||
status: r.status,
|
||||
blocks: r.blocks,
|
||||
shards: r.shards,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Keep a NEAR transaction. The caller has checked it is complete and
|
||||
/// final, so a second write of the same hash would carry the same content
|
||||
/// and is ignored.
|
||||
pub async fn record_near_tx(&self, row: &NearTxRow) -> Result<(), DataError> {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into near_tx (hash, signer, source, status, blocks, shards)
|
||||
values ($1, $2, $3, $4, $5, $6)
|
||||
on conflict (hash) do nothing
|
||||
"#,
|
||||
row.hash,
|
||||
row.signer,
|
||||
row.source,
|
||||
row.status,
|
||||
row.blocks,
|
||||
row.shards,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where this observer has indexed a NEAR transaction, as the kind of
|
||||
/// table it is in: `mint` for a bridge mint, `intents` for anything the
|
||||
/// intents scan read from `intents.near`'s shard. Tells the transaction
|
||||
/// page which shard to ask our nodes about.
|
||||
pub async fn near_tx_seen(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
hash: &str,
|
||||
) -> Result<Option<String>, DataError> {
|
||||
let row = sqlx::query_scalar!(
|
||||
r#"
|
||||
select kind as "kind!" from (
|
||||
select 'mint' as kind from bridge_mint where chain = $1 and tx_hash = $2
|
||||
union all
|
||||
select 'intents' from bridge_withdrawal where chain = $1 and tx_hash = $2
|
||||
union all
|
||||
select 'intents' from intents_transfer where chain = $1 and tx_hash = $2
|
||||
union all
|
||||
select 'intents' from intents_trade where chain = $1 and tx_hash = $2
|
||||
) seen
|
||||
limit 1
|
||||
"#,
|
||||
chain.as_str(),
|
||||
hash,
|
||||
)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Where extrinsics with these hashes are: `(hash, height, index)` for each
|
||||
/// this observer has indexed.
|
||||
pub async fn extrinsics_by_hash(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
hashes: &[String],
|
||||
) -> Result<Vec<(String, u64, u32)>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select hash as "hash!", height, extrinsic_index
|
||||
from chain_extrinsic where chain = $1 and hash = any($2)
|
||||
"#,
|
||||
chain.as_str(),
|
||||
hashes,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.hash,
|
||||
r.height.max(0) as u64,
|
||||
r.extrinsic_index.max(0) as u32,
|
||||
)
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Everything this observer indexed an Intents account doing with `asset`,
|
||||
/// newest first, at most `limit` rows.
|
||||
pub async fn intents_account_history(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
asset: &str,
|
||||
account: &str,
|
||||
limit: i64,
|
||||
) -> Result<Vec<IntentsAccountRow>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select kind as "kind!", tx_hash as "tx_hash!", near_height as "near_height!",
|
||||
at as "at!", amount, other, outgoing as "outgoing!", legs
|
||||
from (
|
||||
select 'bridge_deposit' as kind, tx_hash, near_height, at,
|
||||
amount::text as amount, quantus_tx as other, false as outgoing,
|
||||
null::jsonb as legs
|
||||
from bridge_mint where chain = $1 and account = $3
|
||||
union all
|
||||
select 'bridge_withdrawal', tx_hash, near_height, at,
|
||||
amount::text, destination, true, null::jsonb
|
||||
from bridge_withdrawal where chain = $1 and account = $3
|
||||
union all
|
||||
select 'transfer', tx_hash, near_height, at, amount::text,
|
||||
case when from_account = $3 then to_account else from_account end,
|
||||
from_account = $3, null::jsonb
|
||||
from intents_transfer
|
||||
where chain = $1 and asset = $2 and (from_account = $3 or to_account = $3)
|
||||
union all
|
||||
select 'trade', tx_hash, near_height, at, null, null, false, legs
|
||||
from intents_trade where chain = $1 and asset = $2 and $3 = any(accounts)
|
||||
) history
|
||||
order by near_height desc
|
||||
limit $4
|
||||
"#,
|
||||
chain.as_str(),
|
||||
asset,
|
||||
account,
|
||||
limit,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| IntentsAccountRow {
|
||||
kind: r.kind,
|
||||
tx_hash: r.tx_hash,
|
||||
near_height: r.near_height.max(0) as u64,
|
||||
at: r.at,
|
||||
amount: r.amount,
|
||||
other: r.other,
|
||||
outgoing: r.outgoing,
|
||||
legs: r.legs,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ mod chain;
|
||||
mod error;
|
||||
mod intents;
|
||||
mod miner;
|
||||
mod near;
|
||||
mod reversible;
|
||||
mod roles;
|
||||
mod runtime;
|
||||
@@ -54,6 +55,10 @@ pub use miner::{
|
||||
AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity,
|
||||
NodeIndex, NodeInfo, NodeRow,
|
||||
};
|
||||
pub use near::{
|
||||
IntentsAccount, IntentsAccountEvent, IntentsBalance, NearAction, NearAmount, NearEvent,
|
||||
NearMemo, NearReceipt, NearToken, NearTx, NearTxSource, QuantusTxRef,
|
||||
};
|
||||
pub use reversible::{PendingTransfer, ReversibleState};
|
||||
pub use roles::{AccountRoleEntry, ChainRoles, GenesisDetail, NamedAccount, RoleSource};
|
||||
pub use runtime::{
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
//! A NEAR transaction and an Intents account, as this observer reads them
|
||||
//! (blackbeard/observer#44).
|
||||
//!
|
||||
//! An explorer shows a NEAR transaction's envelope: who signed it and which
|
||||
//! contract it called. What it *did* is in the function-call arguments and the
|
||||
//! receipts' event logs, spread across shards, and that is what these carry —
|
||||
//! decoded, but never summarised away: every log line and every argument is
|
||||
//! still here, so a reader can check any figure against what the chain said.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{BigUintDec, ChainId};
|
||||
|
||||
/// Where a transaction was read from.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export, export_to = "NearTxSource.ts")]
|
||||
pub enum NearTxSource {
|
||||
/// Assembled from the NEAR nodes this observer runs: the transaction from
|
||||
/// the node tracking its signer's shard, each receipt from the node tracking
|
||||
/// its receiver's.
|
||||
Own,
|
||||
/// A public RPC node, for what ours could not serve: a transaction older
|
||||
/// than their history, or a receipt on a shard none of them tracks.
|
||||
Public,
|
||||
}
|
||||
|
||||
/// One NEAR transaction and every receipt it caused.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearTx.ts")]
|
||||
pub struct NearTx {
|
||||
/// Base58 transaction hash.
|
||||
pub hash: String,
|
||||
/// Who signed it.
|
||||
pub signer: String,
|
||||
/// The account it was sent to.
|
||||
pub receiver: String,
|
||||
/// The block it was included in, when known.
|
||||
#[ts(type = "number | null")]
|
||||
pub height: Option<u64>,
|
||||
/// That block's timestamp.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
/// Where it was read from.
|
||||
pub source: NearTxSource,
|
||||
/// Every receipt it spawned has an outcome here. False only while some
|
||||
/// receipt has not executed yet — a transaction read moments after it was
|
||||
/// sent.
|
||||
pub complete: bool,
|
||||
/// Stored permanently: complete, and every block it touched is final, so
|
||||
/// nothing about it can change.
|
||||
pub stored: bool,
|
||||
/// The transaction's own result: true unless its first receipt failed.
|
||||
/// A later receipt can fail while this stays true — that is how a refund
|
||||
/// happens — so each receipt carries its own.
|
||||
pub succeeded: bool,
|
||||
/// The failure, when the transaction's own receipt failed.
|
||||
pub failure: Option<String>,
|
||||
/// Every receipt, depth first from the transaction's own, so a child always
|
||||
/// follows its parent.
|
||||
pub receipts: Vec<NearReceipt>,
|
||||
/// Display facts for every token an amount here names.
|
||||
pub tokens: Vec<NearToken>,
|
||||
/// Quantus transactions named in a `BRIDGED_FROM` memo, resolved to where
|
||||
/// this observer has them.
|
||||
pub quantus_txs: Vec<QuantusTxRef>,
|
||||
}
|
||||
|
||||
/// One receipt: one contract call or transfer, executed on one shard.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearReceipt.ts")]
|
||||
pub struct NearReceipt {
|
||||
/// Base58 receipt id.
|
||||
pub id: String,
|
||||
/// The receipt whose execution created this one; absent for the
|
||||
/// transaction's own.
|
||||
pub parent: Option<String>,
|
||||
/// How many receipts lie between this one and the transaction.
|
||||
pub depth: u32,
|
||||
/// The account that sent it.
|
||||
pub predecessor: String,
|
||||
/// The account that executed it.
|
||||
pub receiver: String,
|
||||
/// The block it executed in.
|
||||
#[ts(type = "number | null")]
|
||||
pub height: Option<u64>,
|
||||
/// That block's timestamp.
|
||||
pub at: Option<DateTime<Utc>>,
|
||||
/// The shard it executed on, from the shard layout at that block.
|
||||
#[ts(type = "number | null")]
|
||||
pub shard: Option<u64>,
|
||||
/// Whether it succeeded. A failed receipt's state changes were rolled back,
|
||||
/// but it still reports the logs it wrote before failing, so a reader must
|
||||
/// check this before believing any event below.
|
||||
pub succeeded: bool,
|
||||
/// What failed, as the runtime words it.
|
||||
pub failure: Option<String>,
|
||||
/// The protocol returning unused gas to the signer: sent by `system`, a
|
||||
/// plain transfer, and the one kind of receipt a reader can skip.
|
||||
pub gas_refund: bool,
|
||||
/// Gas it burnt.
|
||||
#[ts(type = "number")]
|
||||
pub gas_burnt: u64,
|
||||
/// What that gas cost, in yoctoNEAR.
|
||||
pub tokens_burnt: BigUintDec,
|
||||
/// What it asked the receiver to do.
|
||||
pub actions: Vec<NearAction>,
|
||||
/// Events it logged in the `EVENT_JSON:` format.
|
||||
pub events: Vec<NearEvent>,
|
||||
/// Every other log line, verbatim.
|
||||
pub logs: Vec<String>,
|
||||
}
|
||||
|
||||
/// One action in a receipt.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearAction.ts")]
|
||||
pub struct NearAction {
|
||||
/// `FunctionCall`, `Transfer`, `CreateAccount` and so on, as the RPC names
|
||||
/// it.
|
||||
pub kind: String,
|
||||
/// The method, for a function call.
|
||||
pub method: Option<String>,
|
||||
/// The arguments, when they decode as JSON. Most do; a contract is free to
|
||||
/// take bytes instead.
|
||||
#[ts(type = "unknown")]
|
||||
pub args: Option<serde_json::Value>,
|
||||
/// How many argument bytes there were, when they were not JSON.
|
||||
pub args_bytes: Option<u32>,
|
||||
/// NEAR attached, in yoctoNEAR.
|
||||
pub deposit: Option<BigUintDec>,
|
||||
/// Memos in the arguments this observer knows the format of.
|
||||
pub memos: Vec<NearMemo>,
|
||||
}
|
||||
|
||||
/// One `EVENT_JSON:` log line.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearEvent.ts")]
|
||||
pub struct NearEvent {
|
||||
/// `nep141`, `nep245`, `dip4`, `dcl.ref`…
|
||||
pub standard: String,
|
||||
/// The standard's version, as logged.
|
||||
pub version: String,
|
||||
/// `ft_mint`, `mt_burn`, `token_diff`…
|
||||
pub event: String,
|
||||
/// The payload as logged.
|
||||
#[ts(type = "unknown")]
|
||||
pub data: serde_json::Value,
|
||||
/// Every amount the payload names, with its token: the part the UI shows in
|
||||
/// token units. The payload above is still the source.
|
||||
pub amounts: Vec<NearAmount>,
|
||||
/// NEAR Intents accounts the event names, when it was logged by the Intents
|
||||
/// contract — each has a page.
|
||||
pub intents_accounts: Vec<String>,
|
||||
/// Memos in the payload this observer knows the format of.
|
||||
pub memos: Vec<NearMemo>,
|
||||
}
|
||||
|
||||
/// An amount of one token, in its base units.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearAmount.ts")]
|
||||
pub struct NearAmount {
|
||||
/// The token, as NEAR Intents names it: `nep141:<contract>`.
|
||||
pub token: String,
|
||||
/// Base units, as a decimal string. Signed: a `token_diff` leg is negative
|
||||
/// on the side that gives.
|
||||
pub amount: String,
|
||||
/// The account it belongs to, moves to or leaves, when the event says.
|
||||
pub account: Option<String>,
|
||||
}
|
||||
|
||||
/// A memo in a format this observer reads.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearMemo.ts")]
|
||||
pub struct NearMemo {
|
||||
/// `bridged_from` (a bridge mint naming its source transaction),
|
||||
/// `withdraw_to` (a withdrawal naming its destination) or `refund`.
|
||||
pub kind: String,
|
||||
/// The memo as written.
|
||||
pub text: String,
|
||||
/// The source transaction's hash, for `bridged_from`.
|
||||
pub source_tx: Option<String>,
|
||||
/// The source chain, as the bridge names it (`qtc`, `eth`…).
|
||||
pub source_network: Option<String>,
|
||||
/// The destination address, for `withdraw_to`.
|
||||
pub address: Option<String>,
|
||||
}
|
||||
|
||||
/// A token's display facts.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "NearToken.ts")]
|
||||
pub struct NearToken {
|
||||
/// `nep141:<contract>`, or `near` for the native token.
|
||||
pub token: String,
|
||||
/// The token's own `ft_metadata.symbol`. A label, never an identity:
|
||||
/// anyone can deploy a token calling itself USDC.
|
||||
pub symbol: String,
|
||||
/// Decimal places in its base unit.
|
||||
pub decimals: u8,
|
||||
}
|
||||
|
||||
/// A Quantus transaction a bridge memo names.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "QuantusTxRef.ts")]
|
||||
pub struct QuantusTxRef {
|
||||
/// The chain this observer has it on.
|
||||
pub chain: ChainId,
|
||||
/// Its hash, as the memo gives it.
|
||||
pub hash: String,
|
||||
/// The block it is in, when this observer has indexed it.
|
||||
#[ts(type = "number | null")]
|
||||
pub height: Option<u64>,
|
||||
/// Its index in that block.
|
||||
pub extrinsic_index: Option<u32>,
|
||||
}
|
||||
|
||||
/// One NEAR Intents account: what it holds now, and what this observer has
|
||||
/// indexed of its history.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsAccount.ts")]
|
||||
pub struct IntentsAccount {
|
||||
/// Which chain's token this observer follows it for.
|
||||
pub chain: ChainId,
|
||||
/// The account id.
|
||||
pub account: String,
|
||||
/// `implicit_ed25519` (64 hex characters: the account *is* a public key),
|
||||
/// `implicit_eth` (`0x` and 40 hex: an Ethereum address), or `named`.
|
||||
pub kind: String,
|
||||
/// Keys registered with the Intents contract for signing intents, from
|
||||
/// `public_keys_of`. Not the account's NEAR access keys, which are a
|
||||
/// separate thing an implicit account may never have created.
|
||||
pub public_keys: Vec<String>,
|
||||
/// Every token it holds inside `intents.near`, read now. Absent when the
|
||||
/// contract could not be asked.
|
||||
pub balances: Option<Vec<IntentsBalance>>,
|
||||
/// When the balances were read.
|
||||
pub balances_at: DateTime<Utc>,
|
||||
/// Display facts for every token named here.
|
||||
pub tokens: Vec<NearToken>,
|
||||
/// This observer's index of it, newest first: only what involves the
|
||||
/// followed token, never the account's whole history.
|
||||
pub history: Vec<IntentsAccountEvent>,
|
||||
/// The followed token, as NEAR Intents names it.
|
||||
pub asset: String,
|
||||
}
|
||||
|
||||
/// One token balance inside the Intents contract.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsBalance.ts")]
|
||||
pub struct IntentsBalance {
|
||||
/// The token, as NEAR Intents names it.
|
||||
pub token: String,
|
||||
/// Base units.
|
||||
pub amount: BigUintDec,
|
||||
}
|
||||
|
||||
/// One thing this observer indexed an Intents account doing.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsAccountEvent.ts")]
|
||||
pub struct IntentsAccountEvent {
|
||||
/// `bridge_deposit`, `bridge_withdrawal`, `trade`, `transfer_in` or
|
||||
/// `transfer_out`.
|
||||
pub kind: String,
|
||||
/// The NEAR transaction, which has a page.
|
||||
pub tx_hash: String,
|
||||
/// The NEAR block.
|
||||
#[ts(type = "number")]
|
||||
pub near_height: u64,
|
||||
/// When.
|
||||
pub at: DateTime<Utc>,
|
||||
/// Every amount it moved for this account, signed from the account's side:
|
||||
/// positive in, negative out.
|
||||
pub amounts: Vec<NearAmount>,
|
||||
/// Who was on the other side, when there was one account: the sender or
|
||||
/// recipient of a transfer.
|
||||
pub counterparty: Option<String>,
|
||||
/// A Quantus address: where a withdrawal was sent, or where a deposit came
|
||||
/// from.
|
||||
pub quantus_address: Option<String>,
|
||||
/// The Quantus transaction a deposit was minted against.
|
||||
pub quantus_tx: Option<String>,
|
||||
/// The block that transaction is in, when this observer has indexed it.
|
||||
#[ts(type = "number | null")]
|
||||
pub quantus_height: Option<u64>,
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import { MinerPanel } from './components/MinerPanel'
|
||||
import { RuntimePanel } from './components/RuntimePanel'
|
||||
import { NetworkPanel } from './components/NetworkPanel'
|
||||
import { WormholePanel } from './components/WormholePanel'
|
||||
import { IntentsAccountPanel } from './components/IntentsAccountPanel'
|
||||
import { NearTxPanel } from './components/NearTxPanel'
|
||||
import { NodeRoute } from './components/NodeRoute'
|
||||
import { NodesIndex } from './components/NodesIndex'
|
||||
import { MarketPage } from './components/MarketPage'
|
||||
@@ -337,6 +339,10 @@ export default function App() {
|
||||
/>
|
||||
)}
|
||||
{route.index === 'market' && chain && <MarketsIndex chain={chain} />}
|
||||
{route.nearTx && chain && <NearTxPanel chain={chain} hash={route.nearTx} />}
|
||||
{route.intentsAccount && chain && (
|
||||
<IntentsAccountPanel chain={chain} account={route.intentsAccount} />
|
||||
)}
|
||||
{route.market && chain && (
|
||||
<MarketPage
|
||||
chain={chain}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { IntentsAccountEvent } from "./IntentsAccountEvent";
|
||||
import type { IntentsBalance } from "./IntentsBalance";
|
||||
import type { NearToken } from "./NearToken";
|
||||
|
||||
/**
|
||||
* One NEAR Intents account: what it holds now, and what this observer has
|
||||
* indexed of its history.
|
||||
*/
|
||||
export type IntentsAccount = {
|
||||
/**
|
||||
* Which chain's token this observer follows it for.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The account id.
|
||||
*/
|
||||
account: string,
|
||||
/**
|
||||
* `implicit_ed25519` (64 hex characters: the account *is* a public key),
|
||||
* `implicit_eth` (`0x` and 40 hex: an Ethereum address), or `named`.
|
||||
*/
|
||||
kind: string,
|
||||
/**
|
||||
* Keys registered with the Intents contract for signing intents, from
|
||||
* `public_keys_of`. Not the account's NEAR access keys, which are a
|
||||
* separate thing an implicit account may never have created.
|
||||
*/
|
||||
public_keys: Array<string>,
|
||||
/**
|
||||
* Every token it holds inside `intents.near`, read now. Absent when the
|
||||
* contract could not be asked.
|
||||
*/
|
||||
balances: Array<IntentsBalance> | null,
|
||||
/**
|
||||
* When the balances were read.
|
||||
*/
|
||||
balances_at: string,
|
||||
/**
|
||||
* Display facts for every token named here.
|
||||
*/
|
||||
tokens: Array<NearToken>,
|
||||
/**
|
||||
* This observer's index of it, newest first: only what involves the
|
||||
* followed token, never the account's whole history.
|
||||
*/
|
||||
history: Array<IntentsAccountEvent>,
|
||||
/**
|
||||
* The followed token, as NEAR Intents names it.
|
||||
*/
|
||||
asset: string, };
|
||||
@@ -0,0 +1,47 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NearAmount } from "./NearAmount";
|
||||
|
||||
/**
|
||||
* One thing this observer indexed an Intents account doing.
|
||||
*/
|
||||
export type IntentsAccountEvent = {
|
||||
/**
|
||||
* `bridge_deposit`, `bridge_withdrawal`, `trade`, `transfer_in` or
|
||||
* `transfer_out`.
|
||||
*/
|
||||
kind: string,
|
||||
/**
|
||||
* The NEAR transaction, which has a page.
|
||||
*/
|
||||
tx_hash: string,
|
||||
/**
|
||||
* The NEAR block.
|
||||
*/
|
||||
near_height: number,
|
||||
/**
|
||||
* When.
|
||||
*/
|
||||
at: string,
|
||||
/**
|
||||
* Every amount it moved for this account, signed from the account's side:
|
||||
* positive in, negative out.
|
||||
*/
|
||||
amounts: Array<NearAmount>,
|
||||
/**
|
||||
* Who was on the other side, when there was one account: the sender or
|
||||
* recipient of a transfer.
|
||||
*/
|
||||
counterparty: string | null,
|
||||
/**
|
||||
* A Quantus address: where a withdrawal was sent, or where a deposit came
|
||||
* from.
|
||||
*/
|
||||
quantus_address: string | null,
|
||||
/**
|
||||
* The Quantus transaction a deposit was minted against.
|
||||
*/
|
||||
quantus_tx: string | null,
|
||||
/**
|
||||
* The block that transaction is in, when this observer has indexed it.
|
||||
*/
|
||||
quantus_height: number | null, };
|
||||
@@ -0,0 +1,15 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
|
||||
/**
|
||||
* One token balance inside the Intents contract.
|
||||
*/
|
||||
export type IntentsBalance = {
|
||||
/**
|
||||
* The token, as NEAR Intents names it.
|
||||
*/
|
||||
token: string,
|
||||
/**
|
||||
* Base units.
|
||||
*/
|
||||
amount: BigUintDec, };
|
||||
@@ -0,0 +1,34 @@
|
||||
// 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 { NearMemo } from "./NearMemo";
|
||||
|
||||
/**
|
||||
* One action in a receipt.
|
||||
*/
|
||||
export type NearAction = {
|
||||
/**
|
||||
* `FunctionCall`, `Transfer`, `CreateAccount` and so on, as the RPC names
|
||||
* it.
|
||||
*/
|
||||
kind: string,
|
||||
/**
|
||||
* The method, for a function call.
|
||||
*/
|
||||
method: string | null,
|
||||
/**
|
||||
* The arguments, when they decode as JSON. Most do; a contract is free to
|
||||
* take bytes instead.
|
||||
*/
|
||||
args: unknown,
|
||||
/**
|
||||
* How many argument bytes there were, when they were not JSON.
|
||||
*/
|
||||
args_bytes: number | null,
|
||||
/**
|
||||
* NEAR attached, in yoctoNEAR.
|
||||
*/
|
||||
deposit: BigUintDec | null,
|
||||
/**
|
||||
* Memos in the arguments this observer knows the format of.
|
||||
*/
|
||||
memos: Array<NearMemo>, };
|
||||
@@ -0,0 +1,19 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* An amount of one token, in its base units.
|
||||
*/
|
||||
export type NearAmount = {
|
||||
/**
|
||||
* The token, as NEAR Intents names it: `nep141:<contract>`.
|
||||
*/
|
||||
token: string,
|
||||
/**
|
||||
* Base units, as a decimal string. Signed: a `token_diff` leg is negative
|
||||
* on the side that gives.
|
||||
*/
|
||||
amount: string,
|
||||
/**
|
||||
* The account it belongs to, moves to or leaves, when the event says.
|
||||
*/
|
||||
account: string | null, };
|
||||
@@ -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 { NearAmount } from "./NearAmount";
|
||||
import type { NearMemo } from "./NearMemo";
|
||||
|
||||
/**
|
||||
* One `EVENT_JSON:` log line.
|
||||
*/
|
||||
export type NearEvent = {
|
||||
/**
|
||||
* `nep141`, `nep245`, `dip4`, `dcl.ref`…
|
||||
*/
|
||||
standard: string,
|
||||
/**
|
||||
* The standard's version, as logged.
|
||||
*/
|
||||
version: string,
|
||||
/**
|
||||
* `ft_mint`, `mt_burn`, `token_diff`…
|
||||
*/
|
||||
event: string,
|
||||
/**
|
||||
* The payload as logged.
|
||||
*/
|
||||
data: unknown,
|
||||
/**
|
||||
* Every amount the payload names, with its token: the part the UI shows in
|
||||
* token units. The payload above is still the source.
|
||||
*/
|
||||
amounts: Array<NearAmount>,
|
||||
/**
|
||||
* NEAR Intents accounts the event names, when it was logged by the Intents
|
||||
* contract — each has a page.
|
||||
*/
|
||||
intents_accounts: Array<string>,
|
||||
/**
|
||||
* Memos in the payload this observer knows the format of.
|
||||
*/
|
||||
memos: Array<NearMemo>, };
|
||||
@@ -0,0 +1,27 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A memo in a format this observer reads.
|
||||
*/
|
||||
export type NearMemo = {
|
||||
/**
|
||||
* `bridged_from` (a bridge mint naming its source transaction),
|
||||
* `withdraw_to` (a withdrawal naming its destination) or `refund`.
|
||||
*/
|
||||
kind: string,
|
||||
/**
|
||||
* The memo as written.
|
||||
*/
|
||||
text: string,
|
||||
/**
|
||||
* The source transaction's hash, for `bridged_from`.
|
||||
*/
|
||||
source_tx: string | null,
|
||||
/**
|
||||
* The source chain, as the bridge names it (`qtc`, `eth`…).
|
||||
*/
|
||||
source_network: string | null,
|
||||
/**
|
||||
* The destination address, for `withdraw_to`.
|
||||
*/
|
||||
address: string | null, };
|
||||
@@ -0,0 +1,77 @@
|
||||
// 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 { NearAction } from "./NearAction";
|
||||
import type { NearEvent } from "./NearEvent";
|
||||
|
||||
/**
|
||||
* One receipt: one contract call or transfer, executed on one shard.
|
||||
*/
|
||||
export type NearReceipt = {
|
||||
/**
|
||||
* Base58 receipt id.
|
||||
*/
|
||||
id: string,
|
||||
/**
|
||||
* The receipt whose execution created this one; absent for the
|
||||
* transaction's own.
|
||||
*/
|
||||
parent: string | null,
|
||||
/**
|
||||
* How many receipts lie between this one and the transaction.
|
||||
*/
|
||||
depth: number,
|
||||
/**
|
||||
* The account that sent it.
|
||||
*/
|
||||
predecessor: string,
|
||||
/**
|
||||
* The account that executed it.
|
||||
*/
|
||||
receiver: string,
|
||||
/**
|
||||
* The block it executed in.
|
||||
*/
|
||||
height: number | null,
|
||||
/**
|
||||
* That block's timestamp.
|
||||
*/
|
||||
at: string | null,
|
||||
/**
|
||||
* The shard it executed on, from the shard layout at that block.
|
||||
*/
|
||||
shard: number | null,
|
||||
/**
|
||||
* Whether it succeeded. A failed receipt's state changes were rolled back,
|
||||
* but it still reports the logs it wrote before failing, so a reader must
|
||||
* check this before believing any event below.
|
||||
*/
|
||||
succeeded: boolean,
|
||||
/**
|
||||
* What failed, as the runtime words it.
|
||||
*/
|
||||
failure: string | null,
|
||||
/**
|
||||
* The protocol returning unused gas to the signer: sent by `system`, a
|
||||
* plain transfer, and the one kind of receipt a reader can skip.
|
||||
*/
|
||||
gas_refund: boolean,
|
||||
/**
|
||||
* Gas it burnt.
|
||||
*/
|
||||
gas_burnt: number,
|
||||
/**
|
||||
* What that gas cost, in yoctoNEAR.
|
||||
*/
|
||||
tokens_burnt: BigUintDec,
|
||||
/**
|
||||
* What it asked the receiver to do.
|
||||
*/
|
||||
actions: Array<NearAction>,
|
||||
/**
|
||||
* Events it logged in the `EVENT_JSON:` format.
|
||||
*/
|
||||
events: Array<NearEvent>,
|
||||
/**
|
||||
* Every other log line, verbatim.
|
||||
*/
|
||||
logs: Array<string>, };
|
||||
@@ -0,0 +1,19 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* A token's display facts.
|
||||
*/
|
||||
export type NearToken = {
|
||||
/**
|
||||
* `nep141:<contract>`, or `near` for the native token.
|
||||
*/
|
||||
token: string,
|
||||
/**
|
||||
* The token's own `ft_metadata.symbol`. A label, never an identity:
|
||||
* anyone can deploy a token calling itself USDC.
|
||||
*/
|
||||
symbol: string,
|
||||
/**
|
||||
* Decimal places in its base unit.
|
||||
*/
|
||||
decimals: number, };
|
||||
@@ -0,0 +1,69 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { NearReceipt } from "./NearReceipt";
|
||||
import type { NearToken } from "./NearToken";
|
||||
import type { NearTxSource } from "./NearTxSource";
|
||||
import type { QuantusTxRef } from "./QuantusTxRef";
|
||||
|
||||
/**
|
||||
* One NEAR transaction and every receipt it caused.
|
||||
*/
|
||||
export type NearTx = {
|
||||
/**
|
||||
* Base58 transaction hash.
|
||||
*/
|
||||
hash: string,
|
||||
/**
|
||||
* Who signed it.
|
||||
*/
|
||||
signer: string,
|
||||
/**
|
||||
* The account it was sent to.
|
||||
*/
|
||||
receiver: string,
|
||||
/**
|
||||
* The block it was included in, when known.
|
||||
*/
|
||||
height: number | null,
|
||||
/**
|
||||
* That block's timestamp.
|
||||
*/
|
||||
at: string | null,
|
||||
/**
|
||||
* Where it was read from.
|
||||
*/
|
||||
source: NearTxSource,
|
||||
/**
|
||||
* Every receipt it spawned has an outcome here. False only while some
|
||||
* receipt has not executed yet — a transaction read moments after it was
|
||||
* sent.
|
||||
*/
|
||||
complete: boolean,
|
||||
/**
|
||||
* Stored permanently: complete, and every block it touched is final, so
|
||||
* nothing about it can change.
|
||||
*/
|
||||
stored: boolean,
|
||||
/**
|
||||
* The transaction's own result: true unless its first receipt failed.
|
||||
* A later receipt can fail while this stays true — that is how a refund
|
||||
* happens — so each receipt carries its own.
|
||||
*/
|
||||
succeeded: boolean,
|
||||
/**
|
||||
* The failure, when the transaction's own receipt failed.
|
||||
*/
|
||||
failure: string | null,
|
||||
/**
|
||||
* Every receipt, depth first from the transaction's own, so a child always
|
||||
* follows its parent.
|
||||
*/
|
||||
receipts: Array<NearReceipt>,
|
||||
/**
|
||||
* Display facts for every token an amount here names.
|
||||
*/
|
||||
tokens: Array<NearToken>,
|
||||
/**
|
||||
* Quantus transactions named in a `BRIDGED_FROM` memo, resolved to where
|
||||
* this observer has them.
|
||||
*/
|
||||
quantus_txs: Array<QuantusTxRef>, };
|
||||
@@ -0,0 +1,6 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
/**
|
||||
* Where a transaction was read from.
|
||||
*/
|
||||
export type NearTxSource = "own" | "public";
|
||||
@@ -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 { ChainId } from "./ChainId";
|
||||
|
||||
/**
|
||||
* A Quantus transaction a bridge memo names.
|
||||
*/
|
||||
export type QuantusTxRef = {
|
||||
/**
|
||||
* The chain this observer has it on.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* Its hash, as the memo gives it.
|
||||
*/
|
||||
hash: string,
|
||||
/**
|
||||
* The block it is in, when this observer has indexed it.
|
||||
*/
|
||||
height: number | null,
|
||||
/**
|
||||
* Its index in that block.
|
||||
*/
|
||||
extrinsic_index: number | null, };
|
||||
@@ -28,7 +28,9 @@ import type { IntentsInventory } from './generated/IntentsInventory'
|
||||
import type { IntentsPrice } from './generated/IntentsPrice'
|
||||
import type { IntentsSeries } from './generated/IntentsSeries'
|
||||
import type { IntentsSwaps } from './generated/IntentsSwaps'
|
||||
import type { IntentsAccount } from './generated/IntentsAccount'
|
||||
import type { Markets } from './generated/Markets'
|
||||
import type { NearTx } from './generated/NearTx'
|
||||
import type { RefPools } from './generated/RefPools'
|
||||
import type { RefSwaps } from './generated/RefSwaps'
|
||||
import type { ReversibleState } from './generated/ReversibleState'
|
||||
@@ -431,3 +433,25 @@ export async function fetchRefSwaps(
|
||||
const cursor = before ? `?before=${encodeURIComponent(before)}` : ''
|
||||
return get<RefSwaps>(`/chains/${chain}/ref/swaps${cursor}`, signal)
|
||||
}
|
||||
|
||||
/**
|
||||
* One NEAR transaction, every receipt decoded (blackbeard/observer#44). The
|
||||
* first read of one assembles it from NEAR nodes and can take a few seconds;
|
||||
* after that it is kept and answers at once.
|
||||
*/
|
||||
export async function fetchNearTx(
|
||||
chain: string,
|
||||
hash: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<NearTx> {
|
||||
return get<NearTx>(`/chains/${chain}/near/tx/${encodeURIComponent(hash)}`, signal)
|
||||
}
|
||||
|
||||
/** One NEAR Intents account: balances now, and this observer's index of it. */
|
||||
export async function fetchIntentsAccount(
|
||||
chain: string,
|
||||
account: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentsAccount> {
|
||||
return get<IntentsAccount>(`/chains/${chain}/near/intents/${encodeURIComponent(account)}`, signal)
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ import { RequestFailed, fetchBridgeDeposits, fetchBridgeWithdrawals } from '../a
|
||||
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
|
||||
|
||||
@@ -116,14 +115,9 @@ export function BridgeLinksPanel({ chain, symbol }: { chain: string; symbol: str
|
||||
<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)}
|
||||
>
|
||||
<Link to={href({ chain, nearTx: x.mint.near_tx })} title={utc(x.mint.at)}>
|
||||
{x.mint.near_tx.slice(0, 8)}…
|
||||
</a>
|
||||
</Link>
|
||||
) : (
|
||||
<span className="panel-note" style={{ margin: 0 }}>
|
||||
not linked
|
||||
@@ -131,7 +125,13 @@ export function BridgeLinksPanel({ chain, symbol }: { chain: string; symbol: str
|
||||
)}
|
||||
</td>
|
||||
<td className="left" title={x.mint?.account}>
|
||||
{x.mint ? account(x.mint.account) : '—'}
|
||||
{x.mint ? (
|
||||
<Link to={href({ chain, intentsAccount: x.mint.account })}>
|
||||
{account(x.mint.account)}
|
||||
</Link>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="numeral">{minutes(x.seconds_to_mint)}</td>
|
||||
</tr>
|
||||
@@ -167,12 +167,12 @@ export function BridgeLinksPanel({ chain, symbol }: { chain: string; symbol: str
|
||||
{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>
|
||||
<Link to={href({ chain, nearTx: w.near_tx })}>{when(w.at)}</Link>
|
||||
</td>
|
||||
<td className="left" title={w.account}>
|
||||
{account(w.account)}
|
||||
<Link to={href({ chain, intentsAccount: w.account })}>
|
||||
{account(w.account)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="numeral">{amount(w.amount)}</td>
|
||||
<td className="left" title={w.destination}>
|
||||
|
||||
@@ -20,7 +20,6 @@ import { useObserverInstance } from '../lib/observer-context'
|
||||
import { href } from '../lib/routes'
|
||||
import { Field } from './Field'
|
||||
|
||||
const EXPLORER = 'https://nearblocks.io/txns/'
|
||||
const LIVE_LINES = 12
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
@@ -115,12 +114,12 @@ export function FillsPanel({ chain, symbol }: { chain: string; symbol: string })
|
||||
<td className="numeral">{tokens(f.amount, d)}</td>
|
||||
<td className="left">{f.solver}</td>
|
||||
<td className="left" title={f.account}>
|
||||
{account(f.account)}
|
||||
<Link to={href({ chain, intentsAccount: f.account })}>{account(f.account)}</Link>
|
||||
</td>
|
||||
<td className="left">
|
||||
<a href={`${EXPLORER}${f.tx_hash}`} target="_blank" rel="noreferrer">
|
||||
<Link to={href({ chain, nearTx: f.tx_hash })} title={f.tx_hash}>
|
||||
{f.tx_hash.slice(0, 8)}…
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* One NEAR Intents account (blackbeard/observer#44).
|
||||
*
|
||||
* Two kinds of figure, and the page keeps them apart. The balances are read
|
||||
* from `intents.near` now and are complete: every token the account holds
|
||||
* there. The history is this observer's index, which follows one token, so it
|
||||
* lists what the account did with that token and nothing else — a page that
|
||||
* looked like a whole history and was not would be the worse failure.
|
||||
*
|
||||
* It says what the chain links and no more. A bridge deposit names the Quantus
|
||||
* transaction it was minted against because the bridge wrote that hash into
|
||||
* the mint; a withdrawal names the Quantus address because the withdrawal
|
||||
* memo does. Who is behind the account is not something either chain records,
|
||||
* and the page does not guess.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { IntentsAccount } from '../api/generated/IntentsAccount'
|
||||
import type { IntentsAccountEvent } from '../api/generated/IntentsAccountEvent'
|
||||
import { RequestFailed, fetchIntentsAccount } from '../api/rest'
|
||||
import { utc, when } from '../lib/format'
|
||||
import { accountKindText, amountText, amountTitle, shortNear } from '../lib/near'
|
||||
import { href } from '../lib/routes'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
import { Field } from './Field'
|
||||
|
||||
const EXPLORER = 'https://nearblocks.io/address/'
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
const KIND: Record<string, string> = {
|
||||
bridge_deposit: 'bridged in from Quantus',
|
||||
bridge_withdrawal: 'withdrawn to Quantus',
|
||||
trade: 'trade',
|
||||
transfer_in: 'received',
|
||||
transfer_out: 'sent',
|
||||
}
|
||||
|
||||
export function IntentsAccountPanel({ chain, account }: { chain: string; account: string }) {
|
||||
const [data, setData] = useState<IntentsAccount | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setData(null)
|
||||
setError(null)
|
||||
const load = () =>
|
||||
fetchIntentsAccount(chain, account, controller.signal)
|
||||
.then((d) => {
|
||||
setData(d)
|
||||
setError(null)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
load()
|
||||
const id = window.setInterval(load, REFRESH_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain, account])
|
||||
|
||||
const asset = data
|
||||
? tokenLabel(data.asset, data.tokens.find((t) => t.token === data.asset)?.symbol)
|
||||
: 'the token'
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">NEAR Intents account</h2>
|
||||
<span className="eyebrow mono-wrap" title={account}>
|
||||
{account}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !data && <p className="empty">Reading the account…</p>}
|
||||
|
||||
{data && (
|
||||
<>
|
||||
<div className="node-grid fit">
|
||||
<Field label="Account" value={shortNear(data.account)} title={data.account} />
|
||||
<Field label="Kind" value={accountKindText(data.kind)} />
|
||||
<Field
|
||||
label="Intent signing keys"
|
||||
value={String(data.public_keys.length)}
|
||||
note="registered with intents.near"
|
||||
title={data.public_keys.join('\n') || undefined}
|
||||
/>
|
||||
<Field
|
||||
label="Tokens held"
|
||||
value={data.balances === null ? '—' : String(data.balances.length)}
|
||||
note={`inside intents.near, read ${when(data.balances_at)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{data.balances === null && (
|
||||
<p className="panel-note">
|
||||
The Intents contract could not be asked for balances just now.
|
||||
</p>
|
||||
)}
|
||||
{data.balances !== null && data.balances.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Balances inside intents.near</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Token
|
||||
</th>
|
||||
<th scope="col">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.balances.map((b) => (
|
||||
<tr key={b.token}>
|
||||
<td className="left" title={b.token}>
|
||||
{tokenLabel(b.token, data.tokens.find((t) => t.token === b.token)?.symbol)}
|
||||
</td>
|
||||
<td className="numeral" title={amountTitle(b.amount, b.token, data.tokens)}>
|
||||
{amountText(b.amount, b.token, data.tokens)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="panel-head" style={{ borderTop: 'var(--rule)' }}>
|
||||
<h3 className="panel-title">{asset} activity</h3>
|
||||
<span className="eyebrow">
|
||||
{data.history.length
|
||||
? `${data.history.length} indexed, newest first`
|
||||
: 'none indexed'}
|
||||
</span>
|
||||
</div>
|
||||
{data.history.length > 0 && (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">
|
||||
What this observer indexed the account doing with {asset}
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
When
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
What
|
||||
</th>
|
||||
<th scope="col">Amount</th>
|
||||
<th scope="col" className="left">
|
||||
Other side
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
NEAR tx
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.history.map((h) => (
|
||||
<tr key={`${h.tx_hash}-${h.kind}-${h.amounts.map((a) => a.amount).join()}`}>
|
||||
<td className="left numeral event-when" title={utc(h.at)}>
|
||||
{when(h.at)}
|
||||
</td>
|
||||
<td className="left">{KIND[h.kind] ?? h.kind}</td>
|
||||
<td className="numeral">
|
||||
{h.amounts.map((a, i) => (
|
||||
<div key={i} title={amountTitle(a.amount, a.token, data.tokens)}>
|
||||
{amountText(a.amount, a.token, data.tokens)}
|
||||
</div>
|
||||
))}
|
||||
</td>
|
||||
<td className="left">
|
||||
<OtherSide chain={chain} event={h} />
|
||||
</td>
|
||||
<td className="left">
|
||||
<Link to={href({ chain, nearTx: h.tx_hash })} title={h.tx_hash}>
|
||||
{h.tx_hash.slice(0, 8)}…
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="panel-note">
|
||||
Balances are every token the account holds inside intents.near, read now. The activity
|
||||
is only what this observer indexes — {asset} bridged in and out, traded and transferred
|
||||
on NEAR Intents — and not the account's whole history, which spans tokens this site
|
||||
does not follow. Nothing here says who is behind the account: neither chain records
|
||||
that. Also on{' '}
|
||||
<a href={`${EXPLORER}${data.account}`} target="_blank" rel="noreferrer">
|
||||
nearblocks
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function OtherSide({ chain, event: h }: { chain: string; event: IntentsAccountEvent }) {
|
||||
if (h.quantus_tx) {
|
||||
const label = `Quantus tx ${h.quantus_tx.slice(0, 10)}…`
|
||||
return h.quantus_height !== null ? (
|
||||
<Link to={href({ chain, block: String(h.quantus_height) })} title={h.quantus_tx}>
|
||||
{label}
|
||||
</Link>
|
||||
) : (
|
||||
<span title={h.quantus_tx}>{label}</span>
|
||||
)
|
||||
}
|
||||
if (h.quantus_address) {
|
||||
return (
|
||||
<Link to={href({ chain, account: h.quantus_address })} title={h.quantus_address}>
|
||||
{shortNear(h.quantus_address)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
if (h.counterparty) {
|
||||
return (
|
||||
<Link to={href({ chain, intentsAccount: h.counterparty })} title={h.counterparty}>
|
||||
{shortNear(h.counterparty)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return <>—</>
|
||||
}
|
||||
@@ -195,6 +195,7 @@ function NearIntents({
|
||||
<tbody>
|
||||
{live.map(({ key, swap }) => (
|
||||
<SwapRow
|
||||
chain={chain}
|
||||
key={key}
|
||||
swap={swap}
|
||||
asset={asset}
|
||||
@@ -230,6 +231,7 @@ function NearIntents({
|
||||
<tbody>
|
||||
{rows.map((swap) => (
|
||||
<SwapRow
|
||||
chain={chain}
|
||||
key={`${swap.near_height}:${swap.trade_index}`}
|
||||
swap={swap}
|
||||
asset={asset}
|
||||
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* One NEAR transaction, every receipt decoded (blackbeard/observer#44).
|
||||
*
|
||||
* An explorer shows the envelope — `bridge-mng.near` called `omft.near` — and
|
||||
* stops. What the call *did* is spread over a dozen receipts on four shards, in
|
||||
* base64 arguments and event logs, and that is what this page lays out: the
|
||||
* receipt tree in execution order, each call's arguments, each event with its
|
||||
* amounts in token units, and the bridge's memos read and linked back to this
|
||||
* chain.
|
||||
*
|
||||
* Nothing is summarised away. A failed receipt is shown failed with the
|
||||
* runtime's own words, and its logs are still listed — a failed receipt keeps
|
||||
* them, which is exactly why "succeeded" is the first thing to check.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { NearEvent } from '../api/generated/NearEvent'
|
||||
import type { NearMemo } from '../api/generated/NearMemo'
|
||||
import type { NearReceipt } from '../api/generated/NearReceipt'
|
||||
import type { NearTx } from '../api/generated/NearTx'
|
||||
import { RequestFailed, fetchNearTx } from '../api/rest'
|
||||
import { height as fmtHeight, utc, when } from '../lib/format'
|
||||
import { amountText, amountTitle, nearText, shortNear } from '../lib/near'
|
||||
import { href } from '../lib/routes'
|
||||
import { Field } from './Field'
|
||||
|
||||
const EXPLORER = 'https://nearblocks.io/txns/'
|
||||
|
||||
/** How often to re-read a transaction that is not yet kept. */
|
||||
const PENDING_REFRESH_MS = 15_000
|
||||
|
||||
export function NearTxPanel({ chain, hash }: { chain: string; hash: string }) {
|
||||
const [tx, setTx] = useState<NearTx | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [refunds, setRefunds] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
let timer: number | undefined
|
||||
setTx(null)
|
||||
setError(null)
|
||||
const load = () =>
|
||||
fetchNearTx(chain, hash, controller.signal)
|
||||
.then((t) => {
|
||||
setTx(t)
|
||||
setError(null)
|
||||
// Kept means final: nothing about it can change, so stop asking.
|
||||
if (!t.stored) timer = window.setTimeout(load, PENDING_REFRESH_MS)
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (controller.signal.aborted) return
|
||||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||||
})
|
||||
load()
|
||||
return () => {
|
||||
window.clearTimeout(timer)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain, hash])
|
||||
|
||||
const shown = tx ? tx.receipts.filter((r) => refunds || !r.gas_refund) : []
|
||||
const hiddenRefunds = tx ? tx.receipts.filter((r) => r.gas_refund).length : 0
|
||||
const shards = tx ? new Set(tx.receipts.map((r) => r.shard).filter((s) => s !== null)) : new Set()
|
||||
const failed = tx ? tx.receipts.filter((r) => !r.succeeded).length : 0
|
||||
const withdrawals = tx
|
||||
? uniqueMemos(
|
||||
tx.receipts.flatMap((r) => r.events.flatMap((e) => e.memos)),
|
||||
'withdraw_to',
|
||||
)
|
||||
: []
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">NEAR transaction</h2>
|
||||
<span className="eyebrow mono-wrap" title={hash}>
|
||||
{hash}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !tx && (
|
||||
<p className="empty">
|
||||
Reading the transaction from NEAR. The first read assembles it receipt by receipt from the
|
||||
nodes tracking each shard, which can take a few seconds; after that it is kept.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tx && (
|
||||
<>
|
||||
<div className="node-grid fit">
|
||||
<Field label="Signed by" value={shortNear(tx.signer)} title={tx.signer} />
|
||||
<Field label="Sent to" value={shortNear(tx.receiver)} title={tx.receiver} />
|
||||
<Field
|
||||
label="Included"
|
||||
value={tx.height === null ? '—' : `#${fmtHeight(tx.height)}`}
|
||||
note={tx.at ? when(tx.at) : undefined}
|
||||
title={tx.at ? utc(tx.at) : undefined}
|
||||
/>
|
||||
<Field
|
||||
label="Result"
|
||||
value={
|
||||
tx.succeeded
|
||||
? failed
|
||||
? 'succeeded, with a failed receipt'
|
||||
: 'succeeded'
|
||||
: 'failed'
|
||||
}
|
||||
note={tx.failure ?? undefined}
|
||||
/>
|
||||
<Field
|
||||
label="Receipts"
|
||||
value={`${tx.receipts.length - hiddenRefunds}${hiddenRefunds ? ` + ${hiddenRefunds} gas refunds` : ''}`}
|
||||
note={
|
||||
shards.size
|
||||
? `across ${shards.size} shard${shards.size === 1 ? '' : 's'}`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{tx.quantus_txs.length > 0 && (
|
||||
<p className="panel-note">
|
||||
{tx.quantus_txs.map((q) => (
|
||||
<span key={q.hash}>
|
||||
Minted against Quantus transaction{' '}
|
||||
{q.height !== null ? (
|
||||
<Link to={href({ chain, block: String(q.height) })} title={q.hash}>
|
||||
{q.hash.slice(0, 12)}… in block #{fmtHeight(q.height)}
|
||||
{q.extrinsic_index !== null ? `, extrinsic ${q.extrinsic_index}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<span title={q.hash}>
|
||||
{q.hash.slice(0, 12)}… (not in this observer's index)
|
||||
</span>
|
||||
)}
|
||||
. The bridge writes that hash into the mint itself, so this link is exact.{' '}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
{withdrawals.length > 0 && (
|
||||
<p className="panel-note">
|
||||
{withdrawals.map((m) => (
|
||||
<span key={m.text}>
|
||||
A withdrawal to Quantus address{' '}
|
||||
<Link to={href({ chain, account: m.address ?? '' })}>{m.address}</Link>. The
|
||||
payout on Quantus carries no NEAR hash, so the bridge page matches it by
|
||||
rule.{' '}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ol className="receipt-tree">
|
||||
{shown.map((r) => (
|
||||
<Receipt key={r.id} chain={chain} receipt={r} tx={tx} />
|
||||
))}
|
||||
</ol>
|
||||
|
||||
{hiddenRefunds > 0 && (
|
||||
<div className="pager" style={{ justifyContent: 'flex-start' }}>
|
||||
<button
|
||||
className="segmented panel-button"
|
||||
aria-pressed={refunds}
|
||||
onClick={() => setRefunds((v) => !v)}
|
||||
>
|
||||
{refunds ? 'Hide' : 'Show'} {hiddenRefunds} gas refund
|
||||
{hiddenRefunds === 1 ? '' : 's'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="panel-note">
|
||||
Read from {tx.source === 'own' ? 'our own NEAR nodes' : 'a public NEAR node'}
|
||||
{tx.stored
|
||||
? ', and kept: it is complete and every block it touched is final.'
|
||||
: tx.complete
|
||||
? '. Not final yet, so it is read again until it is.'
|
||||
: '. Some receipts have not executed yet, so it is read again until they have.'}{' '}
|
||||
A gas refund is the protocol returning unused gas to the signer. Each receipt executes
|
||||
on the shard of the account it is sent to, a block or more after the one that created
|
||||
it. Amounts are in each token's units, from its own metadata; hover for the exact
|
||||
figure and the contract. Also on{' '}
|
||||
<a href={`${EXPLORER}${tx.hash}`} target="_blank" rel="noreferrer">
|
||||
nearblocks
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function uniqueMemos(memos: NearMemo[], kind: string): NearMemo[] {
|
||||
const out: NearMemo[] = []
|
||||
for (const m of memos) if (m.kind === kind && !out.some((o) => o.text === m.text)) out.push(m)
|
||||
return out
|
||||
}
|
||||
|
||||
function Receipt({ chain, receipt: r, tx }: { chain: string; receipt: NearReceipt; tx: NearTx }) {
|
||||
return (
|
||||
<li className="receipt" style={{ marginLeft: `${Math.min(r.depth, 8) * 18}px` }}>
|
||||
<div className="receipt-head">
|
||||
<span title={r.predecessor}>{shortNear(r.predecessor)}</span>
|
||||
<span className="receipt-arrow" aria-label="to">
|
||||
→
|
||||
</span>
|
||||
<strong title={r.receiver}>{shortNear(r.receiver)}</strong>
|
||||
{!r.succeeded && <span className="chip chip-failed">failed</span>}
|
||||
{r.gas_refund && <span className="chip chip-submitted">gas refund</span>}
|
||||
<span className="receipt-meta">
|
||||
{r.shard !== null && `shard ${r.shard} · `}
|
||||
{r.height !== null && `#${fmtHeight(r.height)}`}
|
||||
{r.at && (
|
||||
<span title={utc(r.at)}>
|
||||
{' · '}
|
||||
{when(r.at)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{r.failure && <div className="receipt-failure">{r.failure}</div>}
|
||||
|
||||
{r.actions.map((a, i) => (
|
||||
<div key={i} className="receipt-line">
|
||||
{a.kind === 'FunctionCall' ? (
|
||||
<details>
|
||||
<summary>
|
||||
<code>{a.method}</code>
|
||||
{a.deposit && (
|
||||
<span className="receipt-meta"> · {nearText(a.deposit)} attached</span>
|
||||
)}
|
||||
{a.memos.map((m) => (
|
||||
<span key={m.text} className="chip chip-submitted" title={m.text}>
|
||||
{m.kind.replace('_', ' ')}
|
||||
</span>
|
||||
))}
|
||||
</summary>
|
||||
{a.args !== null ? (
|
||||
<pre className="receipt-json">{JSON.stringify(a.args, null, 2)}</pre>
|
||||
) : (
|
||||
<p className="panel-note">
|
||||
{a.args_bytes ?? 0} bytes of arguments that are not JSON.
|
||||
</p>
|
||||
)}
|
||||
</details>
|
||||
) : (
|
||||
<span>
|
||||
<code>{a.kind}</code>
|
||||
{a.deposit && <span className="receipt-meta"> · {nearText(a.deposit)}</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{r.events.map((e, i) => (
|
||||
<Event key={i} chain={chain} event={e} tx={tx} />
|
||||
))}
|
||||
|
||||
{r.logs.length > 0 && (
|
||||
<details className="receipt-line">
|
||||
<summary className="receipt-meta">
|
||||
{r.logs.length} other log line{r.logs.length === 1 ? '' : 's'}
|
||||
</summary>
|
||||
<pre className="receipt-json">{r.logs.join('\n')}</pre>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function Event({ chain, event: e, tx }: { chain: string; event: NearEvent; tx: NearTx }) {
|
||||
return (
|
||||
<div className="receipt-line">
|
||||
<details>
|
||||
<summary>
|
||||
<span className="event-key">{e.standard}</span> <code>{e.event}</code>
|
||||
{e.amounts.length > 0 && (
|
||||
<span className="event-fields" style={{ marginLeft: 8 }}>
|
||||
{e.amounts.map((a, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="event-field"
|
||||
title={amountTitle(a.amount, a.token, tx.tokens)}
|
||||
>
|
||||
{amountText(a.amount, a.token, tx.tokens)}
|
||||
{a.account && (
|
||||
<span className="receipt-meta" title={a.account}>
|
||||
{' '}
|
||||
· {shortNear(a.account)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{e.memos.map((m) => (
|
||||
<span key={m.text} className="chip chip-submitted" title={m.text}>
|
||||
{m.kind.replace('_', ' ')}
|
||||
</span>
|
||||
))}
|
||||
</summary>
|
||||
<pre className="receipt-json">{JSON.stringify(e.data, null, 2)}</pre>
|
||||
</details>
|
||||
{e.intents_accounts.length > 0 && (
|
||||
<div className="receipt-meta">
|
||||
Intents account{e.intents_accounts.length === 1 ? '' : 's'}:{' '}
|
||||
{e.intents_accounts.map((a, i) => (
|
||||
<span key={a}>
|
||||
{i > 0 && ', '}
|
||||
<Link to={href({ chain, intentsAccount: a })} title={a}>
|
||||
{shortNear(a)}
|
||||
</Link>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { BidSidePanel } from './BidSidePanel'
|
||||
import { RefV1Panel } from './RefV1Panel'
|
||||
import { SeriesBody } from './PriceChart'
|
||||
|
||||
const EXPLORER = 'https://nearblocks.io/txns/'
|
||||
const REFRESH_MS = 60_000
|
||||
|
||||
/** A share as a percentage, or a dash where there is nothing to divide. */
|
||||
@@ -317,9 +316,9 @@ export function RefDclMarket({
|
||||
</td>
|
||||
<td className="numeral">{fmtPrice(s.price)}</td>
|
||||
<td className="left">
|
||||
<a href={`${EXPLORER}${s.tx_hash}`} target="_blank" rel="noreferrer">
|
||||
<Link to={href({ chain, nearTx: s.tx_hash })} title={s.tx_hash}>
|
||||
{s.tx_hash.slice(0, 8)}…
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
@@ -4,20 +4,23 @@
|
||||
* differently.
|
||||
*/
|
||||
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { IntentsSwap } from '../api/generated/IntentsSwap'
|
||||
import { ago, price as fmtPrice, tokens, utc, when } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
import { tokenLabel } from '../lib/tokens'
|
||||
|
||||
/** NearBlocks, for the transaction. A NEAR tx hash is all it needs. */
|
||||
const EXPLORER = 'https://nearblocks.io/txns/'
|
||||
|
||||
export function SwapRow({
|
||||
chain,
|
||||
swap,
|
||||
asset,
|
||||
decimals,
|
||||
fresh = false,
|
||||
relative = false,
|
||||
}: {
|
||||
/** The chain whose token this is, for the transaction page's address. */
|
||||
chain: string
|
||||
swap: IntentsSwap
|
||||
/** The tracked asset's own symbol. */
|
||||
asset: string
|
||||
@@ -50,9 +53,9 @@ export function SwapRow({
|
||||
{swap.kind === 'unattributed' ? '—' : (swap.referral ?? '—')}
|
||||
</td>
|
||||
<td className="left">
|
||||
<a href={`${EXPLORER}${swap.tx_hash}`} target="_blank" rel="noreferrer">
|
||||
<Link to={href({ chain, nearTx: swap.tx_hash })} title={swap.tx_hash}>
|
||||
{swap.tx_hash.slice(0, 8)}…
|
||||
</a>
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
|
||||
@@ -303,6 +303,12 @@ a {
|
||||
border-top: var(--rule);
|
||||
}
|
||||
|
||||
/* Fields that should share the row between them rather than leave empty
|
||||
tracks showing the grid's border colour, on a panel with a fixed handful. */
|
||||
.node-grid.fit {
|
||||
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
|
||||
}
|
||||
|
||||
.node-field {
|
||||
background: var(--surface-1);
|
||||
padding: 11px 16px 13px;
|
||||
@@ -1994,3 +2000,90 @@ tr.mine .share-bar > i {
|
||||
.chain-chip.is-testnet {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
/* A NEAR transaction's receipts (blackbeard/observer#44), in execution order.
|
||||
Indented by depth with a rule down the left, like the events under an
|
||||
extrinsic on the block page: each receipt is a consequence of the one above
|
||||
it. No colour of its own — a failed receipt is marked with the same chip a
|
||||
failed extrinsic is, the one place the accent belongs. */
|
||||
.receipt-tree {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 8px 16px 12px;
|
||||
}
|
||||
|
||||
.receipt {
|
||||
padding: 8px 0 8px 10px;
|
||||
border-left: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--row-rule);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.receipt-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 4px 8px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.receipt-arrow,
|
||||
.receipt-meta {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.receipt-head .receipt-meta {
|
||||
margin-left: auto;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.receipt-line {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.receipt-line summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.receipt-line code,
|
||||
.receipt-head code {
|
||||
font-family: var(--font-mono);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.receipt-line .chip {
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.receipt-failure {
|
||||
margin-top: 4px;
|
||||
color: var(--accent-bright);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* Arguments and payloads, verbatim. Scrolls inside itself rather than widening
|
||||
the page: a signed intent payload is one very long string. */
|
||||
.receipt-json {
|
||||
margin: 6px 0 2px;
|
||||
padding: 8px 10px;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
background: var(--surface-2);
|
||||
border: var(--rule);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* A hash or account id in a panel header: whole, and allowed to wrap on a
|
||||
phone rather than push the page sideways. */
|
||||
.mono-wrap {
|
||||
font-family: var(--font-mono);
|
||||
overflow-wrap: anywhere;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Amounts and accounts on the NEAR pages (blackbeard/observer#44).
|
||||
*
|
||||
* Amounts arrive in base units with the token they belong to, and the token's
|
||||
* decimals arrive separately — so a token whose metadata could not be read is
|
||||
* still shown, in base units and named by its contract, rather than with a
|
||||
* guessed decimal count.
|
||||
*/
|
||||
|
||||
import type { NearToken } from '../api/generated/NearToken'
|
||||
import { exactTokens, tokens as fmtTokens } from './format'
|
||||
import { contractOf, tokenLabel } from './tokens'
|
||||
|
||||
/** yoctoNEAR per NEAR. */
|
||||
export const NEAR_DECIMALS = 24
|
||||
|
||||
/** A signed base-unit amount in its token's units: `−1.25 QTC`. */
|
||||
export function amountText(raw: string, token: string, known: NearToken[]): string {
|
||||
const negative = raw.startsWith('-')
|
||||
const digits = negative ? raw.slice(1) : raw
|
||||
const meta = known.find((t) => t.token === token)
|
||||
const sign = negative ? '−' : ''
|
||||
if (!meta) return `${sign}${digits} ${contractOf(token)} (base units)`
|
||||
return `${sign}${fmtTokens(digits, meta.decimals)} ${tokenLabel(token, meta.symbol)}`
|
||||
}
|
||||
|
||||
/** The exact figure and the contract, for a `title`. */
|
||||
export function amountTitle(raw: string, token: string, known: NearToken[]): string {
|
||||
const digits = raw.replace(/^-/, '')
|
||||
const meta = known.find((t) => t.token === token)
|
||||
const exact = meta ? exactTokens(digits, meta.decimals) : digits
|
||||
return `${raw.startsWith('-') ? '−' : ''}${exact} — ${token}`
|
||||
}
|
||||
|
||||
/**
|
||||
* NEAR attached to a call, from yoctoNEAR. A deposit under a millionth of a
|
||||
* NEAR is shown in yoctoNEAR: `ft_transfer` requires exactly one attached, as
|
||||
* proof the caller signed with a full-access key, and "0 NEAR" would hide it.
|
||||
*/
|
||||
export function nearText(yocto: string): string {
|
||||
if (yocto.replace(/^0+/, '').length <= NEAR_DECIMALS - 6) return `${yocto} yoctoNEAR`
|
||||
return `${fmtTokens(yocto, NEAR_DECIMALS, 6)} NEAR`
|
||||
}
|
||||
|
||||
/**
|
||||
* An account id short enough for a cell. Named accounts are kept whole up to a
|
||||
* point — `solver-ref-2.near` is the identity and reads as one — while an
|
||||
* implicit 64-hex id keeps both ends, which is how a reader compares two.
|
||||
*/
|
||||
export function shortNear(account: string): string {
|
||||
return account.length > 24 ? `${account.slice(0, 8)}…${account.slice(-6)}` : account
|
||||
}
|
||||
|
||||
/** What NEAR's own rules make an account id, in words. */
|
||||
export function accountKindText(kind: string): string {
|
||||
switch (kind) {
|
||||
case 'implicit_ed25519':
|
||||
return 'implicit — the id is an ed25519 public key'
|
||||
case 'implicit_eth':
|
||||
return 'implicit — the id is an Ethereum address'
|
||||
default:
|
||||
return 'named — registered under this name'
|
||||
}
|
||||
}
|
||||
+29
-1
@@ -95,6 +95,18 @@ export interface Route {
|
||||
node: string | null
|
||||
/** A venue the chain's token is followed on: `near-intents`. */
|
||||
market: string | null
|
||||
/**
|
||||
* A NEAR transaction, by its base58 hash: `/quantus/near/tx/<hash>`.
|
||||
*
|
||||
* Under a chain although NEAR is not one, because the page is about this
|
||||
* chain's token over there — the bridge's mints, the Intents trades — and
|
||||
* the API reads it with the chain's own NEAR configuration. `near` is a
|
||||
* kind, not a chain: `/near/tx/…` would parse as a chain called "near"
|
||||
* (blackbeard/observer#44).
|
||||
*/
|
||||
nearTx: string | null
|
||||
/** A NEAR Intents account: `/quantus/near/intents/<account>`. */
|
||||
intentsAccount: string | null
|
||||
/**
|
||||
* A kind with no value: the index of everything of that kind.
|
||||
*
|
||||
@@ -176,6 +188,8 @@ export const EMPTY: Route = {
|
||||
event: null,
|
||||
node: null,
|
||||
market: null,
|
||||
nearTx: null,
|
||||
intentsAccount: null,
|
||||
index: null,
|
||||
}
|
||||
|
||||
@@ -244,6 +258,14 @@ export function parse(pathname: string): Route {
|
||||
|
||||
const kind = parts[1]
|
||||
const value = parts[2]
|
||||
// `/quantus/near/tx/<hash>` and `/quantus/near/intents/<account>`: two
|
||||
// segments of kind, because NEAR has more than one kind of thing to name.
|
||||
// A hash is base58 and an account id is case-sensitive in principle, so both
|
||||
// pass through exactly as written.
|
||||
if (kind === 'near' && parts[3]) {
|
||||
if (value === 'tx') return { ...EMPTY, chain, nearTx: parts[3] }
|
||||
if (value === 'intents') return { ...EMPTY, chain, intentsAccount: parts[3] }
|
||||
}
|
||||
if (kind && value) {
|
||||
if (kind === 'block') return { ...EMPTY, chain, block: value.toLowerCase() }
|
||||
// A miner carries a window, because its rank and hashrate are *of* a
|
||||
@@ -310,7 +332,9 @@ export function isStandings(route: Route): boolean {
|
||||
route.call === null &&
|
||||
route.event === null &&
|
||||
route.node === null &&
|
||||
route.market === null
|
||||
route.market === null &&
|
||||
route.nearTx === null &&
|
||||
route.intentsAccount === null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -338,12 +362,16 @@ export function sectionOf(route: Route): SectionName | null {
|
||||
if (route.event !== null) return 'event'
|
||||
if (route.node !== null) return 'node'
|
||||
if (route.market !== null) return 'market'
|
||||
// The token on NEAR, like the market pages that link to them.
|
||||
if (route.nearTx !== null || route.intentsAccount !== null) return 'market'
|
||||
return null
|
||||
}
|
||||
|
||||
/** The canonical path for a route. The inverse of {@link parse}. */
|
||||
export function href(route: Partial<Route>): string {
|
||||
const chain = route.chain ?? ''
|
||||
if (route.nearTx) return `/${chain}/near/tx/${route.nearTx}`
|
||||
if (route.intentsAccount) return `/${chain}/near/intents/${route.intentsAccount}`
|
||||
if (route.node) return `/${chain}/node/${route.node}`
|
||||
if (route.market) return `/${chain}/market/${route.market}`
|
||||
if (route.block) return `/${chain}/block/${route.block}`
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// The URL grammar's NEAR pages (blackbeard/observer#44). Every new subject has
|
||||
// to be added to `isStandings`, or the canonicalising redirect in `App` treats
|
||||
// its page as the standings and replaces it — which is how the node page was
|
||||
// first lost. These pin that it was.
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
|
||||
import { href, isStandings, parse, sectionOf } from '../src/lib/routes.ts'
|
||||
|
||||
const tx = 'piPTXyoqDZwSJsKk3DWGVvDSy8T7hWm6KW4LFxpTsLk'
|
||||
const account = '42d72e44014a6500cdefdac410866c848715e36c6d6a3f36457875b01e169978'
|
||||
|
||||
test('a NEAR transaction parses, round-trips, and is not the standings', () => {
|
||||
const route = parse(`/quantus/near/tx/${tx}`)
|
||||
assert.equal(route.chain, 'quantus')
|
||||
assert.equal(route.nearTx, tx)
|
||||
assert.equal(isStandings(route), false)
|
||||
assert.equal(sectionOf(route), 'market')
|
||||
assert.equal(href(route), `/quantus/near/tx/${tx}`)
|
||||
})
|
||||
|
||||
test('a NEAR Intents account parses, round-trips, and is not the standings', () => {
|
||||
const route = parse(`/quantus/near/intents/${account}`)
|
||||
assert.equal(route.intentsAccount, account)
|
||||
assert.equal(isStandings(route), false)
|
||||
assert.equal(sectionOf(route), 'market')
|
||||
assert.equal(href(route), `/quantus/near/intents/${account}`)
|
||||
})
|
||||
|
||||
test('a base58 hash keeps its case', () => {
|
||||
assert.equal(parse(`/quantus/near/tx/${tx}`).nearTx, tx)
|
||||
})
|
||||
|
||||
test('an incomplete NEAR path is not a NEAR page', () => {
|
||||
assert.equal(parse('/quantus/near/tx').nearTx, null)
|
||||
assert.equal(parse('/quantus/near').intentsAccount, null)
|
||||
})
|
||||
Reference in New Issue
Block a user