> ## Documentation Index
> Fetch the complete documentation index at: https://docs.goldsky.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Build a Polymarket copy-trader

> Mirror Polymarket wallets' trades automatically using Compose and Turbo with a webhook pipeline

This guide walks you through building a Polymarket copy-trading bot. A Turbo pipeline decodes on-chain `OrderFilled` events, filters them to a configurable set of wallets, and webhooks each fill into a Compose app that signs and submits the same trade to the Polymarket CLOB. Winning shares auto-redeem on a 5-minute cron.

It demonstrates the Turbo → Compose webhook pattern, cron triggers, sponsored-gas wallets, `ctx.fetch` for external APIs, and collection-based state.

<Note>
  The example targets [Polymarket V2](https://docs.polymarket.com/v2-migration) (cutover 2026-04-28). V2 introduced new Exchange contracts on Polygon, switched collateral from USDC.e to **pUSD** (a 1:1 ERC-20 backed by USDC.e), and ships a new SDK at `@polymarket/clob-client-v2`. Funding still happens in USDC.e — the `setup_approvals` task wraps it into pUSD via the Collateral Onramp.
</Note>

## How it works

```mermaid theme={null}
flowchart LR
    A[Polygon on-chain] -->|"OrderFilled events"| B[Turbo Pipeline]
    B -->|"decode + filter to watched wallets"| B
    B -->|"webhook per fill"| C[Compose: copy_trade]
    C -->|"sign + POST order"| D[Fly.io Proxy]
    D -->|"forward to CLOB"| E[Polymarket CLOB]
    F[Cron every 5m] --> G[Compose: redeem]
    G -->|"redeemPositions"| H[ConditionalTokens contract]
```

1. **Polygon** emits `OrderFilled` from the V2 CTF Exchange (`0xE111…996B`) and V2 NegRisk Exchange (`0xe222…0F59`) contracts
2. **Turbo pipeline** decodes fills, keeps only trades where maker or taker is in your watched list, and posts each to a Compose HTTP task
3. **`copy_trade`** parses the fill, checks the wallet's pUSD balance, looks up market metadata, signs a V2 FAK (Fill-and-Kill) order, and submits it via the Fly.io proxy (Polymarket's CLOB is geo-blocked from US hosts)
4. **`redeem`** runs every 5 minutes, polls Polymarket's data API for redeemable positions, and calls `redeemPositions` on-chain

## Prerequisites

* [Goldsky CLI installed](/installation)
* A Compose API token from your Goldsky project (for the webhook auth secret)
* An EOA private key for the bot's wallet — no Polymarket UI onboarding or proxy wallet required
* USDC.e (`0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174`) on Polygon to fund the bot — the `setup_approvals` task wraps it into pUSD before trading
* A list of wallets you want to mirror (selecting them is out of scope for this guide)

## Project structure

```text theme={null}
copy-trader/
├── compose.yaml                           # Compose app + env config
├── tsconfig.json                          # TypeScript config
├── package.json                           # npm deps (bundled by Compose CLI)
├── pipeline/
│   └── polymarket-ctf-events.yaml         # Turbo pipeline → webhook sink
└── src/
    ├── lib/
    │   ├── clob.ts                        # Polymarket CLOB client
    │   ├── gamma.ts                       # Market metadata lookups
    │   └── types.ts                       # Contract addresses + types
    └── tasks/
        ├── copy_trade.ts                  # HTTP: mirror a fill
        ├── redeem.ts                      # Cron: redeem winning shares
        └── setup_approvals.ts             # HTTP: one-time approvals
```

## Step 1: Set up the project

Clone the example repository and install dependencies:

```bash theme={null}
git clone https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples/compose/copy-trader
npm install
```

The Compose CLI bundles tasks with esbuild against `node_modules/`, so `npm install` is required before `goldsky compose start` or `deploy`.

## Step 2: Pick wallets to copy and update both configs

The pipeline pre-filters on-chain events to the watched list, so the same addresses must appear in two places.

In `compose.yaml`:

```yaml theme={null}
env:
  cloud:
    WATCHED_WALLETS: "0xwhale1,0xwhale2"
```

In `pipeline/polymarket-ctf-events.yaml`, update the `watched_fills` transform:

```yaml theme={null}
watched_fills:
  type: sql
  primary_key: id
  sql: |
    SELECT * FROM order_fills
    WHERE maker IN (
      '0xwhale1',
      '0xwhale2'
    )
    OR taker IN (
      '0xwhale1',
      '0xwhale2'
    )
```

