Building a Polymarket Copy-Trading Bot, Part 1: The Day Every Order Got Rejected

Six weeks ago I set out to build something that sounds simple and turns out not to be: a bot that automatically copies profitable traders on Polymarket, the on-chain prediction market. The pitch to myself was easy — find wallets that consistently win, mirror their trades, size them sensibly, done. The reality was a month of small, humbling discoveries about approvals, signatures, and the difference between an error message and the actual error. This is the first entry in a development log about what truly broke, and what those failures taught me.

I am writing it in English because Polymarket is not available in every jurisdiction, and this series is meant for builders in markets where it is. I am also going to be deliberately vague about the part that makes money — which wallets I follow, how positions are sized, the exact entry filters. That is the edge, and an edge you publish is an edge you no longer have. Everything else — the infrastructure, the bugs, the wrong assumptions — I will share in full, because that is the part I wish someone had written down before I started.

The boring part nobody warns you about: approvals

Before a single trade can happen, you have to teach the blockchain to trust your bot with your money. On Polygon, Polymarket settles in USDC, and the exchange contract can only move your funds if you have granted it an allowance. This is the ERC-20 approve() dance, and it is the first place a copy-trading bot quietly dies.

My first orders failed not because the logic was wrong but because the allowance was simply lower than my balance. The fix is conceptually trivial: check allowance(wallet, exchange) against your balance, and if it is short, call approve(exchange, MaxUint256) once so you never have to think about it again — then actually wait for the transaction to confirm before assuming it is done.

It sounds obvious written down. In practice I spent an evening printing balances and allowances to the console, convinced the order API was broken, when the truth was that I had never approved the contract at all. Lesson one, and it will repeat in every section below: when something fails at the very edge of the system — where your code meets a contract you did not write — verify the boring preconditions before you suspect anything clever.

The error message that lied to me

This is the bug I am most embarrassed by and learned the most from, so it gets the longest section.

My orders started coming back with order_version_mismatch. At the time, the wallet I was copying had been trading a Champions League market that was wrapping up, so I did the natural, lazy thing: I decided the error meant the market had closed. It was a plausible story. It fit the timing. It was completely wrong, and believing it cost me an entire day of trading opportunities.

The next day the same order_version_mismatch appeared on completely unrelated markets — tennis, a brand-new Champions League fixture, things that were obviously open and accepting orders. A diagnosis that only explains one case is a guess; an error that shows up everywhere is structural. Once the same failure appeared where my market-closed theory was impossible, I finally accepted it was my code.

The real cause was the signature type. Polymarket’s order-signing flow lets you sign as a plain externally-owned account (SignatureType.EOA, value 0) or through a Polymarket proxy wallet (SignatureType.POLY_PROXY, value 1), and the default is EOA. My order-building code was not specifying one at all in some paths, and specifying the wrong one in others. The moment I aligned the signature type, the error changed — to market not found. That sounds like a step backward, but it was actually progress: a different error meant the signature was finally being accepted and the request had moved on to the next stage (a token-ID problem). In debugging, a new error is often a victory.

Then came the twist that taught me to verify my own assumptions, not just my code. I had assumed my bot wallet was a Polymarket proxy wallet, so I reached for POLY_PROXY. When I actually checked the addresses, the signing address and the funding address were identical — which meant it was a plain EOA, not a proxy at all. The correct setting was EOA. I had been confidently configuring my own wallet wrong because I never confirmed what kind of wallet it was.

Three lessons fell out of that one bug:

  • A plausible explanation is not a diagnosis. “The market closed” fit the facts and was still false. If a theory only explains the case in front of you, distrust it.
  • Reproduce across cases before you commit to a cause. The same error on an unrelated market is what finally told me the truth.
  • Verify your own configuration empirically. I assumed my wallet type instead of checking it, and the assumption — not the library — was the bug.

I also found that the inconsistency was spread across files: one executor created the order client without a signature type while another set it explicitly. Two code paths that disagree about a core parameter is its own category of bug, and it is the kind static review catches faster than runtime ever will.

Then every single order came back rejected

With signatures fixed, a different failure surfaced — and this time the error really was about the market. I inspected the raw market objects my bot was trying to trade into, and the pattern was immediate: many came back as closed: true and accepting_orders: false.

Here is the trap, and it is specific to copy-trading. The traders worth copying often move fast, in markets that resolve fast. By the time my bot saw the signal, fetched the market, signed an order, and submitted it, the relevant moment had sometimes already passed. I was, in effect, always arriving at the party after the lights came on. After the signature fix, the encouraging part was that open markets now filled correctly and only genuinely closed ones — finished games, in-progress events — were rejected. The rejections had become honest.

