A copy-trading bot has one job that matters more than any other: notice that the wallet you follow just traded. Everything after that, from sizing and slippage control to execution and settlement, works on a signal the detection layer either delivered or didn't.
On 31 August 2026 Polymarket's activity/trades websocket topic went down platform-wide, for every IP at once, for hours. Every bot that listened only to that topic went deaf. Mine had a slow REST poll as a backup, and that was all it had.
That day is why Garnet, the self-hosted Polymarket copy-trading engine I maintain, now hears every trade three independent ways. This post covers how the three circuits work, why they write into one table, and the measurements behind each design decision.
The three circuits
| Speed | Depends on | Silence means | |
|---|---|---|---|
| RTDS websocket | fastest | Polymarket's websocket | a dead subscription (watchdog: 45 s) |
/activity poll |
slow (every 3 s) | Polymarket's REST API | nothing — it is the safety net |
| Polygon logs | not faster than RTDS | your own node | the leaders were not trading (watchdog: 5 min) |
The key idea fits on one line: a third delivery of one truth, not a third truth. All three circuits write to the same table and collapse onto the same dedup key. Which copy arrives first doesn't matter.
Circuit 1: the RTDS websocket, and the keepalive that cost 2.5×
Polymarket's real-time data socket (RTDS) is the fastest source. You subscribe to the activity topic and receive trade frames as they happen.
The surprising lesson came from a sensible-looking habit. Most websocket clients send a periodic keepalive. When I measured delivery with and without one, a keepalive cut delivery by 2.5×. Since then, Garnet writes exactly two things to that socket: the subscription frame, and a Pong when the server asks for one. Nothing else.
The second lesson was about silence. A socket can be connected and healthy while delivering nothing, because the subscription quietly died. "The process is alive" says nothing about whether trades are flowing. So the RTDS circuit has a 45-second silence watchdog: if the topic goes quiet for that long, the subscription is treated as dead and rebuilt.
This generalises into a rule the whole bot follows: health is measured by flow, not by liveness. Two process-level guards once reported OK while the bot had been blind for 6.5 hours.
Circuit 2: /activity polling, and why history is not a signal
The safety net is boring on purpose: every 3 seconds, ask the REST API what each followed wallet did recently. It is slow, but it depends on nothing except an HTTP endpoint answering.
It also contains the nastiest trap in the whole system. /activity?user=…&limit=20 on a quiet wallet returns weeks of history. On the first tick after you add a wallet, all of that looks like news. The first version of this logic produced 69 "market not tradable" refusals and 28 copies of trades up to 3.4 days old. One of them filled at 0.001 against the leader's 0.260.
The fix is a rule: a wallet is copied from the moment it was assigned, never retroactively. Every trade older than wallets.created_at is still recorded (otherwise the dedup would forget it and the next tick would bring the same history back), but it never becomes a signal. The comparison happens at one-second resolution, because RTDS timestamps are in seconds while created_at has microseconds.
Circuit 3: Polygon logs, decoded independently
The third circuit doesn't touch Polymarket's infrastructure at all. Every fill on Polymarket's exchange ends up as an OrderFilled event on Polygon, and a node will push those events to you over eth_subscribe.
Three details made this circuit work.
1. Decode from the V2 ABI, not from memory. The V1 event (five data words, side inferred from which asset ID is zero) yields not one log on the V2 exchange, while that exchange emits 45,911 fills over 500 blocks. In V2 the event has seven data words, and the side is an explicit side: uint8 field. The decoder was written from the verified V2 ABI rather than carried over.
2. Filter on the node, by maker. Without a wallet filter you receive the platform's entire feed: 7,255 fills over 120 blocks, roughly thirty a second. The filter goes on topics[2], the maker. You don't need a second filter on the taker, because the aggressor gets an OrderFilled of its own in which it is the maker. Of 1,877 addresses seen in topics[3], 1,876 also appeared in topics[2].
/// The filter is set on the node's side: exchange addresses and topics.
/// The node wakes us when a leader trades, not when anyone at all trades.
pub fn subscribe_frame(id: u64, exchanges: &[String], wallets: &[String]) -> String {
let topic2: Vec<String> = wallets.iter().map(|w| wallet_topic(w)).collect();
serde_json::json!({
"jsonrpc": "2.0",
"id": id,
"method": "eth_subscribe",
"params": ["logs", {
"address": exchanges,
"topics": [ORDER_FILLED_TOPIC0, serde_json::Value::Null, topic2],
}],
})
.to_string()
}
3. A log carries no time, and you may not invent one. Downstream logic needs the leader's trade time. It decides, for example, when a burst of fills from one order is over. The timestamp has to come from the block (cached). If the node fails to provide it, the circuit does not fall back to the local clock. A trade with an invented time would pass the "assigned after" check by the wrong clock and look real.
Two more rules: a log removed by a chain reorganisation (removed: true) is not a trade, because copying it would buy something that no longer exists on chain. And this circuit's silence watchdog is five minutes, not 45 seconds. Here silence usually means the leaders simply weren't trading.
Is it faster? No. A log appears once the settlement transaction is in a block, and the CLOB matched the order before that. The chain circuit is more reliable than the socket, not faster. Nothing about it lets you get ahead of the leader, and I'd be suspicious of any bot that claims otherwise.
One table, one key
All three circuits write into leader_trades, deduplicated on:
(tx_hash, wallet, token_id, side)
Two decisions hide in that key.
-
No log index. It doesn't exist in the RTDS frame, in
/trades, or in/activity. Measured over 500 trades, every one had a unique hash, so the hash is enough. - No price or size. A redelivery with different rounding would pass as a new trade and double the stake.
A single taker order that sweeps several price levels arrives as several fills with different transaction hashes. The dedup key can't and shouldn't catch those. That is a separate mechanism, the slice window, which collapses one leader order into one copy. It exists because on real data the first copy of a wave returned +7.4% while later slices returned −15%.
A losing delivery is still data
The first version did ON CONFLICT DO NOTHING: the second copy of a trade vanished without a trace. That is correct for dedup and fatal for measurement, because it threw away the only evidence of which circuit was actually useful.
Now the winner goes to leader_trades.source, and every sighting goes to trade_sightings, keyed on (leader_trade_id, source). A circuit's lag is its ts_seen minus the earliest sighting of that trade. The Telegram command /sources shows the picture per circuit, and its main column is "brought by it alone". Zero there means the circuit catches nothing that wouldn't be caught without it. That column is the honest answer to "is this circuit worth running?" One subtlety: zero for every circuit at once means they duplicate each other, so any one of them can be switched off, but not all of them.
What I'd tell anyone building one
- Never trust a single feed. The day it goes down is the day you find out it was your only one.
- Don't send anything to a socket you only read from. Measure before you "keep it alive".
- History is not a signal. Copy from the moment of assignment, by the leader's clock.
- Decode from the ABI you verified, not from the one you remember.
- Deduplicate on identity, never on values.
- Keep the duplicates as observations. You can't defend or switch off a circuit you can't measure.
Garnet is source-available (BUSL-1.1, free for individuals) and self-hosted: your key never leaves your server, and the README lists every host the code talks to. Code, architecture notes with every invariant above, and a shadow mode that trades on paper at real fees:
- GitHub: https://github.com/AndreySchurko/garnet-polymarket
- Site: https://andreyschurko.github.io/garnet-polymarket/
Questions about the detection layer are welcome in the comments or in GitHub Discussions.
Top comments (0)