feat(api): index swaps settled by NEAR Intents, off by default

The price of a token on NEAR Intents is the ratio inside a trade that
`intents.near` actually executed; there is no pool, oracle or stored price,
and a 1Click quote is an offer. This reads `execute_intents` transactions on
the contract's shard, keeps the `token_diff` legs whose receipt succeeded and
whose intent is in `intents_executed`, and records one trade per asset/counter
pair per outcome, counting each unit once whichever side the taker was on.

- core: event parsing, trade building, shard lookup, price and VWAP, tested
  against the logs of mainnet tx 85uy9uCF (USDT on Tron for ZEC).
- data: a NEAR client over the existing RpcClient, live then archival;
  migration 0009 (intents_trade, intents_scan, near_token).
- api: a contiguous-cursor scan and a supply/listing poll per chain, and
  GET /v1/chains/{chain}/intents/{swaps,price}. Prices are in counter-token
  units; no USD until a stablecoin is named by contract.

Gated on `[chains.intents]`, which the config template leaves commented out:
FastNEAR's free RPC sustains about two requests a second against ~1.7 needed
to follow the tip, and neardata allows 30 blocks a minute. It waits for our
own node. A test renders the template and fails if the block is enabled.

Verified end to end against mainnet with ZEC configured: the trade at NEAR
218,261,895 was recorded and served by both routes.