Two structural fixes came out of that:

  • Validate market state before signing. Check accepting_orders and other rejection-worthy states up front instead of discovering them at submission. Signing an order for a closed market is wasted work and wasted latency.
  • Filter already-decided markets. A market trading at a very high price is usually a near-settled outcome, not an opportunity. Skipping those removed an entire class of doomed orders.

There was also a Polymarket-specific surprise: neg-risk markets — the ones that bundle related outcomes like spreads, totals, and moneylines — need an explicit negRisk flag when you build the order. Miss it, and the order is malformed in a way the API rejects without explaining. If you build on Polymarket, that one will quietly cost you an afternoon.

The 57% win rate that was actually 48%

This was the lesson that changed how I think about the entire project.

My wallet scanner ranked a candidate at a 57.1% win rate — comfortably profitable for a binary market, exactly the kind of wallet you want to copy. I almost wired it straight into the live config. Before I did, I pulled its full trade history instead of the scanner’s default sample.

The scanner had been looking at the most recent 100 markets. Across the wallet’s full 1,443-market history, the real win rate was 48.1%. The 57% was a recent hot streak sitting on top of a fundamentally break-even trader. Worse, the wallet was not discretionary at all — it was an automated up/down market-making bot, running a two-sided strategy whose flow looks like skill only if you squint at a truncated window. At 48.1% before fees, copying it is not neutral; it is structurally negative expected value, and the fees make it worse. Another candidate I checked ran 125 BTC up/down markets at a 42% win rate for a realized loss — the same shape of trap.

The bug was not in the code. The bug was in my sampling. A limit of 100 is a survivorship trap: you see whoever is hot right now, not whoever is good over time. I rebuilt the win-rate calculation to run over the full history and to break performance down several ways — by conditionId rather than per-order, by entry price, by position size, and over rolling time windows — because a single blended number hides exactly the kind of strategy that will bleed you slowly. After that, no metric touched real money until it had survived the full-history version of itself.

When the platform changes underneath you

The last reality of building on someone else’s exchange is that the exchange does not hold still. Midway through, Polymarket’s contracts moved toward a new version, and the order model changed in ways that touch any bot directly:

  • Nonce was removed. Per-address order uniqueness now comes from a millisecond timestamp field rather than a tracked nonce. If your code carefully manages nonces, that logic becomes dead weight overnight.
  • The order struct was simplified. Fields like feeRateBps, nonce, and taker went away; timestamp, metadata, and a builder field came in.
  • The signing flow shifted toward a builderCode field, while the relayer credentials used for gasless transactions stayed the same — so the migration is partial, and you have to know which half changed.

None of this is hard once you know it. The danger is assuming the integration you wrote three weeks ago still describes reality. On a moving platform, your own working code has a shelf life, and “it worked last week” is not the same as “it works.”

What I would tell anyone building copy-trade infrastructure

  • Approvals before logic. If trades fail at submission, suspect the contract preconditions before your strategy.
  • A new error is progress. When a fix changes the error message, you moved forward, even if it does not feel like it.
  • Distrust the plausible diagnosis. “The market closed” fit the facts and was wrong. Reproduce across unrelated cases before you commit to a cause.
  • Verify your own configuration. I had my own wallet type wrong. Check what is true; do not assume it.
  • Validate market state up front. accepting_orders: false and near-settled prices should be filtered before you ever sign.
  • Latency is part of the strategy. Copying a fast trader is copying a stale signal. Know how late you really are.
  • Never trust a truncated sample. A 57% win rate over 100 markets and 48% over 1,443 are two different traders.
  • Assume the platform will move. Re-verify your integration on a schedule, not just when it breaks.

None of this is the glamorous part of trading. It is the unglamorous infrastructure layer that sits underneath any strategy, and it is exactly where most copy-trading projects fail silently. The strategy can be brilliant; if the signature type is wrong, the market is closed, your sample is lying, or the contract changed under you, you lose anyway — and the error message will rarely tell you which one it is.

In the next entry I will get into the bug that took me longest to find: orders rejected not because of the market, the signature, or the approval, but because of a circular JSON structure quietly corrupting the request before it ever left the bot. That one was entirely my own fault, and it is a good story.


Disclaimer: This article is a technical development log for informational and educational purposes only. It is not financial advice, not a recommendation to trade prediction markets, and not an endorsement of any platform. Prediction markets are restricted or prohibited in many jurisdictions — check your local laws before participating. Do your own research.

Leave a Comment