feat: index QTC's 1Click fills, which settle as transfers, not token_diff
Since launch the NEAR Intents page showed no price and no settlements while
QTC traded heavily: the scan counts `token_diff` trades, and 1Click settles
no QTC swap that way. A buy is a solver transferring QTC to a one-off
1Click account, passed on to a shared one; a sell is the mirror; the user's
payment is on their own chain and never on NEAR.
The scan now keeps every executed transfer of the asset (migration 0017,
cursor wound back to before the first QTC swap; pool swaps already recorded
are not re-queued), unclassified. `/v1/chains/{chain}/intents/fills`
classifies at read time against the solvers known then — the solver table
plus every Ref pool whitelist, since solver-ref-2.near never appears in a
token_diff — so a solver found later classifies its past fills without a
re-read. Solver to account is a buy, account to solver a sell; hops between
non-solvers (the pass-on, a flat 0.002 QTC per-swap fee to 5880ad…) are not
fills. Fills are pushed live; the NEAR Intents page lists them without a
price, and shows the Ref pool's restock price in its place.
Checked against the chain: the traced buy at #218,780,964 (2,991.6053 QTC,
solver-ref-2.near to d4524d…) and the sell at #218,758,672 classify as such,
and their pass-on hops do not.
Closes #43
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into ref_pending (chain, tx_hash, signer, near_height, trader)\n select $1, $2, $3, $4, $5\n -- A re-read must not fetch again what is already recorded.\n where not exists (select 1 from ref_swap where chain = $1 and tx_hash = $2)\n on conflict (chain, tx_hash) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "0118c48d248c2d16921ce7115913ba28904d41ce1aa495e2f5907d4efb9a08ae"
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into intents_transfer\n (chain, asset, near_height, tx_hash, receipt_id, transfer_index, at,\n intent_hash, from_account, to_account, amount)\n values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::text::numeric)\n on conflict (chain, asset, tx_hash, receipt_id, transfer_index) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int4",
|
||||
"Timestamptz",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "1472bb2aff4e272c25421143c42e9b548e483f59a4ee978363b8d458a7bc8eec"
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "select account from intents_solver order by account",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "account",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": []
|
||||
},
|
||||
"nullable": [
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "3e660aadba299284c9cf43b7f0d5279f621f9c7eb3bb6c64ac41d0a770c70075"
|
||||
}
|
||||
+74
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select near_height, tx_hash, receipt_id, transfer_index, at, intent_hash,\n from_account, to_account, amount::text as \"amount!\"\n from intents_transfer\n where chain = $1 and asset = $2\n and (from_account = any($3)) <> (to_account = any($3))\n and ($4::bigint is null or near_height < $4)\n order by near_height desc, tx_hash, receipt_id, transfer_index\n limit $5\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "near_height",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "tx_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "receipt_id",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "transfer_index",
|
||||
"type_info": "Int4"
|
||||
},
|
||||
{
|
||||
"ordinal": 4,
|
||||
"name": "at",
|
||||
"type_info": "Timestamptz"
|
||||
},
|
||||
{
|
||||
"ordinal": 5,
|
||||
"name": "intent_hash",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 6,
|
||||
"name": "from_account",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 7,
|
||||
"name": "to_account",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 8,
|
||||
"name": "amount!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Int8",
|
||||
"Int8"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "65952c6ec8a59a9a40dbffda055b3658284d6f8546355584fa5bf3ea1b268e3c"
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n select count(*) filter (where from_account = any($3)) as \"buys!\",\n coalesce(sum(amount) filter (where from_account = any($3)), 0)::text as \"bought!\",\n count(*) filter (where to_account = any($3)) as \"sells!\",\n coalesce(sum(amount) filter (where to_account = any($3)), 0)::text as \"sold!\"\n from intents_transfer\n where chain = $1 and asset = $2 and at >= $4\n and (from_account = any($3)) <> (to_account = any($3))\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
"ordinal": 0,
|
||||
"name": "buys!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 1,
|
||||
"name": "bought!",
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"ordinal": 2,
|
||||
"name": "sells!",
|
||||
"type_info": "Int8"
|
||||
},
|
||||
{
|
||||
"ordinal": 3,
|
||||
"name": "sold!",
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"TextArray",
|
||||
"Timestamptz"
|
||||
]
|
||||
},
|
||||
"nullable": [
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
]
|
||||
},
|
||||
"hash": "6a12cce5c4a75c85f2362016cf44e4e6299ac613a053c16e5133ca93841d2f9b"
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n insert into ref_pending (chain, tx_hash, signer, near_height, trader)\n values ($1, $2, $3, $4, $5)\n on conflict (chain, tx_hash) do nothing\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Left": [
|
||||
"Text",
|
||||
"Text",
|
||||
"Text",
|
||||
"Int8",
|
||||
"Text"
|
||||
]
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "e80f6f97ee7b3a51d592191ee83c3271160e8d536b88d8ec36309000d07d9ba3"
|
||||
}
|
||||
@@ -226,7 +226,21 @@ async fn scan(chain: Arc<ChainRuntime>, store: Store, near: NearClient, config:
|
||||
trades,
|
||||
solvers,
|
||||
pool_swaps,
|
||||
transfers,
|
||||
}) => {
|
||||
if !transfers.is_empty() {
|
||||
if let Err(e) = store
|
||||
.record_intent_transfers(&id, &config.asset, &transfers)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(chain = %id, near_height = height, error = %e,
|
||||
"asset transfers not recorded");
|
||||
break;
|
||||
}
|
||||
if !descending && tip.saturating_sub(height) <= LIVE_WITHIN_BLOCKS {
|
||||
announce_fills(&chain, &store, &config, &transfers).await;
|
||||
}
|
||||
}
|
||||
for (tx_hash, signer, trader) in &pool_swaps {
|
||||
if let Err(e) = store
|
||||
.queue_ref_pending(&id, tx_hash, signer, height, trader)
|
||||
@@ -319,6 +333,7 @@ async fn read_block(
|
||||
// Relay transactions that sent a solver's tokens to a Ref pool holding the
|
||||
// asset: `(tx_hash, signer, trader)`, fetched whole later by `refdcl`.
|
||||
let mut pool_swaps: Vec<(String, String, String)> = Vec::new();
|
||||
let mut transfers: Vec<blackbeard_data::store::IntentTransferRecord> = Vec::new();
|
||||
let pools = crate::refdcl::contracts(config);
|
||||
let asset_contract = config.token_contract().unwrap_or_default();
|
||||
for tx in chunk
|
||||
@@ -331,6 +346,28 @@ async fn read_block(
|
||||
if outcome.executor_id != config.contract || !outcome.succeeded {
|
||||
continue;
|
||||
}
|
||||
// 1Click settles QTC swaps as transfers (blackbeard/observer#43):
|
||||
// every executed transfer of the asset is kept, classified later.
|
||||
let moved = core::asset_transfers(&outcome.logs, &config.asset);
|
||||
if !moved.is_empty() {
|
||||
let (_, at) = near.block_at(json!(outcome.block_hash)).await?;
|
||||
for t in moved {
|
||||
transfers.push(blackbeard_data::store::IntentTransferRecord {
|
||||
near_height: height,
|
||||
tx_hash: tx.hash.clone(),
|
||||
receipt_id: outcome.id.clone(),
|
||||
transfer_index: transfers
|
||||
.iter()
|
||||
.filter(|r| r.receipt_id == outcome.id)
|
||||
.count() as u32,
|
||||
at,
|
||||
intent_hash: t.intent_hash,
|
||||
from: t.from,
|
||||
to: t.to,
|
||||
amount: t.amount.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let (legs, malformed) = core::executed_legs(&outcome.logs);
|
||||
for (account, n) in core::solver_fills(&legs) {
|
||||
*solvers.entry(account).or_insert(0) += n;
|
||||
@@ -381,6 +418,7 @@ async fn read_block(
|
||||
trades: out,
|
||||
solvers,
|
||||
pool_swaps,
|
||||
transfers,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -390,6 +428,7 @@ struct BlockRead {
|
||||
trades: Vec<IntentTrade>,
|
||||
solvers: BTreeMap<String, u32>,
|
||||
pool_swaps: Vec<(String, String, String)>,
|
||||
transfers: Vec<blackbeard_data::store::IntentTransferRecord>,
|
||||
}
|
||||
|
||||
/// The legs as the contract logged them, amounts as strings.
|
||||
@@ -609,6 +648,82 @@ async fn reconcile(
|
||||
(holders, unaccounted)
|
||||
}
|
||||
|
||||
/// Every account that fills: solvers seen in any pair, and every Ref pool
|
||||
/// whitelist (`solver-ref-2.near` fills QTC and never appears in a token_diff).
|
||||
pub(crate) async fn solver_set(chain: &Arc<ChainRuntime>, store: &Store) -> Vec<String> {
|
||||
let mut solvers = store.known_solvers().await.unwrap_or_else(|e| {
|
||||
tracing::warn!(error = %e, "known solvers unreadable");
|
||||
Vec::new()
|
||||
});
|
||||
if let Some(pools) = chain
|
||||
.ref_pools
|
||||
.read()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.as_ref()
|
||||
{
|
||||
for pool in &pools.pools {
|
||||
solvers.extend(pool.whitelist.iter().cloned());
|
||||
}
|
||||
}
|
||||
// intents.near is on every whitelist and is the contract itself, never a
|
||||
// counterparty to a fill.
|
||||
solvers.retain(|s| s != "intents.near");
|
||||
solvers.sort();
|
||||
solvers.dedup();
|
||||
solvers
|
||||
}
|
||||
|
||||
/// Push fills just settled to connected browsers (blackbeard/observer#43).
|
||||
async fn announce_fills(
|
||||
chain: &Arc<ChainRuntime>,
|
||||
store: &Store,
|
||||
config: &IntentsConfig,
|
||||
transfers: &[blackbeard_data::store::IntentTransferRecord],
|
||||
) {
|
||||
let solvers = solver_set(chain, store).await;
|
||||
let is_solver = |a: &str| solvers.iter().any(|s| s == a);
|
||||
for t in transfers {
|
||||
let Some(fill) = to_fill(t, &is_solver) else {
|
||||
continue;
|
||||
};
|
||||
chain.broadcast(
|
||||
&blackbeard_entities::ServerMessage::IntentsFill {
|
||||
chain: chain.id(),
|
||||
asset: config.asset.clone(),
|
||||
fill,
|
||||
},
|
||||
None,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A recorded transfer as a fill, when exactly one side is a solver.
|
||||
pub(crate) fn to_fill(
|
||||
t: &blackbeard_data::store::IntentTransferRecord,
|
||||
is_solver: &impl Fn(&str) -> bool,
|
||||
) -> Option<blackbeard_entities::IntentsFill> {
|
||||
let core_t = core::AssetTransfer {
|
||||
intent_hash: t.intent_hash.clone(),
|
||||
from: t.from.clone(),
|
||||
to: t.to.clone(),
|
||||
amount: t.amount.parse().ok()?,
|
||||
};
|
||||
let side = core::fill_side(&core_t, is_solver)?;
|
||||
let (solver, account) = match side {
|
||||
core::Side::Buy => (t.from.clone(), t.to.clone()),
|
||||
core::Side::Sell => (t.to.clone(), t.from.clone()),
|
||||
};
|
||||
Some(blackbeard_entities::IntentsFill {
|
||||
near_height: t.near_height,
|
||||
at: t.at,
|
||||
tx_hash: t.tx_hash.clone(),
|
||||
side: side.as_str().to_owned(),
|
||||
solver,
|
||||
account,
|
||||
amount: blackbeard_entities::BigUintDec(t.amount.clone()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Push freshly settled trades to connected browsers.
|
||||
async fn announce(chain: &Arc<ChainRuntime>, store: &Store, asset: &str, rows: Vec<IntentTrade>) {
|
||||
let mut names: Vec<String> = vec![asset.to_owned()];
|
||||
|
||||
@@ -75,6 +75,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
|
||||
.route("/v1/chains/{chain}/ref/series", get(ref_series))
|
||||
.route("/v1/chains/{chain}/ref/bids", get(ref_bids))
|
||||
.route("/v1/chains/{chain}/intents/flows", get(intents_flows))
|
||||
.route("/v1/chains/{chain}/intents/fills", get(intents_fills))
|
||||
.route("/v1/chains/{chain}/ref/v1", get(ref_v1))
|
||||
.route("/v1/chains/{chain}/bridge", get(bridge))
|
||||
.route("/v1/chains/{chain}/nodes", get(nodes))
|
||||
@@ -2503,6 +2504,80 @@ async fn ref_v1(
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FillsQuery {
|
||||
/// A NEAR height: fills strictly below it.
|
||||
before: Option<u64>,
|
||||
limit: Option<i64>,
|
||||
/// Hours the totals cover, default a day.
|
||||
hours: Option<u64>,
|
||||
}
|
||||
|
||||
/// 1Click fills of the asset (blackbeard/observer#43): transfers between a
|
||||
/// solver and anyone else inside intents.near, classified against the solvers
|
||||
/// known now. No price — the user's payment is on their own chain.
|
||||
async fn intents_fills(
|
||||
State(state): State<AppState>,
|
||||
Path(chain): Path<String>,
|
||||
Query(query): Query<FillsQuery>,
|
||||
) -> Result<Json<blackbeard_entities::IntentsFills>, 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 solvers = crate::intents::solver_set(&runtime, &state.store).await;
|
||||
let limit = query.limit.unwrap_or(50).clamp(1, 500);
|
||||
let hours = query.hours.unwrap_or(24).clamp(1, 24 * 30);
|
||||
let since = chrono::Utc::now() - chrono::Duration::hours(hours as i64);
|
||||
let rows = state
|
||||
.store
|
||||
.intent_fills(&id, &config.asset, &solvers, query.before, limit)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let (buys, bought, sells, sold) = state
|
||||
.store
|
||||
.intent_fill_totals(&id, &config.asset, &solvers, since)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let scan = state
|
||||
.store
|
||||
.intents_scan(&id, &config.asset)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let meta = state
|
||||
.store
|
||||
.near_token(&config.asset)
|
||||
.await
|
||||
.map_err(database_unavailable)?;
|
||||
let is_solver = |a: &str| solvers.iter().any(|s| s == a);
|
||||
let next = rows
|
||||
.last()
|
||||
.filter(|_| rows.len() as i64 == limit)
|
||||
.map(|r| r.near_height);
|
||||
let fills = rows
|
||||
.iter()
|
||||
.filter_map(|t| crate::intents::to_fill(t, &is_solver))
|
||||
.collect();
|
||||
Ok(Json(blackbeard_entities::IntentsFills {
|
||||
chain: id,
|
||||
asset_symbol: meta.as_ref().map(|m| m.0.clone()),
|
||||
asset_decimals: meta.map(|m| m.1),
|
||||
solvers,
|
||||
hours,
|
||||
buys,
|
||||
bought: BigUintDec(bought),
|
||||
sells,
|
||||
sold: BigUintDec(sold),
|
||||
fills,
|
||||
next,
|
||||
indexed_from: scan.map(|s| s.0),
|
||||
indexed_to: scan.map(|s| s.1),
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FlowsQuery {
|
||||
/// Hours of history, default a day.
|
||||
|
||||
@@ -162,6 +162,73 @@ pub fn executed_legs(logs: &[String]) -> (Vec<Leg>, usize) {
|
||||
(legs, malformed)
|
||||
}
|
||||
|
||||
/// One executed `transfer` of the asset inside intents.near
|
||||
/// (blackbeard/observer#43).
|
||||
///
|
||||
/// 1Click settles QTC swaps as transfers, never as `token_diff`: a solver
|
||||
/// transfers the asset to a one-off 1Click account (a user buying), or receives
|
||||
/// it from one (a user selling). Recorded unclassified; who is a solver is
|
||||
/// decided when the transfers are read, against the solvers known then.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AssetTransfer {
|
||||
/// The intent that moved it.
|
||||
pub intent_hash: String,
|
||||
/// Sender.
|
||||
pub from: String,
|
||||
/// Receiver.
|
||||
pub to: String,
|
||||
/// Amount, base units.
|
||||
pub amount: u128,
|
||||
}
|
||||
|
||||
/// Every executed `transfer` of `asset` in one outcome's logs, in order. Like
|
||||
/// [`executed_legs`], only intents in `intents_executed` count: a failed
|
||||
/// receipt still reports what it logged.
|
||||
pub fn asset_transfers(logs: &[String], asset: &str) -> Vec<AssetTransfer> {
|
||||
let mut out = Vec::new();
|
||||
let mut executed = BTreeSet::new();
|
||||
for event in logs.iter().filter_map(|l| parse_event(l)) {
|
||||
if event.get("standard").and_then(Value::as_str) != Some("dip4") {
|
||||
continue;
|
||||
}
|
||||
let data = event
|
||||
.get("data")
|
||||
.and_then(Value::as_array)
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
match event.get("event").and_then(Value::as_str) {
|
||||
Some("intents_executed") => executed.extend(data.iter().filter_map(|e| {
|
||||
e.get("intent_hash")
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
})),
|
||||
Some("transfer") => out.extend(data.iter().filter_map(|e| {
|
||||
Some(AssetTransfer {
|
||||
intent_hash: e.get("intent_hash")?.as_str()?.to_owned(),
|
||||
from: e.get("account_id")?.as_str()?.to_owned(),
|
||||
to: e.get("receiver_id")?.as_str()?.to_owned(),
|
||||
amount: e.get("tokens")?.get(asset)?.as_str()?.parse().ok()?,
|
||||
})
|
||||
})),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
out.retain(|t| executed.contains(&t.intent_hash));
|
||||
out
|
||||
}
|
||||
|
||||
/// A transfer as a 1Click fill, given who the solvers are: `Buy` when a
|
||||
/// solver sent the asset to an account that is not one, `Sell` the reverse.
|
||||
/// Between two non-solvers — the 1Click account passing a buy on — or two
|
||||
/// solvers it is not a fill, and counting it would count a swap twice.
|
||||
pub fn fill_side(t: &AssetTransfer, is_solver: impl Fn(&str) -> bool) -> Option<Side> {
|
||||
match (is_solver(&t.from), is_solver(&t.to)) {
|
||||
(true, false) => Some(Side::Buy),
|
||||
(false, true) => Some(Side::Sell),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()
|
||||
@@ -507,6 +574,56 @@ pub fn bucket_prices(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Logs from relay tx 9CgpySZHNk… at NEAR block 218,819,631: a QTC buy as
|
||||
/// 1Click settles it — the solver transfers to a one-off account, which
|
||||
/// passes it on to a shared one, both in one transaction.
|
||||
fn delivered() -> Vec<String> {
|
||||
let solver = "solver-ref-2.near";
|
||||
let one_off = "91f0b203773bcb6ad1368d706d04de5cac4f11dc006df111bb4ebde4814d521e";
|
||||
let hub = "51e8f94d77b5e90dc9852ca6113771e11e8382ce69473a75e313e41665de7cbe";
|
||||
vec![
|
||||
format!(r#"EVENT_JSON:{{"standard":"dip4","version":"0.3.0","event":"transfer","data":[{{"intent_hash":"7cqJDLqu2QhFPvwfKiTaQE7eomZ1Xg5g1MSARa2Z9akC","account_id":"{solver}","receiver_id":"{one_off}","tokens":{{"nep141:qtc.omft.near":"200580939498753"}}}}]}}"#),
|
||||
format!(r#"EVENT_JSON:{{"standard":"dip4","version":"0.3.0","event":"transfer","data":[{{"intent_hash":"C4cRmHCWuHBHcVeAF8oon9gAEBvj5D5cjBWHJ6i9Jefm","account_id":"{one_off}","receiver_id":"{hub}","tokens":{{"nep141:qtc.omft.near":"200580939498753"}},"memo":"AUUNHJ"}}]}}"#),
|
||||
r#"EVENT_JSON:{"standard":"dip4","version":"0.3.0","event":"intents_executed","data":[{"intent_hash":"7cqJDLqu2QhFPvwfKiTaQE7eomZ1Xg5g1MSARa2Z9akC","account_id":"solver-ref-2.near","nonce":"x"},{"intent_hash":"C4cRmHCWuHBHcVeAF8oon9gAEBvj5D5cjBWHJ6i9Jefm","account_id":"91f0","nonce":"y"}]}"#.to_owned(),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_1click_buy_is_one_fill_and_its_pass_on_is_not_another() {
|
||||
let t = asset_transfers(&delivered(), "nep141:qtc.omft.near");
|
||||
assert_eq!(t.len(), 2, "both transfers are recorded");
|
||||
let solver = |a: &str| a == "solver-ref-2.near";
|
||||
let sides: Vec<_> = t.iter().map(|t| fill_side(t, solver)).collect();
|
||||
assert_eq!(sides, vec![Some(Side::Buy), None]);
|
||||
assert_eq!(t[0].amount, 200_580_939_498_753);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transfer_into_a_solver_is_a_sell() {
|
||||
let t = AssetTransfer {
|
||||
intent_hash: "h".into(),
|
||||
from: "15bb7aa8".into(),
|
||||
to: "solver-ref-2.near".into(),
|
||||
amount: 1,
|
||||
};
|
||||
assert_eq!(
|
||||
fill_side(&t, |a| a == "solver-ref-2.near"),
|
||||
Some(Side::Sell)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_transfer_whose_intent_did_not_execute_is_not_recorded() {
|
||||
let mut logs = delivered();
|
||||
logs.pop(); // no intents_executed: the receipt failed
|
||||
assert!(asset_transfers(&logs, "nep141:qtc.omft.near").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfers_of_other_tokens_are_ignored() {
|
||||
assert!(asset_transfers(&delivered(), "nep141:wrap.near").is_empty());
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- Executed transfers of the tracked asset inside intents.near.
|
||||
-- blackbeard/observer#43.
|
||||
--
|
||||
-- 1Click settles QTC swaps as transfers, not `token_diff`: a solver transfers
|
||||
-- the asset to a one-off 1Click account for a buy, or receives it for a sell,
|
||||
-- and the user's payment happens on their own chain, never on NEAR. Every
|
||||
-- executed transfer of the asset is kept, unclassified: which side is a
|
||||
-- solver is decided when they are read, against the solvers known then, so a
|
||||
-- solver discovered later classifies its past fills without a re-read.
|
||||
create table intents_transfer (
|
||||
chain text not null references chain (id) on delete cascade,
|
||||
asset text not null,
|
||||
near_height bigint not null,
|
||||
tx_hash text not null,
|
||||
receipt_id text not null,
|
||||
transfer_index integer not null,
|
||||
at timestamptz not null,
|
||||
intent_hash text not null,
|
||||
from_account text not null,
|
||||
to_account text not null,
|
||||
amount numeric not null,
|
||||
primary key (chain, asset, tx_hash, receipt_id, transfer_index)
|
||||
);
|
||||
create index intents_transfer_recent on intents_transfer (chain, asset, near_height desc);
|
||||
|
||||
-- The scan read today's blocks before it knew to look for transfers, and
|
||||
-- never revisits a range it has claimed (CLAUDE.md, "Changing what events are
|
||||
-- kept"). Pull its high end back to just before the first QTC swap on NEAR
|
||||
-- (#218,751,935, 2026-10-06 06:04 UTC) so those blocks are read again. Trades
|
||||
-- and solvers upsert; pool swaps already recorded are not queued again.
|
||||
update intents_scan
|
||||
set high = least(high, 218740000)
|
||||
where asset = 'nep141:qtc.omft.near';
|
||||
@@ -314,6 +314,30 @@ pub struct IntentsFlow {
|
||||
pub delta: String,
|
||||
}
|
||||
|
||||
/// One executed transfer of the asset inside intents.near
|
||||
/// (blackbeard/observer#43). `amount` is base units.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IntentTransferRecord {
|
||||
/// NEAR block.
|
||||
pub near_height: u64,
|
||||
/// The relay transaction.
|
||||
pub tx_hash: String,
|
||||
/// intents.near's receipt that logged it.
|
||||
pub receipt_id: String,
|
||||
/// Position among that receipt's transfers of the asset.
|
||||
pub transfer_index: u32,
|
||||
/// The block's time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The intent that moved it.
|
||||
pub intent_hash: String,
|
||||
/// Sender.
|
||||
pub from: String,
|
||||
/// Receiver.
|
||||
pub to: String,
|
||||
/// Amount.
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// One read of a Ref DCL pool's state, base units as decimal strings
|
||||
/// (blackbeard/observer#41).
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -3308,7 +3332,9 @@ impl Store {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into ref_pending (chain, tx_hash, signer, near_height, trader)
|
||||
values ($1, $2, $3, $4, $5)
|
||||
select $1, $2, $3, $4, $5
|
||||
-- A re-read must not fetch again what is already recorded.
|
||||
where not exists (select 1 from ref_swap where chain = $1 and tx_hash = $2)
|
||||
on conflict (chain, tx_hash) do nothing
|
||||
"#,
|
||||
chain.as_str(),
|
||||
@@ -3519,6 +3545,127 @@ impl Store {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Record transfers of the asset, idempotently.
|
||||
pub async fn record_intent_transfers(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
asset: &str,
|
||||
rows: &[IntentTransferRecord],
|
||||
) -> Result<(), DataError> {
|
||||
for r in rows {
|
||||
sqlx::query!(
|
||||
r#"
|
||||
insert into intents_transfer
|
||||
(chain, asset, near_height, tx_hash, receipt_id, transfer_index, at,
|
||||
intent_hash, from_account, to_account, amount)
|
||||
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::text::numeric)
|
||||
on conflict (chain, asset, tx_hash, receipt_id, transfer_index) do nothing
|
||||
"#,
|
||||
chain.as_str(),
|
||||
asset,
|
||||
r.near_height as i64,
|
||||
r.tx_hash,
|
||||
r.receipt_id,
|
||||
r.transfer_index as i32,
|
||||
r.at,
|
||||
r.intent_hash,
|
||||
r.from,
|
||||
r.to,
|
||||
r.amount,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transfers where exactly one side is in `solvers` — 1Click fills —
|
||||
/// newest first, before `before` (`near_height`, exclusive).
|
||||
pub async fn intent_fills(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
asset: &str,
|
||||
solvers: &[String],
|
||||
before: Option<u64>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<IntentTransferRecord>, DataError> {
|
||||
let rows = sqlx::query!(
|
||||
r#"
|
||||
select near_height, tx_hash, receipt_id, transfer_index, at, intent_hash,
|
||||
from_account, to_account, amount::text as "amount!"
|
||||
from intents_transfer
|
||||
where chain = $1 and asset = $2
|
||||
and (from_account = any($3)) <> (to_account = any($3))
|
||||
and ($4::bigint is null or near_height < $4)
|
||||
order by near_height desc, tx_hash, receipt_id, transfer_index
|
||||
limit $5
|
||||
"#,
|
||||
chain.as_str(),
|
||||
asset,
|
||||
solvers,
|
||||
before.map(|b| b as i64),
|
||||
limit,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|r| IntentTransferRecord {
|
||||
near_height: r.near_height.max(0) as u64,
|
||||
tx_hash: r.tx_hash,
|
||||
receipt_id: r.receipt_id,
|
||||
transfer_index: r.transfer_index.max(0) as u32,
|
||||
at: r.at,
|
||||
intent_hash: r.intent_hash,
|
||||
from: r.from_account,
|
||||
to: r.to_account,
|
||||
amount: r.amount,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Fill totals since `since`: `(buys, bought, sells, sold)`, base units.
|
||||
pub async fn intent_fill_totals(
|
||||
&self,
|
||||
chain: &ChainId,
|
||||
asset: &str,
|
||||
solvers: &[String],
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<(u64, String, u64, String), DataError> {
|
||||
let r = sqlx::query!(
|
||||
r#"
|
||||
select count(*) filter (where from_account = any($3)) as "buys!",
|
||||
coalesce(sum(amount) filter (where from_account = any($3)), 0)::text as "bought!",
|
||||
count(*) filter (where to_account = any($3)) as "sells!",
|
||||
coalesce(sum(amount) filter (where to_account = any($3)), 0)::text as "sold!"
|
||||
from intents_transfer
|
||||
where chain = $1 and asset = $2 and at >= $4
|
||||
and (from_account = any($3)) <> (to_account = any($3))
|
||||
"#,
|
||||
chain.as_str(),
|
||||
asset,
|
||||
solvers,
|
||||
since,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok((
|
||||
r.buys.max(0) as u64,
|
||||
r.bought,
|
||||
r.sells.max(0) as u64,
|
||||
r.sold,
|
||||
))
|
||||
}
|
||||
|
||||
/// Every account known to fill: solvers seen in `token_diff` fills of any
|
||||
/// pair. The Ref pools' whitelists are added by the caller.
|
||||
pub async fn known_solvers(&self) -> Result<Vec<String>, DataError> {
|
||||
let rows = sqlx::query!("select account from intents_solver order by account")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|r| r.account).collect())
|
||||
}
|
||||
|
||||
/// Record balance changes, idempotently (a re-read writes the same rows).
|
||||
pub async fn record_intents_flows(
|
||||
&self,
|
||||
|
||||
@@ -643,3 +643,65 @@ pub struct RefV1Pools {
|
||||
/// When.
|
||||
pub checked_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// One 1Click fill of the asset: a solver transferring it to a one-off 1Click
|
||||
/// account (a user buying), or receiving it from one (a user selling)
|
||||
/// (blackbeard/observer#43). There is no price: the user's payment happens on
|
||||
/// their own chain, never on NEAR.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsFill.ts")]
|
||||
pub struct IntentsFill {
|
||||
/// NEAR block.
|
||||
#[ts(type = "number")]
|
||||
pub near_height: u64,
|
||||
/// The block's time.
|
||||
pub at: DateTime<Utc>,
|
||||
/// The relay transaction.
|
||||
pub tx_hash: String,
|
||||
/// `buy` (the user received the asset) or `sell`.
|
||||
pub side: String,
|
||||
/// The solver that filled it.
|
||||
pub solver: String,
|
||||
/// The 1Click account on the other side.
|
||||
pub account: String,
|
||||
/// Amount of the asset, base units.
|
||||
pub amount: BigUintDec,
|
||||
}
|
||||
|
||||
/// 1Click fills of the asset, newest first, with totals over a span.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
|
||||
#[ts(export, export_to = "IntentsFills.ts")]
|
||||
pub struct IntentsFills {
|
||||
/// Which chain's token.
|
||||
pub chain: ChainId,
|
||||
/// The asset's symbol on NEAR.
|
||||
pub asset_symbol: Option<String>,
|
||||
/// Its decimals.
|
||||
pub asset_decimals: Option<u8>,
|
||||
/// The accounts counted as solvers.
|
||||
pub solvers: Vec<String>,
|
||||
/// Hours the totals cover.
|
||||
#[ts(type = "number")]
|
||||
pub hours: u64,
|
||||
/// Buys in the span.
|
||||
#[ts(type = "number")]
|
||||
pub buys: u64,
|
||||
/// The asset bought, base units.
|
||||
pub bought: BigUintDec,
|
||||
/// Sells in the span.
|
||||
#[ts(type = "number")]
|
||||
pub sells: u64,
|
||||
/// The asset sold, base units.
|
||||
pub sold: BigUintDec,
|
||||
/// One page of fills.
|
||||
pub fills: Vec<IntentsFill>,
|
||||
/// Cursor (a NEAR height) for the next page.
|
||||
#[ts(type = "number")]
|
||||
pub next: Option<u64>,
|
||||
/// Lowest NEAR height read.
|
||||
#[ts(type = "number")]
|
||||
pub indexed_from: Option<u64>,
|
||||
/// Highest NEAR height read.
|
||||
#[ts(type = "number")]
|
||||
pub indexed_to: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -42,10 +42,10 @@ pub use chain::{
|
||||
};
|
||||
pub use error::{ApiError, EntityError};
|
||||
pub use intents::{
|
||||
BidChange, BidLevel, BidSide, BidSidePoint, CounterPrice, IntentsFlowPoint, IntentsFlows,
|
||||
IntentsInventory, IntentsListing, IntentsPrice, IntentsSeries, IntentsSeriesPoint, IntentsSwap,
|
||||
IntentsSwaps, InventoryHolder, InventoryToken, MarketSummary, Markets, NamedHolder, RefPool,
|
||||
RefPools, RefRung, RefSwap, RefSwaps, RefV1Pool, RefV1Pools,
|
||||
BidChange, BidLevel, BidSide, BidSidePoint, CounterPrice, IntentsFill, IntentsFills,
|
||||
IntentsFlowPoint, IntentsFlows, IntentsInventory, IntentsListing, IntentsPrice, IntentsSeries,
|
||||
IntentsSeriesPoint, IntentsSwap, IntentsSwaps, InventoryHolder, InventoryToken, MarketSummary,
|
||||
Markets, NamedHolder, RefPool, RefPools, RefRung, RefSwap, RefSwaps, RefV1Pool, RefV1Pools,
|
||||
};
|
||||
pub use miner::{
|
||||
AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, NodeActivity,
|
||||
|
||||
@@ -15,7 +15,8 @@ use serde::{Deserialize, Serialize};
|
||||
use ts_rs::TS;
|
||||
|
||||
use crate::{
|
||||
BigUintDec, ChainId, ChainInfo, ChainSummary, IntentsSwap, LeaderboardRow, RecentBlock,
|
||||
BigUintDec, ChainId, ChainInfo, ChainSummary, IntentsFill, IntentsSwap, LeaderboardRow,
|
||||
RecentBlock,
|
||||
};
|
||||
|
||||
/// How far back a leaderboard looks.
|
||||
@@ -267,6 +268,17 @@ pub enum ServerMessage {
|
||||
/// The trade, exactly as `/intents/swaps` would return it.
|
||||
swap: IntentsSwap,
|
||||
},
|
||||
/// A 1Click fill of the asset just settled (blackbeard/observer#43): a
|
||||
/// solver transferring it to or from a 1Click account. Live only, like
|
||||
/// `IntentsSwap`; history is `/intents/fills`.
|
||||
IntentsFill {
|
||||
/// Which chain's token.
|
||||
chain: ChainId,
|
||||
/// The tracked asset, as a multi-token id.
|
||||
asset: String,
|
||||
/// The fill, exactly as `/intents/fills` would return it.
|
||||
fill: IntentsFill,
|
||||
},
|
||||
/// A chain's reachability changed — the node went away, or a chain that was
|
||||
/// awaiting launch has started producing blocks.
|
||||
ChainStatus {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { BigUintDec } from "./BigUintDec";
|
||||
|
||||
/**
|
||||
* One 1Click fill of the asset: a solver transferring it to a one-off 1Click
|
||||
* account (a user buying), or receiving it from one (a user selling)
|
||||
* (blackbeard/observer#43). There is no price: the user's payment happens on
|
||||
* their own chain, never on NEAR.
|
||||
*/
|
||||
export type IntentsFill = {
|
||||
/**
|
||||
* NEAR block.
|
||||
*/
|
||||
near_height: number,
|
||||
/**
|
||||
* The block's time.
|
||||
*/
|
||||
at: string,
|
||||
/**
|
||||
* The relay transaction.
|
||||
*/
|
||||
tx_hash: string,
|
||||
/**
|
||||
* `buy` (the user received the asset) or `sell`.
|
||||
*/
|
||||
side: string,
|
||||
/**
|
||||
* The solver that filled it.
|
||||
*/
|
||||
solver: string,
|
||||
/**
|
||||
* The 1Click account on the other side.
|
||||
*/
|
||||
account: string,
|
||||
/**
|
||||
* Amount of the asset, base units.
|
||||
*/
|
||||
amount: BigUintDec, };
|
||||
@@ -0,0 +1,61 @@
|
||||
// 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 { ChainId } from "./ChainId";
|
||||
import type { IntentsFill } from "./IntentsFill";
|
||||
|
||||
/**
|
||||
* 1Click fills of the asset, newest first, with totals over a span.
|
||||
*/
|
||||
export type IntentsFills = {
|
||||
/**
|
||||
* Which chain's token.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The asset's symbol on NEAR.
|
||||
*/
|
||||
asset_symbol: string | null,
|
||||
/**
|
||||
* Its decimals.
|
||||
*/
|
||||
asset_decimals: number | null,
|
||||
/**
|
||||
* The accounts counted as solvers.
|
||||
*/
|
||||
solvers: Array<string>,
|
||||
/**
|
||||
* Hours the totals cover.
|
||||
*/
|
||||
hours: number,
|
||||
/**
|
||||
* Buys in the span.
|
||||
*/
|
||||
buys: number,
|
||||
/**
|
||||
* The asset bought, base units.
|
||||
*/
|
||||
bought: BigUintDec,
|
||||
/**
|
||||
* Sells in the span.
|
||||
*/
|
||||
sells: number,
|
||||
/**
|
||||
* The asset sold, base units.
|
||||
*/
|
||||
sold: BigUintDec,
|
||||
/**
|
||||
* One page of fills.
|
||||
*/
|
||||
fills: Array<IntentsFill>,
|
||||
/**
|
||||
* Cursor (a NEAR height) for the next page.
|
||||
*/
|
||||
next: number,
|
||||
/**
|
||||
* Lowest NEAR height read.
|
||||
*/
|
||||
indexed_from: number,
|
||||
/**
|
||||
* Highest NEAR height read.
|
||||
*/
|
||||
indexed_to: number, };
|
||||
@@ -3,6 +3,7 @@ import type { BigUintDec } from "./BigUintDec";
|
||||
import type { ChainId } from "./ChainId";
|
||||
import type { ChainInfo } from "./ChainInfo";
|
||||
import type { ChainSummary } from "./ChainSummary";
|
||||
import type { IntentsFill } from "./IntentsFill";
|
||||
import type { IntentsSwap } from "./IntentsSwap";
|
||||
import type { LeaderboardRow } from "./LeaderboardRow";
|
||||
import type { RecentBlock } from "./RecentBlock";
|
||||
@@ -126,7 +127,19 @@ asset: string,
|
||||
/**
|
||||
* The trade, exactly as `/intents/swaps` would return it.
|
||||
*/
|
||||
swap: IntentsSwap, } | { "type": "chain_status",
|
||||
swap: IntentsSwap, } | { "type": "intents_fill",
|
||||
/**
|
||||
* Which chain's token.
|
||||
*/
|
||||
chain: ChainId,
|
||||
/**
|
||||
* The tracked asset, as a multi-token id.
|
||||
*/
|
||||
asset: string,
|
||||
/**
|
||||
* The fill, exactly as `/intents/fills` would return it.
|
||||
*/
|
||||
fill: IntentsFill, } | { "type": "chain_status",
|
||||
/**
|
||||
* Which chain.
|
||||
*/
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
*/
|
||||
|
||||
import type { BidSide } from './generated/BidSide'
|
||||
import type { IntentsFills } from './generated/IntentsFills'
|
||||
import type { IntentsFlows } from './generated/IntentsFlows'
|
||||
import type { RefV1Pools } from './generated/RefV1Pools'
|
||||
import type { AccountDetail } from './generated/AccountDetail'
|
||||
@@ -336,6 +337,16 @@ export async function fetchRefV1(chain: string, signal?: AbortSignal): Promise<R
|
||||
return get<RefV1Pools | null>(`/chains/${chain}/ref/v1`, signal)
|
||||
}
|
||||
|
||||
/** 1Click fills of the asset, newest first (blackbeard/observer#43). */
|
||||
export async function fetchIntentsFills(
|
||||
chain: string,
|
||||
before: number | null,
|
||||
signal?: AbortSignal,
|
||||
): Promise<IntentsFills> {
|
||||
const cursor = before !== null ? `&before=${before}` : ''
|
||||
return get<IntentsFills>(`/chains/${chain}/intents/fills?hours=24${cursor}`, signal)
|
||||
}
|
||||
|
||||
/** The asset moving into and out of NEAR Intents (blackbeard/observer#39). */
|
||||
export async function fetchIntentsFlows(
|
||||
chain: string,
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import type { ChainInfo } from './generated/ChainInfo'
|
||||
import type { ChainSummary } from './generated/ChainSummary'
|
||||
import type { ClientMessage } from './generated/ClientMessage'
|
||||
import type { IntentsFill } from './generated/IntentsFill'
|
||||
import type { IntentsSwap } from './generated/IntentsSwap'
|
||||
import type { LeaderboardRow } from './generated/LeaderboardRow'
|
||||
import type { RecentBlock } from './generated/RecentBlock'
|
||||
@@ -130,6 +131,7 @@ export class Observer {
|
||||
private pulseListeners = new Set<(pulse: WormholePulse) => void>()
|
||||
/** Swap subscribers, kept apart for the same reason: a swap is an event. */
|
||||
private swapListeners = new Set<(swap: IntentsSwap) => void>()
|
||||
private fillListeners = new Set<(fill: IntentsFill) => void>()
|
||||
private socket: WebSocket | null = null
|
||||
private reconnectDelay = RECONNECT_MIN_MS
|
||||
private reconnectTimer: number | null = null
|
||||
@@ -155,6 +157,14 @@ export class Observer {
|
||||
* Intents. Only trades read near NEAR's tip are pushed; anything older is
|
||||
* a query, not news.
|
||||
*/
|
||||
/** 1Click fills as they settle (blackbeard/observer#43). */
|
||||
onIntentsFill = (listener: (fill: IntentsFill) => void): (() => void) => {
|
||||
this.fillListeners.add(listener)
|
||||
return () => {
|
||||
this.fillListeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
onIntentsSwap = (listener: (swap: IntentsSwap) => void): (() => void) => {
|
||||
this.swapListeners.add(listener)
|
||||
return () => {
|
||||
@@ -332,6 +342,18 @@ export class Observer {
|
||||
break
|
||||
}
|
||||
|
||||
case 'intents_fill': {
|
||||
if (message.chain !== this.state.chain) return
|
||||
for (const listener of this.fillListeners) {
|
||||
try {
|
||||
listener(message.fill)
|
||||
} catch (e) {
|
||||
console.warn('[observer] intents fill subscriber threw', e)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'chain_status':
|
||||
this.patch({
|
||||
chains: this.state.chains.map((c) => (c.id === message.chain ? message.info : c)),
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* QTC as 1Click actually settles it (blackbeard/observer#43).
|
||||
*
|
||||
* A 1Click swap of the asset is not a `token_diff` trade: a solver transfers
|
||||
* the asset to a one-off 1Click account for a buy, or receives it from one for
|
||||
* a sell, and the user's payment happens on their own chain — never on NEAR.
|
||||
* So each row is a real swap being filled, with its size, side and solver, and
|
||||
* no price, because there is no price on NEAR to show. The price solvers pay
|
||||
* to restock is the Ref pool's, shown with the price chart.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { IntentsFill } from '../api/generated/IntentsFill'
|
||||
import type { IntentsFills } from '../api/generated/IntentsFills'
|
||||
import { RequestFailed, fetchIntentsFills } from '../api/rest'
|
||||
import { height as fmtHeight, shortAddress, tokens, utc, when } from '../lib/format'
|
||||
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
|
||||
|
||||
export function FillsPanel({ chain, symbol }: { chain: string; symbol: string }) {
|
||||
const [data, setData] = useState<IntentsFills | null>(null)
|
||||
const [older, setOlder] = useState<IntentsFill[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [live, setLive] = useState<{ key: number; fill: IntentsFill }[]>([])
|
||||
const nextKey = useRef(0)
|
||||
const observer = useObserverInstance()
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const load = () =>
|
||||
fetchIntentsFills(chain, null, 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.')
|
||||
})
|
||||
setOlder([])
|
||||
load()
|
||||
const id = window.setInterval(load, REFRESH_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
controller.abort()
|
||||
}
|
||||
}, [chain])
|
||||
|
||||
useEffect(
|
||||
() =>
|
||||
observer.onIntentsFill((fill) => {
|
||||
const key = nextKey.current++
|
||||
setLive((rows) => [{ key, fill }, ...rows].slice(0, LIVE_LINES))
|
||||
}),
|
||||
[observer],
|
||||
)
|
||||
|
||||
const asset = data?.asset_symbol ?? symbol
|
||||
const d = data?.asset_decimals ?? 0
|
||||
const rows = data ? [...data.fills, ...older] : []
|
||||
const cursor = data?.next ?? null
|
||||
const loadOlder = () => {
|
||||
if (cursor === null) return
|
||||
fetchIntentsFills(chain, cursor)
|
||||
.then((page) => {
|
||||
setOlder((r) => [...r, ...page.fills])
|
||||
setData((cur) => (cur ? { ...cur, next: page.next } : page))
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
const range =
|
||||
data && data.indexed_from !== null && data.indexed_to !== null
|
||||
? `NEAR blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}`
|
||||
: 'nothing read yet'
|
||||
const account = (a: string) => (a.length > 40 ? shortAddress(a) : a)
|
||||
|
||||
const table = (list: IntentsFill[], keyOf: (f: IntentsFill, i: number) => string) => (
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">1Click fills of {asset}, newest first</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Settled
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Side
|
||||
</th>
|
||||
<th scope="col">{asset}</th>
|
||||
<th scope="col" className="left">
|
||||
Solver
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
1Click account
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Tx
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.map((f, i) => (
|
||||
<tr key={keyOf(f, i)}>
|
||||
<td className="left numeral" title={utc(f.at)}>
|
||||
{when(f.at)}
|
||||
</td>
|
||||
<td className="left">{f.side === 'buy' ? 'bought' : 'sold'}</td>
|
||||
<td className="numeral">{tokens(f.amount, d)}</td>
|
||||
<td className="left">{f.solver}</td>
|
||||
<td className="left" title={f.account}>
|
||||
{account(f.account)}
|
||||
</td>
|
||||
<td className="left">
|
||||
<a href={`${EXPLORER}${f.tx_hash}`} target="_blank" rel="noreferrer">
|
||||
{f.tx_hash.slice(0, 8)}…
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">1Click fills</h2>
|
||||
<span className="eyebrow">{range}</span>
|
||||
</div>
|
||||
{error && <p className="empty">{error}</p>}
|
||||
{!error && !data && <p className="empty">Reading…</p>}
|
||||
{data && (
|
||||
<div className="node-grid">
|
||||
<Field
|
||||
label="Bought"
|
||||
value={`${tokens(data.bought, d)} ${asset}`}
|
||||
note={`${fmtHeight(data.buys)} fills in the last ${data.hours} h`}
|
||||
/>
|
||||
<Field
|
||||
label="Sold"
|
||||
value={`${tokens(data.sold, d)} ${asset}`}
|
||||
note={`${fmtHeight(data.sells)} fills in the last ${data.hours} h`}
|
||||
/>
|
||||
<Field
|
||||
label="Solvers"
|
||||
value={`${data.solvers.length}`}
|
||||
note="accounts counted as filling"
|
||||
title={data.solvers.join('\n')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{live.length > 0 &&
|
||||
table(
|
||||
live.map((l) => l.fill),
|
||||
(_, i) => `live-${live[i]?.key}`,
|
||||
)}
|
||||
{data && rows.length === 0 && (
|
||||
<p className="empty">No {asset} has been filled through 1Click in the blocks read.</p>
|
||||
)}
|
||||
{rows.length > 0 &&
|
||||
table(rows, (f) => `${f.tx_hash}-${f.near_height}-${f.amount}-${f.side}`)}
|
||||
{cursor !== null && (
|
||||
<div className="pager">
|
||||
<button className="segmented panel-button" onClick={loadOlder}>
|
||||
Older →
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="panel-note">
|
||||
This is how 1Click settles {asset}: a solver transfers it to a one-off 1Click account when
|
||||
a user buys, or receives it from one when a user sells. Each row is one such swap, with no
|
||||
price, because the user pays on their own chain and that payment never appears on NEAR.
|
||||
The 1Click account passing a buy on is not counted again. The price solvers pay to restock
|
||||
is on the <Link to={href({ chain, market: 'ref-dcl' })}>Ref Finance DCL market</Link>.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -22,9 +22,11 @@ import { useObserverInstance } from '../lib/observer-context'
|
||||
import { href } from '../lib/routes'
|
||||
import { BridgePanel } from './BridgePanel'
|
||||
import { IntentsFlowPanel } from './IntentsFlowPanel'
|
||||
import { FillsPanel } from './FillsPanel'
|
||||
import { IntentsPanel } from './IntentsPanel'
|
||||
import { InventoryPanel } from './InventoryPanel'
|
||||
import { PriceChart } from './PriceChart'
|
||||
import { PoolPriceChart } from './RefDclMarket'
|
||||
import { RefDclMarket } from './RefDclMarket'
|
||||
import { SwapHead, SwapRow } from './SwapRow'
|
||||
|
||||
@@ -162,6 +164,12 @@ function NearIntents({
|
||||
|
||||
<IntentsFlowPanel chain={chain} symbol={symbol} />
|
||||
|
||||
<FillsPanel chain={chain} symbol={symbol} />
|
||||
|
||||
{/* 1Click fills carry no price, so the price on this page is the one the
|
||||
solver pays to restock — the Ref pool's swaps (#43). */}
|
||||
{refMarket && <PoolPriceChart chain={chain} symbol={symbol} />}
|
||||
|
||||
<PriceChart chain={chain} symbol={symbol} />
|
||||
|
||||
<InventoryPanel chain={chain} symbol={symbol} />
|
||||
@@ -173,7 +181,8 @@ function NearIntents({
|
||||
</div>
|
||||
{live.length === 0 ? (
|
||||
<p className="empty">
|
||||
Waiting for the next {asset} swap to settle. Nothing appears here that has not.
|
||||
No {asset} trade has settled as a token swap. 1Click settles {asset} as transfers
|
||||
instead, listed under “1Click fills” above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="scroll-x">
|
||||
@@ -206,8 +215,8 @@ function NearIntents({
|
||||
{!error && !history && <p className="empty">Reading…</p>}
|
||||
{history && rows.length === 0 && (
|
||||
<p className="empty">
|
||||
No {asset} swap has settled in the blocks read. That is a count over {range}, not a
|
||||
guess.
|
||||
No {asset} trade has settled as a token swap in the blocks read ({range}). 1Click
|
||||
settles {asset} as transfers instead; those are the fills above.
|
||||
</p>
|
||||
)}
|
||||
{rows.length > 0 && (
|
||||
|
||||
@@ -427,3 +427,30 @@ function RefPriceChart({
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The pool's swap price for a page that is not the pool's own: the NEAR
|
||||
* Intents market, where 1Click fills carry no price and this is the price the
|
||||
* solver pays to restock (blackbeard/observer#43).
|
||||
*/
|
||||
export function PoolPriceChart({ chain, symbol }: { chain: string; symbol: string }) {
|
||||
const [pools, setPools] = useState<RefPools | null>(null)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
fetchRefPools(chain, controller.signal)
|
||||
.then(setPools)
|
||||
.catch(() => {})
|
||||
return () => controller.abort()
|
||||
}, [chain])
|
||||
const pool = pools?.pools[0]
|
||||
if (!pools || !pool) return null
|
||||
return (
|
||||
<RefPriceChart
|
||||
chain={chain}
|
||||
counter={pool.counter_token}
|
||||
counterSymbol={pool.counter_symbol}
|
||||
asset={pools.asset_symbol ?? symbol}
|
||||
refresh={pools.checked_at}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user