## Step 3: Set the wallet private key

The Compose app signs CLOB orders as an EOA. No Polymarket proxy wallet is involved:

```bash theme={null}
goldsky compose secret set PRIVATE_KEY --value "0x..."
```

## Step 4: Create the webhook auth secret

The Turbo webhook authenticates to your Compose app with a Goldsky-level secret. This is a one-time setup per project:

```bash theme={null}
goldsky secret create --name COMPOSE_WEBHOOK_AUTH \
  --value '{"type": "httpauth", "secretKey": "Authorization", "secretValue": "Bearer YOUR_COMPOSE_API_TOKEN"}'
```

The pipeline references this secret by name in its webhook sink, so every pipeline in the project can reuse it.

## Step 5: Deploy the app and pipeline

```bash theme={null}
goldsky compose deploy
goldsky turbo apply pipeline/polymarket-ctf-events.yaml
```

The pipeline starts at the latest Polygon block, so fills produced before you deployed will not be backfilled.

## Step 6: Fund the wallet

Send USDC.e to the EOA that corresponds to the private key from step 3. Compose sponsors gas on all on-chain calls, so you do not need MATIC in the wallet.

## Step 7: Grant approvals and wrap collateral

V2 markets settle in pUSD, so the bot needs to (a) approve the Collateral Onramp to spend USDC.e, (b) wrap the wallet's USDC.e balance into pUSD, and (c) approve pUSD + ConditionalTokens to both V2 Exchanges. The `setup_approvals` task does all of this in one call. Compose sponsors every transaction:

```bash theme={null}
curl -X POST -H "Authorization: Bearer $COMPOSE_TOKEN" \
  https://api.goldsky.com/api/admin/compose/v1/copy-trader/tasks/setup_approvals
```

The task is idempotent: re-call it after every USDC.e top-up to wrap the new balance. Approvals are max-allowance, so re-approving is a cheap no-op.

The bot begins trading as soon as the next fill on a watched wallet lands in the pipeline.

## Understanding the code

### Parsing a fill

V2's `OrderFilled` event encodes the maker's side as a `uint8` (`0 = BUY`, `1 = SELL`) and exposes a single `tokenId` instead of the V1 maker/taker asset-id pair. The whale we want to copy can be the maker or the taker — the taker takes the opposite side:

```typescript theme={null}
function parseFill(
  row: OrderFillRow,
  watchedWallets: Set<string>
): { side: "BUY" | "SELL"; tokenId: string; whalePrice: number } {
  const makerIsWhale = watchedWallets.has(row.maker.toLowerCase());
  const takerIsWhale = watchedWallets.has(row.taker.toLowerCase());

  // Maker's side as encoded in the event: "0" = BUY, "1" = SELL.
  const makerSide: "BUY" | "SELL" = row.side === "0" ? "BUY" : "SELL";
  const oppositeSide: "BUY" | "SELL" = makerSide === "BUY" ? "SELL" : "BUY";

  const whaleSide = makerIsWhale ? makerSide : oppositeSide;

  // Price = pUSD per share. Layout depends on the maker's side.
  //   makerSide = BUY:  makerAmount = pUSD,   takerAmount = shares
  //   makerSide = SELL: makerAmount = shares, takerAmount = pUSD
  const usdcAmount = makerSide === "BUY" ? row.maker_amount : row.taker_amount;
  const sharesAmount = makerSide === "BUY" ? row.taker_amount : row.maker_amount;
  const price = sharesAmount > 0 ? usdcAmount / sharesAmount : 0;

  return {
    side: whaleSide,
    tokenId: row.token_id,
    whalePrice: price,
  };
}
```

### On-chain balance as source of truth

Before every BUY, the task reads pUSD balance directly from Polygon rather than keeping a local counter. pUSD has 6 decimals (matching USDC.e):

```typescript theme={null}
const balResp = (await ctx.fetch("https://polygon-bor-rpc.publicnode.com", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    jsonrpc: "2.0",
    method: "eth_call",
    params: [
      {
        to: "0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB", // pUSD
        data: "0x70a08231000000000000000000000000" + address.slice(2).toLowerCase(),
      },
      "latest",
    ],
    id: 1,
  }),
})) as { result?: string };

const pusdBalance = balResp?.result ? Number(BigInt(balResp.result)) / 1e6 : 0;
if (pusdBalance < 1.1) {
  return { status: "BALANCE_LOW", balance: pusdBalance };
}
```

