Skip to main content
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.

How it works

  1. Polygon emits OrderFilled from the CTF Exchange and NegRisk Exchange 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 USDC balance, looks up market metadata, signs a 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
  • 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
  • A list of wallets you want to mirror (selecting them is out of scope for this guide)

Project structure

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:
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:
env:
  cloud:
    WATCHED_WALLETS: "0xwhale1,0xwhale2"
In pipeline/polymarket-ctf-events.yaml, update the watched_fills transform:
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:
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:
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

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

One-time ERC-20 and ERC-1155 approvals to the CTF Exchange and NegRisk Exchange. Compose sponsors the four on-chain transactions:
curl -X POST -H "Authorization: Bearer $COMPOSE_TOKEN" \
  https://api.goldsky.com/api/admin/compose/v1/copy-trader/tasks/setup_approvals
The bot begins trading as soon as the next fill on a watched wallet lands in the pipeline.

Understanding the code

Parsing a fill

copy_trade receives the decoded OrderFilled row and determines the direction the watched wallet took. The USDC side of the swap is tagged with asset_id = "0"; whichever side gave USDC is the buyer:
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());

  const makerIsUsdc = row.maker_asset_id === "0";
  const takerIsUsdc = row.taker_asset_id === "0";

  const shareTokenId = makerIsUsdc ? row.taker_asset_id : row.maker_asset_id;
  const sharesAmount = makerIsUsdc ? row.taker_amount : row.maker_amount;
  const usdcAmount = makerIsUsdc ? row.maker_amount : row.taker_amount;
  const price = sharesAmount > 0 ? usdcAmount / sharesAmount : 0;

  const whaleIsBuyer =
    (makerIsUsdc && makerIsWhale) || (takerIsUsdc && takerIsWhale);

  return {
    side: whaleIsBuyer ? "BUY" : "SELL",
    tokenId: shareTokenId,
    whalePrice: price,
  };
}

On-chain balance as source of truth

Before every BUY, the task reads USDC.e balance directly from Polygon rather than keeping a local counter:
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: "0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174",
        data: "0x70a08231000000000000000000000000" + address.slice(2).toLowerCase(),
      },
      "latest",
    ],
    id: 1,
  }),
})) as { result?: string };

const usdcBalance = balResp?.result ? Number(BigInt(balResp.result)) / 1e6 : 0;
if (usdcBalance < 1.1) {
  return { status: "BALANCE_LOW", balance: usdcBalance };
}
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 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:
import { OrderBuilder } from "@polymarket/clob-client/dist/order-builder/builder.js";
import { orderToJson } from "@polymarket/clob-client/dist/utilities.js";
import { createL2Headers } from "@polymarket/clob-client/dist/headers/index.js";

// Build + sign locally (no HTTP)
const signedOrder = await builder.buildMarketOrder(
  { tokenID, price, amount, side, feeRateBps },
  { tickSize, negRisk }
);

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

const resp = await ctx.fetch(`${host}/order`, {
  method: "POST",
  headers: { ...l2Headers, "Content-Type": "application/json" },
  body,
});
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 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 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.collectionpositions 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:
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 yourself and point CLOB_HOST at your deployment.

Resources