Refs #27

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
2026-10-03 02:02:38 +03:00
co-authored by Claude Opus 5.5
parent 0de203c819
commit 49f4d4b69b
32 changed files with 2670 additions and 0 deletions
@@ -0,0 +1,28 @@
{
"db_name": "PostgreSQL",
"query": "select symbol, decimals from near_token where token_id = $1",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "symbol",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "decimals",
"type_info": "Int2"
}
],
"parameters": {
"Left": [
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "07052e42232e1816532a2652cf17ae3e55e7fb3b5dcffeea482a52f33624de5c"
}
@@ -0,0 +1,116 @@
{
"db_name": "PostgreSQL",
"query": "\n select chain, asset, near_height, trade_index, at, tx_hash, receipt_id,\n counter_token, asset_amount::text as \"asset_amount!\",\n counter_amount::text as \"counter_amount!\", side, taker, referral,\n accounts, intent_hashes, legs\n from intents_trade\n where chain = $1 and asset = $2 and (near_height, trade_index) > ($3, $4)\n order by near_height, trade_index\n limit $5\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "chain",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "asset",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "near_height",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "trade_index",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "tx_hash",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "receipt_id",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "counter_token",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "asset_amount!",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "counter_amount!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "side",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "taker",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "referral",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "accounts",
"type_info": "TextArray"
},
{
"ordinal": 14,
"name": "intent_hashes",
"type_info": "TextArray"
},
{
"ordinal": 15,
"name": "legs",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
null,
null,
true,
true,
true,
false,
false,
false
]
},
"hash": "0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "select low, high from intents_scan where chain = $1 and asset = $2",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "low",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "high",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false
]
},
"hash": "31bf30fc5a538346f8187e3f069e1c872e31f9ba103b27fdec00f52f1a9b7417"
}
@@ -0,0 +1,29 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into intents_trade (\n chain, asset, near_height, trade_index, at, tx_hash, receipt_id,\n counter_token, asset_amount, counter_amount, side, taker, referral,\n accounts, intent_hashes, legs\n ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::numeric, $10::text::numeric,\n $11, $12, $13, $14, $15, $16)\n on conflict (chain, asset, near_height, trade_index) do update set\n at = excluded.at, tx_hash = excluded.tx_hash,\n receipt_id = excluded.receipt_id, counter_token = excluded.counter_token,\n asset_amount = excluded.asset_amount,\n counter_amount = excluded.counter_amount, side = excluded.side,\n taker = excluded.taker, referral = excluded.referral,\n accounts = excluded.accounts, intent_hashes = excluded.intent_hashes,\n legs = excluded.legs\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int4",
"Timestamptz",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"Text",
"TextArray",
"TextArray",
"Jsonb"
]
},
"nullable": []
},
"hash": "52a060c30ca48a63d2fe9dafaa4df386ee582a83346e5c560dfcec5b00b3f26c"
}
@@ -0,0 +1,17 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into intents_scan (chain, asset, low, high) values ($1, $2, $3, $4)\n on conflict (chain, asset) do update\n set low = least(intents_scan.low, excluded.low),\n high = greatest(intents_scan.high, excluded.high),\n updated_at = now()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int8",
"Int8"
]
},
"nullable": []
},
"hash": "6b025ce59057975f0feb1e7f7687e065e0ccdf5c4aeb56690c9c15667b2a7314"
}
@@ -0,0 +1,16 @@
{
"db_name": "PostgreSQL",
"query": "\n insert into near_token (token_id, symbol, decimals) values ($1, $2, $3)\n on conflict (token_id) do update\n set symbol = excluded.symbol, decimals = excluded.decimals, fetched_at = now()\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Text",
"Text",
"Int2"
]
},
"nullable": []
},
"hash": "7b002140797907f9344085c0f17e3098c659e998aaf0ce413c93080949db73f3"
}
@@ -0,0 +1,114 @@
{
"db_name": "PostgreSQL",
"query": "\n select chain as \"chain!\", asset as \"asset!\", near_height as \"near_height!\",\n trade_index as \"trade_index!\", at as \"at!\", tx_hash as \"tx_hash!\",\n receipt_id as \"receipt_id!\", counter_token as \"counter_token!\",\n asset_amount::text as \"asset_amount!\",\n counter_amount::text as \"counter_amount!\", side, taker, referral,\n accounts as \"accounts!\", intent_hashes as \"intent_hashes!\", legs as \"legs!\"\n from (\n select *, row_number() over (\n partition by counter_token order by near_height desc, trade_index desc\n ) as n\n from intents_trade\n where chain = $1 and asset = $2\n ) ranked\n where n <= $3\n order by counter_token, near_height desc, trade_index desc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "chain!",
"type_info": "Text"
},
{
"ordinal": 1,
"name": "asset!",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "near_height!",
"type_info": "Int8"
},
{
"ordinal": 3,
"name": "trade_index!",
"type_info": "Int4"
},
{
"ordinal": 4,
"name": "at!",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "tx_hash!",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "receipt_id!",
"type_info": "Text"
},
{
"ordinal": 7,
"name": "counter_token!",
"type_info": "Text"
},
{
"ordinal": 8,
"name": "asset_amount!",
"type_info": "Text"
},
{
"ordinal": 9,
"name": "counter_amount!",
"type_info": "Text"
},
{
"ordinal": 10,
"name": "side",
"type_info": "Text"
},
{
"ordinal": 11,
"name": "taker",
"type_info": "Text"
},
{
"ordinal": 12,
"name": "referral",
"type_info": "Text"
},
{
"ordinal": 13,
"name": "accounts!",
"type_info": "TextArray"
},
{
"ordinal": 14,
"name": "intent_hashes!",
"type_info": "TextArray"
},
{
"ordinal": 15,
"name": "legs!",
"type_info": "Jsonb"
}
],
"parameters": {
"Left": [
"Text",
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
false,
false,
false,
false,
false,
null,
null,
true,
true,
true,
false,
false,
false
]
},
"hash": "b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c"
}
+34
View File
@@ -477,6 +477,40 @@ infer "is this a name or an address?" from the string: several real node names
begin with `q`, so `RecentBlock` and `LeaderboardRow` carry the
`AttributionSource` and the UI reads that.
## NEAR Intents is read from logs, and every free source is rate-limited
`/intents/*` prices QTC by what `intents.near` actually settled (#27). There is
no pool or stored price on NEAR Intents; a price is the ratio inside an executed
`token_diff`, and nothing else is one — not a 1Click quote, and not the swap
screen in wallet 1.7.1 (144), whose "rate" is a hard-coded `50.0` in a demo flow.
**Both free block sources throttle, and neither says so until it does.**
neardata: 30 blocks a minute, against NEAR's ~100. FastNEAR RPC: about two
requests a second sustained, 429 after ~20 in a burst, recovering in ~2 s —
and following one shard at the tip needs ~1.7. Measured 2026-10-03. That is why
the integration is commented out in `asset/config/config.toml.tmpl` until our
own node serves `rpc_urls`, and a test in `config.rs` fails if it is enabled
without deleting that assertion.
**A failed receipt keeps its logs.** NEAR rolls back its state but still reports
what it logged, so a `token_diff` is a trade only when the outcome succeeded
*and* its `intent_hash` is in `intents_executed`. Either check alone admits
trades that never happened.
**A transaction lands in its signer's shard, not its receiver's.** Scanning
`intents.near`'s shard sees `execute_intents` only because the 1Click relay signs
as `intents.near`. Intents arriving as cross-contract receipts are not read; the
price response says so in `reads` rather than leaving it to be found.
**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.
**A token's symbol is not its identity.** `tron-d28a…omft.near` calls itself
`USDT` — it is USDT on Tron — and the issue that started this read the contract
name and called it TRON. Anyone can deploy a token called USDC, so a stablecoin
is named by contract id, never by `ft_metadata.symbol`.
## The frontend's types are generated
`web/src/api/generated/` is written by `ts-rs` from `blackbeard-entities` when
Generated
+1
View File
@@ -306,6 +306,7 @@ dependencies = [
name = "blackbeard-data"
version = "0.1.0"
dependencies = [
"base64",
"bigdecimal",
"blackbeard-core",
"blackbeard-entities",
+2
View File
@@ -16,6 +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.
base64 = "0.22"
axum = { version = "0.8", features = ["ws", "macros"] }
chrono = { version = "0.4", default-features = false, features = ["clock", "serde", "std"] }
clap = { version = "4", features = ["derive", "env"] }
+16
View File
@@ -130,6 +130,22 @@ target_block_time_seconds = 12.0
# the window — the warm start simply finds fewer blocks than it asks for.
warm_start_blocks = 100800
max_gap_fill_blocks = 5000
# QTC's settled swaps on NEAR Intents (blackbeard/observer#27). OFF until our own
# NEAR RPC node is up: with this block absent nothing NEAR-related starts and the
# /intents routes answer 404 `no_intents`. FastNEAR's free RPC throttles at about
# two requests a second sustained, and following the tip alone needs ~1.7.
#
# `start_height` is how far back the backfill walks. Nothing can trade before the
# token holds any supply on NEAR, and on 2026-10-03 `qtc.omft.near` held none, so
# the height at which this is first enabled is a sound floor while that stays
# true. `rpc_urls` should name our node; the archive covers anything older.
# Enabling it also means deleting the assertion in config.rs's
# `the_committed_config_parses_and_leaves_intents_off`.
#
# [chains.intents]
# asset = "nep141:qtc.omft.near"
# start_height = 218262584
# rpc_urls = ["http://<near-node>:3030"]
[[chains]]
id = "heisenberg"
+101
View File
@@ -185,6 +185,49 @@ pub struct ChainConfig {
/// the oldest part of the gap is forgone.
#[serde(default = "default_max_gap_fill")]
pub max_gap_fill_blocks: u64,
/// Where this chain's token is traded on NEAR Intents, if anywhere.
///
/// Optional, and absent for every chain whose token has no bridged form.
/// See blackbeard/observer#27.
#[serde(default)]
pub intents: Option<IntentsConfig>,
}
/// Following one token's settled swaps on NEAR Intents.
///
/// The asset is configuration rather than a constant because it was proven
/// against a token that already trades (ZEC) before QTC had a single swap,
/// and because the wallet's own asset id was a placeholder when this was
/// written. Changing it starts a fresh scan; it never inherits another
/// asset's cursor.
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(deny_unknown_fields)]
pub struct IntentsConfig {
/// The token, as `intents.near` names it: `nep141:<contract>`.
pub asset: String,
/// The NEAR height the backfill walks back to and no further — the token
/// contract's deployment, since nothing can trade before that.
pub start_height: u64,
/// The settlement contract.
#[serde(default = "default_intents_contract")]
pub contract: String,
/// NEAR JSON-RPC endpoints for the tip. These keep about a day.
#[serde(default = "default_near_rpc")]
pub rpc_urls: Vec<String>,
/// Archival NEAR JSON-RPC endpoints, for anything older.
#[serde(default = "default_near_archive")]
pub archive_urls: Vec<String>,
/// 1Click's API, read only for whether it lists the asset. Never for a
/// price: a quote is an offer, not a trade.
#[serde(default = "default_oneclick")]
pub oneclick_url: String,
}
impl IntentsConfig {
/// The asset's token contract, from its multi-token id.
pub fn token_contract(&self) -> Option<&str> {
self.asset.strip_prefix("nep141:")
}
}
impl ChainConfig {
@@ -241,6 +284,21 @@ fn default_warm_start_blocks() -> usize {
fn default_max_gap_fill() -> u64 {
5_000
}
fn default_intents_contract() -> String {
"intents.near".into()
}
fn default_near_rpc() -> Vec<String> {
vec!["https://rpc.mainnet.fastnear.com".into()]
}
fn default_near_archive() -> Vec<String> {
vec![
"https://archival-rpc.mainnet.fastnear.com".into(),
"https://archival-rpc.mainnet.near.org".into(),
]
}
fn default_oneclick() -> String {
"https://1click.chaindefuser.com".into()
}
fn default_max_tracked_chains() -> usize {
16
}
@@ -298,6 +356,22 @@ impl Config {
chain.id
);
}
if let Some(intents) = &chain.intents {
if intents.token_contract().is_none() {
anyhow::bail!(
"chain `{}` tracks intents asset `{}`, which is not a `nep141:` id; supply \
and decimals are read from the token contract it names",
chain.id,
intents.asset
);
}
if intents.rpc_urls.is_empty() || intents.archive_urls.is_empty() {
anyhow::bail!(
"chain `{}` tracks intents but names no NEAR rpc or archive endpoint",
chain.id
);
}
}
if chain.target_block_time_seconds <= 0.0 {
anyhow::bail!(
"chain `{}` has a target block time of {}; hashrate is difficulty divided by it",
@@ -340,9 +414,36 @@ mod tests {
target_block_time_seconds: 6.0,
warm_start_blocks: 10,
max_gap_fill_blocks: 10,
intents: None,
}
}
/// The deployed config must not reach NEAR until the intents block is
/// uncommented on purpose: the free endpoints throttle below what following
/// the tip needs, so the integration waits for our own node
/// (blackbeard/observer#27). Delete the second assertion in the same commit
/// that enables it.
///
/// Read from the template the deploy renders, with its `{{…}}`
/// placeholders filled, because that template is the only config committed.
#[test]
fn the_deployed_config_parses_and_leaves_intents_off() {
use figment::providers::{Format, Toml};
let rendered = include_str!("../../../asset/config/config.toml.tmpl")
.replace("{{BIND_ADDRESS}}", "127.0.0.1")
.replace("{{TARGET_FQDN}}", "host.example");
assert!(
!rendered.contains("{{"),
"a placeholder this test does not fill"
);
let config: Config = figment::Figment::new()
.merge(Toml::string(&rendered))
.extract()
.expect("the rendered template parses");
assert!(!config.chains.is_empty());
assert!(config.chains.iter().all(|c| c.intents.is_none()));
}
/// A config naming one endpoint is still a valid config. Every existing
/// deployment writes `rpc_url`, and requiring them all to change would be
/// this feature breaking the thing it exists to make more reliable.
+349
View File
@@ -0,0 +1,349 @@
//! Following a chain's token through NEAR Intents.
//!
//! Two tasks per chain that configures `intents`:
//!
//! - **`scan`** reads `intents.near`'s shard one NEAR block at a time and
//! records every settled trade of the asset. One contiguous cursor, extended
//! upward to the final tip first and then downward to `start_height`, for the
//! reasons `ingest::backfill` gives.
//! - **`listing`** reads whether there is anything to trade at all: the token's
//! supply on NEAR, what `intents.near` holds, and whether 1Click lists it.
//!
//! What is read: `execute_intents` transactions in the chunk of the shard
//! `intents.near` lives on. The 1Click relay signs those as `intents.near`
//! itself, so they land in that shard and settle in the block that includes
//! them. An intent that arrives as a cross-contract receipt instead — an
//! `ft_transfer_call` carrying intents in its message — lands from another
//! shard's chunk and is **not read**. That is the known blind spot, and it is
//! stated in every price response rather than left to be discovered.
//! blackbeard/observer#27.
use std::sync::Arc;
use std::time::Duration;
use blackbeard_core::intents as core;
use blackbeard_data::near::{NearClient, oneclick_lists};
use blackbeard_data::store::{IntentTrade, Store};
use blackbeard_entities::{BigUintDec, IntentsListing};
use serde_json::json;
use crate::config::IntentsConfig;
use crate::state::ChainRuntime;
/// What the indexer reads, for the price response to carry. See the module
/// docs; this is the sentence a reader of the API needs from them.
pub const READS: &str = "execute_intents transactions submitted to intents.near by its own relay, \
which is how 1Click settles. Intents reaching the contract as cross-contract receipts are not \
read, so a trade made that way is missing rather than misreported.";
/// NEAR blocks per scan batch. Also the most progress a restart can lose.
const BATCH: u64 = 64;
/// Pause between blocks when walking back through history.
///
/// The archive is someone else's free service and the history is not going
/// anywhere. FastNEAR's free tier was measured at about two requests a second
/// sustained, and 429s after ~20 in a burst; one block a second leaves room for
/// the transaction lookups a block with trades in it adds.
const BACKFILL_PACE: Duration = Duration::from_secs(1);
/// Pause when the scan has caught up with the tip. NEAR finalises a block
/// every ~0.6 s; checking twice a block is plenty.
const TIP_WAIT: Duration = Duration::from_secs(1);
/// Pause after a failure before trying again.
const RETRY: Duration = Duration::from_secs(15);
/// How often supply and listing are re-read.
const LISTING_INTERVAL: Duration = Duration::from_secs(300);
/// Every NEAR call's timeout. The archive is the slow one.
const NEAR_TIMEOUT: Duration = Duration::from_secs(20);
/// Start both tasks, if the chain tracks an asset.
pub fn spawn(chain: Arc<ChainRuntime>, store: Store) {
let Some(config) = chain.spec.intents.clone() else {
return;
};
let near = match NearClient::new(
config.rpc_urls.clone(),
config.archive_urls.clone(),
NEAR_TIMEOUT,
) {
Ok(n) => n,
Err(e) => {
tracing::error!(chain = %chain.id(), error = %e, "intents tracking not started");
return;
}
};
tracing::info!(chain = %chain.id(), asset = %config.asset, "following the asset on NEAR Intents");
tokio::spawn(listing(
Arc::clone(&chain),
store.clone(),
near.clone(),
config.clone(),
));
tokio::spawn(scan(chain, store, near, config));
}
/// Walk NEAR blocks, recording trades.
async fn scan(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config: IntentsConfig) {
let id = chain.id();
loop {
// The cursor references the chain row, which the chain's own poll
// writes once its node has answered.
if chain.read().current_runtime().is_none() {
tokio::time::sleep(RETRY).await;
continue;
}
let (tip, live_from) = match near.heights().await {
Ok(h) => h,
Err(e) => {
tracing::warn!(chain = %id, error = %e, "near tip unreadable");
tokio::time::sleep(RETRY).await;
continue;
}
};
let scan = match store.intents_scan(&id, &config.asset).await {
Ok(s) => s,
Err(e) => {
tracing::warn!(chain = %id, error = %e, "intents scan cursor unreadable");
tokio::time::sleep(RETRY).await;
continue;
}
};
// An empty cursor starts at the tip: what is trading now matters more
// than what traded last week, and both arrive in the end.
let (low, high) = scan.unwrap_or((tip, tip.saturating_sub(1)));
let (heights, descending): (Vec<u64>, bool) = if high < tip {
((high + 1..=tip.min(high + BATCH)).collect(), false)
} else if low > config.start_height {
let floor = low.saturating_sub(BATCH).max(config.start_height);
((floor..low).rev().collect(), true)
} else {
tokio::time::sleep(TIP_WAIT).await;
continue;
};
// Per batch, not once: resharding renumbers shards, and a layout read
// at startup would follow the wrong one after it while every call
// kept succeeding.
let shard = match near.shard_layout(heights[0]).await {
Ok(layout) => core::shard_for(
&config.contract,
&layout.boundary_accounts,
&layout.shard_ids,
),
Err(e) => {
tracing::warn!(chain = %id, error = %e, "near shard layout unreadable");
tokio::time::sleep(RETRY).await;
continue;
}
};
let Some(shard) = shard else {
tracing::error!(chain = %id, contract = %config.contract, "contract maps to no shard");
tokio::time::sleep(RETRY).await;
continue;
};
// Record each block as it is read, and the cursor up to the last one
// that succeeded: a failure part way leaves the interval contiguous.
let mut reached = None;
for &height in &heights {
match read_block(&near, &store, &config, height, shard, live_from).await {
Ok(trades) => {
if !trades.is_empty() {
let rows: Vec<IntentTrade> = trades
.into_iter()
.map(|t| IntentTrade {
chain: id.clone(),
..t
})
.collect();
if let Err(e) = store.record_intent_trades(&rows).await {
tracing::warn!(chain = %id, near_height = height, error = %e,
"intents trades not recorded");
break;
}
tracing::info!(chain = %id, near_height = height, trades = rows.len(),
"intents trades recorded");
}
reached = Some(height);
}
Err(e) => {
tracing::warn!(chain = %id, near_height = height, error = %e,
"near block unreadable; will retry");
break;
}
}
if descending {
tokio::time::sleep(BACKFILL_PACE).await;
}
}
if let Some(reached) = reached {
let (new_low, new_high) = if descending {
(reached, high)
} else {
(low.min(heights[0]), reached)
};
if let Err(e) = store
.set_intents_scan(&id, &config.asset, new_low, new_high)
.await
{
tracing::warn!(chain = %id, error = %e, "intents scan cursor not saved");
}
} else {
tokio::time::sleep(RETRY).await;
}
}
}
/// Every trade of the asset settled by `execute_intents` transactions included
/// at `height`. Chain id is left for the caller to fill in.
async fn read_block(
near: &NearClient,
store: &Store,
config: &IntentsConfig,
height: u64,
shard: u64,
live_from: u64,
) -> Result<Vec<IntentTrade>, blackbeard_data::DataError> {
let Some(chunk) = near.chunk(height, shard, live_from).await? else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for tx in chunk
.transactions
.iter()
.filter(|t| t.receiver_id == config.contract && t.calls("execute_intents"))
{
for outcome in near.tx_outcomes(&tx.hash, &tx.signer_id).await? {
// A failed receipt still reports the logs it wrote before failing.
if outcome.executor_id != config.contract || !outcome.succeeded {
continue;
}
let (legs, malformed) = core::executed_legs(&outcome.logs);
if malformed > 0 {
tracing::warn!(tx = %tx.hash, malformed,
"token_diff entries that did not parse; the event format may have changed");
}
let (trades, unpriced) = core::trades(&legs, &config.asset);
if unpriced > 0 {
tracing::debug!(tx = %tx.hash, unpriced, "legs of the asset with no single price");
}
if trades.is_empty() {
continue;
}
let (_, at) = near.block_at(json!(outcome.block_hash)).await?;
for t in trades {
remember_token(near, store, &t.counter).await;
out.push(IntentTrade {
chain: blackbeard_entities::ChainId(String::new()),
asset: config.asset.clone(),
near_height: height,
trade_index: out.len() as u32,
at,
tx_hash: tx.hash.clone(),
receipt_id: outcome.id.clone(),
asset_amount: t.asset_amount.to_string(),
counter_amount: t.counter_amount.to_string(),
side: t.side.map(|s| s.as_str().to_owned()),
taker: t.taker.clone(),
referral: t.referral.clone(),
accounts: t.accounts.clone(),
intent_hashes: t.intent_hashes.clone(),
legs: legs_json(&t.legs),
counter_token: t.counter,
});
}
}
}
Ok(out)
}
/// The legs as the contract logged them, amounts as strings.
fn legs_json(legs: &[core::Leg]) -> serde_json::Value {
serde_json::Value::Array(
legs.iter()
.map(|l| {
json!({
"intent_hash": l.intent_hash,
"account_id": l.account_id,
"diff": l.diff.iter().map(|(k, v)| (k.clone(), json!(v.to_string()))).collect::<serde_json::Map<_, _>>(),
"referral": l.referral,
"fees_collected": l.fees.iter().map(|(k, v)| (k.clone(), json!(v.to_string()))).collect::<serde_json::Map<_, _>>(),
})
})
.collect(),
)
}
/// Cache a token's decimals the first time it is seen.
///
/// Best effort: a token whose metadata cannot be read is left out of the cache
/// and its trades show without a price, which is honest, rather than failing
/// the block, which would stall the scan on one odd token.
async fn remember_token(near: &NearClient, store: &Store, token: &str) {
if matches!(store.near_token(token).await, Ok(Some(_))) {
return;
}
match near.token_metadata(token).await {
Ok(Some(meta)) => {
if let Err(e) = store
.save_near_token(token, &meta.symbol, meta.decimals)
.await
{
tracing::warn!(token, error = %e, "token metadata not cached");
}
}
Ok(None) => tracing::debug!(token, "not a nep141 token; its trades stay unpriced"),
Err(e) => tracing::warn!(token, error = %e, "token metadata unreadable"),
}
}
/// Read supply and listing, now and every few minutes.
async fn listing(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config: IntentsConfig) {
let Some(contract) = config.token_contract().map(str::to_owned) else {
return;
};
loop {
remember_token(&near, &store, &config.asset).await;
let amount = |v: serde_json::Value| v.as_str().map(|s| BigUintDec(s.to_owned()));
let total_supply = near
.view(&contract, "ft_total_supply", json!({}))
.await
.map_err(|e| tracing::warn!(error = %e, "ft_total_supply unreadable"))
.ok()
.and_then(amount);
let in_intents = near
.view(
&contract,
"ft_balance_of",
json!({"account_id": config.contract}),
)
.await
.map_err(|e| tracing::warn!(error = %e, "ft_balance_of unreadable"))
.ok()
.and_then(amount);
let oneclick_listed = oneclick_lists(&config.oneclick_url, &config.asset, NEAR_TIMEOUT)
.await
.map_err(|e| tracing::warn!(error = %e, "1Click token list unreadable"))
.ok();
*chain
.intents_listing
.write()
.unwrap_or_else(|p| p.into_inner()) = Some(IntentsListing {
checked_at: chrono::Utc::now(),
total_supply,
in_intents,
oneclick_listed,
});
tokio::time::sleep(LISTING_INTERVAL).await;
}
}
+1
View File
@@ -10,6 +10,7 @@
mod config;
mod ingest;
mod intents;
mod registry;
mod routes;
mod state;
+4
View File
@@ -53,6 +53,8 @@ pub struct ChainSpec {
pub warm_start_blocks: usize,
/// Cap on a single gap fill.
pub max_gap_fill_blocks: u64,
/// NEAR Intents tracking for this chain's token, if configured.
pub intents: Option<crate::config::IntentsConfig>,
}
impl ChainSpec {
@@ -72,6 +74,7 @@ impl ChainSpec {
target_block_time_seconds: chain.target_block_time_seconds,
warm_start_blocks: chain.warm_start_blocks,
max_gap_fill_blocks: chain.max_gap_fill_blocks,
intents: chain.intents.clone(),
}
}
}
@@ -138,6 +141,7 @@ impl Registry {
let runtime = Arc::new(ChainRuntime::new(spec, rpc));
crate::ingest::warm_start(&runtime, &store, config.server.ticker_blocks).await;
crate::ingest::spawn(Arc::clone(&runtime), store.clone(), &config);
crate::intents::spawn(Arc::clone(&runtime), store.clone());
by_slug.insert(runtime.spec.slug.clone(), Arc::clone(&runtime));
tracked.push(runtime);
}
+260
View File
@@ -62,6 +62,8 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
.route("/v1/chains/{chain}/vesting", get(vesting))
.route("/v1/chains/{chain}/distribution", get(distribution))
.route("/v1/chains/{chain}/wormhole", get(wormhole))
.route("/v1/chains/{chain}/intents/swaps", get(intents_swaps))
.route("/v1/chains/{chain}/intents/price", get(intents_price))
.route("/v1/chains/{chain}/nodes", get(nodes))
.route("/v1/chains/{chain}/nodes/{peer}", get(node))
.route("/v1/chains/{chain}/accounts", get(accounts))
@@ -1692,6 +1694,264 @@ async fn wormhole_deposits(
}))
}
/// Swaps per page, by default and at most.
const SWAP_PAGE: i64 = 200;
const SWAP_PAGE_MAX: i64 = 1000;
/// Trades per counter token a price is taken over, by default and at most.
///
/// A count, not a duration — the same rule as the mining windows. On a thin
/// market a duration would put a single trade from hours ago behind a label
/// that sounds current; a count says exactly what it is.
const PRICE_TRADES: i64 = 50;
const PRICE_TRADES_MAX: i64 = 1000;
#[derive(Debug, Deserialize)]
struct SwapsQuery {
/// `near_height:trade_index`, exclusive.
after: Option<String>,
limit: Option<i64>,
}
#[derive(Debug, Deserialize)]
struct PriceQuery {
/// Latest trades per counter token to summarise.
trades: Option<i64>,
}
fn no_intents(chain: &str) -> Failure {
Failure(
StatusCode::NOT_FOUND,
ApiError::new(
"no_intents",
format!("`{chain}` does not track a token on NEAR Intents"),
),
)
}
fn parse_trade_cursor(raw: &str) -> Result<(u64, u32), Failure> {
raw.split_once(':')
.and_then(|(h, i)| Some((h.parse().ok()?, i.parse().ok()?)))
.ok_or_else(|| {
Failure(
StatusCode::BAD_REQUEST,
ApiError::new(
"malformed_cursor",
format!("`{raw}` is not a `near_height:trade_index` cursor"),
),
)
})
}
/// Token decimals and symbols from the cache, for every token named.
async fn near_tokens(
store: &blackbeard_data::store::Store,
tokens: Vec<String>,
) -> Result<std::collections::HashMap<String, (String, u8)>, Failure> {
let mut out = std::collections::HashMap::new();
for token in tokens {
if out.contains_key(&token) {
continue;
}
if let Some(meta) = store
.near_token(&token)
.await
.map_err(database_unavailable)?
{
out.insert(token, meta);
}
}
Ok(out)
}
/// Settled swaps of the chain's token on NEAR Intents, oldest first.
///
/// Trades only: every row is a `token_diff` pair that `intents.near` executed
/// in a receipt that succeeded. blackbeard/observer#27.
async fn intents_swaps(
State(state): State<AppState>,
Path(chain): Path<String>,
Query(query): Query<SwapsQuery>,
) -> Result<Json<blackbeard_entities::IntentsSwaps>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let config = runtime
.spec
.intents
.clone()
.ok_or_else(|| no_intents(&chain))?;
let id = runtime.id();
let after = match query.after.as_deref() {
Some(raw) => parse_trade_cursor(raw)?,
None => (0, 0),
};
let limit = query.limit.unwrap_or(SWAP_PAGE).clamp(1, SWAP_PAGE_MAX);
let rows = state
.store
.intent_trades(&id, &config.asset, after, limit)
.await
.map_err(database_unavailable)?;
let scan = state
.store
.intents_scan(&id, &config.asset)
.await
.map_err(database_unavailable)?;
let named: Vec<String> = std::iter::once(config.asset.clone())
.chain(rows.iter().map(|r| r.counter_token.clone()))
.collect();
let tokens = near_tokens(&state.store, named).await?;
let asset_decimals = tokens.get(&config.asset).map(|t| t.1);
let full = rows.len() as i64 == limit;
let next = rows
.last()
.filter(|_| full)
.map(|r| format!("{}:{}", r.near_height, r.trade_index));
let swaps = rows
.into_iter()
.map(|r| {
let counter = tokens.get(&r.counter_token);
let price = match (
asset_decimals,
counter,
r.asset_amount.parse::<u128>(),
r.counter_amount.parse::<u128>(),
) {
(Some(ad), Some((_, cd)), Ok(a), Ok(c)) => {
Some(blackbeard_core::intents::price(a, ad, c, *cd))
}
_ => None,
};
blackbeard_entities::IntentsSwap {
near_height: r.near_height,
trade_index: r.trade_index,
at: r.at,
tx_hash: r.tx_hash,
receipt_id: r.receipt_id,
counter_symbol: counter.map(|c| c.0.clone()),
counter_token: r.counter_token,
asset_amount: BigUintDec(r.asset_amount),
counter_amount: BigUintDec(r.counter_amount),
price,
side: r.side,
taker: r.taker,
referral: r.referral,
accounts: r.accounts,
intent_hashes: r.intent_hashes,
}
})
.collect();
Ok(Json(blackbeard_entities::IntentsSwaps {
chain: id,
asset: config.asset,
swaps,
next,
indexed_from: scan.map(|(low, _)| low),
indexed_to: scan.map(|(_, high)| high),
}))
}
/// The chain's token priced by what NEAR Intents settled, per counter token.
///
/// In counter-token units only. No USD until a stablecoin pairing exists to
/// derive one from — identified by contract, never by the symbol a token gave
/// itself — and never from a quote.
async fn intents_price(
State(state): State<AppState>,
Path(chain): Path<String>,
Query(query): Query<PriceQuery>,
) -> Result<Json<blackbeard_entities::IntentsPrice>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let config = runtime
.spec
.intents
.clone()
.ok_or_else(|| no_intents(&chain))?;
let id = runtime.id();
let window = query
.trades
.unwrap_or(PRICE_TRADES)
.clamp(1, PRICE_TRADES_MAX);
let rows = state
.store
.latest_intent_trades(&id, &config.asset, window)
.await
.map_err(database_unavailable)?;
let scan = state
.store
.intents_scan(&id, &config.asset)
.await
.map_err(database_unavailable)?;
let named: Vec<String> = std::iter::once(config.asset.clone())
.chain(rows.iter().map(|r| r.counter_token.clone()))
.collect();
let tokens = near_tokens(&state.store, named).await?;
let asset_decimals = tokens.get(&config.asset).map(|t| t.1);
// Rows arrive grouped by counter, newest first within each.
let mut counters: Vec<blackbeard_entities::CounterPrice> = Vec::new();
for group in rows.chunk_by(|a, b| a.counter_token == b.counter_token) {
let amounts: Vec<(u128, u128)> = group
.iter()
.filter_map(|r| Some((r.asset_amount.parse().ok()?, r.counter_amount.parse().ok()?)))
.collect();
// Exact sums. A u128 holds 3.4e38; a thousand trades of any real
// token's entire supply does not reach it, and if one ever did the
// counter is left out rather than reported wrapped.
let sum = |pick: fn(&(u128, u128)) -> u128| {
amounts
.iter()
.try_fold(0u128, |acc, t| acc.checked_add(pick(t)))
};
let (Some(asset_volume), Some(counter_volume)) = (sum(|t| t.0), sum(|t| t.1)) else {
tracing::warn!(counter = %group[0].counter_token, "volume overflowed a u128; omitted");
continue;
};
let newest = &group[0];
let oldest = &group[group.len() - 1];
let counter = tokens.get(&newest.counter_token);
let decimals = counter.map(|c| c.1);
let priced = asset_decimals.zip(decimals);
counters.push(blackbeard_entities::CounterPrice {
counter_token: newest.counter_token.clone(),
symbol: counter.map(|c| c.0.clone()),
decimals,
trades: group.len() as u32,
last_price: priced.and_then(|(ad, cd)| {
let (a, c) = amounts.first()?;
Some(blackbeard_core::intents::price(*a, ad, *c, cd))
}),
last_at: newest.at,
last_near_height: newest.near_height,
vwap: priced.and_then(|(ad, cd)| blackbeard_core::intents::vwap(&amounts, ad, cd)),
asset_volume: BigUintDec(asset_volume.to_string()),
counter_volume: BigUintDec(counter_volume.to_string()),
first_at: oldest.at,
});
}
counters.sort_by_key(|c| std::cmp::Reverse(c.last_at));
let listing = runtime
.intents_listing
.read()
.unwrap_or_else(|p| p.into_inner())
.clone();
Ok(Json(blackbeard_entities::IntentsPrice {
chain: id,
asset: config.asset,
asset_decimals,
window_trades: window as u32,
counters,
listing,
reads: crate::intents::READS.to_owned(),
indexed_from: scan.map(|(low, _)| low),
indexed_to: scan.map(|(_, high)| high),
}))
}
/// Paging cursor for the block index.
#[derive(Debug, Deserialize)]
struct BeforeQuery {
+6
View File
@@ -178,6 +178,10 @@ pub struct ChainRuntime {
/// The vesting schedules and what they have released, cached the same way.
pub vesting:
tokio::sync::RwLock<Option<(std::time::Instant, blackbeard_entities::VestingSummary)>>,
/// The intents asset's supply and listing, as last read. `std` lock, never
/// held across an await: the reader clones it out and the writer swaps a
/// whole value in.
pub intents_listing: RwLock<Option<blackbeard_entities::IntentsListing>>,
/// 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
@@ -223,6 +227,7 @@ impl ChainRuntime {
roles: tokio::sync::RwLock::new(None),
concentration: tokio::sync::RwLock::new(None),
vesting: tokio::sync::RwLock::new(None),
intents_listing: RwLock::new(None),
pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()),
subscribers: Default::default(),
}
@@ -521,6 +526,7 @@ mod tests {
target_block_time_seconds: 6.0,
warm_start_blocks: 100,
max_gap_fill_blocks: 100,
intents: None,
},
RpcClient::new("http://127.0.0.1:9944", Duration::from_secs(5)).unwrap(),
))
+440
View File
@@ -0,0 +1,440 @@
//! Swaps settled by NEAR Intents, read from `intents.near`'s own event logs.
//!
//! NEAR Intents has no pool, no oracle and no stored price. `intents.near` is a
//! settlement contract: signed `token_diff` intents are executed atomically,
//! and a price exists only as the ratio between two amounts that actually
//! changed hands. That ratio is what this module extracts, and nothing else —
//! a quote from 1Click is an offer, not a trade, and never comes through here.
//! See blackbeard/observer#27.
//!
//! Every executed intent logs NEP-297 events, `EVENT_JSON:{…}`, in the receipt
//! outcome that executed it. Two filters decide what counts, and both are
//! needed:
//!
//! - **The receipt succeeded.** NEAR rolls state back on a failed receipt but
//! still reports the logs it wrote first, so a `token_diff` in a failed
//! outcome describes a trade that did not happen. That check is the caller's,
//! because the status is on the outcome, not in the logs.
//! - **Its `intent_hash` is in `intents_executed`** in the same outcome. A
//! transaction can carry intents that do not execute.
//!
//! No I/O here: the logs are handed in, and the tests run against bytes
//! captured off mainnet.
use std::collections::{BTreeMap, BTreeSet};
use serde_json::Value;
/// Prefix NEP-297 puts on every structured log line.
pub const EVENT_PREFIX: &str = "EVENT_JSON:";
/// One `token_diff` entry: one signer's side of an executed intent.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Leg {
/// The intent this diff belongs to.
pub intent_hash: String,
/// Who signed it — a solver's named account or a user's implicit one.
pub account_id: String,
/// Signed amount per multi-token id, in that token's base units: negative
/// is what the signer gave, positive what they received.
pub diff: BTreeMap<String, i128>,
/// The front end that submitted it, when it said. Optional in the
/// protocol, which is why it can only ever *suggest* who the taker was.
pub referral: Option<String>,
/// Protocol fee taken, per token, in base units.
pub fees: BTreeMap<String, u128>,
}
/// Which way a trade went, from the taker's side, in terms of the tracked
/// asset.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Side {
/// The taker received the asset.
Buy,
/// The taker gave the asset up.
Sell,
}
impl Side {
/// `buy` or `sell`, as stored.
pub fn as_str(self) -> &'static str {
match self {
Side::Buy => "buy",
Side::Sell => "sell",
}
}
}
/// The tracked asset against one counter token, within one receipt outcome.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Trade {
/// The other token, as a multi-token id (`nep141:…`).
pub counter: String,
/// The tracked asset that changed hands, in base units.
///
/// The sum of every *positive* delta of the asset across the legs, so each
/// unit is counted once whichever side the taker was on. Positive rather
/// than negative because what a receiver got is net of the protocol fee —
/// the amount that actually arrived.
pub asset_amount: u128,
/// The counter token that changed hands, counted the same way.
pub counter_amount: u128,
/// The taker's side, when exactly one leg carried a `referral`. Inferred:
/// the field is optional, and solvers can be implicit accounts too.
pub side: Option<Side>,
/// The leg with the referral, when there was exactly one.
pub taker: Option<String>,
/// That leg's referral.
pub referral: Option<String>,
/// Every account with a leg in this trade, in log order.
pub accounts: Vec<String>,
/// Every intent in this trade, in log order.
pub intent_hashes: Vec<String>,
/// The legs themselves, so the figures above stay checkable.
pub legs: Vec<Leg>,
}
/// Every `token_diff` leg in one receipt outcome's logs whose intent executed.
///
/// The caller must already know the outcome succeeded; see the module docs.
/// A log line that is not a NEP-297 event, or an event this does not read, is
/// skipped. A `token_diff` entry with an amount that does not parse is skipped
/// too and counted in the second value, so a format change shows up as a
/// number rather than as quietly fewer trades.
pub fn executed_legs(logs: &[String]) -> (Vec<Leg>, usize) {
let mut legs = Vec::new();
let mut executed = BTreeSet::new();
let mut malformed = 0;
for line in logs {
let Some(event) = parse_event(line) else {
continue;
};
if event.get("standard").and_then(Value::as_str) != Some("dip4") {
continue;
}
let Some(data) = event.get("data").and_then(Value::as_array) else {
continue;
};
match event.get("event").and_then(Value::as_str) {
Some("intents_executed") => {
for entry in data {
if let Some(hash) = entry.get("intent_hash").and_then(Value::as_str) {
executed.insert(hash.to_owned());
}
}
}
Some("token_diff") => {
for entry in data {
match leg(entry) {
Some(l) => legs.push(l),
None => malformed += 1,
}
}
}
_ => {}
}
}
legs.retain(|l| executed.contains(&l.intent_hash));
(legs, malformed)
}
/// The JSON of one `EVENT_JSON:` log line.
fn parse_event(line: &str) -> Option<Value> {
serde_json::from_str(line.strip_prefix(EVENT_PREFIX)?).ok()
}
fn leg(entry: &Value) -> Option<Leg> {
let diff = entry
.get("diff")?
.as_object()?
.iter()
.map(|(token, amount)| Some((token.clone(), amount.as_str()?.parse::<i128>().ok()?)))
.collect::<Option<BTreeMap<_, _>>>()?;
let fees = match entry.get("fees_collected") {
None | Some(Value::Null) => BTreeMap::new(),
Some(v) => v
.as_object()?
.iter()
.map(|(token, amount)| Some((token.clone(), amount.as_str()?.parse::<u128>().ok()?)))
.collect::<Option<BTreeMap<_, _>>>()?,
};
Some(Leg {
intent_hash: entry.get("intent_hash")?.as_str()?.to_owned(),
account_id: entry.get("account_id")?.as_str()?.to_owned(),
diff,
referral: entry
.get("referral")
.and_then(Value::as_str)
.map(str::to_owned),
fees,
})
}
/// Group one outcome's legs into trades of `asset` against each counter token.
///
/// Only a leg whose diff names exactly two tokens — the asset and one other —
/// is a pricing leg. One with three or more is a bundle with no single price
/// in it, and one naming only the asset is a transfer; both are left out and
/// counted in the second value, so they are visible rather than silently
/// absent.
///
/// A swap is *not* assumed to be two legs. One user intent can be filled by
/// several solvers, so legs are grouped by counter token and summed, and a
/// trade is one asset/counter pair within one outcome.
pub fn trades(legs: &[Leg], asset: &str) -> (Vec<Trade>, usize) {
let mut by_counter: BTreeMap<String, Vec<&Leg>> = BTreeMap::new();
let mut unpriced = 0;
for leg in legs.iter().filter(|l| l.diff.contains_key(asset)) {
let others: Vec<&String> = leg.diff.keys().filter(|t| *t != asset).collect();
match others.as_slice() {
[counter] => by_counter.entry((*counter).clone()).or_default().push(leg),
_ => unpriced += 1,
}
}
let mut out = Vec::new();
for (counter, legs) in by_counter {
let positive = |token: &str| -> u128 {
legs.iter()
.filter_map(|l| l.diff.get(token).copied())
.filter(|d| *d > 0)
.map(|d| d as u128)
.sum()
};
let asset_amount = positive(asset);
let counter_amount = positive(&counter);
if asset_amount == 0 || counter_amount == 0 {
unpriced += legs.len();
continue;
}
let referred: Vec<&&Leg> = legs.iter().filter(|l| l.referral.is_some()).collect();
let (side, taker, referral) = match referred.as_slice() {
[one] => (
one.diff
.get(asset)
.map(|d| if *d < 0 { Side::Sell } else { Side::Buy }),
Some(one.account_id.clone()),
one.referral.clone(),
),
_ => (None, None, None),
};
out.push(Trade {
asset_amount,
counter_amount,
side,
taker,
referral,
accounts: legs.iter().map(|l| l.account_id.clone()).collect(),
intent_hashes: legs.iter().map(|l| l.intent_hash.clone()).collect(),
legs: legs.iter().map(|l| (*l).clone()).collect(),
counter,
});
}
(out, unpriced)
}
/// The shard an account lives on, under a shard layout.
///
/// Accounts are split into contiguous ranges by `boundary_accounts`, compared
/// as byte strings; an account belongs to the range whose lower boundary is the
/// greatest one not above it. `shard_ids` lists the shard for each range in
/// order — and shard ids are *not* range indices: resharding renumbers them, so
/// a layout read once and kept would quietly start reading the wrong shard.
pub fn shard_for(account: &str, boundary_accounts: &[String], shard_ids: &[u64]) -> Option<u64> {
let index = boundary_accounts
.iter()
.take_while(|b| b.as_bytes() <= account.as_bytes())
.count();
shard_ids.get(index).copied()
}
/// Base units as a token amount, for display arithmetic only.
///
/// The stored amounts are exact; this is where precision is given up, and it is
/// only ever given up on the way to a ratio.
pub fn scaled(amount: u128, decimals: u8) -> f64 {
amount as f64 / 10f64.powi(i32::from(decimals))
}
/// Counter tokens per one unit of the asset.
pub fn price(
asset_amount: u128,
asset_decimals: u8,
counter_amount: u128,
counter_decimals: u8,
) -> f64 {
scaled(counter_amount, counter_decimals) / scaled(asset_amount, asset_decimals)
}
/// Volume-weighted price over a set of trades, `(asset_amount, counter_amount)`.
///
/// The ratio of the sums, not the mean of the ratios: a dust trade at a silly
/// price moves a mean as much as a large one does, and moves this by its size.
pub fn vwap(trades: &[(u128, u128)], asset_decimals: u8, counter_decimals: u8) -> Option<f64> {
let asset: f64 = trades.iter().map(|t| scaled(t.0, asset_decimals)).sum();
let counter: f64 = trades.iter().map(|t| scaled(t.1, counter_decimals)).sum();
(asset > 0.0).then(|| counter / asset)
}
#[cfg(test)]
mod tests {
use super::*;
const ZEC: &str = "nep141:zec.omft.near";
/// USDT on Tron, bridged. Its `ft_metadata.symbol` is `USDT`; the issue
/// that motivated this read the contract name and called it TRON, which is
/// the case for never taking identity from a name.
const USDT: &str = "nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near";
/// The `intents.near` receipt outcome of mainnet tx `85uy9uCF…`, block
/// 218,261,895: a 1Click swap of USDT (on Tron) for ZEC, captured from
/// neardata.
fn fixture() -> Vec<String> {
serde_json::from_str(include_str!("../tests/intents-zec-usdt-218261895.json")).unwrap()
}
#[test]
fn both_legs_of_a_real_swap_are_read_and_both_executed() {
let (legs, malformed) = executed_legs(&fixture());
assert_eq!(malformed, 0);
assert_eq!(legs.len(), 2);
assert_eq!(legs[0].account_id, "blockzen-1-main.near");
assert_eq!(legs[0].diff[ZEC], -702_585_891);
assert_eq!(legs[0].fees[ZEC], 703);
assert_eq!(legs[1].referral.as_deref(), Some("1click-swapkit"));
assert_eq!(legs[1].diff[USDT], -9_036_300_000);
}
#[test]
fn a_diff_whose_intent_did_not_execute_is_not_a_trade() {
let logs: Vec<String> = fixture()
.into_iter()
.filter(|l| !l.contains("\"intents_executed\""))
.collect();
let (legs, _) = executed_legs(&logs);
assert!(legs.is_empty());
}
#[test]
fn a_real_swap_is_one_trade_counted_once() {
let (legs, _) = executed_legs(&fixture());
let (trades, unpriced) = trades(&legs, ZEC);
assert_eq!(unpriced, 0);
assert_eq!(trades.len(), 1);
let t = &trades[0];
assert_eq!(t.counter, USDT);
// What the user received, net of the 703 fee — not both sides added.
assert_eq!(t.asset_amount, 702_585_188);
// What the solver received, net of the 9,037 fee.
assert_eq!(t.counter_amount, 9_036_290_963);
assert_eq!(t.side, Some(Side::Buy));
assert_eq!(t.referral.as_deref(), Some("1click-swapkit"));
assert_eq!(t.intent_hashes.len(), 2);
// ZEC has 8 decimals and USDT 6: about 1,286 USDT per ZEC.
let p = price(t.asset_amount, 8, t.counter_amount, 6);
assert!((p - 1286.15).abs() < 0.01, "{p}");
}
#[test]
fn the_same_trade_from_the_other_asset_is_the_other_side() {
let (legs, _) = executed_legs(&fixture());
let (trades, _) = trades(&legs, USDT);
assert_eq!(trades[0].side, Some(Side::Sell));
assert_eq!(trades[0].asset_amount, 9_036_290_963);
}
#[test]
fn an_asset_not_in_the_swap_yields_nothing() {
let (legs, _) = executed_legs(&fixture());
assert!(trades(&legs, "nep141:qtc.omft.near").0.is_empty());
}
#[test]
fn several_solvers_filling_one_intent_are_summed_not_paired() {
let leg = |who: &str, a: i128, c: i128, referral: Option<&str>| Leg {
intent_hash: format!("h-{who}"),
account_id: who.into(),
diff: [(ZEC.to_string(), a), (USDT.to_string(), c)].into(),
referral: referral.map(Into::into),
fees: BTreeMap::new(),
};
let legs = vec![
leg("user", -300, 3000, Some("quantus")),
leg("solver-a", 100, -1000, None),
leg("solver-b", 200, -2000, None),
];
let (trades, unpriced) = trades(&legs, ZEC);
assert_eq!(unpriced, 0);
assert_eq!(trades.len(), 1);
assert_eq!(trades[0].asset_amount, 300);
assert_eq!(trades[0].counter_amount, 3000);
assert_eq!(trades[0].side, Some(Side::Sell));
assert_eq!(trades[0].taker.as_deref(), Some("user"));
}
#[test]
fn a_bundle_of_three_tokens_has_no_price_and_says_so() {
let legs = vec![Leg {
intent_hash: "h".into(),
account_id: "a".into(),
diff: [
(ZEC.to_string(), 1),
(USDT.to_string(), -1),
("nep141:x".to_string(), -1),
]
.into(),
referral: None,
fees: BTreeMap::new(),
}];
let (trades, unpriced) = trades(&legs, ZEC);
assert!(trades.is_empty());
assert_eq!(unpriced, 1);
}
#[test]
fn without_exactly_one_referral_the_side_is_not_guessed() {
let (mut legs, _) = executed_legs(&fixture());
legs[1].referral = None;
let (trades, _) = trades(&legs, ZEC);
assert_eq!(trades[0].side, None);
assert_eq!(trades[0].taker, None);
}
#[test]
fn intents_near_is_on_shard_seven_under_layout_v3() {
// `EXPERIMENTAL_protocol_config.shard_layout.V3`, mainnet, 2026-10-02.
let boundaries: Vec<String> = [
"650",
"aurora",
"aurora-0",
"earn.kaiching",
"game.hot.tg",
"game.hot.tg-0",
"kkuuue2akv_1630967379.near",
"tge-lockup.sweat",
"wallet.ka",
]
.map(String::from)
.into();
let ids = [10, 11, 1, 8, 9, 6, 7, 4, 12, 13];
assert_eq!(shard_for("intents.near", &boundaries, &ids), Some(7));
// A boundary belongs to the range it opens, not the one it closes.
assert_eq!(shard_for("aurora", &boundaries, &ids), Some(1));
assert_eq!(shard_for("0", &boundaries, &ids), Some(10));
assert_eq!(shard_for("zzz", &boundaries, &ids), Some(13));
}
#[test]
fn vwap_weights_by_size() {
// 1 unit at 10 and 9 units at 20: the mean of ratios is 15, the volume
// weighted price 19.
let v = vwap(&[(1, 10), (9, 180)], 0, 0).unwrap();
assert!((v - 19.0).abs() < 1e-9);
assert_eq!(vwap(&[], 0, 0), None);
}
}
+1
View File
@@ -18,6 +18,7 @@ pub mod concentration;
pub mod digest;
pub mod geo;
pub mod hashrate;
pub mod intents;
pub mod pallet;
pub mod runtime;
pub mod scale;
@@ -0,0 +1,10 @@
[
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"812iSWw5jq51fAku4UzSYx1t3aBejoyv23mFMbHpBhK6\",\"account_id\":\"blockzen-1-main.near\",\"diff\":{\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\":\"9036290963\",\"nep141:zec.omft.near\":\"-702585891\"},\"fees_collected\":{\"nep141:zec.omft.near\":\"703\"}}]}",
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"GZEag38RnD9UooNUEwE4CGUks6gv3zRzKXeJz1izcb8S\",\"account_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"diff\":{\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\":\"-9036300000\",\"nep141:zec.omft.near\":\"702585188\"},\"referral\":\"1click-swapkit\",\"fees_collected\":{\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\":\"9037\"}}]}",
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"transfer\",\"data\":[{\"intent_hash\":\"GZEag38RnD9UooNUEwE4CGUks6gv3zRzKXeJz1izcb8S\",\"account_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"receiver_id\":\"143f2bb439575c286f4f585163f0e5fb4e464f8ce57ec06ae80741407d3cfb31\",\"tokens\":{\"nep141:zec.omft.near\":\"975706\"}}]}",
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"transfer\",\"data\":[{\"intent_hash\":\"GZEag38RnD9UooNUEwE4CGUks6gv3zRzKXeJz1izcb8S\",\"account_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"receiver_id\":\"2f09a4dc4b29cfdfe5d7a14afb04f0aa6c68e0b54f3bb606b168919b68085059\",\"tokens\":{\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\":\"63700000\"}}]}",
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"ft_withdraw\",\"data\":[{\"intent_hash\":\"GZEag38RnD9UooNUEwE4CGUks6gv3zRzKXeJz1izcb8S\",\"account_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"token\":\"zec.omft.near\",\"receiver_id\":\"omni.bridge.near\",\"amount\":\"701609482\",\"msg\":\"{\\\"recipient\\\":\\\"zcash:t1ZDvmCzfadaYBMMkaLjtwiCy2ThivFUeFg\\\",\\\"fee\\\":\\\"0\\\",\\\"native_token_fee\\\":\\\"0\\\",\\\"external_id\\\":\\\"44a44e4e-ca73-41fe-b8b8-07a235d209ca\\\",\\\"msg\\\":\\\"{\\\\\\\"MaxGasFee\\\\\\\":\\\\\\\"22000\\\\\\\"}\\\"}\",\"min_gas\":\"37400000000000\"}]}",
"EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"intents_executed\",\"data\":[{\"intent_hash\":\"812iSWw5jq51fAku4UzSYx1t3aBejoyv23mFMbHpBhK6\",\"account_id\":\"blockzen-1-main.near\",\"nonce\":\"opzQM8Om4DlM6R4UD2xIzSnjfZnHhw5f6jXQ51GJY+Q=\"},{\"intent_hash\":\"GZEag38RnD9UooNUEwE4CGUks6gv3zRzKXeJz1izcb8S\",\"account_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"nonce\":\"Vij2xgAlKBKzgIfZV+7V2hieyzKhDhK14/M2W/bsLlA=\"}]}",
"EVENT_JSON:{\"standard\":\"nep245\",\"version\":\"1.0.0\",\"event\":\"mt_transfer\",\"data\":[{\"old_owner_id\":\"blockzen-1-main.near\",\"new_owner_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"token_ids\":[\"nep141:zec.omft.near\"],\"amounts\":[\"701609482\"]},{\"old_owner_id\":\"blockzen-1-main.near\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:zec.omft.near\"],\"amounts\":[\"703\"]},{\"old_owner_id\":\"blockzen-1-main.near\",\"new_owner_id\":\"143f2bb439575c286f4f585163f0e5fb4e464f8ce57ec06ae80741407d3cfb31\",\"token_ids\":[\"nep141:zec.omft.near\"],\"amounts\":[\"975706\"]},{\"old_owner_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"new_owner_id\":\"2f09a4dc4b29cfdfe5d7a14afb04f0aa6c68e0b54f3bb606b168919b68085059\",\"token_ids\":[\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\"],\"amounts\":[\"63700000\"]},{\"old_owner_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\"],\"amounts\":[\"9037\"]},{\"old_owner_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"new_owner_id\":\"blockzen-1-main.near\",\"token_ids\":[\"nep141:tron-d28a265909efecdcee7c5028585214ea0b96f015.omft.near\"],\"amounts\":[\"9036290963\"]}]}",
"EVENT_JSON:{\"standard\":\"nep245\",\"version\":\"1.0.0\",\"event\":\"mt_burn\",\"data\":[{\"owner_id\":\"42f3f2161b85004ad5b937d1fea9729925450e15ce61698b746214101d178171\",\"token_ids\":[\"nep141:zec.omft.near\"],\"amounts\":[\"701609482\"],\"memo\":\"withdraw\"}]}"
]
+1
View File
@@ -12,6 +12,7 @@ repository.workspace = true
blackbeard-core.workspace = true
blackbeard-entities.workspace = true
base64.workspace = true
bigdecimal.workspace = true
chrono.workspace = true
futures-util.workspace = true
@@ -0,0 +1,72 @@
-- Swaps settled by NEAR Intents, for the price of a chain's token there.
-- blackbeard/observer#27.
--
-- NEAR Intents has no pool and no stored price: a price exists only as the
-- ratio between two amounts that actually changed hands in `intents.near`, so
-- that is all this holds. Quotes are offers and never land here.
--
-- Keyed by the tracked `asset` as well as the chain, because the asset is
-- configuration: the indexer was proven against ZEC before QTC had a single
-- trade, and switching it must start a fresh scan rather than inherit one that
-- was looking for something else.
-- One asset/counter pair settled within one receipt outcome.
--
-- `near_height` is the block the `execute_intents` transaction was included in.
-- The relay signs as `intents.near` itself, so its first receipt executes in
-- that same block and the trade is settled there; `at` is that block's own
-- timestamp, the NEAR counterpart of `authored_at`.
create table intents_trade (
chain text not null references chain (id) on delete cascade,
asset text not null,
near_height bigint not null,
trade_index integer not null,
at timestamptz not null,
tx_hash text not null,
receipt_id text not null,
counter_token text not null,
-- Base units, exact. The sum of positive deltas across the trade's legs,
-- so each unit is counted once whichever side the taker was on.
asset_amount numeric not null,
counter_amount numeric not null,
-- Inferred from the one leg carrying a `referral`, when there is exactly
-- one. Null means "could not tell", never "neither".
side text check (side in ('buy', 'sell')),
taker text,
referral text,
accounts text[] not null,
intent_hashes text[] not null,
-- The legs as the contract logged them, so every figure above stays
-- checkable against the source.
legs jsonb not null,
primary key (chain, asset, near_height, trade_index)
);
create index intents_trade_by_counter
on intents_trade (chain, asset, counter_token, near_height desc, trade_index desc);
-- How far the scan has read, as one contiguous interval of NEAR heights.
--
-- The same shape as `event_scan` and for the same reason: a block with no trade
-- and a block never read look identical in `intents_trade`, and nearly every
-- block has no trade. `high` follows the tip; `low` walks back towards the
-- configured start.
create table intents_scan (
chain text not null references chain (id) on delete cascade,
asset text not null,
low bigint not null,
high bigint not null,
updated_at timestamptz not null default now(),
primary key (chain, asset)
);
-- A NEP-141 token's own `ft_metadata`, so decimals are asked for once.
--
-- `symbol` is the token's self-description and is display only: anyone can
-- deploy a token that calls itself USDC, so nothing may treat it as identity.
create table near_token (
token_id text primary key,
symbol text not null,
decimals smallint not null,
fetched_at timestamptz not null default now()
);
+1
View File
@@ -8,6 +8,7 @@
#![deny(missing_docs)]
pub mod near;
pub mod rpc;
pub mod store;
pub mod telemetry;
+356
View File
@@ -0,0 +1,356 @@
//! Reading NEAR mainnet, for the swaps `intents.near` settles.
//!
//! Plain JSON-RPC against public endpoints, through the same [`RpcClient`] the
//! Quantus side uses — so failover means the same thing here: a transport
//! failure moves to the next endpoint, a JSON-RPC error is the node answering
//! and stops. See blackbeard/observer#27 for how the source was chosen:
//!
//! - **neardata** returns a whole block with every outcome's logs in one
//! request, which would be ideal, but its free tier allows 30 blocks a minute
//! against NEAR's ~100. It falls behind the tip immediately.
//! - **`chunk` per block on one shard** is about 10/s on FastNEAR's free RPC,
//! so following one shard is comfortable. That is what this reads.
//!
//! Two clients, because a NEAR RPC node keeps about a day: `rpc.mainnet.*` held
//! ~21 hours when measured. Anything older goes to an archival endpoint, and so
//! does anything the live one says it does not know.
use std::time::Duration;
use chrono::{DateTime, Utc};
use serde::Deserialize;
use serde_json::{Value, json};
use crate::DataError;
use crate::rpc::RpcClient;
/// A transaction in a chunk, reduced to what decides whether it is read.
#[derive(Debug, Clone, Deserialize)]
pub struct ChunkTransaction {
/// Base58 transaction hash.
pub hash: String,
/// Who signed it — also the account `tx_status` needs to find it.
pub signer_id: String,
/// The contract it calls.
pub receiver_id: String,
/// Its actions, as the RPC renders them.
pub actions: Vec<Value>,
}
impl ChunkTransaction {
/// Whether any action is a function call to `method`.
pub fn calls(&self, method: &str) -> bool {
self.actions.iter().any(|a| {
a.get("FunctionCall")
.and_then(|f| f.get("method_name"))
.and_then(Value::as_str)
== Some(method)
})
}
}
/// One shard's chunk at one height.
#[derive(Debug, Clone)]
pub struct Chunk {
/// Transactions included in it.
pub transactions: Vec<ChunkTransaction>,
}
/// One receipt's execution, from `tx_status`.
#[derive(Debug, Clone)]
pub struct ReceiptOutcome {
/// Receipt id.
pub id: String,
/// The account that executed it.
pub executor_id: String,
/// Block it executed in.
pub block_hash: String,
/// Its log lines, `EVENT_JSON:` ones included.
pub logs: Vec<String>,
/// True for `SuccessValue` and `SuccessReceiptId`. A failed receipt still
/// reports the logs it wrote before failing, and its state changes were
/// rolled back — so this is the first thing a reader must check.
pub succeeded: bool,
}
/// A shard layout: which shard each account range is on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShardLayout {
/// Lower bounds of every range but the first.
pub boundary_accounts: Vec<String>,
/// Shard id for each range, in range order.
pub shard_ids: Vec<u64>,
}
/// Live and archival NEAR endpoints.
#[derive(Debug, Clone)]
pub struct NearClient {
live: RpcClient,
archive: RpcClient,
}
/// What a NEAR node calls "I have no such block", in its error's `cause.name`.
///
/// Said both for a height that was never produced — NEAR skips heights — and
/// for one the node has pruned. Only the endpoint's retention tells them apart,
/// which is why a live node saying it is followed by asking an archive.
fn unknown(e: &DataError) -> bool {
matches!(e, DataError::Rpc { message, .. }
if message.contains("UNKNOWN_BLOCK")
|| message.contains("UNKNOWN_CHUNK")
|| message.contains("UNKNOWN_TRANSACTION")
|| message.contains("GARBAGE_COLLECTED_BLOCK"))
}
impl NearClient {
/// A client over `live` endpoints, falling back to `archive` ones.
pub fn new(
live: Vec<String>,
archive: Vec<String>,
timeout: Duration,
) -> Result<Self, DataError> {
Ok(Self {
live: RpcClient::with_endpoints(live, timeout)?,
archive: RpcClient::with_endpoints(archive, timeout)?,
})
}
/// Ask the live endpoints, and the archive when they do not know.
async fn deep(&self, method: &str, params: Value) -> Result<Value, DataError> {
match self.live.call(method, params.clone()).await {
Err(e) if unknown(&e) => self.archive.call(method, params).await,
other => other,
}
}
/// The latest final height, and the oldest the live endpoint still holds.
pub async fn heights(&self) -> Result<(u64, u64), DataError> {
let status = self.live.call("status", json!([])).await?;
let sync = &status["sync_info"];
let field = |name: &str| {
sync[name].as_u64().ok_or_else(|| DataError::Malformed {
method: "status".into(),
message: format!("no {name}"),
})
};
// `latest_block_height` is the head, not finality. Reading only final
// blocks keeps a fork from writing a trade that did not happen, and two
// or three blocks is the whole cost.
let block = self
.live
.call("block", json!({"finality": "final"}))
.await?;
let final_height =
block["header"]["height"]
.as_u64()
.ok_or_else(|| DataError::Malformed {
method: "block".into(),
message: "no header.height".into(),
})?;
Ok((final_height, field("earliest_block_height")?))
}
/// The shard layout in force at `height`.
///
/// Read per batch rather than once: resharding renumbers shards, and a
/// layout cached across one would follow the wrong shard while every call
/// kept succeeding.
pub async fn shard_layout(&self, height: u64) -> Result<ShardLayout, DataError> {
let config = self
.deep("EXPERIMENTAL_protocol_config", json!({"block_id": height}))
.await?;
let layout = config["shard_layout"]
.as_object()
.and_then(|o| o.values().next())
.ok_or_else(|| DataError::Malformed {
method: "EXPERIMENTAL_protocol_config".into(),
message: "no shard_layout".into(),
})?;
let boundary_accounts = serde_json::from_value(layout["boundary_accounts"].clone())?;
let shard_ids = serde_json::from_value(layout["shard_ids"].clone())?;
Ok(ShardLayout {
boundary_accounts,
shard_ids,
})
}
/// The chunk `shard` produced at `height`, if it produced one.
///
/// `None` for a height that was never produced, and for a block that
/// carried no new chunk for this shard: the RPC then returns the shard's
/// *previous* chunk, and reading it again would count its trades twice.
/// `height_included` is what tells them apart.
///
/// `live_from` is the oldest height the live endpoint holds. At or above
/// it, the live endpoint's "unknown block" means the height was skipped;
/// below it, it means pruned, and the archive is asked.
pub async fn chunk(
&self,
height: u64,
shard: u64,
live_from: u64,
) -> Result<Option<Chunk>, DataError> {
let params = json!({"block_id": height, "shard_id": shard});
let result = if height >= live_from {
self.live.call("chunk", params).await
} else {
self.archive.call("chunk", params).await
};
let chunk = match result {
Ok(c) => c,
Err(e) if unknown(&e) => return Ok(None),
Err(e) => return Err(e),
};
if chunk["header"]["height_included"].as_u64() != Some(height) {
return Ok(None);
}
Ok(Some(Chunk {
transactions: serde_json::from_value(chunk["transactions"].clone())?,
}))
}
/// Every receipt outcome of a transaction, once it has executed.
pub async fn tx_outcomes(
&self,
hash: &str,
signer: &str,
) -> Result<Vec<ReceiptOutcome>, DataError> {
let status = self
.deep(
"EXPERIMENTAL_tx_status",
json!({"tx_hash": hash, "sender_account_id": signer, "wait_until": "EXECUTED"}),
)
.await?;
let outcomes =
status["receipts_outcome"]
.as_array()
.ok_or_else(|| DataError::Malformed {
method: "EXPERIMENTAL_tx_status".into(),
message: "no receipts_outcome".into(),
})?;
Ok(outcomes
.iter()
.filter_map(|o| {
let outcome = &o["outcome"];
let status = outcome["status"].as_object()?;
Some(ReceiptOutcome {
id: o["id"].as_str()?.to_owned(),
executor_id: outcome["executor_id"].as_str()?.to_owned(),
block_hash: o["block_hash"].as_str()?.to_owned(),
logs: serde_json::from_value(outcome["logs"].clone()).ok()?,
succeeded: status.contains_key("SuccessValue")
|| status.contains_key("SuccessReceiptId"),
})
})
.collect())
}
/// A block's height and timestamp, by hash or height.
pub async fn block_at(&self, block_id: Value) -> Result<(u64, DateTime<Utc>), DataError> {
let block = self.deep("block", json!({"block_id": block_id})).await?;
let header = &block["header"];
let malformed = |what: &str| DataError::Malformed {
method: "block".into(),
message: format!("no header.{what}"),
};
let height = header["height"]
.as_u64()
.ok_or_else(|| malformed("height"))?;
// Nanoseconds, as a string — past what an f64 holds exactly.
let nanos: i64 = header["timestamp_nanosec"]
.as_str()
.and_then(|s| s.parse().ok())
.or_else(|| header["timestamp"].as_i64())
.ok_or_else(|| malformed("timestamp"))?;
Ok((height, DateTime::from_timestamp_nanos(nanos)))
}
/// Call a view method at the final block and decode its JSON result.
pub async fn view(
&self,
contract: &str,
method: &str,
args: Value,
) -> Result<Value, DataError> {
use base64::Engine as _;
let result = self
.live
.call(
"query",
json!({
"request_type": "call_function",
"finality": "final",
"account_id": contract,
"method_name": method,
"args_base64": base64::engine::general_purpose::STANDARD.encode(args.to_string()),
}),
)
.await?;
let bytes: Vec<u8> = serde_json::from_value(result["result"].clone())?;
Ok(serde_json::from_slice(&bytes)?)
}
}
/// A NEP-141 token's display facts.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TokenMetadata {
/// Its own `ft_metadata.symbol`. Self-reported: anyone can deploy a token
/// calling itself USDC, so this is a label and never an identity.
pub symbol: String,
/// Decimal places in its base unit.
pub decimals: u8,
}
impl NearClient {
/// `ft_metadata` for a multi-token id, when it is a NEP-141 one.
///
/// `None` for `nep245:` and `nep171:` ids, which have no `ft_metadata` to
/// ask — those counters are left unpriced rather than given a guessed
/// decimal count.
pub async fn token_metadata(&self, token_id: &str) -> Result<Option<TokenMetadata>, DataError> {
let Some(contract) = token_id.strip_prefix("nep141:") else {
return Ok(None);
};
let meta = self.view(contract, "ft_metadata", json!({})).await?;
let malformed = |what: &str| DataError::Malformed {
method: "ft_metadata".into(),
message: format!("{contract}: no {what}"),
};
Ok(Some(TokenMetadata {
symbol: meta["symbol"]
.as_str()
.ok_or_else(|| malformed("symbol"))?
.to_owned(),
decimals: meta["decimals"]
.as_u64()
.and_then(|d| u8::try_from(d).ok())
.ok_or_else(|| malformed("decimals"))?,
}))
}
}
/// Whether 1Click's public token list names `asset`.
///
/// Off-chain, and only ever a listing: it says a swap can be *requested*, not
/// that one has settled. Read for the "is there a market yet" panel and for
/// nothing else — never for a price, because 1Click's prices are its own and a
/// quote is an offer.
pub async fn oneclick_lists(
base_url: &str,
asset: &str,
timeout: Duration,
) -> Result<bool, DataError> {
let url = format!("{}/v0/tokens", base_url.trim_end_matches('/'));
let tokens: Vec<Value> = reqwest::Client::builder()
.timeout(timeout)
.build()?
.get(&url)
.send()
.await?
.error_for_status()?
.json()
.await?;
Ok(tokens
.iter()
.any(|t| t.get("assetId").and_then(Value::as_str) == Some(asset)))
}
+276
View File
@@ -257,6 +257,46 @@ impl From<&crate::telemetry::NodeMetadata> for NodeTelemetry {
}
}
/// One asset/counter pair settled by NEAR Intents, as stored.
///
/// Amounts are decimal strings in base units, because they are `u128` on NEAR
/// and `numeric` here, and nothing between the two should round them.
#[derive(Debug, Clone)]
pub struct IntentTrade {
/// Which chain's token this tracks.
pub chain: ChainId,
/// The tracked asset, as a multi-token id.
pub asset: String,
/// NEAR block the `execute_intents` transaction was included in.
pub near_height: u64,
/// Position among the trades read from that block.
pub trade_index: u32,
/// That block's timestamp.
pub at: DateTime<Utc>,
/// The transaction.
pub tx_hash: String,
/// The receipt whose outcome logged the trade.
pub receipt_id: String,
/// The other token.
pub counter_token: String,
/// Asset that changed hands, base units.
pub asset_amount: String,
/// Counter token that changed hands, base units.
pub counter_amount: String,
/// `buy` or `sell` from the inferred taker's side, if it could be told.
pub side: Option<String>,
/// The leg carrying the referral, when exactly one did.
pub taker: Option<String>,
/// That leg's referral.
pub referral: Option<String>,
/// Every account with a leg in it.
pub accounts: Vec<String>,
/// Every intent in it.
pub intent_hashes: Vec<String>,
/// The legs as logged.
pub legs: serde_json::Value,
}
/// A connected Postgres store.
#[derive(Debug, Clone)]
pub struct Store {
@@ -2703,4 +2743,240 @@ impl Store {
sqlx::query!("select 1 as ok").fetch_one(&self.pool).await?;
Ok(())
}
/// Persist trades settled by NEAR Intents. Idempotent on `(chain, asset,
/// near_height, trade_index)`, so re-reading a block rewrites rather than
/// doubles. One statement per trade: there are a handful a day.
pub async fn record_intent_trades(&self, trades: &[IntentTrade]) -> Result<(), DataError> {
let mut tx = self.pool.begin().await?;
for t in trades {
sqlx::query!(
r#"
insert into intents_trade (
chain, asset, near_height, trade_index, at, tx_hash, receipt_id,
counter_token, asset_amount, counter_amount, side, taker, referral,
accounts, intent_hashes, legs
) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::numeric, $10::text::numeric,
$11, $12, $13, $14, $15, $16)
on conflict (chain, asset, near_height, trade_index) do update set
at = excluded.at, tx_hash = excluded.tx_hash,
receipt_id = excluded.receipt_id, counter_token = excluded.counter_token,
asset_amount = excluded.asset_amount,
counter_amount = excluded.counter_amount, side = excluded.side,
taker = excluded.taker, referral = excluded.referral,
accounts = excluded.accounts, intent_hashes = excluded.intent_hashes,
legs = excluded.legs
"#,
t.chain.as_str(),
t.asset,
t.near_height as i64,
t.trade_index as i32,
t.at,
t.tx_hash,
t.receipt_id,
t.counter_token,
t.asset_amount,
t.counter_amount,
t.side,
t.taker,
t.referral,
&t.accounts,
&t.intent_hashes,
t.legs,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
/// The NEAR heights the intents scan has read for an asset, `(low, high)`.
///
/// Not `max(near_height) from intents_trade`, for the reason `event_scan`
/// is not: almost no block has a trade in it.
pub async fn intents_scan(
&self,
chain: &ChainId,
asset: &str,
) -> Result<Option<(u64, u64)>, DataError> {
let row = sqlx::query!(
"select low, high from intents_scan where chain = $1 and asset = $2",
chain.as_str(),
asset,
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.low.max(0) as u64, r.high.max(0) as u64)))
}
/// Record how far the intents scan has reached. Only ever grows; the
/// statement enforces it, as `set_event_scan` does.
pub async fn set_intents_scan(
&self,
chain: &ChainId,
asset: &str,
low: u64,
high: u64,
) -> Result<(), DataError> {
sqlx::query!(
r#"
insert into intents_scan (chain, asset, low, high) values ($1, $2, $3, $4)
on conflict (chain, asset) do update
set low = least(intents_scan.low, excluded.low),
high = greatest(intents_scan.high, excluded.high),
updated_at = now()
"#,
chain.as_str(),
asset,
low as i64,
high as i64,
)
.execute(&self.pool)
.await?;
Ok(())
}
/// Trades after a `(near_height, trade_index)` cursor, oldest first.
pub async fn intent_trades(
&self,
chain: &ChainId,
asset: &str,
after: (u64, u32),
limit: i64,
) -> Result<Vec<IntentTrade>, DataError> {
let rows = sqlx::query!(
r#"
select chain, asset, near_height, trade_index, at, tx_hash, receipt_id,
counter_token, asset_amount::text as "asset_amount!",
counter_amount::text as "counter_amount!", side, taker, referral,
accounts, intent_hashes, legs
from intents_trade
where chain = $1 and asset = $2 and (near_height, trade_index) > ($3, $4)
order by near_height, trade_index
limit $5
"#,
chain.as_str(),
asset,
after.0 as i64,
after.1 as i32,
limit,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| IntentTrade {
chain: ChainId(r.chain),
asset: r.asset,
near_height: r.near_height.max(0) as u64,
trade_index: r.trade_index.max(0) as u32,
at: r.at,
tx_hash: r.tx_hash,
receipt_id: r.receipt_id,
counter_token: r.counter_token,
asset_amount: r.asset_amount,
counter_amount: r.counter_amount,
side: r.side,
taker: r.taker,
referral: r.referral,
accounts: r.accounts,
intent_hashes: r.intent_hashes,
legs: r.legs,
})
.collect())
}
/// The latest `per_counter` trades against each counter token, newest
/// first within each.
///
/// A count per counter rather than a time range: a window here is a
/// number of trades, as a window elsewhere on this site is a number of
/// blocks. On a thin market a time range would put one trade from hours ago
/// behind a label that sounds current.
pub async fn latest_intent_trades(
&self,
chain: &ChainId,
asset: &str,
per_counter: i64,
) -> Result<Vec<IntentTrade>, DataError> {
let rows = sqlx::query!(
r#"
select chain as "chain!", asset as "asset!", near_height as "near_height!",
trade_index as "trade_index!", at as "at!", tx_hash as "tx_hash!",
receipt_id as "receipt_id!", counter_token as "counter_token!",
asset_amount::text as "asset_amount!",
counter_amount::text as "counter_amount!", side, taker, referral,
accounts as "accounts!", intent_hashes as "intent_hashes!", legs as "legs!"
from (
select *, row_number() over (
partition by counter_token order by near_height desc, trade_index desc
) as n
from intents_trade
where chain = $1 and asset = $2
) ranked
where n <= $3
order by counter_token, near_height desc, trade_index desc
"#,
chain.as_str(),
asset,
per_counter,
)
.fetch_all(&self.pool)
.await?;
Ok(rows
.into_iter()
.map(|r| IntentTrade {
chain: ChainId(r.chain),
asset: r.asset,
near_height: r.near_height.max(0) as u64,
trade_index: r.trade_index.max(0) as u32,
at: r.at,
tx_hash: r.tx_hash,
receipt_id: r.receipt_id,
counter_token: r.counter_token,
asset_amount: r.asset_amount,
counter_amount: r.counter_amount,
side: r.side,
taker: r.taker,
referral: r.referral,
accounts: r.accounts,
intent_hashes: r.intent_hashes,
legs: r.legs,
})
.collect())
}
/// A NEP-141 token's cached `ft_metadata`, `(symbol, decimals)`.
pub async fn near_token(&self, token_id: &str) -> Result<Option<(String, u8)>, DataError> {
let row = sqlx::query!(
"select symbol, decimals from near_token where token_id = $1",
token_id
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| (r.symbol, r.decimals.clamp(0, 255) as u8)))
}
/// Cache a token's `ft_metadata`.
pub async fn save_near_token(
&self,
token_id: &str,
symbol: &str,
decimals: u8,
) -> Result<(), DataError> {
sqlx::query!(
r#"
insert into near_token (token_id, symbol, decimals) values ($1, $2, $3)
on conflict (token_id) do update
set symbol = excluded.symbol, decimals = excluded.decimals, fetched_at = now()
"#,
token_id,
symbol,
i16::from(decimals),
)
.execute(&self.pool)
.await?;
Ok(())
}
}
+157
View File
@@ -0,0 +1,157 @@
//! A chain's token as NEAR Intents settles it.
//!
//! There is no price on NEAR Intents except the ratio inside a trade that
//! actually settled, so that is all this reports. Every figure is in units of
//! the counter token — no USD is derived until a stablecoin pairing exists to
//! derive it from, and a quote, which is an offer and not a trade, never
//! appears here. See blackbeard/observer#27.
//!
//! What is read is `execute_intents` transactions signed by `intents.near`,
//! which is how the 1Click relay submits. An intent reaching the contract as a
//! cross-contract receipt instead is not read; that is the indexer's known
//! blind spot and the reason [`IntentsPrice::reads`] exists.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{BigUintDec, ChainId};
/// One settled trade of the asset against one counter token.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "IntentsSwap.ts")]
pub struct IntentsSwap {
/// NEAR block it settled in.
#[ts(type = "number")]
pub near_height: u64,
/// Position among the trades in that block. With the height, the cursor.
pub trade_index: u32,
/// That block's own timestamp.
pub at: DateTime<Utc>,
/// The `execute_intents` transaction.
pub tx_hash: String,
/// The receipt whose outcome logged it.
pub receipt_id: String,
/// The other token, as a multi-token id (`nep141:…`).
pub counter_token: String,
/// That token's own `ft_metadata.symbol`. A label it chose for itself —
/// never an identity, because anyone can deploy a token called USDC.
pub counter_symbol: Option<String>,
/// The asset that changed hands, in base units.
pub asset_amount: BigUintDec,
/// The counter token that changed hands, in base units.
pub counter_amount: BigUintDec,
/// Counter tokens per one whole unit of the asset. Absent when either
/// token's decimals are unknown.
pub price: Option<f64>,
/// `buy` or `sell`, from the taker's side. **Inferred** from the one leg
/// that carried a `referral`; absent when that could not be told.
pub side: Option<String>,
/// The account inferred to be the taker.
pub taker: Option<String>,
/// The front end the taker came through, as it named itself.
pub referral: Option<String>,
/// Every account with a leg in the trade — solvers by their named
/// accounts, nothing more is claimed about who they are.
pub accounts: Vec<String>,
/// Every intent in the trade.
pub intent_hashes: Vec<String>,
}
/// A page of swaps, oldest first.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "IntentsSwaps.ts")]
pub struct IntentsSwaps {
/// Which chain's token.
pub chain: ChainId,
/// The tracked asset, as a multi-token id.
pub asset: String,
/// The swaps.
pub swaps: Vec<IntentsSwap>,
/// Cursor for the next page, `near_height:trade_index`, when this one was
/// full. Absent when there is nothing more *in the indexed range*.
pub next: Option<String>,
/// Lowest NEAR height read. A swap below it is missing from this answer,
/// not from the chain.
#[ts(type = "number")]
pub indexed_from: Option<u64>,
/// Highest NEAR height read.
#[ts(type = "number")]
pub indexed_to: Option<u64>,
}
/// The asset's price against one counter token, over its latest trades.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "CounterPrice.ts")]
pub struct CounterPrice {
/// The counter token, as a multi-token id.
pub counter_token: String,
/// Its self-reported symbol. Display only.
pub symbol: Option<String>,
/// Its decimals, when it is a NEP-141 token that said.
pub decimals: Option<u8>,
/// How many trades the figures below cover. Every figure is only as good
/// as this count, and one trade is one trade however it is labelled.
pub trades: u32,
/// The latest trade's price.
pub last_price: Option<f64>,
/// When it settled.
pub last_at: DateTime<Utc>,
/// Where it settled.
#[ts(type = "number")]
pub last_near_height: u64,
/// Volume-weighted over the trades counted — the ratio of the summed
/// amounts, so a dust trade at a silly price moves it by its size.
pub vwap: Option<f64>,
/// Asset traded over those trades, base units.
pub asset_volume: BigUintDec,
/// Counter token traded over those trades, base units.
pub counter_volume: BigUintDec,
/// When the oldest of them settled.
pub first_at: DateTime<Utc>,
}
/// Whether there is anything to trade yet, read rather than assumed.
///
/// Until the asset exists on NEAR there can be no swap, and saying so with
/// the evidence beats an empty panel — or a price from somewhere else.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "IntentsListing.ts")]
pub struct IntentsListing {
/// When these were read.
pub checked_at: DateTime<Utc>,
/// The token contract's `ft_total_supply`: everything bridged to NEAR.
pub total_supply: Option<BigUintDec>,
/// What `intents.near` holds of it: what can be traded at all.
pub in_intents: Option<BigUintDec>,
/// Whether 1Click's public token list names the asset. Off-chain, and only
/// a listing — it says swaps can be *requested*, not that any settled.
pub oneclick_listed: Option<bool>,
}
/// The asset's price on NEAR Intents, per counter token.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "IntentsPrice.ts")]
pub struct IntentsPrice {
/// Which chain's token.
pub chain: ChainId,
/// The tracked asset, as a multi-token id.
pub asset: String,
/// Its decimals, from its own `ft_metadata`.
pub asset_decimals: Option<u8>,
/// Trades per counter token the figures are taken over, at most.
pub window_trades: u32,
/// One entry per counter token ever traded in the indexed range, most
/// recently traded first.
pub counters: Vec<CounterPrice>,
/// Supply and listing, when they have been read.
pub listing: Option<IntentsListing>,
/// What the indexer reads, in words — and so what it cannot see.
pub reads: String,
/// Lowest NEAR height read.
#[ts(type = "number")]
pub indexed_from: Option<u64>,
/// Highest NEAR height read.
#[ts(type = "number")]
pub indexed_to: Option<u64>,
}
+2
View File
@@ -20,6 +20,7 @@ mod block;
mod call;
mod chain;
mod error;
mod intents;
mod miner;
mod reversible;
mod roles;
@@ -38,6 +39,7 @@ pub use chain::{
WormholeDeposit, WormholeDeposits, WormholeSummary,
};
pub use error::{ApiError, EntityError};
pub use intents::{CounterPrice, IntentsListing, IntentsPrice, IntentsSwap, IntentsSwaps};
pub use miner::{
AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity,
NodeIndex, NodeInfo, NodeRow,
+53
View File
@@ -0,0 +1,53 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
/**
* The asset's price against one counter token, over its latest trades.
*/
export type CounterPrice = {
/**
* The counter token, as a multi-token id.
*/
counter_token: string,
/**
* Its self-reported symbol. Display only.
*/
symbol: string | null,
/**
* Its decimals, when it is a NEP-141 token that said.
*/
decimals: number | null,
/**
* How many trades the figures below cover. Every figure is only as good
* as this count, and one trade is one trade however it is labelled.
*/
trades: number,
/**
* The latest trade's price.
*/
last_price: number | null,
/**
* When it settled.
*/
last_at: string,
/**
* Where it settled.
*/
last_near_height: number,
/**
* Volume-weighted over the trades counted — the ratio of the summed
* amounts, so a dust trade at a silly price moves it by its size.
*/
vwap: number | null,
/**
* Asset traded over those trades, base units.
*/
asset_volume: BigUintDec,
/**
* Counter token traded over those trades, base units.
*/
counter_volume: BigUintDec,
/**
* When the oldest of them settled.
*/
first_at: string, };
+27
View File
@@ -0,0 +1,27 @@
// 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";
/**
* Whether there is anything to trade yet, read rather than assumed.
*
* Until the asset exists on NEAR there can be no swap, and saying so with
* the evidence beats an empty panel — or a price from somewhere else.
*/
export type IntentsListing = {
/**
* When these were read.
*/
checked_at: string,
/**
* The token contract's `ft_total_supply`: everything bridged to NEAR.
*/
total_supply: BigUintDec | null,
/**
* What `intents.near` holds of it: what can be traded at all.
*/
in_intents: BigUintDec | null,
/**
* Whether 1Click's public token list names the asset. Off-chain, and only
* a listing — it says swaps can be *requested*, not that any settled.
*/
oneclick_listed: boolean | null, };
+46
View File
@@ -0,0 +1,46 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChainId } from "./ChainId";
import type { CounterPrice } from "./CounterPrice";
import type { IntentsListing } from "./IntentsListing";
/**
* The asset's price on NEAR Intents, per counter token.
*/
export type IntentsPrice = {
/**
* Which chain's token.
*/
chain: ChainId,
/**
* The tracked asset, as a multi-token id.
*/
asset: string,
/**
* Its decimals, from its own `ft_metadata`.
*/
asset_decimals: number | null,
/**
* Trades per counter token the figures are taken over, at most.
*/
window_trades: number,
/**
* One entry per counter token ever traded in the indexed range, most
* recently traded first.
*/
counters: Array<CounterPrice>,
/**
* Supply and listing, when they have been read.
*/
listing: IntentsListing | null,
/**
* What the indexer reads, in words — and so what it cannot see.
*/
reads: string,
/**
* Lowest NEAR height read.
*/
indexed_from: number,
/**
* Highest NEAR height read.
*/
indexed_to: number, };
+71
View File
@@ -0,0 +1,71 @@
// 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 settled trade of the asset against one counter token.
*/
export type IntentsSwap = {
/**
* NEAR block it settled in.
*/
near_height: number,
/**
* Position among the trades in that block. With the height, the cursor.
*/
trade_index: number,
/**
* That block's own timestamp.
*/
at: string,
/**
* The `execute_intents` transaction.
*/
tx_hash: string,
/**
* The receipt whose outcome logged it.
*/
receipt_id: string,
/**
* The other token, as a multi-token id (`nep141:…`).
*/
counter_token: string,
/**
* That token's own `ft_metadata.symbol`. A label it chose for itself —
* never an identity, because anyone can deploy a token called USDC.
*/
counter_symbol: string | null,
/**
* The asset that changed hands, in base units.
*/
asset_amount: BigUintDec,
/**
* The counter token that changed hands, in base units.
*/
counter_amount: BigUintDec,
/**
* Counter tokens per one whole unit of the asset. Absent when either
* token's decimals are unknown.
*/
price: number | null,
/**
* `buy` or `sell`, from the taker's side. **Inferred** from the one leg
* that carried a `referral`; absent when that could not be told.
*/
side: string | null,
/**
* The account inferred to be the taker.
*/
taker: string | null,
/**
* The front end the taker came through, as it named itself.
*/
referral: string | null,
/**
* Every account with a leg in the trade — solvers by their named
* accounts, nothing more is claimed about who they are.
*/
accounts: Array<string>,
/**
* Every intent in the trade.
*/
intent_hashes: Array<string>, };
+34
View File
@@ -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 { ChainId } from "./ChainId";
import type { IntentsSwap } from "./IntentsSwap";
/**
* A page of swaps, oldest first.
*/
export type IntentsSwaps = {
/**
* Which chain's token.
*/
chain: ChainId,
/**
* The tracked asset, as a multi-token id.
*/
asset: string,
/**
* The swaps.
*/
swaps: Array<IntentsSwap>,
/**
* Cursor for the next page, `near_height:trade_index`, when this one was
* full. Absent when there is nothing more *in the indexed range*.
*/
next: string | null,
/**
* Lowest NEAR height read. A swap below it is missing from this answer,
* not from the chain.
*/
indexed_from: number,
/**
* Highest NEAR height read.
*/
indexed_to: number, };