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}