Skip to main content
This guide walks you through building a self-contained binary prediction market on Gnosis ConditionalTokens (CTF). A single cron orchestrator runs every 5 minutes, fetches the current BTC/USD price, resolves the previous cycle’s market, and prepares a new one for the next 5-minute bucket. It demonstrates multi-task orchestration, callTask fan-out, Compose-managed wallets as on-chain oracles, and idempotent chain writes.

How it works

Each 5-minute cycle:
  1. market_data fetches the current BTC/USD price from CoinGecko.
  2. resolve_market reports payouts for any market whose endTime has passed. Outcome is [1, 0] (UP) if the current price is at or above the market’s openPrice, else [0, 1] (DOWN).
  3. launch_market prepares a new condition on the CTF for the current 5-minute bucket, using the same fetched price as the new market’s openPrice.
One price fetch per cycle serves both purposes — the closing tick of the expiring market sits at the same moment as the opening tick of the new one.

Prerequisites

Project structure

prediction-market/
├── compose.yaml                  # 1 cron + 4 callable tasks
├── package.json                  # viem dependency
├── tsconfig.json                 # Compose path alias
├── src/
│   ├── lib/
│   │   ├── constants.ts          # Chain, CTF address, wallet name, salt
│   │   ├── types.ts              # Market type
│   │   └── utils.ts              # questionId derivation, wallet helper
│   └── tasks/
│       ├── orchestrator.ts       # Cron: fetch → resolve → launch
│       ├── market-data.ts        # HTTP: CoinGecko
│       ├── launch-market.ts      # Chain: prepareCondition
│       ├── resolve-market.ts     # Chain: reportPayouts
│       └── generate-wallet.ts    # HTTP: returns oracle address

Step 1: Set up the project

Clone the example repository:
git clone https://github.com/goldsky-io/documentation-examples.git
cd documentation-examples/compose/prediction-market
npm install

Step 2: Understand the orchestrator

orchestrator.ts is the only task with a cron trigger. It fans out to the three worker tasks via context.callTask:
import type { TaskContext } from "compose";
import { ASSET_PAIR, DURATION_SEC } from "../lib/constants";
import type { Market } from "../lib/types";
import { computeQuestionId, floorToMarketStart } from "../lib/utils";
import type { TaskPayload as LaunchPayload } from "./launch-market";
import type { TaskPayload as ResolvePayload } from "./resolve-market";
import type { ResponsePayload as PriceData } from "./market-data";

export async function main(context: TaskContext) {
  const { collection, callTask } = context;
  const nowMs = Date.now();
  const currentMarketStart = floorToMarketStart(nowMs);

  const markets = await collection<Market>("markets", [
    { path: "endTime", type: "numeric" },
    { path: "resolved", type: "boolean" },
  ]);

  // One HTTP hit — used for BOTH the closePrice of the expiring market
  // AND the openPrice of the new one (same 5-min boundary).
  const { priceUsd } = await callTask<Record<string, never>, PriceData>(
    "market_data",
    {},
  );

  // Resolve any overdue, unresolved markets.
  const overdue = await markets.findMany({
    endTime: { $lte: nowMs },
    resolved: false,
  });
  for (const market of overdue) {
    const close = market.closePrice ?? priceUsd;
    // Snapshot closePrice before the chain call so retries produce a
    // deterministic outcome even if prices move between attempts.
    if (market.closePrice === undefined) {
      await markets.setById(market.questionId, { ...market, closePrice: close });
    }
    await callTask<ResolvePayload, Market>("resolve_market", {
      market: { ...market, closePrice: close },
    });
  }

  // Launch the market for the current 5-min bucket if it doesn't exist yet.
  const currentQid = computeQuestionId({
    assetPair: ASSET_PAIR,
    durationSec: DURATION_SEC,
    startTimestampSec: Math.floor(currentMarketStart / 1000),
  });
  if (!(await markets.getById(currentQid))) {
    await callTask<LaunchPayload, Market>("launch_market", {
      startTime: currentMarketStart,
      openPrice: priceUsd,
    });
  }
}

Key Compose features used

  • context.callTask — orchestrator delegates work to specialized child tasks with their own retry configs.
  • context.collection — persistent document storage indexed by endTime and resolved for fast lookup of overdue markets.
  • evm.wallet — a Compose-managed EOA named prediction-market-oracle serves as the CTF oracle.
  • context.fetch — CoinGecko HTTP request with built-in retries (see market-data.ts).
  • writeContract — raw ABI-signature calls for prepareCondition and reportPayouts on the CTF.

