> ## 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 binary prediction market

> Build a binary prediction market on Gnosis ConditionalTokens using Compose, CoinGecko, and multi-task orchestration

This guide walks you through building a self-contained binary prediction market on [Gnosis ConditionalTokens (CTF)](https://docs.gnosis.io/conditionaltokens/). 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

```mermaid theme={null}
flowchart LR
    A[Cron Trigger] -->|"every 5 min"| B[orchestrator]
    B -->|callTask| C[market_data]
    C -->|"BTC/USD price"| D[CoinGecko API]
    B -->|callTask| E[launch_market]
    B -->|callTask| F[resolve_market]
    E -->|prepareCondition| G[Gnosis CTF on Base Sepolia]
    F -->|reportPayouts| G
    B -->|"persist state"| H[Collection]
```

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

* [Goldsky CLI installed](/installation)

## Project structure

```text theme={null}
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:

```bash theme={null}
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`:

```typescript theme={null}
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:

```typescript theme={null}
// 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 });
}
```

```typescript theme={null}
// 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"],
);
```

```typescript theme={null}
// 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:

```typescript theme={null}
// 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:

```yaml theme={null}
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 can be invoked from outside the app. `launch_market`, `resolve_market`, and `market_data` are called exclusively via `context.callTask` from the orchestrator.

## Step 6: Deploy to Goldsky

```bash theme={null}
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:

```bash theme={null}
goldsky compose logs
```

Look for `cycle complete:` log lines and on-chain `ConditionPreparation` / `ConditionResolution` events on [BaseScan](https://sepolia.basescan.org/address/0xb04639fB29CC8D27e13727c249EbcAb0CDA92331), filtered by your oracle EOA (topic\[2]).

To print the oracle EOA address:

```bash theme={null}
goldsky compose wallet list
```

## Customization

### Change the asset

Replace `BTC_USD` and the CoinGecko URL with any asset CoinGecko supports:

```typescript theme={null}
// 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

```typescript theme={null}
// 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:

```yaml theme={null}
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](https://github.com/gnosis/conditional-tokens-contracts) to deploy your own if none exists there.

```typescript theme={null}
// src/lib/constants.ts
export const CHAIN = "arbitrumSepolia" as const;
export const CTF_ADDRESS = "0xYOUR_CTF_ADDRESS";
```

## Resources

* [Compose introduction](/compose/introduction)
* [Task triggers](/compose/task-triggers)
* [Collections](/compose/context/collections)
* [EVM wallets](/compose/context/evm/wallets)
* [Gnosis ConditionalTokens](https://docs.gnosis.io/conditionaltokens/)
* [CoinGecko API](https://www.coingecko.com/en/api)
* [GitHub repository](https://github.com/goldsky-io/documentation-examples/tree/main/compose/prediction-market)
