Polymarket Wash Trading Claims: Volume Traps in the Data API and Matching Logs
A WIRED investigation published on October 15, 2026 put two election markets under scrutiny. Citing MSR Decode, it attributed about $284 million of trading in the Ethiopia market to repeated buying and selling between paired accounts. Polymarket said it had investigated and blocked the accounts involved.
That allegation deserves scrutiny, and it rests on counterparty cycles—not merely on a losing candidate having a large volume figure. But when you open the API to investigate, there are several ways to get the arithmetic wrong first. Does all the volume under a candidate’s name represent bets on that candidate winning? Does a hash appearing three times mean three duplicate trades?
I checked cumulative totals for the two events, two small trade windows, and three Polygon matching transactions. Large volume identifies where to look; the transaction structure is what needs unpacking. In these two small windows, more than 98% of notional turnover came from No-token purchases and sales combined, not from “98% of the money buying a losing outcome.” No contracts trading near $1 can change hands repeatedly too. Both ordinary trading and manufactured cycles can produce this outcome distribution; counterparties and fund flows are needed to distinguish them.
For the controversy and accounting definitions, start with the amount comparisons. For a collector or indexer, expand the Python example and work through the matching-log rules.
Events, conditions, tokens, and cumulative volume
On Polymarket, an election event can contain several candidate markets. Each market has its own condition and two outcome tokens, Yes and No. Either token can then be bought or sold.
event: the election as a whole
└─ condition: will a particular candidate win?
├─ Yes token → BUY / SELL
└─ No token → BUY / SELL
The Gamma API snapshots I retained report the following totals. For each event, summing the child markets’ volume reproduces the event total.
| Official Gamma event | Child markets | Reported cumulative turnover |
|---|---|---|
| Next Prime Minister of Ethiopia | 33 | $286,138,162.70 |
| (Old) Romania Election | 6 | $371,837,992.48 |
| Total | 39 | $657,976,155.18 |
The roughly $658 million here is lifetime cumulative volume in these API snapshots. WIRED’s headline concerns trading after the races had been decided. I have not split these cumulative totals at the respective points when each result became clear. The figures provide a scale comparison, not a reconstruction of post-result turnover.
What is the denominator for MSR’s $284 million?
Dividing the rounded $284,000,000 in the report by Gamma’s $286,138,162.70 gives about 99.25%. The division is fine; the numerator and denominator come from different scopes.
MSR’s public study and its JSON data table provide their own denominator. The study covers April 28–September 9, 2026, with Polygon blocks 86107336–93524955. Seven Ethiopian child-market rows have event_id=411239, each with turnover_usd and the study’s flagged recycled_usd. Their sums are:
| Source and scope | Amount | What it measures |
|---|---|---|
| MSR public table: 7 markets within its block range | $283,964,639.89 | Sum of recycled_usd |
| Same table, same 7 markets and block range | $285,027,224.40 | Corresponding turnover_usd denominator |
| Gamma snapshot: all 33 markets in the event | $286,138,162.70 | Reported cumulative turnover |
The same-table ratio is $283,964,639.89 / $285,027,224.40 ≈ 99.63%, consistent with MSR’s one-decimal 99.6%. That high percentage is specifically its result for those seven markets during its study interval.
Reconciling it with Gamma would require the same seven condition_id values, start and end blocks, and economic-fill counting rules. MSR says direct share trades are valued at their cash price, paired creation or merging at $1 per pair, with each economic fill counted once. Different paths require their own amount definitions; one API side’s size × price is not a universal substitute.
What I reproduced here is the public table’s arithmetic. MSR has not published its precise episode-classification thresholds, and I have not established that the public census and the commissioned WIRED analysis use the same frozen data version. Those gaps remain for independent classification replication. The public source and denominator behind “about $284 million” can nevertheless be checked.
No-token turnover in two small windows
The Adanech Abiebie market reports about $83.46 million cumulatively; Nicolae Ciucă’s market reports about $326.5 million, around 87.8% of the Romania event’s total. A candidate’s name is prominent, but the traded asset still needs to be identified as Yes or No.
I retrieved the latest 250 taker records for each market and selected UTC windows fully bracketed by the returned timestamps:
| Market and UTC window | Records | Sum of size × price | No-token amount: BUY + SELL |
|---|---|---|---|
| Adanech, 2026-09-01 to 09-30, end excluded | 83 | $2,997.93 | $2,987.15; 99.64% |
| Ciucă, all of 2025-04-30 | 128 | $95,930.59 | $94,609.25; 98.62% |
outcome=No, side=BUY means buying No; outcome=No, side=SELL means selling No. Both contribute to the table’s turnover. Net purchases or position changes need a separate calculation by account, share quantity, and direction.
The two windows total about $98,900—only 0.0241% of these two child markets’ cumulative turnover—and are not a random sample. I chose them because a single page completely covered each window, making the fields and arithmetic inspectable. They illustrate accounting definitions, not the markets’ complete histories.
Data API v2: today’s first page is not a historical snapshot
The official OpenAPI contract specifies condition as the market filter for GET /v2/trades, the top-level data list, and condition_id in each Trade. Pagination uses pagination.next_cursor. taker_only=true returns the taker side. For condition queries, start/end are ignored, so time filtering must happen after retrieval.
A limit=250 request without a cursor returns the current latest page, not the historical snapshot I saved. New records or corrections can change its coverage. The script below aggregates only when the page crosses both time boundaries. The 83 records and amounts belong to the retained snapshot; they are not promised outputs at every future execution.
Python example: checking the time window
Save as window_check.py and run python window_check.py. It requests the current page, checks the condition, ordering, amounts, and time boundaries, then decides whether to print a total.
import json
from datetime import datetime, timezone
from decimal import Decimal
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
CONDITION = '0x7d0aaf81bbd3fd73b6a1651cce08a452c0cbf9c0cbb4520ce0f981065b639d88'
START = int(datetime(2026, 9, 1, tzinfo=timezone.utc).timestamp())
END = int(datetime(2026, 9, 30, tzinfo=timezone.utc).timestamp())
def summarize(payload):
rows = payload.get('data')
if not isinstance(rows, list) or not rows:
raise ValueError('Missing or empty v2 data list')
if any(r['condition_id'] != CONDITION for r in rows):
raise ValueError('Unexpected condition_id')
times = [int(r['timestamp']) for r in rows]
if times != sorted(times, reverse=True):
raise ValueError('Unexpected trade ordering')
if times[0] < END:
raise ValueError('Upper bound unproven: older pages cannot fix this')
if times[-1] >= START:
raise ValueError('Window not bracketed: follow pagination.next_cursor')
sample = [r for r in rows if START <= int(r['timestamp']) < END]
amounts = {'Yes': Decimal('0'), 'No': Decimal('0')}
for r in sample:
size, price = Decimal(str(r['size'])), Decimal(str(r['price']))
if not size.is_finite() or not price.is_finite() or size < 0 or not 0 <= price <= 1:
raise ValueError('Invalid size or price')
if r['outcome'] not in amounts:
raise ValueError('Unlabelled outcome: inspect before aggregation')
amounts[r['outcome']] += size * price
return len(sample), sum(amounts.values()), amounts['No']
def build_request():
params = urlencode({'condition': CONDITION, 'limit': 250, 'taker_only': 'true'})
return Request('https://data-api.polymarket.com/v2/trades?' + params,
headers={'Accept': 'application/json',
'User-Agent': 'PolymarketVolumeCheck/1.0'})
if __name__ == '__main__':
try:
with urlopen(build_request(), timeout=20) as response:
payload = json.load(response, parse_float=Decimal)
except HTTPError as error:
raise SystemExit(f'HTTP {error.code}: stop; check API access or Retry-After')
except URLError as error:
raise SystemExit(f'Network error: {error.reason}')
count, total, no_total = summarize(payload)
print('rows:', count)
print('all:', total.quantize(Decimal('0.01')))
print('No:', no_total.quantize(Decimal('0.01')))
The request identifies the script with an explicit User-Agent. A header cannot guarantee access from every network. For 403, inspect the access conditions; for 429/503, consider the service’s Retry-After. A network failure is not an empty dataset.
Window not bracketed means the page does not reach far enough back: follow pagination.next_cursor while preserving the same filters. This example does not paginate automatically. Upper bound unproven means even the newest record predates the window’s end; older pages cannot fix that. Check the market’s trading cessation and dataset coverage rather than deleting the guard to force a total.
The test is deliberately conservative. A market may already have stopped trading, leaving no post-end records even in a complete history. Verified termination or full-history coverage can justify another completeness test, but that is different from this example’s “one page crosses both boundaries” rule.
CTF Exchange V2: identify the logs before choosing the amount definition
A separate taker_only=false page for Adanech contained 100 rows but only 49 distinct transaction hashes. Forty-eight hashes appeared more than once, with at most 3 rows per hash. Computing 100 - 49 = 51 and calling the remainder wash trades confuses response rows with onchain transactions. Keeping only the first row per hash can also discard different records within a transaction.
The pinned CTF Exchange V2 source explains the event hierarchy. One matchOrders call processes a taker order and a group of maker orders. Each maker fill emits an OrderFilled; once the taker is processed, a separate summary OrderFilled is emitted, immediately followed by OrdersMatched. The last two are emitted in _emitTakerFilledEvents.
The three inspected transactions have the following counts. This table verifies event counts, not equality between the two sides’ monetary amounts.
| Polygon transaction | OrderFilled | OrdersMatched | Event hierarchy |
|---|---|---|---|
| A | 2 | 1 | 1 maker detail + 1 taker summary |
| B | 3 | 1 | 2 maker details + 1 taker summary |
| C | 2 | 1 | 1 maker detail + 1 taker summary |
These are order-record counts; one wallet may have multiple orders. The taker summary’s OrderFilled.maker is also populated with takerOrder.maker, the creator of the aggressive order. The field name maker alone is not a passive-fill-role classifier.
Same-token trades and Yes/No pairing use different accounts
Start with a constructed same-outcome-token BUY-against-SELL example. Someone buys 10 shares at $0.60 each, filling sell orders of 4 and 6 shares. The maker details represent $2.40 and $3.60; the taker total is $6. Counting all three as independent economic amounts produces $12. This is not a reconstruction of A/B/C’s amounts.
Cross-outcome MINT/MERGE paths require different accounting. In a constructed MINT example with no fees or residual amounts, one party pays $4 for 10 Yes shares and another pays $6 for 10 No shares. Together they supply $10 in collateral. These are payments for different outcome tokens; the taker’s $6 is not a duplicate of the maker’s $4 merely because it appears in a summary event.
Direct trade: maker details 2.40 + 3.60 = taker total 6.00
MINT: Yes payment 4.00 + No payment 6.00 = collateral 10.00
For that simplified pure-MINT case, with aligned units and no fees or residuals, the taker’s collateral payment is the paired total collateral minus the maker-side payment; received outcome shares are aggregated on the other side. Real code must also identify side, tokenId, collateral precision, fees, and residual amounts. A naming trap in the pinned source is that COMPLEMENTARY means same-token BUY/SELL matching; two buys or two sells across complementary outcome tokens use MINT or MERGE instead.
Store maker details and taker summaries separately. Log classification identifies the role of a record. The economic-volume definition comes after identifying the match type. Correct event counts do not guarantee correct monetary aggregation.
Identifying a summary event
Use (chain_id, transaction_hash, log_index) to deduplicate repeated retrieval of raw events. Here log_index is the RPC’s block-level logIndex. Build a lookup keyed by that value; do not use it directly as an array index into receipt.logs. If your indexer uses transaction-local indices, consistently use that system while preserving original order and adjacency.
The following remains pseudocode, limited to a complete successful receipt from an emitter confirmed to use the pinned V2 path. Decode the ABI and normalize addresses and integer representations first. A failed require stops automatic aggregation for that receipt.
for m in events_of(receipt, 'OrdersMatched'):
f = log_at(receipt, m.log_index - 1)
require(f.event == 'OrderFilled' and f.address == m.address)
require((f.orderHash, f.maker, f.side, f.tokenId,
f.makerAmountFilled, f.takerAmountFilled) ==
(m.takerOrderHash, m.takerOrderMaker, m.side, m.tokenId,
m.makerAmountFilled, m.takerAmountFilled))
taker_summary_ids.add(raw_id(f))
maker_fills = [f for f in order_fills if raw_id(f) not in taker_summary_ids]
events_of and order_fills include only the confirmed contract. Before the last line, every relevant OrderFilled must belong to a complete, explainable match group. Missing logs, conflicting fields, or unclosed groups should be held for investigation. A transaction can contain multiple match calls, so pair each group separately. If a later version breaks adjacency, stop aggregation for that receipt and recheck the version; do not fall back to the last matching orderHash in the whole transaction, because an order can be partially filled across calls. Preserve each table’s amounts, then calculate according to direct-trade, MINT, or MERGE semantics.
Rapid-reversal screens depend heavily on the threshold
I used the same rule on both windows: group by proxy-wallet identifier and outcome token, then look for opposite-side trades with share sizes differing by no more than 2%. Each record can be paired only once. Changing only the maximum time gap changes the result substantially.
| Maximum pairing gap | Adanech: 83 window records | Ciucă: 128 window records |
|---|---|---|
| 30 seconds | 1 pair | 2 pairs |
| 60 seconds | 2 pairs | 37 pairs |
| 5 minutes | 2 pairs | 39 pairs |
| 15 minutes | 19 pairs | 39 pairs |
| 60 minutes | 20 pairs | 44 pairs |
The 15-minute rule selects records representing 8.39% and 2.50% of the respective window totals. These are investigation candidates, not established wash trades.
A constructed counterexample shows why. A market-making account buys 100 No shares at $0.985 and sells the same quantity nine seconds later at $0.986, a $0.10 spread before fees. Ordinary inventory adjustment and deliberate volume recycling can both satisfy the short-gap, similar-size, opposite-side rule.
The next evidence needed is counterparty relationships, cross-wallet fund flows, fees, and inventory changes. This same-wallet reversal screen has different identification conditions from the paired accounts repeatedly trading with each other described in WIRED.
Five-minute prices changed the initial interpretation
For Adanech’s Yes contract on September 22, 2026, several adjacent twelve-hour observations were all 0.0030. At first, that showed continuing trades without a change at the coarse sample points. Five-minute data made that interpretation incomplete.
The full series has 288 ordinary five-minute buckets. Comparing successive buckets finds three price changes. The table shows only the pre-change baseline and the starts of the three changes, omitting unchanged intervals; these are not four consecutive samples.
| UTC time | Historical Yes bucket price |
|---|---|
| 20:25 | 0.0030 |
| 20:30 | 0.0020 |
| 20:35 | 0.0025 |
| 21:00 | 0.0030 |
The finer series reveals a drop and recovery missed by coarse sampling. These are bucket prices. Causal price-impact analysis would still need contemporaneous quotes, depth, and information timestamps.
The query for this settled contract also appended a payoff endpoint with resolution_seconds=0, price=0. Under the API convention, keep it separate from ordinary five-minute observations and check settlement through resolutions. Mixing it into the market-price series would draw a fictitious crash to zero.
For price curves or signal analysis, historical prices are a reasonable starting point without reconstructing every OrderFilled onchain. Removing the payoff endpoint is only one cleaning step: check coverage, missing intervals, and settlement too. Estimating achievable strategy returns additionally requires fill assumptions, fees, and slippage. Historical bucket prices are not executable quotes.
The reusable sequence is: align contracts, time, and denominators; distinguish Yes/No from BUY/SELL; identify logs; then choose monetary accounting by match type. The same discipline applies to external studies: reproducing a public table and rebuilding its classifier are separate tasks. Preserving the evidence at each layer is more useful than arguing over a percentage first. For a related denominator problem in equity backtests, Can You Trust a Quant Strategy’s Win Rate When the Same Stock Keeps Reappearing? compares three definitions with complete Python and SQL examples.
This is a discussion of data accounting and engineering, not trading advice. Gamma totals and local windows come from October 20, 2026 UTC snapshots; the MSR table, API contract, and pinned source were checked on October 21, 2026 UTC.