Step 3: Understand the oracle pattern

The Gnosis CTF requires an oracle address when a condition is prepared. Only that address can later call reportPayouts for the condition. In this example, the oracle is a Compose-managed EOA — the same wallet that signs both transactions:
// src/lib/utils.ts
import type { TaskContext, IWallet } from "compose";
import { ORACLE_WALLET_NAME } from "./constants";

export function getOracleWallet(context: TaskContext): Promise<IWallet> {
  return context.evm.wallet({ name: ORACLE_WALLET_NAME });
}
// src/tasks/launch-market.ts — prepareCondition uses the oracle as an input
await oracle.writeContract(
  evm.chains[CHAIN],
  CTF_ADDRESS,
  "prepareCondition(address,bytes32,uint256)",
  [oracle.address, questionId, "2"],
);
// src/tasks/resolve-market.ts — reportPayouts is signed by the oracle
await oracle.writeContract(
  evm.chains[CHAIN],
  CTF_ADDRESS,
  "reportPayouts(bytes32,uint256[])",
  [market.questionId, payouts],
);
Because each deploy of this example produces a fresh oracle address, the conditions it creates on the shared CTF are namespaced cleanly — they never collide with any other user of the same CTF.

Step 4: Understand questionId derivation

Every CTF condition is keyed by a questionId the oracle chooses. This example derives one deterministically from the market parameters so retries always compute the same value:
// src/lib/utils.ts
export function computeQuestionId(args: {
  assetPair: string;
  durationSec: number;
  startTimestampSec: number;
}): Hex {
  return keccak256(
    concat([
      stringToHex(SALT, { size: 32 }),
      stringToHex(args.assetPair, { size: 32 }),
      numberToHex(args.durationSec, { size: 32 }),
      numberToHex(args.startTimestampSec, { size: 32 }),
    ]),
  );
}
The SALT constant ("GOLDSKY_COMPOSE_DEMO") scopes every questionId to this example. Change it if you fork the app for your own purposes.

Step 5: Configure the Compose app

compose.yaml declares one cron plus four callable tasks:
name: "prediction-market"
api_version: "stable"

tasks:
  - path: "./src/tasks/orchestrator.ts"
    name: "orchestrator"
    triggers:
      - type: "cron"
        expression: "10 */5 * * * *"
    retry_config:
      max_attempts: 3
      initial_interval_ms: 500
      backoff_factor: 1

  - path: "./src/tasks/launch-market.ts"
    name: "launch_market"

  - path: "./src/tasks/resolve-market.ts"
    name: "resolve_market"

  - path: "./src/tasks/market-data.ts"
    name: "market_data"
    retry_config:
      max_attempts: 3
      initial_interval_ms: 500
      backoff_factor: 1

  - path: "./src/tasks/generate-wallet.ts"
    name: "generate_wallet"
    triggers:
      - type: "http"
        authentication: "auth_token"
Only tasks with a triggers block are called directly; launch_market, resolve_market, and market_data are invoked exclusively via context.callTask from the orchestrator.

Step 6: Deploy to Goldsky

goldsky compose deploy
Compose sponsors gas by default, so the oracle wallet needs no funding. The first cron tick fires on the next 5-minute boundary. Watch the cycles:
goldsky compose logs
Look for cycle complete: log lines and on-chain ConditionPreparation / ConditionResolution events on BaseScan, filtered by your oracle EOA (topic[2]). To print the oracle EOA address:
goldsky compose wallet list

Customization

Change the asset

Replace BTC_USD and the CoinGecko URL with any asset CoinGecko supports:
// src/lib/constants.ts
export const ASSET_PAIR = "ETH_USD" as const;
export const PRICE_URL =
  "https://api.coingecko.com/api/v3/simple/price?ids=ethereum&vs_currencies=usd";
Update the response parsing in market-data.ts to read response.ethereum.usd.

Change the market duration

// src/lib/constants.ts
export const DURATION_SEC = 900; // 15 minutes
export const DURATION_MS = DURATION_SEC * 1000;
Update the cron expression to match the new cadence:
triggers:
  - type: "cron"
    expression: "10 */15 * * * *"  # every 15 minutes

Change the chain

Swap baseSepolia for any other EVM testnet. You’ll also need a deployed ConditionalTokens contract on that chain — see Gnosis’s ConditionalTokens repository to deploy your own if none exists there.
// src/lib/constants.ts
export const CHAIN = "arbitrumSepolia" as const;
export const CTF_ADDRESS = "0xYOUR_CTF_ADDRESS";

Resources