From 40bc2f67d1e4d7a8e431179e525897dca000a4fd Mon Sep 17 00:00:00 2001
From: rob thijssen
Date: Sat, 3 Oct 2026 19:18:43 +0300
Subject: [PATCH] fix(core): one swap per taker leg, and solver hops are never
trades
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A 3,000-block survey of intents.near from our own node (350 outcomes, 1,026
legs) showed the referral leg is the user — per-quote implicit accounts, none
on a solver — and that its own diff mirrors the rest of the outcome to within
the protocol fee in all 576 amounts checked. So each taker leg is now its own
trade, priced from what that user gave and got.
It also showed the pooling rule counted solver routing as trading: in 45% of
outcomes with a taker the solvers traded through a pair no taker touched,
usually USDT on Ethereum, and each such leg on the tracked asset became a
phantom trade against a token the user never saw. Two takers on one pair —
an account round-tripping wNEAR, in the case captured — were merged into one
trade with a nonsense price. Now an outcome with any taker yields only the
takers' trades; only an outcome with no referral at all (solvers trading with
each other) is pooled, as `kind: unattributed`, with no side.
`kind` is stored (migration 0010, no re-scan: production held no QTC rows) and
on the wire, and the panel says how many of a figure's trades were users'.
Tests run on the three captured outcomes. Checked live against near.internal
with wNEAR: 18 trades from 18 outcomes, every taker an implicit account, and
no counter that was only a hop.
Closes #30
Co-Authored-By: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
---
...9caa035e13c2638a4edefc7584b5138f9565.json} | 10 +-
...5cceb4eb75b6cee597c4cf600661d46e515f0.json | 30 +++
...df386ee582a83346e5c560dfcec5b00b3f26c.json | 29 ---
...feb1d2e953bd748fdc56f828725524e1a35d.json} | 10 +-
crates/blackbeard-api/src/intents.rs | 2 +
crates/blackbeard-api/src/routes.rs | 1 +
crates/blackbeard-core/src/intents.rs | 233 ++++++++++++++----
.../intents-roundtrip-routed-218371635.json | 16 ++
.../intents-solver-to-solver-218372091.json | 6 +
.../migrations/0010_intents_trade_kind.sql | 16 ++
crates/blackbeard-data/src/store.rs | 16 +-
crates/blackbeard-entities/src/intents.rs | 10 +-
web/src/api/generated/CounterPrice.ts | 5 +
web/src/api/generated/IntentsSwap.ts | 9 +-
web/src/components/IntentsPanel.tsx | 10 +-
15 files changed, 312 insertions(+), 91 deletions(-)
rename .sqlx/{query-b02824ff4cc6414dc701b019123cf618a3cb4e4cca9bfe817fbf52686de85a9c.json => query-0d5dd0665113854dc431196f74d79caa035e13c2638a4edefc7584b5138f9565.json} (79%)
create mode 100644 .sqlx/query-403085a02fcd3ab867a986fc6875cceb4eb75b6cee597c4cf600661d46e515f0.json
delete mode 100644 .sqlx/query-52a060c30ca48a63d2fe9dafaa4df386ee582a83346e5c560dfcec5b00b3f26c.json
rename .sqlx/{query-0c9cb8089edbad6caa8b933e334317f54b0081aa5f4f3caa5cef2a73321835d2.json => query-8dfc7242a7f7510ca92e6c5b38a4feb1d2e953bd748fdc56f828725524e1a35d.json} (83%)
create mode 100644 crates/blackbeard-core/tests/intents-roundtrip-routed-218371635.json
create mode 100644 crates/blackbeard-core/tests/intents-solver-to-solver-218372091.json
create mode 100644 crates/blackbeard-data/migrations/0010_intents_trade_kind.sql
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}