diff --git a/.sqlx/query-b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c.json b/.sqlx/query-0d5dd0665113854dc431196f74d79caa035e13c2638a4edefc7584b5138f9565.json similarity index 79% rename from .sqlx/query-b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c.json rename to .sqlx/query-0d5dd0665113854dc431196f74d79caa035e13c2638a4edefc7584b5138f9565.json index 8b637ff..b50a574 100644 --- a/.sqlx/query-b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c.json +++ b/.sqlx/query-0d5dd0665113854dc431196f74d79caa035e13c2638a4edefc7584b5138f9565.json @@ -1,6 +1,6 @@ { "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 ", + "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 kind as \"kind!\"\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": [ { @@ -82,6 +82,11 @@ "ordinal": 15, "name": "legs!", "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "kind!", + "type_info": "Text" } ], "parameters": { @@ -107,8 +112,9 @@ true, false, false, + false, false ] }, - "hash": "b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c" + "hash": "0d5dd0665113854dc431196f74d79caa035e13c2638a4edefc7584b5138f9565" } diff --git a/.sqlx/query-403085a02fcd3ab867a986fc6875cceb4eb75b6cee597c4cf600661d46e515f0.json b/.sqlx/query-403085a02fcd3ab867a986fc6875cceb4eb75b6cee597c4cf600661d46e515f0.json new file mode 100644 index 0000000..915f2c1 --- /dev/null +++ b/.sqlx/query-403085a02fcd3ab867a986fc6875cceb4eb75b6cee597c4cf600661d46e515f0.json @@ -0,0 +1,30 @@ +{ + "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, kind\n ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::numeric, $10::text::numeric,\n $11, $12, $13, $14, $15, $16, $17)\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, kind = excluded.kind\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Int8", + "Int4", + "Timestamptz", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text", + "TextArray", + "TextArray", + "Jsonb", + "Text" + ] + }, + "nullable": [] + }, + "hash": "403085a02fcd3ab867a986fc6875cceb4eb75b6cee597c4cf600661d46e515f0" +} diff --git a/.sqlx/query-52a060c30ca48a63d2fe9dafaa4df386ee582a83346e5c560dfcec5b00b3f26c.json b/.sqlx/query-52a060c30ca48a63d2fe9dafaa4df386ee582a83346e5c560dfcec5b00b3f26c.json deleted file mode 100644 index 01abc0f..0000000 --- a/.sqlx/query-52a060c30ca48a63d2fe9dafaa4df386ee582a83346e5c560dfcec5b00b3f26c.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "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" -} diff --git a/.sqlx/query-0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2.json b/.sqlx/query-8dfc7242a7f7510ca92e6c5b38a4feb1d2e953bd748fdc56f828725524e1a35d.json similarity index 83% rename from .sqlx/query-0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2.json rename to .sqlx/query-8dfc7242a7f7510ca92e6c5b38a4feb1d2e953bd748fdc56f828725524e1a35d.json index 8623899..6c91d58 100644 --- a/.sqlx/query-0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2.json +++ b/.sqlx/query-8dfc7242a7f7510ca92e6c5b38a4feb1d2e953bd748fdc56f828725524e1a35d.json @@ -1,6 +1,6 @@ { "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 ", + "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, kind\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": [ { @@ -82,6 +82,11 @@ "ordinal": 15, "name": "legs", "type_info": "Jsonb" + }, + { + "ordinal": 16, + "name": "kind", + "type_info": "Text" } ], "parameters": { @@ -109,8 +114,9 @@ true, false, false, + false, false ] }, - "hash": "0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2" + "hash": "8dfc7242a7f7510ca92e6c5b38a4feb1d2e953bd748fdc56f828725524e1a35d" } diff --git a/crates/blackbeard-api/src/intents.rs b/crates/blackbeard-api/src/intents.rs index 696369e..6049b4f 100644 --- a/crates/blackbeard-api/src/intents.rs +++ b/crates/blackbeard-api/src/intents.rs @@ -263,6 +263,7 @@ async fn read_block( receipt_id: outcome.id.clone(), asset_amount: t.asset_amount.to_string(), counter_amount: t.counter_amount.to_string(), + kind: t.kind.as_str().to_owned(), side: t.side.map(|s| s.as_str().to_owned()), taker: t.taker.clone(), referral: t.referral.clone(), @@ -427,6 +428,7 @@ pub fn to_swap( receipt_id: r.receipt_id, counter_symbol: counter.map(|c| c.0.clone()), counter_token: r.counter_token, + kind: r.kind, asset_amount: BigUintDec(r.asset_amount), counter_amount: BigUintDec(r.counter_amount), price, diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 6f37280..45ca238 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -1890,6 +1890,7 @@ async fn intents_price( symbol: counter.map(|c| c.0.clone()), decimals, trades: group.len() as u32, + taker_trades: group.iter().filter(|r| r.kind == "taker").count() as u32, last_price: priced.and_then(|(ad, cd)| { let (a, c) = amounts.first()?; Some(blackbeard_core::intents::price(*a, ad, *c, cd)) diff --git a/crates/blackbeard-core/src/intents.rs b/crates/blackbeard-core/src/intents.rs index f6041b1..fdc70b3 100644 --- a/crates/blackbeard-core/src/intents.rs +++ b/crates/blackbeard-core/src/intents.rs @@ -65,26 +65,48 @@ impl Side { } } -/// The tracked asset against one counter token, within one receipt outcome. +/// Whose trade it was, as far as the logs can say. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Kind { + /// A leg carrying a `referral`: a user who came through a front end. One + /// trade per such leg, priced from that leg alone. + Taker, + /// No leg in the outcome carried a referral — solvers trading with each + /// other. A real exchange at a real price, but with no side to name. + Unattributed, +} + +impl Kind { + /// `taker` or `unattributed`, as stored. + pub fn as_str(self) -> &'static str { + match self { + Kind::Taker => "taker", + Kind::Unattributed => "unattributed", + } + } +} + +/// One trade of the tracked asset against one counter token. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Trade { + /// Whose trade it was. See [`Kind`]. + pub kind: Kind, /// 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. + /// For a [`Kind::Taker`] trade, the taker's own delta: what it gave if it + /// sold, what it received (net of the protocol fee) if it bought. For an + /// unattributed one, the sum of the positive deltas across the pair's + /// legs, so each unit is counted once. 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. + /// The taker's side. `None` for an unattributed trade, never a guess. pub side: Option, - /// The leg with the referral, when there was exactly one. + /// The account whose leg carried the referral. pub taker: Option, - /// That leg's referral. + /// That leg's referral — the front end, as it named itself. pub referral: Option, /// Every account with a leg in this trade, in log order. pub accounts: Vec, @@ -172,29 +194,81 @@ fn leg(entry: &Value) -> Option { }) } -/// Group one outcome's legs into trades of `asset` against each counter token. +/// The trades of `asset` in one outcome's executed legs. /// -/// 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 leg with a `referral` is a taker, and each one is its own trade**, +/// priced from that leg's diff alone. Measured over 350 mainnet outcomes +/// (blackbeard/observer#30): referral legs are the per-quote implicit accounts +/// 1Click creates, solvers never carry one, and in every single-taker outcome +/// the taker's diff mirrors the other legs' to within the protocol fee — so the +/// taker's leg *is* the trade, at the price the user actually got. /// -/// 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. +/// **Every other leg in an outcome with a taker is a fill or a hop, never a +/// trade.** Solvers route: in 45% of those outcomes they traded through a pair +/// no taker touched (USDT on Ethereum, most often), and counting those legs +/// would invent volume against a token the user never saw. Nor are legs summed +/// per pair: two takers on one pair — the same account round-tripping, in the +/// case that showed it — are two trades in opposite directions, and pooling +/// them prices nonsense. +/// +/// Only an outcome with **no** referral at all — solvers trading with each +/// other — is pooled per counter token, as [`Kind::Unattributed`], with no side. +/// +/// A pricing leg names exactly the asset and one other token; anything else +/// that touches the asset is counted in the second value, so a leg left out is +/// visible rather than silently absent. pub fn trades(legs: &[Leg], asset: &str) -> (Vec, usize) { - let mut by_counter: BTreeMap> = 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, + /// The counter token, when the leg names exactly the asset and one other. + fn counter_of<'a>(leg: &'a Leg, asset: &str) -> Option<&'a String> { + let mut others = leg.diff.keys().filter(|t| *t != asset); + match (leg.diff.contains_key(asset), others.next(), others.next()) { + (true, Some(counter), None) => Some(counter), + _ => None, } } let mut out = Vec::new(); + let mut unpriced = 0; + let takers: Vec<&Leg> = legs.iter().filter(|l| l.referral.is_some()).collect(); + + if !takers.is_empty() { + let fills: Vec<&Leg> = legs.iter().filter(|l| l.referral.is_none()).collect(); + for taker in takers.iter().filter(|l| l.diff.contains_key(asset)) { + let Some(counter) = counter_of(taker, asset) else { + unpriced += 1; + continue; + }; + let (a, c) = (taker.diff[asset], taker.diff[counter]); + // A swap gives one token for the other. Both signs alike is not a + // swap, and a zero is not a price. + if a == 0 || c == 0 || (a < 0) == (c < 0) { + unpriced += 1; + continue; + } + let with_fills = std::iter::once(*taker).chain(fills.iter().copied()); + out.push(Trade { + kind: Kind::Taker, + counter: counter.clone(), + asset_amount: a.unsigned_abs(), + counter_amount: c.unsigned_abs(), + side: Some(if a < 0 { Side::Sell } else { Side::Buy }), + taker: Some(taker.account_id.clone()), + referral: taker.referral.clone(), + accounts: with_fills.clone().map(|l| l.account_id.clone()).collect(), + intent_hashes: with_fills.clone().map(|l| l.intent_hash.clone()).collect(), + legs: with_fills.cloned().collect(), + }); + } + return (out, unpriced); + } + + let mut by_counter: BTreeMap> = BTreeMap::new(); + for leg in legs.iter().filter(|l| l.diff.contains_key(asset)) { + match counter_of(leg, asset) { + Some(counter) => by_counter.entry(counter.clone()).or_default().push(leg), + None => unpriced += 1, + } + } for (counter, legs) in by_counter { let positive = |token: &str| -> u128 { legs.iter() @@ -209,25 +283,13 @@ pub fn trades(legs: &[Leg], asset: &str) -> (Vec, usize) { 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 { + kind: Kind::Unattributed, asset_amount, counter_amount, - side, - taker, - referral, + side: None, + taker: None, + referral: None, 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(), @@ -327,10 +389,11 @@ mod tests { 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.kind, Kind::Taker); + // The taker's own leg: the ZEC it received, net of the 703 fee, and + // the USDT it gave — the price the user actually paid. 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.counter_amount, 9_036_300_000); assert_eq!(t.side, Some(Side::Buy)); assert_eq!(t.referral.as_deref(), Some("1click-swapkit")); assert_eq!(t.intent_hashes.len(), 2); @@ -345,7 +408,7 @@ mod tests { 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); + assert_eq!(trades[0].asset_amount, 9_036_300_000); } #[test] @@ -397,14 +460,92 @@ mod tests { } #[test] - fn without_exactly_one_referral_the_side_is_not_guessed() { + fn without_a_referral_the_trade_is_unattributed_and_the_side_not_guessed() { let (mut legs, _) = executed_legs(&fixture()); legs[1].referral = None; let (trades, _) = trades(&legs, ZEC); + assert_eq!(trades[0].kind, Kind::Unattributed); assert_eq!(trades[0].side, None); assert_eq!(trades[0].taker, None); } + const WNEAR: &str = "nep141:wrap.near"; + const SOL_USDC: &str = "nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near"; + const ETH_USDT: &str = "nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near"; + + /// Tx `GTH3v6ff…`, NEAR 218,371,635: one account (`1click-e2e_tests`) + /// sells wNEAR for Solana USDC and buys some back, in one outcome, and the + /// solvers route both legs through USDT on Ethereum. + fn round_trip() -> Vec { + serde_json::from_str(include_str!( + "../tests/intents-roundtrip-routed-218371635.json" + )) + .unwrap() + } + + #[test] + fn two_takers_on_one_pair_are_two_trades_in_opposite_directions() { + let (legs, _) = executed_legs(&round_trip()); + assert_eq!(legs.len(), 6); + let (trades, unpriced) = trades(&legs, WNEAR); + assert_eq!(unpriced, 0); + assert_eq!(trades.len(), 2, "{trades:#?}"); + assert!( + trades + .iter() + .all(|t| t.kind == Kind::Taker && t.counter == SOL_USDC) + ); + let sells: Vec<_> = trades + .iter() + .filter(|t| t.side == Some(Side::Sell)) + .collect(); + let buys: Vec<_> = trades + .iter() + .filter(|t| t.side == Some(Side::Buy)) + .collect(); + assert_eq!((sells.len(), buys.len()), (1, 1)); + assert_eq!(sells[0].counter_amount, 1_335_270); + assert_eq!(buys[0].counter_amount, 322_478); + assert!( + trades + .iter() + .all(|t| t.referral.as_deref() == Some("1click-e2e_tests")) + ); + } + + #[test] + fn a_solver_hop_through_another_token_is_never_a_trade() { + let (legs, _) = executed_legs(&round_trip()); + // The solvers' wNEAR/eth-USDT legs are how the swap was filled, not a + // trade of wNEAR against USDT: no wNEAR trade names that counter... + let (wnear, _) = trades(&legs, WNEAR); + assert!(wnear.iter().all(|t| t.counter != ETH_USDT)); + // ...and the hop token itself, which no taker touched, has no trades. + let (usdt, _) = trades(&legs, ETH_USDT); + assert!(usdt.is_empty(), "{usdt:#?}"); + } + + #[test] + fn solvers_trading_with_each_other_are_one_unattributed_trade() { + // Tx `538k7FUJ…`, NEAR 218,372,091: crux-solver.near and + // blockzen-1-main.near swap eth-USDC for Solana USDT, no referral. + let logs: Vec = serde_json::from_str(include_str!( + "../tests/intents-solver-to-solver-218372091.json" + )) + .unwrap(); + let (legs, _) = executed_legs(&logs); + let sol_usdt = "nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near"; + let (trades, unpriced) = trades(&legs, sol_usdt); + assert_eq!(unpriced, 0); + assert_eq!(trades.len(), 1); + assert_eq!(trades[0].kind, Kind::Unattributed); + assert_eq!(trades[0].side, None); + assert_eq!( + trades[0].accounts, + ["crux-solver.near", "blockzen-1-main.near"] + ); + } + #[test] fn intents_near_is_on_shard_seven_under_layout_v3() { // `EXPERIMENTAL_protocol_config.shard_layout.V3`, mainnet, 2026-10-02. diff --git a/crates/blackbeard-core/tests/intents-roundtrip-routed-218371635.json b/crates/blackbeard-core/tests/intents-roundtrip-routed-218371635.json new file mode 100644 index 0000000..1521504 --- /dev/null +++ b/crates/blackbeard-core/tests/intents-roundtrip-routed-218371635.json @@ -0,0 +1,16 @@ +[ + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"FM3BSVh86p9BWDfE6DFrwmMw6g2iwPxT1eyn3Uvre2N9\",\"account_id\":\"solver-priv-liq.near\",\"diff\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"-1335408\",\"nep141:wrap.near\":\"287513664729933290294813\"},\"fees_collected\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"2\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"7qmWKcYzTpcqkFPcaYi6VCwbeU6TW4LfZk8cmZXfwBRw\",\"account_id\":\"solver-multichain-asset.near\",\"diff\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"1335406\",\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"-1335272\"},\"fees_collected\":{\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"2\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"7McXbZGpqrcQ59kJESnJAty3wnBcCxfAz4xBHPKJRhFD\",\"account_id\":\"solver-priv-liq.near\",\"diff\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"322444\",\"nep141:wrap.near\":\"-68591691781345450365099\"},\"fees_collected\":{\"nep141:wrap.near\":\"68591691781345451\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"5CuLt73CsmirMtiTe5aZeMkMYDKaFWusQcsZ5fo86yjZ\",\"account_id\":\"solver-multichain-asset-escrow.near\",\"diff\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"-322445\",\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"322477\"},\"fees_collected\":{\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\":\"1\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"diff\":{\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"1335270\",\"nep141:wrap.near\":\"-287513952243885534180348\"},\"referral\":\"1click-e2e_tests\",\"fees_collected\":{\"nep141:wrap.near\":\"287513952243885535\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"transfer\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"receiver_id\":\"143f2bb439575c286f4f585163f0e5fb4e464f8ce57ec06ae80741407d3cfb31\",\"tokens\":{\"nep141:wrap.near\":\"11593761375927224013\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"transfer\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"receiver_id\":\"5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd\",\"tokens\":{\"nep141:wrap.near\":\"576180265017806681725\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"diff\":{\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"-322478\",\"nep141:wrap.near\":\"68591623189653669019648\"},\"referral\":\"1click-e2e_tests\",\"fees_collected\":{\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\":\"1\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"storage_deposit\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"contract_id\":\"omni.bridge.near\",\"deposit_for_account_id\":\"6c3ebdb7cba68d6e320151aea8b67a8a59e6d4d78dd4851ddfd33f96d4b5bfb3\",\"amount\":\"68591623189653669019648\"}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"ft_withdraw\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"token\":\"sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\",\"receiver_id\":\"omni.bridge.near\",\"amount\":\"1012792\",\"msg\":\"{\\\"recipient\\\":\\\"sol:AKNV5fpCpDh2fRFtFZGXoxxgcETwvviPH7FgJai3ChBt\\\",\\\"fee\\\":\\\"0\\\",\\\"native_token_fee\\\":\\\"68591623189653669019648\\\",\\\"external_id\\\":\\\"80d490cb-5ef8-4852-847f-bfe9c1840227\\\"}\",\"min_gas\":\"37400000000000\"}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"native_withdraw\",\"data\":[{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"receiver_id\":\"e2e-tests-prod.near\",\"amount\":\"2921829317251232227146\"}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"intents_executed\",\"data\":[{\"intent_hash\":\"FM3BSVh86p9BWDfE6DFrwmMw6g2iwPxT1eyn3Uvre2N9\",\"account_id\":\"solver-priv-liq.near\",\"nonce\":\"uLvAZPKqEZCMNavtCKGbesnJkMW2oSQiJFGEFO2vD0E=\"},{\"intent_hash\":\"7qmWKcYzTpcqkFPcaYi6VCwbeU6TW4LfZk8cmZXfwBRw\",\"account_id\":\"solver-multichain-asset.near\",\"nonce\":\"ms59EqORdD1xFVrxsCr8kEsVyIn+Kw68H9LwRrYnhQg=\"},{\"intent_hash\":\"7McXbZGpqrcQ59kJESnJAty3wnBcCxfAz4xBHPKJRhFD\",\"account_id\":\"solver-priv-liq.near\",\"nonce\":\"Mw95uYT0/oGGhywxGIXVg6Zo7qs6YjAlORRqQLL2/9U=\"},{\"intent_hash\":\"5CuLt73CsmirMtiTe5aZeMkMYDKaFWusQcsZ5fo86yjZ\",\"account_id\":\"solver-multichain-asset-escrow.near\",\"nonce\":\"Qfar0Y172cw6wP8DgXE9Vj1/f/2Oli5TAIt+ENM80vA=\"},{\"intent_hash\":\"3662n1fW52YdSsXFA2asayuQ3xudedV58oduVSrLr8Hq\",\"account_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"nonce\":\"Vij2xgAlKBKzgCoKB3wQ2xhxVLgC40IO6LUiUZBa8G0=\"}]}", + "EVENT_JSON:{\"standard\":\"nep245\",\"version\":\"1.0.0\",\"event\":\"mt_transfer\",\"data\":[{\"old_owner_id\":\"solver-multichain-asset.near\",\"new_owner_id\":\"solver-multichain-asset-escrow.near\",\"token_ids\":[\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\"],\"amounts\":[\"322477\"]},{\"old_owner_id\":\"solver-multichain-asset.near\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\"],\"amounts\":[\"3\"]},{\"old_owner_id\":\"solver-multichain-asset.near\",\"new_owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"token_ids\":[\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\"],\"amounts\":[\"1012792\"]},{\"old_owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"new_owner_id\":\"5880ad2b362620fadf759cbceb1cd5737ce8c6ed7fb8e9942881e6731f9247dd\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"576180265017806681725\"]},{\"old_owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"356105644025230986\"]},{\"old_owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"new_owner_id\":\"solver-priv-liq.near\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"218921972948587839929714\"]},{\"old_owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"new_owner_id\":\"143f2bb439575c286f4f585163f0e5fb4e464f8ce57ec06ae80741407d3cfb31\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"11593761375927224013\"]},{\"old_owner_id\":\"solver-multichain-asset-escrow.near\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\"],\"amounts\":[\"3\"]},{\"old_owner_id\":\"solver-multichain-asset-escrow.near\",\"new_owner_id\":\"solver-multichain-asset.near\",\"token_ids\":[\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\"],\"amounts\":[\"322442\"]},{\"old_owner_id\":\"solver-priv-liq.near\",\"new_owner_id\":\"solver-multichain-asset.near\",\"token_ids\":[\"nep141:eth-0xdac17f958d2ee523a2206206994597c13d831ec7.omft.near\"],\"amounts\":[\"1012964\"]}]}", + "EVENT_JSON:{\"standard\":\"nep245\",\"version\":\"1.0.0\",\"event\":\"mt_burn\",\"data\":[{\"owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"68591623189653669019648\"],\"memo\":\"withdraw\"},{\"owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"token_ids\":[\"nep141:sol-c800a4bd850783ccb82c2b2c7e84175443606352.omft.near\"],\"amounts\":[\"1012792\"],\"memo\":\"withdraw\"},{\"owner_id\":\"a5e8dc1ba428a8ce8ff5284a5dd52ce3337d02e61feaf2a62f4921a4eb582665\",\"token_ids\":[\"nep141:wrap.near\"],\"amounts\":[\"2921829317251232227146\"],\"memo\":\"withdraw\"}]}" +] \ No newline at end of file diff --git a/crates/blackbeard-core/tests/intents-solver-to-solver-218372091.json b/crates/blackbeard-core/tests/intents-solver-to-solver-218372091.json new file mode 100644 index 0000000..8197666 --- /dev/null +++ b/crates/blackbeard-core/tests/intents-solver-to-solver-218372091.json @@ -0,0 +1,6 @@ +[ + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"Fbt8CGuMuiuG6qjXPjNjXh5wcUh2A8vBqW87tPYk1Heb\",\"account_id\":\"crux-solver.near\",\"diff\":{\"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near\":\"9998531775\",\"nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near\":\"-9997631907\"},\"fees_collected\":{\"nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near\":\"9998\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"token_diff\",\"data\":[{\"intent_hash\":\"7GXRmSYYZ3hi4fUvvAaEFoioukuYh6HtckZHN2iXa7G6\",\"account_id\":\"blockzen-1-main.near\",\"diff\":{\"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near\":\"-9998541774\",\"nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near\":\"9997621909\"},\"fees_collected\":{\"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near\":\"9999\"}}]}", + "EVENT_JSON:{\"standard\":\"dip4\",\"version\":\"0.4.3\",\"event\":\"intents_executed\",\"data\":[{\"intent_hash\":\"Fbt8CGuMuiuG6qjXPjNjXh5wcUh2A8vBqW87tPYk1Heb\",\"account_id\":\"crux-solver.near\",\"nonce\":\"dPmpJWi8hbzE26Toq88IeyIN2TEq0D21GT12R+GLN9E=\"},{\"intent_hash\":\"7GXRmSYYZ3hi4fUvvAaEFoioukuYh6HtckZHN2iXa7G6\",\"account_id\":\"blockzen-1-main.near\",\"nonce\":\"nAdpcl+B7P/I6PzVm0L6kJTmdPNlXeWlLmgt1GMiu90=\"}]}", + "EVENT_JSON:{\"standard\":\"nep245\",\"version\":\"1.0.0\",\"event\":\"mt_transfer\",\"data\":[{\"old_owner_id\":\"blockzen-1-main.near\",\"new_owner_id\":\"crux-solver.near\",\"token_ids\":[\"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near\"],\"amounts\":[\"9998531775\"]},{\"old_owner_id\":\"blockzen-1-main.near\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:eth-0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48.omft.near\"],\"amounts\":[\"9999\"]},{\"old_owner_id\":\"crux-solver.near\",\"new_owner_id\":\"blockzen-1-main.near\",\"token_ids\":[\"nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near\"],\"amounts\":[\"9997621909\"]},{\"old_owner_id\":\"crux-solver.near\",\"new_owner_id\":\"7066024d3f20f94de601c003163367873cca78507eeca4df66d9be645f197f05\",\"token_ids\":[\"nep141:sol-5ce3bf3a31af18be40ba30f721101b4341690186.omft.near\"],\"amounts\":[\"9998\"]}]}" +] \ No newline at end of file diff --git a/crates/blackbeard-data/migrations/0010_intents_trade_kind.sql b/crates/blackbeard-data/migrations/0010_intents_trade_kind.sql new file mode 100644 index 0000000..4e8fb25 --- /dev/null +++ b/crates/blackbeard-data/migrations/0010_intents_trade_kind.sql @@ -0,0 +1,16 @@ +-- Whose trade an intents_trade row is: a taker's, or unattributed. +-- blackbeard/observer#30. +-- +-- Rows written before this came from pooling every leg of a pair, which +-- double-counted solver hops and merged opposite trades. They are marked from +-- what they recorded — a side meant a single referral leg — but are NOT re-read: +-- production held no row of the tracked asset when this landed (QTC had no +-- supply on NEAR at all), so a cursor collapse would re-scan hours of empty +-- blocks to rebuild a table that is already correctly empty. An environment +-- that does hold old rows and wants them redone needs its own collapse, the +-- way 0008 did it for chain events. +alter table intents_trade add column kind text; +update intents_trade set kind = case when side is null then 'unattributed' else 'taker' end; +alter table intents_trade alter column kind set not null; +alter table intents_trade + add constraint intents_trade_kind check (kind in ('taker', 'unattributed')); diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 55e9fcf..f655bcc 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -277,6 +277,8 @@ pub struct IntentTrade { pub tx_hash: String, /// The receipt whose outcome logged the trade. pub receipt_id: String, + /// `taker` or `unattributed` (`blackbeard_core::intents::Kind`). + pub kind: String, /// The other token. pub counter_token: String, /// Asset that changed hands, base units. @@ -2755,9 +2757,9 @@ impl Store { 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 + accounts, intent_hashes, legs, kind ) values ($1, $2, $3, $4, $5, $6, $7, $8, $9::text::numeric, $10::text::numeric, - $11, $12, $13, $14, $15, $16) + $11, $12, $13, $14, $15, $16, $17) 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, @@ -2765,7 +2767,7 @@ impl Store { 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 + legs = excluded.legs, kind = excluded.kind "#, t.chain.as_str(), t.asset, @@ -2783,6 +2785,7 @@ impl Store { &t.accounts, &t.intent_hashes, t.legs, + t.kind, ) .execute(&mut *tx) .await?; @@ -2850,7 +2853,7 @@ impl Store { 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 + accounts, intent_hashes, legs, kind from intents_trade where chain = $1 and asset = $2 and (near_height, trade_index) > ($3, $4) order by near_height, trade_index @@ -2883,6 +2886,7 @@ impl Store { accounts: r.accounts, intent_hashes: r.intent_hashes, legs: r.legs, + kind: r.kind, }) .collect()) } @@ -2907,7 +2911,8 @@ impl Store { 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!" + accounts as "accounts!", intent_hashes as "intent_hashes!", legs as "legs!", + kind as "kind!" from ( select *, row_number() over ( partition by counter_token order by near_height desc, trade_index desc @@ -2943,6 +2948,7 @@ impl Store { accounts: r.accounts, intent_hashes: r.intent_hashes, legs: r.legs, + kind: r.kind, }) .collect()) } diff --git a/crates/blackbeard-entities/src/intents.rs b/crates/blackbeard-entities/src/intents.rs index 1a06899..5b0a546 100644 --- a/crates/blackbeard-entities/src/intents.rs +++ b/crates/blackbeard-entities/src/intents.rs @@ -44,8 +44,11 @@ pub struct IntentsSwap { /// Counter tokens per one whole unit of the asset. Absent when either /// token's decimals are unknown. pub price: Option, - /// `buy` or `sell`, from the taker's side. **Inferred** from the one leg - /// that carried a `referral`; absent when that could not be told. + /// `taker` — a user's trade, from the leg that carried a `referral` — or + /// `unattributed`, solvers trading with each other with no taker to name. + pub kind: String, + /// `buy` or `sell`, from the taker's side. Absent for an unattributed + /// trade: there is no side to take. pub side: Option, /// The account inferred to be the taker. pub taker: Option, @@ -93,6 +96,9 @@ pub struct CounterPrice { /// 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, + /// How many of them were users' trades rather than solvers trading with + /// each other. + pub taker_trades: u32, /// The latest trade's price. pub last_price: Option, /// When it settled. diff --git a/web/src/api/generated/CounterPrice.ts b/web/src/api/generated/CounterPrice.ts index f4890a1..d4229cf 100644 --- a/web/src/api/generated/CounterPrice.ts +++ b/web/src/api/generated/CounterPrice.ts @@ -22,6 +22,11 @@ decimals: number | null, * as this count, and one trade is one trade however it is labelled. */ trades: number, +/** + * How many of them were users' trades rather than solvers trading with + * each other. + */ +taker_trades: number, /** * The latest trade's price. */ diff --git a/web/src/api/generated/IntentsSwap.ts b/web/src/api/generated/IntentsSwap.ts index 9158d57..a854554 100644 --- a/web/src/api/generated/IntentsSwap.ts +++ b/web/src/api/generated/IntentsSwap.ts @@ -48,8 +48,13 @@ counter_amount: BigUintDec, */ 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. + * `taker` — a user's trade, from the leg that carried a `referral` — or + * `unattributed`, solvers trading with each other with no taker to name. + */ +kind: string, +/** + * `buy` or `sell`, from the taker's side. Absent for an unattributed + * trade: there is no side to take. */ side: string | null, /** diff --git a/web/src/components/IntentsPanel.tsx b/web/src/components/IntentsPanel.tsx index 33d1f6a..d15d656 100644 --- a/web/src/components/IntentsPanel.tsx +++ b/web/src/components/IntentsPanel.tsx @@ -164,7 +164,9 @@ export function IntentsPanel({ key={c.counter_token} label={`${asset} in ${unit}`} value={`${fmtPrice(c.vwap)} ${unit}`} - note={`${fmtHeight(c.trades)} trade${c.trades === 1 ? '' : 's'} · last ${fmtPrice(c.last_price)} ${unit}, ${ago(c.last_at)}`} + note={`${fmtHeight(c.trades)} trade${c.trades === 1 ? '' : 's'}${ + c.taker_trades === c.trades ? '' : `, ${fmtHeight(c.taker_trades)} by users` + } · last ${fmtPrice(c.last_price)} ${unit}, ${ago(c.last_at)}`} title={c.counter_token} /> ) @@ -180,8 +182,10 @@ export function IntentsPanel({ Some of these rest on fewer than five trades; a figure from one trade is one trade.{' '} )} - Token names are each token's own description of itself; the contract behind a figure is - in its tooltip. + A trade is one user's side of a swap, priced from what they gave and what they got; a + solver's routing through other tokens is never counted as a trade of its own, and + solvers trading with each other are counted but name no side. Token names are each + token's own description of itself; the contract behind a figure is in its tooltip.

What is read: {data.reads}