Wash trading signals
A large share of reported DEX volume on any chain is generated by accounts trading with themselves. This page gives you the queries for the signals people use to flag it.
There is no on-chain field that says "this was a wash trade", and every heuristic here has legitimate explanations. A market maker quoting both sides, an arbitrageur round-tripping within a block, and a bot rebalancing all look similar to a wash trader in the data.
Nothing below classifies a trade. Each query surfaces a pattern; you choose the thresholds and own the conclusion. Treat a single signal as weak and a stack of them on the same account as worth investigating.
Signal 1: both sides of the same trade
The cleanest case. Pull both accounts and compare them:
query TradeCounterparties {
Solana {
DEXTrades(
where: { Transaction: { Result: { Success: true } } }
limit: { count: 100 }
orderBy: { descending: Block_Time }
) {
Block {
Time
}
Transaction {
Signature
Signer
}
Trade {
Dex {
ProtocolName
}
Buy {
Account {
Address
}
Amount
AmountInUSD
}
Sell {
Account {
Address
}
Amount
}
}
}
}
}
Compare Buy.Account.Address against Sell.Account.Address client-side. An exact match is a
self-trade. It is rarer than people expect, because anyone doing this at scale uses separate
addresses — which is what the next signal is for.
Signal 2: round-tripping
Far more common than literal self-trades: one account buys and sells the same token repeatedly,
ending roughly flat. sum with an if condition splits buy and sell volume in a single pass:
query RoundTripTraders {
Solana {
DEXTradeByTokens(
where: {
Trade: { Currency: { MintAddress: { is: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" } } }
Transaction: { Result: { Success: true } }
}
orderBy: { descendingByField: "trades" }
limit: { count: 50 }
) {
Trade {
Account {
Owner
}
}
trades: count
bought: sum(of: Trade_Amount, if: { Trade: { Side: { Type: { is: buy } } } })
sold: sum(of: Trade_Amount, if: { Trade: { Side: { Type: { is: sell } } } })
volumeUsd: sum(of: Trade_Side_AmountInUSD)
}
}
}
What to compute from the result:
- Imbalance =
abs(bought - sold) / (bought + sold). Near zero with a high trade count means the account cycled inventory rather than taking a position. - Notional per trade =
volumeUsd / trades. Wash volume is usually many trades of trivial size, because the point is trade count and printed volume, not exposure.
The two together are the useful test. An account with hundreds of trades, near-zero net position and a few dollars of notional per trade is doing something other than investing. An account with the same imbalance but large notional per trade is more likely a market maker.
Signal 3: churn concentrated in a few accounts
Organic volume comes from many accounts trading a few times each. Inorganic volume is the inverse. Compare the two distributions for a token:
query TraderConcentration {
Solana {
DEXTradeByTokens(
where: {
Trade: { Currency: { MintAddress: { is: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" } } }
Transaction: { Result: { Success: true } }
}
limit: { count: 1 }
) {
trades: count
traders: count(distinct: Trade_Account_Owner)
volumeUsd: sum(of: Trade_Side_AmountInUSD)
}
}
}
trades / traders is trades per account. A token where that ratio is high while volumeUsd is
low is printing activity rather than turnover. Run it across several tokens and compare — the
absolute number means little on its own, the ranking means a lot.
Signal 4: same counterparty, over and over
Two addresses passing a position back and forth show up as a pair that trades almost exclusively with each other. Group trades by token and account, then look at how many distinct counterparties each account has:
query CounterpartyDiversity {
Solana {
DEXTrades(
where: {
Trade: { Buy: { Currency: { MintAddress: { is: "DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263" } } } }
Transaction: { Result: { Success: true } }
}
orderBy: { descendingByField: "trades" }
limit: { count: 50 }
) {
Trade {
Buy {
Account {
Address
}
}
}
trades: count
counterparties: count(distinct: Trade_Sell_Account_Address)
volumeUsd: sum(of: Trade_Buy_AmountInUSD)
}
}
}
A high trades count against a very low counterparties count is the pattern. One or two
counterparties across hundreds of trades is a closed loop; genuine flow touches many.
Putting it together
None of these is conclusive alone. A workable approach is to score rather than classify:
- Pull per-account aggregates for the token (signal 2).
- Flag accounts that clear all of your thresholds — low imbalance, low notional per trade, low counterparty diversity.
- Report the flagged share of volume as a range, not a number, and state your thresholds alongside it.
Two things worth being strict about if you publish results:
- Say what you measured. "Volume from accounts with net position under 2% and under $10 median trade size" is defensible. "Wash volume" is a claim about intent that the data does not support.
- Do not extrapolate from a sample. Scoring the top 50 accounts and multiplying up is the most common way these numbers end up wrong by an order of magnitude. Score the full account set, or report only the portion you measured.