A local budget collection would drift out of sync with the real wallet after redemptions, unsponsored gas, or manual top-ups. Reading the chain avoids that entirely.

### Signing and submitting via `ctx.fetch`

Polymarket's `@polymarket/clob-client-v2` SDK uses axios for HTTP, which fails under Compose's task runtime because task binaries run without `--allow-net`. The template reuses the SDK's pure signing utilities (local crypto only) and routes every HTTP call through `ctx.fetch`. The `version: 2` argument selects the V2 Exchange domain and order struct (V2 fees are computed on-chain, so they are no longer signed):

```typescript theme={null}
import {
  OrderBuilder,
  Side,
  OrderType,
  SignatureTypeV2,
  createL2Headers,
  orderToJsonV2,
} from "@polymarket/clob-client-v2";

const builder = new OrderBuilder(wallet, 137, SignatureTypeV2.EOA);

// Build + sign locally (no HTTP)
const signedOrder = await builder.buildMarketOrder(
  { tokenID, price, amount, side: Side.BUY },
  { tickSize, negRisk },
  2 // V2 order version
);

// Submit through ctx.fetch → Fly proxy → CLOB
const body = orderToJsonV2(signedOrder, creds.key, OrderType.FAK);
const bodyStr = JSON.stringify(body);
const l2Headers = await createL2Headers(wallet, creds, {
  method: "POST",
  requestPath: "/order",
  body: bodyStr,
});

const resp = await ctx.fetch(`${host}/order`, {
  method: "POST",
  headers: { ...l2Headers, "Content-Type": "application/json" },
  body: bodyStr,
});
```

`ctx.fetch` is host-mediated, which means it has network access even though the task itself does not. This is the general pattern for calling external APIs from a Compose task — see [calling external APIs](/compose/context/fetch) for more.

### Why the Fly.io proxy

Polymarket's CLOB API geo-blocks the US. Compose tasks run from `us-west`. The template points `CLOB_HOST` at a shared Goldsky-hosted Fly.io proxy in Amsterdam that forwards every request from an EU IP. If you want to isolate yourself from the shared proxy, deploy your own copy of [`fly-polymarket-proxy`](https://github.com/goldsky-io/fly-polymarket-proxy) and update `CLOB_HOST` in `compose.yaml`.

## Key Compose features used

* **Turbo → Compose webhook pattern** — a pipeline sinks decoded on-chain events directly to an HTTP task via a `COMPOSE_WEBHOOK_AUTH` secret
* **`ctx.fetch`** — all outbound HTTP (CLOB, Gamma, Polymarket data API, Polygon RPC) goes through Compose's host-mediated fetch
* **`ctx.evm.wallet` with `sponsorGas: true`** — approvals and redemptions use a Compose-sponsored wallet so the EOA does not need MATIC
* **Cron triggers** — the `redeem` task runs every 5 minutes via `0 */5 * * * *`
* **`ctx.collection`** — `positions` and `trades` collections persist bot state across invocations
* **Compose secrets** — the wallet private key is stored via `goldsky compose secret set`, not baked into the manifest

## Customization

### Trade size

The default is Polymarket's \$1 minimum notional. Raise it in `compose.yaml`:

```yaml theme={null}
TRADE_AMOUNT_USD: "10"
```

### Different markets

The pipeline filters by address only, so it picks up every fill on the CTF Exchange and NegRisk Exchange. To scope to a specific market type (e.g. a particular event's outcomes), add a `token_id` filter to the `watched_fills` transform.

### Your own proxy

`CLOB_HOST` defaults to the Goldsky-hosted Fly proxy. To isolate from it, deploy [`fly-polymarket-proxy`](https://github.com/goldsky-io/fly-polymarket-proxy) yourself and point `CLOB_HOST` at your deployment.

## Resources

* [Compose introduction](/compose/introduction)
* [Turbo introduction](/turbo-pipelines/introduction)
* [Task triggers](/compose/task-triggers)
* [Calling external APIs](/compose/context/fetch)
* [Collections](/compose/context/collections)
* [Secrets](/compose/secrets)
* [GitHub repository](https://github.com/goldsky-io/documentation-examples/tree/main/compose/copy-trader)
