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.
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:
market_data fetches the current BTC/USD price from CoinGecko.
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).
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.
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 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
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:
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