From 64682d2f1705c0ced493c7982d5ed9791f02adc9 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Tue, 7 Apr 2026 15:24:15 -0400 Subject: [PATCH 1/2] New prediction market data stream template --- .../.cre/template.yaml | 26 + .../.gitignore | 2 + .../README.md | 313 ++++ .../contracts/abi/PredictionMarket.ts | 15 + .../contracts/abi/index.ts | 1 + .../contracts/evm/src/IERC165.sol | 27 + .../contracts/evm/src/IReceiver.sol | 18 + .../contracts/evm/src/PredictionMarket.sol | 196 +++ .../contracts/evm/src/ReceiverTemplate.sol | 242 +++ .../evm/src/abi/PredictionMarket.abi | 1 + .../evm/ts/generated/PredictionMarket.ts | 1447 +++++++++++++++++ .../evm/ts/generated/PredictionMarket_mock.ts | 28 + .../contracts/evm/ts/generated/index.ts | 3 + .../contracts/package.json | 10 + .../data-streams/client.ts | 278 ++++ .../data-streams/package.json | 10 + .../market-creation/config.production.json | 15 + .../market-creation/config.staging.json | 15 + .../market-creation/main.ts | 9 + .../market-creation/package.json | 18 + .../market-creation/tsconfig.json | 12 + .../market-creation/workflow.test.ts | 79 + .../market-creation/workflow.ts | 116 ++ .../market-creation/workflow.yaml | 15 + .../market-dispute/config.production.json | 14 + .../market-dispute/config.staging.json | 14 + .../market-dispute/main.ts | 9 + .../market-dispute/package.json | 18 + .../market-dispute/tsconfig.json | 12 + .../market-dispute/workflow.test.ts | 105 ++ .../market-dispute/workflow.ts | 137 ++ .../market-dispute/workflow.yaml | 15 + .../market-resolution/config.production.json | 16 + .../market-resolution/config.staging.json | 16 + .../market-resolution/main.ts | 9 + .../market-resolution/package.json | 18 + .../market-resolution/tsconfig.json | 12 + .../market-resolution/workflow.test.ts | 81 + .../market-resolution/workflow.ts | 133 ++ .../market-resolution/workflow.yaml | 15 + .../project.yaml | 15 + .../secrets.yaml | 5 + .../market-creation/.cre_build_tmp.js | 6 +- 43 files changed, 3544 insertions(+), 2 deletions(-) create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/.cre/template.yaml create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/.gitignore create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/README.md create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/PredictionMarket.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/index.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IERC165.sol create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IReceiver.sol create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/PredictionMarket.sol create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/ReceiverTemplate.sol create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/abi/PredictionMarket.abi create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket_mock.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/index.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/package.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/client.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/package.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.production.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.staging.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/main.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/package.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/tsconfig.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.test.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.yaml create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/main.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/package.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/tsconfig.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.yaml create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/main.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/package.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/tsconfig.json create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.yaml create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/project.yaml create mode 100644 starter-templates/prediction-market/prediction-market-data-streams-ts/secrets.yaml diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/.cre/template.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/.cre/template.yaml new file mode 100644 index 00000000..8fbcd90b --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/.cre/template.yaml @@ -0,0 +1,26 @@ +kind: starter-template +id: prediction-market-data-streams-ts +projectDir: . +title: "Prediction Market (Data Streams)" +description: "Full prediction market lifecycle: create, resolve, and dispute binary markets using Chainlink Data Streams." +language: typescript +category: workflow +capabilities: + - cron + - log-trigger + - chain-read + - chain-write + - http +tags: + - prediction-market + - data-streams + - defi +workflows: + - dir: market-creation + - dir: market-resolution + - dir: market-dispute +postInit: | + A demo PredictionMarket contract is pre-deployed on Sepolia. See README.md for details. + This template uses Chainlink Data Streams (off-chain) instead of Data Feeds (on-chain). + You will need Data Streams API credentials — see README.md. + Run `cd market-creation && bun install && cd ..` for each workflow, then simulate. diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/.gitignore b/starter-templates/prediction-market/prediction-market-data-streams-ts/.gitignore new file mode 100644 index 00000000..b2880191 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/.gitignore @@ -0,0 +1,2 @@ +*.env +.cre_build_tmp.js diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md b/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md new file mode 100644 index 00000000..3d08ebc6 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md @@ -0,0 +1,313 @@ +# Prediction Market Template (Data Streams) + +A CRE starter template implementing a full prediction market lifecycle with three workflows: **Market Creation**, **Market Resolution**, and **Dispute Management**. All three workflows share a single `PredictionMarket` smart contract. Price resolution uses **Chainlink Data Streams** — an off-chain, low-latency price feed accessed via the Data Streams REST API. + +> **Data Feeds vs Data Streams:** This template fetches prices from the [Data Streams REST API](https://docs.chain.link/data-streams) using HMAC-authenticated HTTP requests. For the on-chain Data Feeds variant, see the sibling `prediction-market-ts` template. + +**⚠️ DISCLAIMER** + +This template is an educational example to demonstrate how to interact with Chainlink systems, products, and services. It is provided **"AS IS"** and **"AS AVAILABLE"** without warranties of any kind, has **not** been audited, and may omit checks or error handling for clarity. **Do not use this code in production** without performing your own audits and applying best practices. Neither Chainlink Labs, the Chainlink Foundation, nor Chainlink node operators are responsible for unintended outputs generated due to errors in code. + +--- + +## Architecture + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ 1. CREATION │────>│ 2. RESOLUTION │────>│ 3. DISPUTE │ +│ │ │ │ │ │ +│ Cron trigger │ │ Cron trigger │ │ LogTrigger │ +│ Creates new │ │ Checks expired │ │ (DisputeRaised) │ +│ markets │ │ markets, calls │ │ Calls Data │ +│ │ │ Data Streams │ │ Streams API, │ +│ │ │ API, resolves │ │ resolves dispute│ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ │ + └────────────────────────┼────────────────────────┘ + │ + ┌────────────▼────────────┐ + │ PredictionMarket.sol │ + │ (shared contract) │ + │ │ + │ - createMarket() │ + │ - resolveMarket() │ + │ - resolveDispute() │ + │ - raiseDispute() │ + └────────────┬────────────┘ + │ + ┌────────────▼────────────┐ + │ Chainlink Data Streams │ + │ REST API (off-chain) │ + │ HMAC-SHA256 auth │ + └─────────────────────────┘ +``` + +## Project Structure + +``` +prediction-market-data-streams-ts/ +├── .cre/template.yaml # Template metadata +├── project.yaml # CRE project settings (RPCs) +├── secrets.yaml # Secrets for Data Streams API credentials +├── .env # Private key + API credentials (local) +├── README.md +├── contracts/ # Shared contracts and bindings +│ ├── package.json +│ ├── abi/ # Human-readable ABI (viem parseAbi) +│ └── evm/ +│ ├── src/ # Solidity source + ABI JSON +│ │ ├── PredictionMarket.sol +│ │ ├── ReceiverTemplate.sol +│ │ ├── IReceiver.sol +│ │ ├── IERC165.sol +│ │ └── abi/ # Compiled ABI JSON files +│ └── ts/generated/ # Generated TypeScript bindings +├── data-streams/ # Shared Data Streams API client +│ └── client.ts # HMAC auth, report decoding, price fetch +├── market-creation/ # Workflow 1: Create markets +│ ├── main.ts +│ ├── workflow.ts +│ ├── workflow.test.ts +│ ├── workflow.yaml +│ ├── config.staging.json +│ └── config.production.json +├── market-resolution/ # Workflow 2: Resolve expired markets +│ ├── main.ts +│ ├── workflow.ts +│ ├── workflow.test.ts +│ ├── workflow.yaml +│ ├── config.staging.json +│ └── config.production.json +└── market-dispute/ # Workflow 3: Handle disputes + ├── main.ts + ├── workflow.ts + ├── workflow.test.ts + ├── workflow.yaml + ├── config.staging.json + └── config.production.json +``` + +--- + +## Quick Start + +### Prerequisites + +- [Bun](https://bun.sh/) (v1.2.21+) +- [CRE CLI](https://docs.chain.link/cre/getting-started) +- Chainlink Data Streams API credentials ([request access](https://chain.link/data-streams)) + +### 1. Set Up API Credentials + +Edit `.env` with your Data Streams credentials: + +```bash +CRE_ETH_PRIVATE_KEY= +DATA_STREAMS_API_KEY= +DATA_STREAMS_API_SECRET= +``` + +### 2. Install Dependencies + +```bash +cd market-creation && bun install && cd .. +cd market-resolution && bun install && cd .. +cd market-dispute && bun install && cd .. +``` + +### 3. Run Tests + +```bash +cd market-creation && bun test && cd .. +cd market-resolution && bun test && cd .. +cd market-dispute && bun test && cd .. +``` + +### 4. Simulate + +```bash +# Create a market +cre workflow simulate market-creation --target staging + +# Resolve expired markets (requires Data Streams API credentials) +cre workflow simulate market-resolution --target staging + +# Handle a dispute (triggered by on-chain DisputeRaised event) +cre workflow simulate market-dispute --target staging +``` + +### 5. Broadcast (Sepolia Testnet) + +```bash +cre workflow broadcast market-creation --target staging +cre workflow broadcast market-resolution --target staging +cre workflow broadcast market-dispute --target staging +``` + +--- + +## Workflows + +### 1. Market Creation (Cron Trigger) + +Creates a new binary prediction market every hour. The market asks: "Will BTC be above $100,000 by {date}?" + +**Config** (`market-creation/config.staging.json`): +- `schedule`: Cron expression (default: hourly) +- `evms[].predictionMarketAddress`: Deployed PredictionMarket contract +- `marketDefaults.strikePriceUsd`: Price threshold in USD +- `marketDefaults.durationSeconds`: Market duration (default: 24h) + +**How it works:** +1. Reads `getNextMarketId()` from the contract +2. Computes strike price in 8 decimal places +3. Encodes a `CREATE` action and writes it on-chain via CRE report + +### 2. Market Resolution (Cron Trigger + Data Streams) + +Checks markets every 10 minutes. For each resolvable market, fetches the latest BTC/USD price from Chainlink Data Streams and resolves the market. + +**Config** (`market-resolution/config.staging.json`): +- `schedule`: Cron expression (default: every 10 min) +- `dataStreams.apiUrl`: Data Streams REST API base URL +- `dataStreams.feedId`: The Data Streams feed ID (BTC/USD) +- `marketIdsToCheck`: Array of market IDs to monitor + +**How it works:** +1. Calls `isResolvable(marketId)` on the contract +2. Fetches the latest report from the Data Streams REST API with HMAC authentication +3. Decodes the v3 (Crypto Advanced) report to extract the median price +4. Converts from 18 decimal places (Data Streams) to 8 decimal places (on-chain) +5. Encodes a `RESOLVE` action and writes it on-chain via CRE report + +### 3. Dispute Management (Log Trigger + Data Streams) + +Listens for `DisputeRaised` events. When triggered, fetches a fresh price from Data Streams and re-resolves the market. + +**Config** (`market-dispute/config.staging.json`): +- `dataStreams.apiUrl`: Data Streams REST API base URL +- `dataStreams.feedId`: The Data Streams feed ID (BTC/USD) + +**How it works:** +1. Decodes `DisputeRaised` event (marketId, disputor, reason) +2. Verifies the market is in `Disputed` status +3. Fetches a fresh price from Data Streams +4. Encodes a `RESOLVE_DISPUTE` action and writes it on-chain + +--- + +## Data Streams Integration + +### Authentication + +The Data Streams REST API requires HMAC-SHA256 authentication. Three headers are sent with every request: + +| Header | Description | +|--------|-------------| +| `Authorization` | Your API key (UUID) | +| `X-Authorization-Timestamp` | Current timestamp in milliseconds | +| `X-Authorization-Signature-SHA256` | HMAC-SHA256 signature of the request | + +The HMAC signature is computed over: `METHOD PATH BODY_HASH API_KEY TIMESTAMP` + +See [`data-streams/client.ts`](./data-streams/client.ts) for the full implementation. + +### Report Schema (v3 — Crypto Advanced) + +Data Streams returns a `fullReport` blob that is ABI-decoded in two steps: + +1. **Outer wrapper**: `(bytes32[3] reportContext, bytes reportData, bytes32[] rawRs, bytes32[] rawSs, bytes32 rawVs)` +2. **Report body (v3)**: `(bytes32 feedId, uint32 validFromTimestamp, uint32 observationsTimestamp, uint192 nativeFee, uint192 linkFee, uint32 expiresAt, int192 price, int192 bid, int192 ask)` + +The `price` field uses **18 decimal places** for crypto feeds. This template converts to 8 decimals to match the on-chain PredictionMarket contract's strike price format. + +### Feed IDs + +| Feed | Testnet Feed ID | Network | +|------|----------------|---------| +| BTC/USD | `0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00` | Sepolia | + +Find more feed IDs at [docs.chain.link/data-streams](https://docs.chain.link/data-streams). + +### API Endpoints + +| Environment | Base URL | +|------------|----------| +| Testnet | `https://api.testnet-dataengine.chain.link` | +| Mainnet | `https://api.dataengine.chain.link` | + +--- + +## Smart Contract + +The template uses the same `PredictionMarket.sol` contract as the Data Feeds variant. The contract is agnostic to the price source — it receives prices via CRE reports regardless of whether the workflow reads them from on-chain Data Feeds or the off-chain Data Streams API. + +### Pre-deployed Contract (Sepolia) + +| Component | Address | +|-----------|---------| +| PredictionMarket | `0xEb792aF46AB2c2f1389A774AB806423DB43aA425` | +| MockKeystoneForwarder | `0x15fc6ae953e024d975e77382eeec56a9101f9f88` | + +### Constructor Arguments + +| Argument | Value (Sepolia) | Description | +|----------|----------------|-------------| +| `forwarder` | `0x15fc6ae953e024d975e77382eeec56a9101f9f88` | MockKeystoneForwarder | +| `_priceFeed` | `0x1b44F3514812d835EB1BDB0acB33d3fA3351Ee43` | BTC/USD feed (stored for reference) | +| `_disputeWindow` | `86400` | 24-hour dispute window | + +--- + +## Differences from Data Feeds Template + +| Aspect | Data Feeds (`prediction-market-ts`) | Data Streams (this template) | +|--------|-------------------------------------|------------------------------| +| Price source | On-chain `PriceFeedAggregator` contract | Off-chain Data Streams REST API | +| Price read | `priceFeed.latestAnswer(runtime)` | HTTP GET + HMAC auth + ABI decode | +| Decimals | 8 (native) | 18 → converted to 8 | +| Latency | Block-finality dependent | Sub-second updates | +| Auth | None (public on-chain read) | HMAC-SHA256 with API key/secret | +| Capabilities | `chain-read`, `chain-write`, `cron`, `log-trigger` | + `http` | +| Secrets | None | `DATA_STREAMS_API_KEY`, `DATA_STREAMS_API_SECRET` | + +--- + +## Customization + +### Different asset +Change `dataStreams.feedId` in the config files and update the market question and strike price accordingly. + +### Different duration +Adjust `marketDefaults.durationSeconds` in market-creation config. + +### Different resolution frequency +Modify the `schedule` cron expression in market-resolution config. + +### Different dispute window +Redeploy the contract with a different `_disputeWindow` constructor argument. + +--- + +## Security Notes + +- **Forwarder Validation**: The contract validates that only the trusted Chainlink Forwarder can call `onReport()` +- **HMAC Authentication**: Data Streams API access is secured with HMAC-SHA256 signatures +- **Secrets Management**: API credentials are stored as CRE secrets, never hardcoded in workflow code +- **Consensus**: CRE nodes reach consensus on the HTTP response before writing on-chain +- **Dispute Window**: Prevents disputes after `expirationTime + disputeWindow` + +## Known Limitations + +- **Spot-price resolution**: Markets resolve using the latest price at resolution time (not historical high/low) +- **Single feed**: All markets use the same BTC/USD feed +- **No betting**: This contract does not implement token deposits or payouts +- **API access required**: Data Streams requires approved API credentials + +--- + +## Alternative Patterns + +- **On-chain Data Feeds**: Use the sibling `prediction-market-ts` template for simpler zero-setup price resolution +- **HTTP trigger**: Replace the Cron trigger with an HTTP trigger for on-demand market creation/resolution +- **WebSocket**: For real-time price monitoring, Data Streams also offers a WebSocket API diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/PredictionMarket.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/PredictionMarket.ts new file mode 100644 index 00000000..99747a29 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/PredictionMarket.ts @@ -0,0 +1,15 @@ +import { parseAbi } from "viem" + +export const PredictionMarketAbi = parseAbi([ + "function getMarket(uint256 marketId) view returns ((uint256 marketId, string question, uint256 strikePrice, uint256 expirationTime, uint256 disputeDeadline, uint8 status, uint8 outcome, int256 resolutionPrice, uint256 resolvedAt))", + "function getMarketStatus(uint256 marketId) view returns (uint8)", + "function getNextMarketId() view returns (uint256)", + "function isExpired(uint256 marketId) view returns (bool)", + "function isResolvable(uint256 marketId) view returns (bool)", + "function priceFeed() view returns (address)", + "function disputeWindow() view returns (uint256)", + "event MarketCreated(uint256 indexed marketId, string question, uint256 strikePrice, uint256 expirationTime, uint256 disputeDeadline)", + "event MarketResolved(uint256 indexed marketId, uint8 outcome, int256 resolutionPrice, uint256 resolvedAt)", + "event DisputeRaised(uint256 indexed marketId, address indexed disputor, string reason)", + "event DisputeResolved(uint256 indexed marketId, uint8 outcome, int256 newPrice, bool overturned)", +]) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/index.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/index.ts new file mode 100644 index 00000000..197f840b --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/abi/index.ts @@ -0,0 +1 @@ +export { PredictionMarketAbi } from "./PredictionMarket" diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IERC165.sol b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IERC165.sol new file mode 100644 index 00000000..5d28886a --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IERC165.sol @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts (last updated v5.4.0) (utils/introspection/IERC165.sol) + +pragma solidity >=0.4.16; + +/** + * @dev Interface of the ERC-165 standard, as defined in the + * https://eips.ethereum.org/EIPS/eip-165[ERC]. + * + * Implementers can declare support of contract interfaces, which can then be + * queried by others ({ERC165Checker}). + * + * For an implementation, see {ERC165}. + */ +interface IERC165 { + /** + * @dev Returns true if this contract implements the interface defined by + * `interfaceId`. See the corresponding + * https://eips.ethereum.org/EIPS/eip-165#how-interfaces-are-identified[ERC section] + * to learn more about how these ids are created. + * + * This function call must use less than 30 000 gas. + */ + function supportsInterface( + bytes4 interfaceId + ) external view returns (bool); +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IReceiver.sol b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IReceiver.sol new file mode 100644 index 00000000..e7f047b1 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/IReceiver.sol @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "./IERC165.sol"; + +/// @title IReceiver - receives keystone reports +/// @notice Implementations must support the IReceiver interface through ERC165. +interface IReceiver is IERC165 { + /// @notice Handles incoming keystone reports. + /// @dev If this function call reverts, it can be retried with a higher gas + /// limit. The receiver is responsible for discarding stale reports. + /// @param metadata Report's metadata. + /// @param report Workflow report. + function onReport( + bytes calldata metadata, + bytes calldata report + ) external; +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/PredictionMarket.sol b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/PredictionMarket.sol new file mode 100644 index 00000000..49cad6de --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/PredictionMarket.sol @@ -0,0 +1,196 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.19; + +import {ReceiverTemplate} from "./ReceiverTemplate.sol"; + +/** + * @title PredictionMarket + * @notice A simple binary prediction market resolved by Chainlink Data Feeds via CRE. + * Markets ask: "Will BTC be above $X by timestamp Y?" + * Resolution reads the on-chain Chainlink BTC/USD price feed — no AI, no off-chain APIs. + * + * Three CRE workflows interact with this contract: + * 1. Creation — opens new markets + * 2. Resolution — settles expired markets using price feed data + * 3. Dispute — re-checks resolution if disputed within the dispute window + */ +contract PredictionMarket is ReceiverTemplate { + + // ─── Types ────────────────────────────────────── + enum MarketStatus { Open, Resolved, Disputed, DisputeResolved } + enum Outcome { Unresolved, Yes, No } + + struct Market { + uint256 marketId; + string question; // e.g. "Will BTC be above $100,000 by 2026-04-01?" + uint256 strikePrice; // price threshold in feed decimals (e.g. 100000 * 1e8) + uint256 expirationTime; // unix timestamp when market can be resolved + uint256 disputeDeadline; // unix timestamp after which disputes are no longer accepted + MarketStatus status; + Outcome outcome; + int256 resolutionPrice; // the price used to resolve (from Chainlink feed) + uint256 resolvedAt; + } + + // ─── State ────────────────────────────────────── + uint256 public nextMarketId; + mapping(uint256 => Market) public markets; + address public priceFeed; // Chainlink BTC/USD aggregator proxy address + uint256 public disputeWindow; // seconds after resolution during which disputes are accepted + + // ─── Events ───────────────────────────────────── + event MarketCreated( + uint256 indexed marketId, + string question, + uint256 strikePrice, + uint256 expirationTime, + uint256 disputeDeadline + ); + event MarketResolved( + uint256 indexed marketId, + Outcome outcome, + int256 resolutionPrice, + uint256 resolvedAt + ); + event DisputeRaised( + uint256 indexed marketId, + address indexed disputor, + string reason + ); + event DisputeResolved( + uint256 indexed marketId, + Outcome outcome, + int256 newPrice, + bool overturned + ); + + // ─── Action Types (decoded from CRE report) ──── + uint8 constant ACTION_CREATE = 1; + uint8 constant ACTION_RESOLVE = 2; + uint8 constant ACTION_RESOLVE_DISPUTE = 3; + + constructor( + address forwarder, + address _priceFeed, + uint256 _disputeWindow + ) ReceiverTemplate(forwarder) { + priceFeed = _priceFeed; + disputeWindow = _disputeWindow; + } + + // ─── CRE Entry Point ──────────────────────────── + + function _processReport(bytes calldata report) internal override { + (uint8 action, bytes memory data) = abi.decode(report, (uint8, bytes)); + + if (action == ACTION_CREATE) { + _createMarket(data); + } else if (action == ACTION_RESOLVE) { + _resolveMarket(data); + } else if (action == ACTION_RESOLVE_DISPUTE) { + _resolveDispute(data); + } else { + revert("Unknown action"); + } + } + + // ─── Action: Create Market ────────────────────── + + function _createMarket(bytes memory data) internal { + ( + string memory question, + uint256 strikePrice, + uint256 expirationTime + ) = abi.decode(data, (string, uint256, uint256)); + + require(expirationTime > block.timestamp, "Expiration must be in the future"); + + uint256 marketId = nextMarketId++; + uint256 disputeDeadline = expirationTime + disputeWindow; + + markets[marketId] = Market({ + marketId: marketId, + question: question, + strikePrice: strikePrice, + expirationTime: expirationTime, + disputeDeadline: disputeDeadline, + status: MarketStatus.Open, + outcome: Outcome.Unresolved, + resolutionPrice: 0, + resolvedAt: 0 + }); + + emit MarketCreated(marketId, question, strikePrice, expirationTime, disputeDeadline); + } + + // ─── Action: Resolve Market ───────────────────── + + function _resolveMarket(bytes memory data) internal { + (uint256 marketId, int256 price) = abi.decode(data, (uint256, int256)); + + Market storage m = markets[marketId]; + require(m.status == MarketStatus.Open, "Market not open"); + require(block.timestamp >= m.expirationTime, "Market not expired"); + + m.outcome = price >= int256(m.strikePrice) ? Outcome.Yes : Outcome.No; + m.resolutionPrice = price; + m.resolvedAt = block.timestamp; + m.status = MarketStatus.Resolved; + + emit MarketResolved(marketId, m.outcome, price, block.timestamp); + } + + // ─── Action: Resolve Dispute ──────────────────── + + function _resolveDispute(bytes memory data) internal { + (uint256 marketId, int256 newPrice) = abi.decode(data, (uint256, int256)); + + Market storage m = markets[marketId]; + require(m.status == MarketStatus.Disputed, "Market not disputed"); + require(block.timestamp <= m.disputeDeadline, "Dispute window closed"); + + Outcome newOutcome = newPrice >= int256(m.strikePrice) ? Outcome.Yes : Outcome.No; + bool overturned = newOutcome != m.outcome; + + m.outcome = newOutcome; + m.resolutionPrice = newPrice; + m.resolvedAt = block.timestamp; + m.status = MarketStatus.DisputeResolved; + + emit DisputeResolved(marketId, newOutcome, newPrice, overturned); + } + + // ─── Public: Raise Dispute (called by users, not CRE) ── + + function raiseDispute(uint256 marketId, string calldata reason) external { + Market storage m = markets[marketId]; + require(m.status == MarketStatus.Resolved, "Market not resolved"); + require(block.timestamp <= m.disputeDeadline, "Dispute window closed"); + + m.status = MarketStatus.Disputed; + emit DisputeRaised(marketId, msg.sender, reason); + } + + // ─── View Functions (read by CRE workflows) ───── + + function getMarket(uint256 marketId) external view returns (Market memory) { + return markets[marketId]; + } + + function getMarketStatus(uint256 marketId) external view returns (MarketStatus) { + return markets[marketId].status; + } + + function getNextMarketId() external view returns (uint256) { + return nextMarketId; + } + + function isExpired(uint256 marketId) external view returns (bool) { + return block.timestamp >= markets[marketId].expirationTime; + } + + function isResolvable(uint256 marketId) external view returns (bool) { + Market storage m = markets[marketId]; + return m.status == MarketStatus.Open && block.timestamp >= m.expirationTime; + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/ReceiverTemplate.sol b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/ReceiverTemplate.sol new file mode 100644 index 00000000..488324dd --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/ReceiverTemplate.sol @@ -0,0 +1,242 @@ +// SPDX-License-Identifier: MIT +pragma solidity ^0.8.0; + +import {IERC165} from "./IERC165.sol"; +import {IReceiver} from "./IReceiver.sol"; +import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol"; + +/// @title ReceiverTemplate - Abstract receiver with optional permission controls +/// @notice Provides flexible, updatable security checks for receiving workflow reports +/// @dev The forwarder address is required at construction time for security. +/// Additional permission fields can be configured using setter functions. +abstract contract ReceiverTemplate is IReceiver, Ownable { + // Required permission field at deployment, configurable after + address private s_forwarderAddress; // If set, only this address can call onReport + + // Optional permission fields (all default to zero = disabled) + address private s_expectedAuthor; // If set, only reports from this workflow owner are accepted + bytes10 private s_expectedWorkflowName; // Only validated when s_expectedAuthor is also set + bytes32 private s_expectedWorkflowId; // If set, only reports from this specific workflow ID are accepted + + // Hex character lookup table for bytes-to-hex conversion + bytes private constant HEX_CHARS = "0123456789abcdef"; + + // Custom errors + error InvalidForwarderAddress(); + error InvalidSender(address sender, address expected); + error InvalidAuthor(address received, address expected); + error InvalidWorkflowName(bytes10 received, bytes10 expected); + error InvalidWorkflowId(bytes32 received, bytes32 expected); + error WorkflowNameRequiresAuthorValidation(); + + // Events + event ForwarderAddressUpdated(address indexed previousForwarder, address indexed newForwarder); + event ExpectedAuthorUpdated(address indexed previousAuthor, address indexed newAuthor); + event ExpectedWorkflowNameUpdated(bytes10 indexed previousName, bytes10 indexed newName); + event ExpectedWorkflowIdUpdated(bytes32 indexed previousId, bytes32 indexed newId); + event SecurityWarning(string message); + + /// @notice Constructor sets msg.sender as the owner and configures the forwarder address + /// @param _forwarderAddress The address of the Chainlink Forwarder contract (cannot be address(0)) + /// @dev The forwarder address is required for security - it ensures only verified reports are processed + constructor( + address _forwarderAddress + ) Ownable(msg.sender) { + if (_forwarderAddress == address(0)) { + revert InvalidForwarderAddress(); + } + s_forwarderAddress = _forwarderAddress; + emit ForwarderAddressUpdated(address(0), _forwarderAddress); + } + + /// @notice Returns the configured forwarder address + /// @return The forwarder address (address(0) if disabled) + function getForwarderAddress() external view returns (address) { + return s_forwarderAddress; + } + + /// @notice Returns the expected workflow author address + /// @return The expected author address (address(0) if not set) + function getExpectedAuthor() external view returns (address) { + return s_expectedAuthor; + } + + /// @notice Returns the expected workflow name + /// @return The expected workflow name (bytes10(0) if not set) + function getExpectedWorkflowName() external view returns (bytes10) { + return s_expectedWorkflowName; + } + + /// @notice Returns the expected workflow ID + /// @return The expected workflow ID (bytes32(0) if not set) + function getExpectedWorkflowId() external view returns (bytes32) { + return s_expectedWorkflowId; + } + + /// @inheritdoc IReceiver + /// @dev Performs optional validation checks based on which permission fields are set + function onReport( + bytes calldata metadata, + bytes calldata report + ) external override { + // Security Check 1: Verify caller is the trusted Chainlink Forwarder (if configured) + if (s_forwarderAddress != address(0) && msg.sender != s_forwarderAddress) { + revert InvalidSender(msg.sender, s_forwarderAddress); + } + + // Security Checks 2-4: Verify workflow identity - ID, owner, and/or name (if any are configured) + if (s_expectedWorkflowId != bytes32(0) || s_expectedAuthor != address(0) || s_expectedWorkflowName != bytes10(0)) { + (bytes32 workflowId, bytes10 workflowName, address workflowOwner) = _decodeMetadata(metadata); + + if (s_expectedWorkflowId != bytes32(0) && workflowId != s_expectedWorkflowId) { + revert InvalidWorkflowId(workflowId, s_expectedWorkflowId); + } + if (s_expectedAuthor != address(0) && workflowOwner != s_expectedAuthor) { + revert InvalidAuthor(workflowOwner, s_expectedAuthor); + } + + // ================================================================ + // WORKFLOW NAME VALIDATION - REQUIRES AUTHOR VALIDATION + // ================================================================ + // Do not rely on workflow name validation alone. Workflow names are unique + // per owner, but not across owners. + // Furthermore, workflow names use 40-bit truncation (bytes10), making collisions possible. + // Therefore, workflow name validation REQUIRES author (workflow owner) validation. + // The code enforces this dependency at runtime. + // ================================================================ + if (s_expectedWorkflowName != bytes10(0)) { + // Author must be configured if workflow name is used + if (s_expectedAuthor == address(0)) { + revert WorkflowNameRequiresAuthorValidation(); + } + // Validate workflow name matches (author already validated above) + if (workflowName != s_expectedWorkflowName) { + revert InvalidWorkflowName(workflowName, s_expectedWorkflowName); + } + } + } + + _processReport(report); + } + + /// @notice Updates the forwarder address that is allowed to call onReport + /// @param _forwarder The new forwarder address + /// @dev WARNING: Setting to address(0) disables forwarder validation. + /// This makes your contract INSECURE - anyone can call onReport() with arbitrary data. + /// Only use address(0) if you fully understand the security implications. + function setForwarderAddress( + address _forwarder + ) external onlyOwner { + address previousForwarder = s_forwarderAddress; + + // Emit warning if disabling forwarder check + if (_forwarder == address(0)) { + emit SecurityWarning("Forwarder address set to zero - contract is now INSECURE"); + } + + s_forwarderAddress = _forwarder; + emit ForwarderAddressUpdated(previousForwarder, _forwarder); + } + + /// @notice Updates the expected workflow owner address + /// @param _author The new expected author address (use address(0) to disable this check) + function setExpectedAuthor( + address _author + ) external onlyOwner { + address previousAuthor = s_expectedAuthor; + s_expectedAuthor = _author; + emit ExpectedAuthorUpdated(previousAuthor, _author); + } + + /// @notice Updates the expected workflow name from a plaintext string + /// @param _name The workflow name as a string (use empty string "" to disable this check) + /// @dev IMPORTANT: Workflow name validation REQUIRES author validation to be enabled. + /// The workflow name uses only 40-bit truncation, making collision attacks feasible + /// when used alone. However, since workflow names are unique per owner, validating + /// both the name AND the author address provides adequate security. + /// You must call setExpectedAuthor() before or after calling this function. + /// The name is hashed using SHA256 and truncated to bytes10. + function setExpectedWorkflowName( + string calldata _name + ) external onlyOwner { + bytes10 previousName = s_expectedWorkflowName; + + if (bytes(_name).length == 0) { + s_expectedWorkflowName = bytes10(0); + emit ExpectedWorkflowNameUpdated(previousName, bytes10(0)); + return; + } + + // Convert workflow name to bytes10: + // SHA256 hash → hex encode → take first 10 chars → hex encode those chars + bytes32 hash = sha256(bytes(_name)); + bytes memory hexString = _bytesToHexString(abi.encodePacked(hash)); + bytes memory first10 = new bytes(10); + for (uint256 i = 0; i < 10; i++) { + first10[i] = hexString[i]; + } + s_expectedWorkflowName = bytes10(first10); + emit ExpectedWorkflowNameUpdated(previousName, s_expectedWorkflowName); + } + + /// @notice Updates the expected workflow ID + /// @param _id The new expected workflow ID (use bytes32(0) to disable this check) + function setExpectedWorkflowId( + bytes32 _id + ) external onlyOwner { + bytes32 previousId = s_expectedWorkflowId; + s_expectedWorkflowId = _id; + emit ExpectedWorkflowIdUpdated(previousId, _id); + } + + /// @notice Helper function to convert bytes to hex string + /// @param data The bytes to convert + /// @return The hex string representation + function _bytesToHexString( + bytes memory data + ) private pure returns (bytes memory) { + bytes memory hexString = new bytes(data.length * 2); + + for (uint256 i = 0; i < data.length; i++) { + hexString[i * 2] = HEX_CHARS[uint8(data[i] >> 4)]; + hexString[i * 2 + 1] = HEX_CHARS[uint8(data[i] & 0x0f)]; + } + + return hexString; + } + + /// @notice Extracts all metadata fields from the onReport metadata parameter + /// @param metadata The metadata bytes encoded using abi.encodePacked(workflowId, workflowName, workflowOwner) + /// @return workflowId The unique identifier of the workflow (bytes32) + /// @return workflowName The name of the workflow (bytes10) + /// @return workflowOwner The owner address of the workflow + function _decodeMetadata( + bytes memory metadata + ) internal pure returns (bytes32 workflowId, bytes10 workflowName, address workflowOwner) { + // Metadata structure (encoded using abi.encodePacked by the Forwarder): + // - First 32 bytes: length of the byte array (standard for dynamic bytes) + // - Offset 32, size 32: workflow_id (bytes32) + // - Offset 64, size 10: workflow_name (bytes10) + // - Offset 74, size 20: workflow_owner (address) + assembly { + workflowId := mload(add(metadata, 32)) + workflowName := mload(add(metadata, 64)) + workflowOwner := shr(mul(12, 8), mload(add(metadata, 74))) + } + return (workflowId, workflowName, workflowOwner); + } + + /// @notice Abstract function to process the report data + /// @param report The report calldata containing your workflow's encoded data + /// @dev Implement this function with your contract's business logic + function _processReport( + bytes calldata report + ) internal virtual; + + /// @inheritdoc IERC165 + function supportsInterface( + bytes4 interfaceId + ) public view virtual override returns (bool) { + return interfaceId == type(IReceiver).interfaceId || interfaceId == type(IERC165).interfaceId; + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/abi/PredictionMarket.abi b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/abi/PredictionMarket.abi new file mode 100644 index 00000000..acde93de --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/src/abi/PredictionMarket.abi @@ -0,0 +1 @@ +[{"type":"constructor","inputs":[{"name":"forwarder","type":"address","internalType":"address"},{"name":"_priceFeed","type":"address","internalType":"address"},{"name":"_disputeWindow","type":"uint256","internalType":"uint256"}],"stateMutability":"nonpayable"},{"type":"function","name":"disputeWindow","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getExpectedAuthor","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowId","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowName","inputs":[],"outputs":[{"name":"","type":"bytes10","internalType":"bytes10"}],"stateMutability":"view"},{"type":"function","name":"getForwarderAddress","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"getMarket","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"tuple","internalType":"struct PredictionMarket.Market","components":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"question","type":"string","internalType":"string"},{"name":"strikePrice","type":"uint256","internalType":"uint256"},{"name":"expirationTime","type":"uint256","internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","internalType":"uint256"},{"name":"status","type":"uint8","internalType":"enum PredictionMarket.MarketStatus"},{"name":"outcome","type":"uint8","internalType":"enum PredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","internalType":"int256"},{"name":"resolvedAt","type":"uint256","internalType":"uint256"}]}],"stateMutability":"view"},{"type":"function","name":"getMarketStatus","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"uint8","internalType":"enum PredictionMarket.MarketStatus"}],"stateMutability":"view"},{"type":"function","name":"getNextMarketId","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"isExpired","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isResolvable","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"markets","inputs":[{"name":"","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"question","type":"string","internalType":"string"},{"name":"strikePrice","type":"uint256","internalType":"uint256"},{"name":"expirationTime","type":"uint256","internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","internalType":"uint256"},{"name":"status","type":"uint8","internalType":"enum PredictionMarket.MarketStatus"},{"name":"outcome","type":"uint8","internalType":"enum PredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","internalType":"int256"},{"name":"resolvedAt","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"nextMarketId","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"onReport","inputs":[{"name":"metadata","type":"bytes","internalType":"bytes"},{"name":"report","type":"bytes","internalType":"bytes"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"owner","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"priceFeed","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"raiseDispute","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"reason","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceOwnership","inputs":[],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedAuthor","inputs":[{"name":"_author","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowId","inputs":[{"name":"_id","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowName","inputs":[{"name":"_name","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setForwarderAddress","inputs":[{"name":"_forwarder","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"transferOwnership","inputs":[{"name":"newOwner","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"DisputeRaised","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"disputor","type":"address","indexed":true,"internalType":"address"},{"name":"reason","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"DisputeResolved","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"outcome","type":"uint8","indexed":false,"internalType":"enum PredictionMarket.Outcome"},{"name":"newPrice","type":"int256","indexed":false,"internalType":"int256"},{"name":"overturned","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"ExpectedAuthorUpdated","inputs":[{"name":"previousAuthor","type":"address","indexed":true,"internalType":"address"},{"name":"newAuthor","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowIdUpdated","inputs":[{"name":"previousId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newId","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowNameUpdated","inputs":[{"name":"previousName","type":"bytes10","indexed":true,"internalType":"bytes10"},{"name":"newName","type":"bytes10","indexed":true,"internalType":"bytes10"}],"anonymous":false},{"type":"event","name":"ForwarderAddressUpdated","inputs":[{"name":"previousForwarder","type":"address","indexed":true,"internalType":"address"},{"name":"newForwarder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"MarketCreated","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"question","type":"string","indexed":false,"internalType":"string"},{"name":"strikePrice","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"expirationTime","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"MarketResolved","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"outcome","type":"uint8","indexed":false,"internalType":"enum PredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","indexed":false,"internalType":"int256"},{"name":"resolvedAt","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"OwnershipTransferred","inputs":[{"name":"previousOwner","type":"address","indexed":true,"internalType":"address"},{"name":"newOwner","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"SecurityWarning","inputs":[{"name":"message","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"error","name":"InvalidAuthor","inputs":[{"name":"received","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidForwarderAddress","inputs":[]},{"type":"error","name":"InvalidSender","inputs":[{"name":"sender","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidWorkflowId","inputs":[{"name":"received","type":"bytes32","internalType":"bytes32"},{"name":"expected","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"InvalidWorkflowName","inputs":[{"name":"received","type":"bytes10","internalType":"bytes10"},{"name":"expected","type":"bytes10","internalType":"bytes10"}]},{"type":"error","name":"OwnableInvalidOwner","inputs":[{"name":"owner","type":"address","internalType":"address"}]},{"type":"error","name":"OwnableUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"}]},{"type":"error","name":"WorkflowNameRequiresAuthorValidation","inputs":[]}] \ No newline at end of file diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket.ts new file mode 100644 index 00000000..ffae053a --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket.ts @@ -0,0 +1,1447 @@ +// Code generated — DO NOT EDIT. +import { + decodeEventLog, + decodeFunctionResult, + encodeEventTopics, + encodeFunctionData, + zeroAddress, +} from 'viem' +import type { Address, Hex } from 'viem' +import { + bytesToHex, + encodeCallMsg, + EVMClient, + hexToBase64, + LAST_FINALIZED_BLOCK_NUMBER, + prepareReportRequest, + type EVMLog, + type Runtime, +} from '@chainlink/cre-sdk' + +export interface DecodedLog extends Omit { data: T } + + + + + +/** + * Filter params for DisputeRaised. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type DisputeRaisedTopics = { + marketId?: bigint + disputor?: `0x${string}` +} + +/** + * Decoded DisputeRaised event data. + */ +export type DisputeRaisedDecoded = { + marketId: bigint + disputor: `0x${string}` + reason: string +} + + +/** + * Filter params for DisputeResolved. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type DisputeResolvedTopics = { + marketId?: bigint +} + +/** + * Decoded DisputeResolved event data. + */ +export type DisputeResolvedDecoded = { + marketId: bigint + outcome: number + newPrice: bigint + overturned: boolean +} + + +/** + * Filter params for ExpectedAuthorUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedAuthorUpdatedTopics = { + previousAuthor?: `0x${string}` + newAuthor?: `0x${string}` +} + +/** + * Decoded ExpectedAuthorUpdated event data. + */ +export type ExpectedAuthorUpdatedDecoded = { + previousAuthor: `0x${string}` + newAuthor: `0x${string}` +} + + +/** + * Filter params for ExpectedWorkflowIdUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedWorkflowIdUpdatedTopics = { + previousId?: `0x${string}` + newId?: `0x${string}` +} + +/** + * Decoded ExpectedWorkflowIdUpdated event data. + */ +export type ExpectedWorkflowIdUpdatedDecoded = { + previousId: `0x${string}` + newId: `0x${string}` +} + + +/** + * Filter params for ExpectedWorkflowNameUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ExpectedWorkflowNameUpdatedTopics = { + previousName?: `0x${string}` + newName?: `0x${string}` +} + +/** + * Decoded ExpectedWorkflowNameUpdated event data. + */ +export type ExpectedWorkflowNameUpdatedDecoded = { + previousName: `0x${string}` + newName: `0x${string}` +} + + +/** + * Filter params for ForwarderAddressUpdated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type ForwarderAddressUpdatedTopics = { + previousForwarder?: `0x${string}` + newForwarder?: `0x${string}` +} + +/** + * Decoded ForwarderAddressUpdated event data. + */ +export type ForwarderAddressUpdatedDecoded = { + previousForwarder: `0x${string}` + newForwarder: `0x${string}` +} + + +/** + * Filter params for MarketCreated. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type MarketCreatedTopics = { + marketId?: bigint +} + +/** + * Decoded MarketCreated event data. + */ +export type MarketCreatedDecoded = { + marketId: bigint + question: string + strikePrice: bigint + expirationTime: bigint + disputeDeadline: bigint +} + + +/** + * Filter params for MarketResolved. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type MarketResolvedTopics = { + marketId?: bigint +} + +/** + * Decoded MarketResolved event data. + */ +export type MarketResolvedDecoded = { + marketId: bigint + outcome: number + resolutionPrice: bigint + resolvedAt: bigint +} + + +/** + * Filter params for OwnershipTransferred. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type OwnershipTransferredTopics = { + previousOwner?: `0x${string}` + newOwner?: `0x${string}` +} + +/** + * Decoded OwnershipTransferred event data. + */ +export type OwnershipTransferredDecoded = { + previousOwner: `0x${string}` + newOwner: `0x${string}` +} + + +/** + * Filter params for SecurityWarning. Only indexed fields can be used for filtering. + * Indexed string/bytes must be passed as keccak256 hash (Hex). + */ +export type SecurityWarningTopics = { +} + +/** + * Decoded SecurityWarning event data. + */ +export type SecurityWarningDecoded = { + message: string +} + + +export const PredictionMarketABI = [{"type":"constructor","inputs":[{"name":"forwarder","type":"address","internalType":"address"},{"name":"_priceFeed","type":"address","internalType":"address"},{"name":"_disputeWindow","type":"uint256","internalType":"uint256"}],"stateMutability":"nonpayable"},{"type":"function","name":"disputeWindow","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"getExpectedAuthor","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowId","inputs":[],"outputs":[{"name":"","type":"bytes32","internalType":"bytes32"}],"stateMutability":"view"},{"type":"function","name":"getExpectedWorkflowName","inputs":[],"outputs":[{"name":"","type":"bytes10","internalType":"bytes10"}],"stateMutability":"view"},{"type":"function","name":"getForwarderAddress","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"getMarket","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"tuple","internalType":"structPredictionMarket.Market","components":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"question","type":"string","internalType":"string"},{"name":"strikePrice","type":"uint256","internalType":"uint256"},{"name":"expirationTime","type":"uint256","internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","internalType":"uint256"},{"name":"status","type":"uint8","internalType":"enumPredictionMarket.MarketStatus"},{"name":"outcome","type":"uint8","internalType":"enumPredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","internalType":"int256"},{"name":"resolvedAt","type":"uint256","internalType":"uint256"}]}],"stateMutability":"view"},{"type":"function","name":"getMarketStatus","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"uint8","internalType":"enumPredictionMarket.MarketStatus"}],"stateMutability":"view"},{"type":"function","name":"getNextMarketId","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"isExpired","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"isResolvable","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"markets","inputs":[{"name":"","type":"uint256","internalType":"uint256"}],"outputs":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"question","type":"string","internalType":"string"},{"name":"strikePrice","type":"uint256","internalType":"uint256"},{"name":"expirationTime","type":"uint256","internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","internalType":"uint256"},{"name":"status","type":"uint8","internalType":"enumPredictionMarket.MarketStatus"},{"name":"outcome","type":"uint8","internalType":"enumPredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","internalType":"int256"},{"name":"resolvedAt","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"nextMarketId","inputs":[],"outputs":[{"name":"","type":"uint256","internalType":"uint256"}],"stateMutability":"view"},{"type":"function","name":"onReport","inputs":[{"name":"metadata","type":"bytes","internalType":"bytes"},{"name":"report","type":"bytes","internalType":"bytes"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"owner","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"priceFeed","inputs":[],"outputs":[{"name":"","type":"address","internalType":"address"}],"stateMutability":"view"},{"type":"function","name":"raiseDispute","inputs":[{"name":"marketId","type":"uint256","internalType":"uint256"},{"name":"reason","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"renounceOwnership","inputs":[],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedAuthor","inputs":[{"name":"_author","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowId","inputs":[{"name":"_id","type":"bytes32","internalType":"bytes32"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setExpectedWorkflowName","inputs":[{"name":"_name","type":"string","internalType":"string"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"setForwarderAddress","inputs":[{"name":"_forwarder","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"function","name":"supportsInterface","inputs":[{"name":"interfaceId","type":"bytes4","internalType":"bytes4"}],"outputs":[{"name":"","type":"bool","internalType":"bool"}],"stateMutability":"view"},{"type":"function","name":"transferOwnership","inputs":[{"name":"newOwner","type":"address","internalType":"address"}],"outputs":[],"stateMutability":"nonpayable"},{"type":"event","name":"DisputeRaised","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"disputor","type":"address","indexed":true,"internalType":"address"},{"name":"reason","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"event","name":"DisputeResolved","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"outcome","type":"uint8","indexed":false,"internalType":"enumPredictionMarket.Outcome"},{"name":"newPrice","type":"int256","indexed":false,"internalType":"int256"},{"name":"overturned","type":"bool","indexed":false,"internalType":"bool"}],"anonymous":false},{"type":"event","name":"ExpectedAuthorUpdated","inputs":[{"name":"previousAuthor","type":"address","indexed":true,"internalType":"address"},{"name":"newAuthor","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowIdUpdated","inputs":[{"name":"previousId","type":"bytes32","indexed":true,"internalType":"bytes32"},{"name":"newId","type":"bytes32","indexed":true,"internalType":"bytes32"}],"anonymous":false},{"type":"event","name":"ExpectedWorkflowNameUpdated","inputs":[{"name":"previousName","type":"bytes10","indexed":true,"internalType":"bytes10"},{"name":"newName","type":"bytes10","indexed":true,"internalType":"bytes10"}],"anonymous":false},{"type":"event","name":"ForwarderAddressUpdated","inputs":[{"name":"previousForwarder","type":"address","indexed":true,"internalType":"address"},{"name":"newForwarder","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"MarketCreated","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"question","type":"string","indexed":false,"internalType":"string"},{"name":"strikePrice","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"expirationTime","type":"uint256","indexed":false,"internalType":"uint256"},{"name":"disputeDeadline","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"MarketResolved","inputs":[{"name":"marketId","type":"uint256","indexed":true,"internalType":"uint256"},{"name":"outcome","type":"uint8","indexed":false,"internalType":"enumPredictionMarket.Outcome"},{"name":"resolutionPrice","type":"int256","indexed":false,"internalType":"int256"},{"name":"resolvedAt","type":"uint256","indexed":false,"internalType":"uint256"}],"anonymous":false},{"type":"event","name":"OwnershipTransferred","inputs":[{"name":"previousOwner","type":"address","indexed":true,"internalType":"address"},{"name":"newOwner","type":"address","indexed":true,"internalType":"address"}],"anonymous":false},{"type":"event","name":"SecurityWarning","inputs":[{"name":"message","type":"string","indexed":false,"internalType":"string"}],"anonymous":false},{"type":"error","name":"InvalidAuthor","inputs":[{"name":"received","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidForwarderAddress","inputs":[]},{"type":"error","name":"InvalidSender","inputs":[{"name":"sender","type":"address","internalType":"address"},{"name":"expected","type":"address","internalType":"address"}]},{"type":"error","name":"InvalidWorkflowId","inputs":[{"name":"received","type":"bytes32","internalType":"bytes32"},{"name":"expected","type":"bytes32","internalType":"bytes32"}]},{"type":"error","name":"InvalidWorkflowName","inputs":[{"name":"received","type":"bytes10","internalType":"bytes10"},{"name":"expected","type":"bytes10","internalType":"bytes10"}]},{"type":"error","name":"OwnableInvalidOwner","inputs":[{"name":"owner","type":"address","internalType":"address"}]},{"type":"error","name":"OwnableUnauthorizedAccount","inputs":[{"name":"account","type":"address","internalType":"address"}]},{"type":"error","name":"WorkflowNameRequiresAuthorValidation","inputs":[]}] as const + +export class PredictionMarket { + constructor( + private readonly client: EVMClient, + public readonly address: Address, + ) {} + + disputeWindow( + runtime: Runtime, + ): bigint { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'disputeWindow' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'disputeWindow' as const, + data: bytesToHex(result.data), + }) as bigint + } + + getExpectedAuthor( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getExpectedAuthor' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getExpectedAuthor' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getExpectedWorkflowId( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getExpectedWorkflowId' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getExpectedWorkflowId' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getExpectedWorkflowName( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getExpectedWorkflowName' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getExpectedWorkflowName' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getForwarderAddress( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getForwarderAddress' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getForwarderAddress' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + getMarket( + runtime: Runtime, + marketId: bigint, + ): { marketId: bigint; question: string; strikePrice: bigint; expirationTime: bigint; disputeDeadline: bigint; status: number; outcome: number; resolutionPrice: bigint; resolvedAt: bigint } { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getMarket' as const, + args: [marketId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getMarket' as const, + data: bytesToHex(result.data), + }) as { marketId: bigint; question: string; strikePrice: bigint; expirationTime: bigint; disputeDeadline: bigint; status: number; outcome: number; resolutionPrice: bigint; resolvedAt: bigint } + } + + getMarketStatus( + runtime: Runtime, + marketId: bigint, + ): number { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getMarketStatus' as const, + args: [marketId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getMarketStatus' as const, + data: bytesToHex(result.data), + }) as number + } + + getNextMarketId( + runtime: Runtime, + ): bigint { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'getNextMarketId' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'getNextMarketId' as const, + data: bytesToHex(result.data), + }) as bigint + } + + isExpired( + runtime: Runtime, + marketId: bigint, + ): boolean { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'isExpired' as const, + args: [marketId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'isExpired' as const, + data: bytesToHex(result.data), + }) as boolean + } + + isResolvable( + runtime: Runtime, + marketId: bigint, + ): boolean { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'isResolvable' as const, + args: [marketId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'isResolvable' as const, + data: bytesToHex(result.data), + }) as boolean + } + + markets( + runtime: Runtime, + arg0: bigint, + ): readonly [bigint, string, bigint, bigint, bigint, number, number, bigint, bigint] { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'markets' as const, + args: [arg0], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'markets' as const, + data: bytesToHex(result.data), + }) as readonly [bigint, string, bigint, bigint, bigint, number, number, bigint, bigint] + } + + nextMarketId( + runtime: Runtime, + ): bigint { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'nextMarketId' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'nextMarketId' as const, + data: bytesToHex(result.data), + }) as bigint + } + + owner( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'owner' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'owner' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + priceFeed( + runtime: Runtime, + ): `0x${string}` { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'priceFeed' as const, + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'priceFeed' as const, + data: bytesToHex(result.data), + }) as `0x${string}` + } + + supportsInterface( + runtime: Runtime, + interfaceId: `0x${string}`, + ): boolean { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'supportsInterface' as const, + args: [interfaceId], + }) + + const result = this.client + .callContract(runtime, { + call: encodeCallMsg({ from: zeroAddress, to: this.address, data: callData }), + blockNumber: LAST_FINALIZED_BLOCK_NUMBER, + }) + .result() + + return decodeFunctionResult({ + abi: PredictionMarketABI, + functionName: 'supportsInterface' as const, + data: bytesToHex(result.data), + }) as boolean + } + + writeReportFromOnReport( + runtime: Runtime, + metadata: `0x${string}`, + report: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'onReport' as const, + args: [metadata, report], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromRaiseDispute( + runtime: Runtime, + marketId: bigint, + reason: string, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'raiseDispute' as const, + args: [marketId, reason], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedAuthor( + runtime: Runtime, + author: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'setExpectedAuthor' as const, + args: [author], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedWorkflowId( + runtime: Runtime, + id: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'setExpectedWorkflowId' as const, + args: [id], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetExpectedWorkflowName( + runtime: Runtime, + name: string, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'setExpectedWorkflowName' as const, + args: [name], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromSetForwarderAddress( + runtime: Runtime, + forwarder: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'setForwarderAddress' as const, + args: [forwarder], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReportFromTransferOwnership( + runtime: Runtime, + newOwner: `0x${string}`, + gasConfig?: { gasLimit?: string }, + ) { + const callData = encodeFunctionData({ + abi: PredictionMarketABI, + functionName: 'transferOwnership' as const, + args: [newOwner], + }) + + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + writeReport( + runtime: Runtime, + callData: Hex, + gasConfig?: { gasLimit?: string }, + ) { + const reportResponse = runtime + .report(prepareReportRequest(callData)) + .result() + + return this.client + .writeReport(runtime, { + receiver: this.address, + report: reportResponse, + gasConfig, + }) + .result() + } + + /** + * Creates a log trigger for DisputeRaised events. + * The returned trigger's adapt method decodes the raw log into DisputeRaisedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerDisputeRaised( + filters?: DisputeRaisedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeRaised' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + marketId: f.marketId, + disputor: f.disputor, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeRaised' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + marketId: f.marketId, + disputor: f.disputor, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeRaised' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeDisputeRaised(rawOutput), + } + } + + /** + * Decodes a log into DisputeRaised data, preserving all log metadata. + */ + decodeDisputeRaised(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as DisputeRaisedDecoded } + } + + /** + * Creates a log trigger for DisputeResolved events. + * The returned trigger's adapt method decodes the raw log into DisputeResolvedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerDisputeResolved( + filters?: DisputeResolvedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeResolved' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + marketId: f.marketId, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeResolved' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + marketId: f.marketId, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'DisputeResolved' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeDisputeResolved(rawOutput), + } + } + + /** + * Decodes a log into DisputeResolved data, preserving all log metadata. + */ + decodeDisputeResolved(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as DisputeResolvedDecoded } + } + + /** + * Creates a log trigger for ExpectedAuthorUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedAuthorUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedAuthorUpdated( + filters?: ExpectedAuthorUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedAuthorUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousAuthor: f.previousAuthor, + newAuthor: f.newAuthor, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedAuthorUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousAuthor: f.previousAuthor, + newAuthor: f.newAuthor, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedAuthorUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedAuthorUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedAuthorUpdated data, preserving all log metadata. + */ + decodeExpectedAuthorUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedAuthorUpdatedDecoded } + } + + /** + * Creates a log trigger for ExpectedWorkflowIdUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedWorkflowIdUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedWorkflowIdUpdated( + filters?: ExpectedWorkflowIdUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousId: f.previousId, + newId: f.newId, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousId: f.previousId, + newId: f.newId, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowIdUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedWorkflowIdUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedWorkflowIdUpdated data, preserving all log metadata. + */ + decodeExpectedWorkflowIdUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedWorkflowIdUpdatedDecoded } + } + + /** + * Creates a log trigger for ExpectedWorkflowNameUpdated events. + * The returned trigger's adapt method decodes the raw log into ExpectedWorkflowNameUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerExpectedWorkflowNameUpdated( + filters?: ExpectedWorkflowNameUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousName: f.previousName, + newName: f.newName, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousName: f.previousName, + newName: f.newName, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ExpectedWorkflowNameUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeExpectedWorkflowNameUpdated(rawOutput), + } + } + + /** + * Decodes a log into ExpectedWorkflowNameUpdated data, preserving all log metadata. + */ + decodeExpectedWorkflowNameUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ExpectedWorkflowNameUpdatedDecoded } + } + + /** + * Creates a log trigger for ForwarderAddressUpdated events. + * The returned trigger's adapt method decodes the raw log into ForwarderAddressUpdatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerForwarderAddressUpdated( + filters?: ForwarderAddressUpdatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ForwarderAddressUpdated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousForwarder: f.previousForwarder, + newForwarder: f.newForwarder, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ForwarderAddressUpdated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousForwarder: f.previousForwarder, + newForwarder: f.newForwarder, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'ForwarderAddressUpdated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeForwarderAddressUpdated(rawOutput), + } + } + + /** + * Decodes a log into ForwarderAddressUpdated data, preserving all log metadata. + */ + decodeForwarderAddressUpdated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as ForwarderAddressUpdatedDecoded } + } + + /** + * Creates a log trigger for MarketCreated events. + * The returned trigger's adapt method decodes the raw log into MarketCreatedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerMarketCreated( + filters?: MarketCreatedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketCreated' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + marketId: f.marketId, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketCreated' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + marketId: f.marketId, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketCreated' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeMarketCreated(rawOutput), + } + } + + /** + * Decodes a log into MarketCreated data, preserving all log metadata. + */ + decodeMarketCreated(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as MarketCreatedDecoded } + } + + /** + * Creates a log trigger for MarketResolved events. + * The returned trigger's adapt method decodes the raw log into MarketResolvedDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerMarketResolved( + filters?: MarketResolvedTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketResolved' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + marketId: f.marketId, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketResolved' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + marketId: f.marketId, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'MarketResolved' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeMarketResolved(rawOutput), + } + } + + /** + * Decodes a log into MarketResolved data, preserving all log metadata. + */ + decodeMarketResolved(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as MarketResolvedDecoded } + } + + /** + * Creates a log trigger for OwnershipTransferred events. + * The returned trigger's adapt method decodes the raw log into OwnershipTransferredDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerOwnershipTransferred( + filters?: OwnershipTransferredTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'OwnershipTransferred' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + previousOwner: f.previousOwner, + newOwner: f.newOwner, + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'OwnershipTransferred' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + previousOwner: f.previousOwner, + newOwner: f.newOwner, + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'OwnershipTransferred' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeOwnershipTransferred(rawOutput), + } + } + + /** + * Decodes a log into OwnershipTransferred data, preserving all log metadata. + */ + decodeOwnershipTransferred(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as OwnershipTransferredDecoded } + } + + /** + * Creates a log trigger for SecurityWarning events. + * The returned trigger's adapt method decodes the raw log into SecurityWarningDecoded, + * so the handler receives typed event data directly. + * When multiple filters are provided, topic values are merged with OR semantics (match any). + */ + logTriggerSecurityWarning( + filters?: SecurityWarningTopics[], + ) { + let topics: { values: string[] }[] + if (!filters || filters.length === 0) { + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'SecurityWarning' as const, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else if (filters.length === 1) { + const f = filters[0] + const args = { + } + const encoded = encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'SecurityWarning' as const, + args, + }) + topics = encoded.map((t) => ({ values: [hexToBase64(t)] })) + } else { + const allEncoded = filters.map((f) => { + const args = { + } + return encodeEventTopics({ + abi: PredictionMarketABI, + eventName: 'SecurityWarning' as const, + args, + }) + }) + topics = allEncoded[0].map((_, i) => ({ + values: [...new Set(allEncoded.map((row) => hexToBase64(row[i])))], + })) + } + const baseTrigger = this.client.logTrigger({ + addresses: [hexToBase64(this.address)], + topics, + }) + const contract = this + return { + capabilityId: () => baseTrigger.capabilityId(), + method: () => baseTrigger.method(), + outputSchema: () => baseTrigger.outputSchema(), + configAsAny: () => baseTrigger.configAsAny(), + adapt: (rawOutput: EVMLog): DecodedLog => contract.decodeSecurityWarning(rawOutput), + } + } + + /** + * Decodes a log into SecurityWarning data, preserving all log metadata. + */ + decodeSecurityWarning(log: EVMLog): DecodedLog { + const decoded = decodeEventLog({ + abi: PredictionMarketABI, + data: bytesToHex(log.data), + topics: log.topics.map((t) => bytesToHex(t)) as readonly Hex[], + }) + const { data: _, ...rest } = log + return { ...rest, data: decoded.args as unknown as SecurityWarningDecoded } + } +} + diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket_mock.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket_mock.ts new file mode 100644 index 00000000..dc01753e --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/PredictionMarket_mock.ts @@ -0,0 +1,28 @@ +// Code generated — DO NOT EDIT. +import type { Address } from 'viem' +import { addContractMock, type ContractMock, type EvmMock } from '@chainlink/cre-sdk/test' + +import { PredictionMarketABI } from './PredictionMarket' + +export type PredictionMarketMock = { + disputeWindow?: () => bigint + getExpectedAuthor?: () => `0x${string}` + getExpectedWorkflowId?: () => `0x${string}` + getExpectedWorkflowName?: () => `0x${string}` + getForwarderAddress?: () => `0x${string}` + getMarket?: (marketId: bigint) => { marketId: bigint; question: string; strikePrice: bigint; expirationTime: bigint; disputeDeadline: bigint; status: number; outcome: number; resolutionPrice: bigint; resolvedAt: bigint } + getMarketStatus?: (marketId: bigint) => number + getNextMarketId?: () => bigint + isExpired?: (marketId: bigint) => boolean + isResolvable?: (marketId: bigint) => boolean + markets?: (arg0: bigint) => readonly [bigint, string, bigint, bigint, bigint, number, number, bigint, bigint] + nextMarketId?: () => bigint + owner?: () => `0x${string}` + priceFeed?: () => `0x${string}` + supportsInterface?: (interfaceId: `0x${string}`) => boolean +} & Pick, 'writeReport'> + +export function newPredictionMarketMock(address: Address, evmMock: EvmMock): PredictionMarketMock { + return addContractMock(evmMock, { address, abi: PredictionMarketABI }) as PredictionMarketMock +} + diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/index.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/index.ts new file mode 100644 index 00000000..adf4caac --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/evm/ts/generated/index.ts @@ -0,0 +1,3 @@ +// Code generated — DO NOT EDIT. +export * from './PredictionMarket' +export * from './PredictionMarket_mock' diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/package.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/package.json new file mode 100644 index 00000000..c389cb42 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/contracts/package.json @@ -0,0 +1,10 @@ +{ + "name": "contracts", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@chainlink/cre-sdk": "^1.1.2", + "viem": "2.34.0" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/client.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/client.ts new file mode 100644 index 00000000..6bc461a6 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/client.ts @@ -0,0 +1,278 @@ +import { ConfidentialHTTPClient, ok, type Runtime } from "@chainlink/cre-sdk" +import { decodeAbiParameters } from "viem" + +// ─── Types ────────────────────────────────────────────────── + +/** Top-level response from Data Streams REST API */ +export interface DataStreamsApiResponse { + report: { + feedID: string + validFromTimestamp: number + observationsTimestamp: number + fullReport: string + } +} + +/** Decoded fields from a v3 (Crypto Advanced) report body */ +export interface DecodedReportV3 { + feedId: `0x${string}` + validFromTimestamp: number + observationsTimestamp: number + nativeFee: bigint + linkFee: bigint + expiresAt: number + price: bigint // int192, 18 decimals for crypto feeds + bid: bigint // int192, 18 decimals + ask: bigint // int192, 18 decimals +} + +// ─── Pure-JS SHA-256 Implementation ───────────────────────── +// Needed because CRE WASM runtime does not have Node.js crypto. + +const K: number[] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, + 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, + 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, + 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, + 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, + 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, + 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2, +] + +function rotr(n: number, x: number): number { return (x >>> n) | (x << (32 - n)) } + +function sha256(message: Uint8Array): Uint8Array { + let h0 = 0x6a09e667, h1 = 0xbb67ae85, h2 = 0x3c6ef372, h3 = 0xa54ff53a + let h4 = 0x510e527f, h5 = 0x9b05688c, h6 = 0x1f83d9ab, h7 = 0x5be0cd19 + + const msgLen = message.length + const bitLen = msgLen * 8 + + // Pre-processing: pad to 512-bit blocks + const padLen = ((msgLen + 8) >> 6 << 6) + 64 + const padded = new Uint8Array(padLen) + padded.set(message) + padded[msgLen] = 0x80 + + // Append length as big-endian 64-bit + const view = new DataView(padded.buffer) + view.setUint32(padLen - 4, bitLen, false) + + const W = new Int32Array(64) + + for (let offset = 0; offset < padLen; offset += 64) { + for (let i = 0; i < 16; i++) { + W[i] = view.getInt32(offset + i * 4, false) + } + for (let i = 16; i < 64; i++) { + const s0 = rotr(7, W[i - 15] >>> 0) ^ rotr(18, W[i - 15] >>> 0) ^ (W[i - 15] >>> 3) + const s1 = rotr(17, W[i - 2] >>> 0) ^ rotr(19, W[i - 2] >>> 0) ^ (W[i - 2] >>> 10) + W[i] = (W[i - 16] + s0 + W[i - 7] + s1) | 0 + } + + let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, h = h7 + + for (let i = 0; i < 64; i++) { + const S1 = rotr(6, e >>> 0) ^ rotr(11, e >>> 0) ^ rotr(25, e >>> 0) + const ch = (e & f) ^ (~e & g) + const temp1 = (h + S1 + ch + K[i] + W[i]) | 0 + const S0 = rotr(2, a >>> 0) ^ rotr(13, a >>> 0) ^ rotr(22, a >>> 0) + const maj = (a & b) ^ (a & c) ^ (b & c) + const temp2 = (S0 + maj) | 0 + + h = g; g = f; f = e; e = (d + temp1) | 0 + d = c; c = b; b = a; a = (temp1 + temp2) | 0 + } + + h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0 + h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + h) | 0 + } + + const result = new Uint8Array(32) + const rv = new DataView(result.buffer) + rv.setUint32(0, h0, false); rv.setUint32(4, h1, false) + rv.setUint32(8, h2, false); rv.setUint32(12, h3, false) + rv.setUint32(16, h4, false); rv.setUint32(20, h5, false) + rv.setUint32(24, h6, false); rv.setUint32(28, h7, false) + return result +} + +function hmacSha256(key: Uint8Array, message: Uint8Array): Uint8Array { + const BLOCK_SIZE = 64 + + // If key is longer than block size, hash it + let keyBlock = key.length > BLOCK_SIZE ? sha256(key) : key + + // Pad key to block size + const paddedKey = new Uint8Array(BLOCK_SIZE) + paddedKey.set(keyBlock) + + const oKeyPad = new Uint8Array(BLOCK_SIZE) + const iKeyPad = new Uint8Array(BLOCK_SIZE) + for (let i = 0; i < BLOCK_SIZE; i++) { + oKeyPad[i] = paddedKey[i] ^ 0x5c + iKeyPad[i] = paddedKey[i] ^ 0x36 + } + + // Inner hash: SHA-256(iKeyPad || message) + const inner = new Uint8Array(BLOCK_SIZE + message.length) + inner.set(iKeyPad) + inner.set(message, BLOCK_SIZE) + const innerHash = sha256(inner) + + // Outer hash: SHA-256(oKeyPad || innerHash) + const outer = new Uint8Array(BLOCK_SIZE + 32) + outer.set(oKeyPad) + outer.set(innerHash, BLOCK_SIZE) + return sha256(outer) +} + +function toHex(bytes: Uint8Array): string { + let hex = "" + for (let i = 0; i < bytes.length; i++) { + hex += bytes[i].toString(16).padStart(2, "0") + } + return hex +} + +function textToBytes(text: string): Uint8Array { + const bytes = new Uint8Array(text.length) + for (let i = 0; i < text.length; i++) { + bytes[i] = text.charCodeAt(i) + } + return bytes +} + +// ─── HMAC Authentication ──────────────────────────────────── + +function generateHMAC( + method: string, + path: string, + body: string, + apiKey: string, + apiSecret: string, + timestamp: number, +): string { + const bodyHash = toHex(sha256(textToBytes(body || ""))) + const stringToSign = `${method} ${path} ${bodyHash} ${apiKey} ${timestamp}` + return toHex(hmacSha256(textToBytes(apiSecret), textToBytes(stringToSign))) +} + +// ─── Report Decoding ──────────────────────────────────────── + +/** + * Decode a v3 (Crypto Advanced) fullReport blob into its constituent fields. + * + * The fullReport is ABI-encoded as: + * (bytes32[3] reportContext, bytes reportData, bytes32[] rawRs, bytes32[] rawSs, bytes32 rawVs) + * + * The reportData inside is ABI-encoded as: + * (bytes32 feedId, uint32 validFromTimestamp, uint32 observationsTimestamp, + * uint192 nativeFee, uint192 linkFee, uint32 expiresAt, + * int192 price, int192 bid, int192 ask) + */ +export function decodeFullReportV3(fullReportHex: `0x${string}`): DecodedReportV3 { + // Step 1: Decode the outer wrapper to extract reportData + const [, reportData] = decodeAbiParameters( + [ + { type: "bytes32[3]", name: "reportContext" }, + { type: "bytes", name: "reportData" }, + { type: "bytes32[]", name: "rawRs" }, + { type: "bytes32[]", name: "rawSs" }, + { type: "bytes32", name: "rawVs" }, + ], + fullReportHex, + ) + + // Step 2: Decode the report body (v3 Crypto Advanced schema) + const [feedId, validFromTimestamp, observationsTimestamp, nativeFee, linkFee, expiresAt, price, bid, ask] = + decodeAbiParameters( + [ + { type: "bytes32", name: "feedId" }, + { type: "uint32", name: "validFromTimestamp" }, + { type: "uint32", name: "observationsTimestamp" }, + { type: "uint192", name: "nativeFee" }, + { type: "uint192", name: "linkFee" }, + { type: "uint32", name: "expiresAt" }, + { type: "int192", name: "price" }, + { type: "int192", name: "bid" }, + { type: "int192", name: "ask" }, + ], + reportData as `0x${string}`, + ) + + return { + feedId: feedId as `0x${string}`, + validFromTimestamp, + observationsTimestamp, + nativeFee, + linkFee, + expiresAt, + price, + bid, + ask, + } +} + +// ─── Fetch Latest Report ──────────────────────────────────── + +/** + * Fetch the latest Data Streams report for a given feed ID. + * + * Uses the CRE ConfidentialHTTPClient which executes in a secure enclave + * and supports the Authorization header. The HMAC signature and timestamp + * are pre-computed and passed as templatePublicValues so all nodes produce + * the same request. The API key is injected via vaultDonSecrets. + */ +export function fetchLatestReport( + runtime: Runtime, + apiUrl: string, + feedId: string, +): DataStreamsApiResponse { + const method = "GET" + const path = `/api/v1/reports/latest?feedID=${feedId}` + const timestamp = Math.floor(runtime.now().getTime()) + + // Read secrets to compute HMAC (needed for the signature only) + const apiKey = runtime.getSecret({ id: "DATA_STREAMS_API_KEY" }).result().value + const apiSecret = runtime.getSecret({ id: "DATA_STREAMS_API_SECRET" }).result().value + const signature = generateHMAC(method, path, "", apiKey, apiSecret, timestamp) + + const confHTTPClient = new ConfidentialHTTPClient() + const response = confHTTPClient.sendRequest(runtime, { + request: { + url: `${apiUrl}${path}`, + method, + multiHeaders: { + "Authorization": { values: ["{{.DATA_STREAMS_API_KEY}}"] }, + "X-Authorization-Timestamp": { values: [String(timestamp)] }, + "X-Authorization-Signature-SHA256": { values: [signature] }, + }, + }, + vaultDonSecrets: [{ key: "DATA_STREAMS_API_KEY" }], + }).result() + + if (!ok(response)) { + throw new Error(`Data Streams API error (${response.statusCode}): ${Buffer.from(response.body).toString("utf-8")}`) + } + + const body = Buffer.from(response.body).toString("utf-8") + return JSON.parse(body) as DataStreamsApiResponse +} + +// ─── Price Extraction Helper ──────────────────────────────── + +/** + * Data Streams crypto feeds use 18 decimal places. + * On-chain Chainlink Data Feeds use 8 decimal places for USD pairs. + * This helper converts from 18 to 8 decimals to match the PredictionMarket + * contract's strike price format. + */ +export const DATA_STREAMS_DECIMALS = 18 +export const PRICE_FEED_DECIMALS = 8 +const SCALE_FACTOR = BigInt(10 ** (DATA_STREAMS_DECIMALS - PRICE_FEED_DECIMALS)) + +export function toPriceFeedDecimals(priceWith18Decimals: bigint): bigint { + return priceWith18Decimals / SCALE_FACTOR +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/package.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/package.json new file mode 100644 index 00000000..a224c68d --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/data-streams/package.json @@ -0,0 +1,10 @@ +{ + "name": "data-streams", + "version": "1.0.0", + "private": true, + "type": "module", + "dependencies": { + "@chainlink/cre-sdk": "^1.1.2", + "viem": "2.34.0" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.production.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.production.json new file mode 100644 index 00000000..8fe45994 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.production.json @@ -0,0 +1,15 @@ +{ + "schedule": "0 0 * * * *", + "evms": [ + { + "chainSelectorName": "ethereum-mainnet", + "predictionMarketAddress": "0xYOUR_DEPLOYED_CONTRACT_ADDRESS", + "gasLimit": "500000" + } + ], + "marketDefaults": { + "question": "Will BTC be above $100,000 by {expirationDate}?", + "strikePriceUsd": 100000, + "durationSeconds": 86400 + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.staging.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.staging.json new file mode 100644 index 00000000..6f7aa882 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/config.staging.json @@ -0,0 +1,15 @@ +{ + "schedule": "0 0 * * * *", + "evms": [ + { + "chainSelectorName": "ethereum-testnet-sepolia", + "predictionMarketAddress": "0xEb792aF46AB2c2f1389A774AB806423DB43aA425", + "gasLimit": "500000" + } + ], + "marketDefaults": { + "question": "Will BTC be above $100,000 by {expirationDate}?", + "strikePriceUsd": 100000, + "durationSeconds": 86400 + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/main.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/main.ts new file mode 100644 index 00000000..26876a57 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/main.ts @@ -0,0 +1,9 @@ +import { Runner } from '@chainlink/cre-sdk' +import { configSchema, initWorkflow } from './workflow' + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} + +main() diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/package.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/package.json new file mode 100644 index 00000000..f3b8ebdb --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/package.json @@ -0,0 +1,18 @@ +{ + "name": "market-creation-workflow", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bun x cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.1.2", + "viem": "2.34.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/tsconfig.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/tsconfig.json new file mode 100644 index 00000000..a3b641ab --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["main.ts"] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.test.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.test.ts new file mode 100644 index 00000000..82f43e20 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.test.ts @@ -0,0 +1,79 @@ +import { describe, expect } from 'bun:test' +import { cre, getNetwork, TxStatus } from '@chainlink/cre-sdk' +import { EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import type { Address } from 'viem' +import { PredictionMarket } from '../contracts/evm/ts/generated/PredictionMarket' +import { newPredictionMarketMock } from '../contracts/evm/ts/generated/PredictionMarket_mock' +import { initWorkflow, onCronTrigger } from './workflow' + +const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia +const PREDICTION_MARKET = '0xEb792aF46AB2c2f1389A774AB806423DB43aA425' as Address + +const makeConfig = () => ({ + schedule: '0 0 * * * *', + evms: [ + { + chainSelectorName: 'ethereum-testnet-sepolia', + predictionMarketAddress: PREDICTION_MARKET, + gasLimit: '500000', + }, + ], + marketDefaults: { + question: 'Will BTC be above $100,000 by {expirationDate}?', + strikePriceUsd: 100000, + durationSeconds: 86400, + }, +}) + +describe('market-creation', () => { + test('creates a market successfully', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + pmMock.getNextMarketId = () => 0n + pmMock.writeReport = () => ({ + txStatus: TxStatus.SUCCESS, + txHash: new Uint8Array(32), + }) + + const runtime = newTestRuntime() + ;(runtime as any).config = makeConfig() + + const result = onCronTrigger(runtime as any, { scheduledExecutionTime: new Date().toISOString() } as any) + const parsed = JSON.parse(result) + + expect(parsed.marketId).toBe('0') + expect(parsed.question).toContain('Will BTC be above $100,000') + expect(parsed.txHash).toBeDefined() + }) + + test('reads next market ID from contract via mock', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + pmMock.getNextMarketId = () => 5n + + const runtime = newTestRuntime() + const network = getNetwork({ + chainFamily: 'evm', + chainSelectorName: 'ethereum-testnet-sepolia', + }) + expect(network).toBeDefined() + if (!network) return + + const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) + const pm = new PredictionMarket(evmClient, PREDICTION_MARKET) + const nextId = pm.getNextMarketId(runtime) + expect(nextId).toBe(5n) + }) +}) + +describe('initWorkflow', () => { + test('returns a single cron handler', () => { + const config = makeConfig() + const handlers = initWorkflow(config) + + expect(handlers).toHaveLength(1) + expect(handlers[0].fn).toBe(onCronTrigger) + const cronTrigger = handlers[0].trigger as { config?: { schedule?: string } } + expect(cronTrigger.config?.schedule).toBe(config.schedule) + }) +}) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.ts new file mode 100644 index 00000000..24042c51 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.ts @@ -0,0 +1,116 @@ +import { + cre, + getNetwork, + TxStatus, + bytesToHex, + type Runtime, + type CronPayload, +} from "@chainlink/cre-sdk" +import { type Address, encodeAbiParameters, parseAbiParameters, parseUnits } from "viem" +import { z } from "zod" +import { PredictionMarket } from "../contracts/evm/ts/generated/PredictionMarket" + +// ─── Config Schema ────────────────────────────────────────── +export const configSchema = z.object({ + schedule: z.string(), + evms: z.array( + z.object({ + chainSelectorName: z.string(), + predictionMarketAddress: z.string(), + gasLimit: z.string().optional(), + }) + ), + marketDefaults: z.object({ + question: z.string(), + strikePriceUsd: z.number(), + durationSeconds: z.number(), + }), +}) +type Config = z.infer + +const ACTION_CREATE = 1 +const PRICE_FEED_DECIMALS = 8 as const // Strike prices use 8 decimals (matching Chainlink USD feeds) + +// ─── Helpers ──────────────────────────────────────────────── + +const safeJsonStringify = (obj: unknown): string => + JSON.stringify(obj, (_, v) => (typeof v === "bigint" ? v.toString() : v), 2) + +// ─── Callback ─────────────────────────────────────────────── +export const onCronTrigger = (runtime: Runtime, _payload: CronPayload): string => { + const evmConfig = runtime.config.evms[0] + const defaults = runtime.config.marketDefaults + + // 1. Get network and create EVM client + const network = getNetwork({ + chainFamily: "evm", + chainSelectorName: evmConfig.chainSelectorName, + }) + if (!network) throw new Error(`Network not found: ${evmConfig.chainSelectorName}`) + + const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) + const predictionMarket = new PredictionMarket( + evmClient, + evmConfig.predictionMarketAddress as Address, + ) + + // 2. Read current state — check next market ID (to log it) + const nextMarketId = predictionMarket.getNextMarketId(runtime) + runtime.log(`Next market ID will be: ${nextMarketId}`) + + // 3. Build market parameters + const now = runtime.now() + const expirationTime = BigInt(Math.floor(now.getTime() / 1000)) + BigInt(defaults.durationSeconds) + const strikePriceScaled = parseUnits(defaults.strikePriceUsd.toString(), PRICE_FEED_DECIMALS) + + // Format question with expiration date + const expirationDate = new Date(Number(expirationTime) * 1000).toISOString().split("T")[0] + const question = defaults.question.replace("{expirationDate}", expirationDate) + + runtime.log(`Creating market: "${question}" | Strike: $${defaults.strikePriceUsd} | Expires: ${expirationDate}`) + + // 4. Encode the CREATE action + const innerData = encodeAbiParameters( + parseAbiParameters("string question, uint256 strikePrice, uint256 expirationTime"), + [question, strikePriceScaled, expirationTime], + ) + + const reportData = encodeAbiParameters( + parseAbiParameters("uint8 action, bytes data"), + [ACTION_CREATE, innerData], + ) + + // 5. Write: Create market via signed report + const resp = predictionMarket.writeReport(runtime, reportData, { + gasLimit: evmConfig.gasLimit || "500000", + }) + + if (resp.txStatus !== TxStatus.SUCCESS) { + throw new Error(`Create market TX failed: ${resp.errorMessage || resp.txStatus}`) + } + + if (!resp.txHash) { + runtime.log("Warning: transaction succeeded but no tx hash returned") + } + const txHash = bytesToHex(resp.txHash || new Uint8Array(32)) + runtime.log(`Market created! ID: ${nextMarketId}, TX: ${txHash}`) + + return safeJsonStringify({ + marketId: nextMarketId.toString(), + question, + strikePrice: strikePriceScaled.toString(), + expirationTime: expirationTime.toString(), + txHash, + }) +} + +// ─── Workflow Init ────────────────────────────────────────── +export function initWorkflow(config: Config) { + const cronTrigger = new cre.capabilities.CronCapability() + return [ + cre.handler( + cronTrigger.trigger({ schedule: config.schedule }), + onCronTrigger, + ), + ] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.yaml new file mode 100644 index 00000000..fbcc5a59 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-creation/workflow.yaml @@ -0,0 +1,15 @@ +staging-settings: + user-workflow: + workflow-name: "prediction-market-ds-creation-staging" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.staging.json" + secrets-path: "" + +production-settings: + user-workflow: + workflow-name: "prediction-market-ds-creation-production" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.production.json" + secrets-path: "" diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json new file mode 100644 index 00000000..ab184684 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json @@ -0,0 +1,14 @@ +{ + "evms": [ + { + "chainSelectorName": "ethereum-mainnet", + "predictionMarketAddress": "0xYOUR_DEPLOYED_CONTRACT_ADDRESS", + "gasLimit": "500000" + } + ], + "dataStreams": { + "apiUrl": "https://api.dataengine.chain.link", + "feedId": "0xYOUR_FEED_ID", + "owner": "" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json new file mode 100644 index 00000000..47ecdaf0 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json @@ -0,0 +1,14 @@ +{ + "evms": [ + { + "chainSelectorName": "ethereum-testnet-sepolia", + "predictionMarketAddress": "0xEb792aF46AB2c2f1389A774AB806423DB43aA425", + "gasLimit": "500000" + } + ], + "dataStreams": { + "apiUrl": "https://api.dataengine.chain.link", + "feedId": "0x00021f1c95b33f5e56fa5c07968c566586d0e6cea93c9fb79127915892de430d", + "owner": "" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/main.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/main.ts new file mode 100644 index 00000000..26876a57 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/main.ts @@ -0,0 +1,9 @@ +import { Runner } from '@chainlink/cre-sdk' +import { configSchema, initWorkflow } from './workflow' + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} + +main() diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/package.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/package.json new file mode 100644 index 00000000..9b581406 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/package.json @@ -0,0 +1,18 @@ +{ + "name": "market-dispute-workflow", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bun x cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.1.2", + "viem": "2.34.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/tsconfig.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/tsconfig.json new file mode 100644 index 00000000..a3b641ab --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["main.ts"] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts new file mode 100644 index 00000000..da0b007b --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts @@ -0,0 +1,105 @@ +import { describe, expect } from 'bun:test' +import { cre, getNetwork, TxStatus } from '@chainlink/cre-sdk' +import { EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import type { Address } from 'viem' +import { PredictionMarket } from '../contracts/evm/ts/generated/PredictionMarket' +import { newPredictionMarketMock } from '../contracts/evm/ts/generated/PredictionMarket_mock' +import { initWorkflow, onDisputeRaised } from './workflow' + +const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia +const PREDICTION_MARKET = '0xEb792aF46AB2c2f1389A774AB806423DB43aA425' as Address +const DISPUTOR = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984' as Address + +const makeConfig = () => ({ + evms: [ + { + chainSelectorName: 'ethereum-testnet-sepolia', + predictionMarketAddress: PREDICTION_MARKET, + gasLimit: '500000', + }, + ], + dataStreams: { + apiUrl: 'https://api.testnet-dataengine.chain.link', + feedId: '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00', + }, +}) + +describe('market-dispute', () => { + test('resolves dispute when market is in Disputed status', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + + pmMock.getMarketStatus = () => 2 // Disputed + pmMock.writeReport = () => ({ + txStatus: TxStatus.SUCCESS, + txHash: new Uint8Array(32), + }) + + const runtime = newTestRuntime() + ;(runtime as any).config = makeConfig() + + // Mock runtime.getSecret to return test credentials + ;(runtime as any).getSecret = (name: string) => { + if (name === 'DATA_STREAMS_API_KEY') return 'test-api-key' + if (name === 'DATA_STREAMS_API_SECRET') return 'test-api-secret' + return '' + } + + const payload = { + topics: [], + data: { + marketId: 0n, + disputor: DISPUTOR as `0x${string}`, + reason: 'Price was stale', + }, + } + + // Note: This test requires HTTP mock for Data Streams API response. + // The test verifies structure and contract interactions. + const result = onDisputeRaised(runtime as any, payload as any) + const parsed = JSON.parse(result) + + expect(parsed.marketId).toBe('0') + expect(parsed.freshPrice).toBeDefined() + expect(parsed.txHash).toBeDefined() + }) + + test('skips when market is not in Disputed status', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + + pmMock.getMarketStatus = () => 1 // Resolved, not Disputed + + const runtime = newTestRuntime() + ;(runtime as any).config = makeConfig() + + const payload = { + topics: [], + data: { + marketId: 0n, + disputor: DISPUTOR as `0x${string}`, + reason: 'Price was stale', + }, + } + + const result = onDisputeRaised(runtime as any, payload as any) + expect(result).toContain('Skipped') + expect(result).toContain('not in Disputed status') + }) +}) + +describe('initWorkflow', () => { + test('returns a single log trigger handler', () => { + const config = makeConfig() + const handlers = initWorkflow(config) + + expect(handlers).toHaveLength(1) + expect(handlers[0].fn).toBe(onDisputeRaised) + const logTrigger = handlers[0].trigger as { + adapt: (raw: any) => any + configAsAny: () => any + } + expect(typeof logTrigger.adapt).toBe('function') + expect(typeof logTrigger.configAsAny).toBe('function') + }) +}) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts new file mode 100644 index 00000000..a0252fa0 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts @@ -0,0 +1,137 @@ +import { + cre, + getNetwork, + TxStatus, + bytesToHex, + type Runtime, +} from "@chainlink/cre-sdk" +import { + type Address, + encodeAbiParameters, + parseAbiParameters, + formatUnits, +} from "viem" +import { z } from "zod" +import { PredictionMarket, type DecodedLog, type DisputeRaisedDecoded } from "../contracts/evm/ts/generated/PredictionMarket" +import { + fetchLatestReport, + decodeFullReportV3, + toPriceFeedDecimals, + PRICE_FEED_DECIMALS, +} from "../data-streams/client" + +// ─── Config Schema ────────────────────────────────────────── +export const configSchema = z.object({ + evms: z.array( + z.object({ + chainSelectorName: z.string(), + predictionMarketAddress: z.string(), + gasLimit: z.string().optional(), + }) + ), + dataStreams: z.object({ + apiUrl: z.string(), + feedId: z.string(), + owner: z.string(), + }), +}) +type Config = z.infer + +const ACTION_RESOLVE_DISPUTE = 3 + +// ─── Helpers ──────────────────────────────────────────────── + +const safeJsonStringify = (obj: unknown): string => + JSON.stringify(obj, (_, v) => (typeof v === "bigint" ? v.toString() : v), 2) + +// ─── Callback ─────────────────────────────────────────────── +export const onDisputeRaised = (runtime: Runtime, log: DecodedLog): string => { + const evmConfig = runtime.config.evms[0] + const dsConfig = runtime.config.dataStreams + + // 1. Extract decoded event data + const marketId = log.data.marketId + const disputor = log.data.disputor + const reason = log.data.reason + + runtime.log(`DisputeRaised: market ${marketId}, disputor ${disputor}, reason: "${reason}"`) + + // 2. Get network and create EVM client + const network = getNetwork({ + chainFamily: "evm", + chainSelectorName: evmConfig.chainSelectorName, + }) + if (!network) throw new Error("Network not found") + + const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) + + const predictionMarket = new PredictionMarket( + evmClient, + evmConfig.predictionMarketAddress as Address, + ) + + // 3. Verify market is in Disputed status + const status = predictionMarket.getMarketStatus(runtime, marketId) + if (status !== 2) { // 2 = Disputed + runtime.log(`Market ${marketId}: status is ${status}, not Disputed (2). Skipping.`) + return `Skipped — market ${marketId} is not in Disputed status` + } + + // 4. Fetch fresh BTC/USD price from Chainlink Data Streams + const apiResponse = fetchLatestReport(runtime, dsConfig.apiUrl, dsConfig.feedId) + const decoded = decodeFullReportV3(apiResponse.report.fullReport as `0x${string}`) + + // Convert from Data Streams 18 decimals to on-chain feed 8 decimals + const latestAnswer = toPriceFeedDecimals(decoded.price) + const priceScaled = formatUnits(latestAnswer, PRICE_FEED_DECIMALS) + + runtime.log(`Fresh BTC/USD price: $${priceScaled} (raw: ${latestAnswer}, source: Data Streams)`) + + // 5. Encode the RESOLVE_DISPUTE action + const innerData = encodeAbiParameters( + parseAbiParameters("uint256 marketId, int256 newPrice"), + [marketId, latestAnswer], + ) + + const reportData = encodeAbiParameters( + parseAbiParameters("uint8 action, bytes data"), + [ACTION_RESOLVE_DISPUTE, innerData], + ) + + // 6. Write: Resolve dispute via signed report + const resp = predictionMarket.writeReport(runtime, reportData, { + gasLimit: evmConfig.gasLimit || "500000", + }) + + if (resp.txStatus !== TxStatus.SUCCESS) { + throw new Error(`Dispute resolution TX failed: ${resp.errorMessage || resp.txStatus}`) + } + + if (!resp.txHash) { + runtime.log("Warning: transaction succeeded but no tx hash returned") + } + const txHash = bytesToHex(resp.txHash || new Uint8Array(32)) + runtime.log(`Dispute resolved for market ${marketId}! Fresh price: $${priceScaled}, TX: ${txHash}`) + + return safeJsonStringify({ + marketId: marketId.toString(), + freshPrice: priceScaled, + txHash, + }) +} + +// ─── Workflow Init ────────────────────────────────────────── +export function initWorkflow(config: Config) { + const evmConfig = config.evms[0] + + const network = getNetwork({ + chainFamily: "evm", + chainSelectorName: evmConfig.chainSelectorName, + }) + if (!network) throw new Error(`Network not found: ${evmConfig.chainSelectorName}`) + + const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) + const predictionMarket = new PredictionMarket(evmClient, evmConfig.predictionMarketAddress as Address) + + return [cre.handler(predictionMarket.logTriggerDisputeRaised(), onDisputeRaised)] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.yaml new file mode 100644 index 00000000..325fa181 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.yaml @@ -0,0 +1,15 @@ +staging-settings: + user-workflow: + workflow-name: "prediction-market-ds-dispute-staging" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.staging.json" + secrets-path: "../secrets.yaml" + +production-settings: + user-workflow: + workflow-name: "prediction-market-ds-dispute-production" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.production.json" + secrets-path: "../secrets.yaml" diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json new file mode 100644 index 00000000..58e2ca04 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json @@ -0,0 +1,16 @@ +{ + "schedule": "0 */10 * * * *", + "evms": [ + { + "chainSelectorName": "ethereum-mainnet", + "predictionMarketAddress": "0xYOUR_DEPLOYED_CONTRACT_ADDRESS", + "gasLimit": "500000" + } + ], + "dataStreams": { + "apiUrl": "https://api.dataengine.chain.link", + "feedId": "0xYOUR_FEED_ID", + "owner": "" + }, + "marketIdsToCheck": [0, 1, 2] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json new file mode 100644 index 00000000..537bd071 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json @@ -0,0 +1,16 @@ +{ + "schedule": "0 */10 * * * *", + "evms": [ + { + "chainSelectorName": "ethereum-testnet-sepolia", + "predictionMarketAddress": "0xEb792aF46AB2c2f1389A774AB806423DB43aA425", + "gasLimit": "500000" + } + ], + "dataStreams": { + "apiUrl": "https://api.dataengine.chain.link", + "feedId": "0x00021f1c95b33f5e56fa5c07968c566586d0e6cea93c9fb79127915892de430d", + "owner": "" + }, + "marketIdsToCheck": [0, 1, 2] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/main.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/main.ts new file mode 100644 index 00000000..26876a57 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/main.ts @@ -0,0 +1,9 @@ +import { Runner } from '@chainlink/cre-sdk' +import { configSchema, initWorkflow } from './workflow' + +export async function main() { + const runner = await Runner.newRunner({ configSchema }) + await runner.run(initWorkflow) +} + +main() diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/package.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/package.json new file mode 100644 index 00000000..34d53a74 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/package.json @@ -0,0 +1,18 @@ +{ + "name": "market-resolution-workflow", + "version": "1.0.0", + "main": "dist/main.js", + "private": true, + "scripts": { + "postinstall": "bun x cre-setup" + }, + "license": "UNLICENSED", + "dependencies": { + "@chainlink/cre-sdk": "^1.1.2", + "viem": "2.34.0", + "zod": "3.25.76" + }, + "devDependencies": { + "@types/bun": "1.2.21" + } +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/tsconfig.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/tsconfig.json new file mode 100644 index 00000000..a3b641ab --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "commonjs", + "outDir": "./dist", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["main.ts"] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts new file mode 100644 index 00000000..c09b448f --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts @@ -0,0 +1,81 @@ +import { describe, expect } from 'bun:test' +import { cre, getNetwork, TxStatus } from '@chainlink/cre-sdk' +import { EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import type { Address } from 'viem' +import { newPredictionMarketMock } from '../contracts/evm/ts/generated/PredictionMarket_mock' +import { initWorkflow, onCronTrigger } from './workflow' + +const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia +const PREDICTION_MARKET = '0xEb792aF46AB2c2f1389A774AB806423DB43aA425' as Address + +const makeConfig = () => ({ + schedule: '0 */10 * * * *', + evms: [ + { + chainSelectorName: 'ethereum-testnet-sepolia', + predictionMarketAddress: PREDICTION_MARKET, + gasLimit: '500000', + }, + ], + dataStreams: { + apiUrl: 'https://api.testnet-dataengine.chain.link', + feedId: '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00', + }, + marketIdsToCheck: [0, 1, 2], +}) + +describe('market-resolution', () => { + test('resolves a market when resolvable', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + + pmMock.isResolvable = (marketId: bigint) => marketId === 0n + pmMock.writeReport = () => ({ + txStatus: TxStatus.SUCCESS, + txHash: new Uint8Array(32), + }) + + const runtime = newTestRuntime() + ;(runtime as any).config = makeConfig() + + // Mock runtime.getSecret to return test credentials + ;(runtime as any).getSecret = (name: string) => { + if (name === 'DATA_STREAMS_API_KEY') return 'test-api-key' + if (name === 'DATA_STREAMS_API_SECRET') return 'test-api-secret' + return '' + } + + // Note: In a full test, the HTTP client mock would intercept the Data Streams + // API call and return a mock fullReport. For unit tests, the HTTP capability + // needs to be mocked at the CRE SDK level. + // This test verifies the workflow structure and contract interactions. + + const result = onCronTrigger(runtime as any, { scheduledExecutionTime: new Date().toISOString() } as any) + expect(result).toBe('Resolved markets: 0') + }) + + test('skips markets that are not resolvable', () => { + const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) + const pmMock = newPredictionMarketMock(PREDICTION_MARKET, evmMock) + + pmMock.isResolvable = () => false + + const runtime = newTestRuntime() + ;(runtime as any).config = makeConfig() + + const result = onCronTrigger(runtime as any, { scheduledExecutionTime: new Date().toISOString() } as any) + expect(result).toBe('No markets were resolvable') + }) +}) + +describe('initWorkflow', () => { + test('returns a single cron handler', () => { + const config = makeConfig() + const handlers = initWorkflow(config) + + expect(handlers).toHaveLength(1) + expect(handlers[0].fn).toBe(onCronTrigger) + const cronTrigger = handlers[0].trigger as { config?: { schedule?: string } } + expect(cronTrigger.config?.schedule).toBe(config.schedule) + }) +}) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts new file mode 100644 index 00000000..1e8813a7 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts @@ -0,0 +1,133 @@ +import { + cre, + getNetwork, + TxStatus, + bytesToHex, + type Runtime, + type CronPayload, +} from "@chainlink/cre-sdk" +import { type Address, encodeAbiParameters, parseAbiParameters, formatUnits } from "viem" +import { z } from "zod" +import { PredictionMarket } from "../contracts/evm/ts/generated/PredictionMarket" +import { + fetchLatestReport, + decodeFullReportV3, + toPriceFeedDecimals, + PRICE_FEED_DECIMALS, +} from "../data-streams/client" + +// ─── Config Schema ────────────────────────────────────────── +export const configSchema = z.object({ + schedule: z.string(), + evms: z.array( + z.object({ + chainSelectorName: z.string(), + predictionMarketAddress: z.string(), + gasLimit: z.string().optional(), + }) + ), + dataStreams: z.object({ + apiUrl: z.string(), + feedId: z.string(), + owner: z.string(), + }), + marketIdsToCheck: z.array(z.number()), +}) +type Config = z.infer + +const ACTION_RESOLVE = 2 + +// ─── Helpers ──────────────────────────────────────────────── + +const safeJsonStringify = (obj: unknown): string => + JSON.stringify(obj, (_, v) => (typeof v === "bigint" ? v.toString() : v), 2) + +// ─── Callback ─────────────────────────────────────────────── +export const onCronTrigger = (runtime: Runtime, _payload: CronPayload): string => { + const evmConfig = runtime.config.evms[0] + const dsConfig = runtime.config.dataStreams + + // 1. Get network and create EVM client + const network = getNetwork({ + chainFamily: "evm", + chainSelectorName: evmConfig.chainSelectorName, + }) + if (!network) throw new Error(`Network not found: ${evmConfig.chainSelectorName}`) + + const evmClient = new cre.capabilities.EVMClient(network.chainSelector.selector) + + const predictionMarket = new PredictionMarket( + evmClient, + evmConfig.predictionMarketAddress as Address, + ) + + const resolved: string[] = [] + + // 2. Check each market ID for resolvability + for (const marketId of runtime.config.marketIdsToCheck) { + const isResolvable = predictionMarket.isResolvable(runtime, BigInt(marketId)) + + if (!isResolvable) { + runtime.log(`Market ${marketId}: not resolvable (either not expired or already resolved)`) + continue + } + + runtime.log(`Market ${marketId}: resolvable — fetching price from Data Streams...`) + + // 3. Fetch BTC/USD price from Chainlink Data Streams API + const apiResponse = fetchLatestReport(runtime, dsConfig.apiUrl, dsConfig.feedId) + const decoded = decodeFullReportV3(apiResponse.report.fullReport as `0x${string}`) + + // Convert from Data Streams 18 decimals to on-chain feed 8 decimals + const latestAnswer = toPriceFeedDecimals(decoded.price) + const priceScaled = formatUnits(latestAnswer, PRICE_FEED_DECIMALS) + + runtime.log(`BTC/USD price: $${priceScaled} (raw: ${latestAnswer}, source: Data Streams)`) + + // 4. Encode the RESOLVE action with the price + const innerData = encodeAbiParameters( + parseAbiParameters("uint256 marketId, int256 price"), + [BigInt(marketId), latestAnswer], + ) + + const reportData = encodeAbiParameters( + parseAbiParameters("uint8 action, bytes data"), + [ACTION_RESOLVE, innerData], + ) + + // 5. Write: Resolve market via signed report + const resp = predictionMarket.writeReport(runtime, reportData, { + gasLimit: evmConfig.gasLimit || "500000", + }) + + if (resp.txStatus !== TxStatus.SUCCESS) { + runtime.log(`Market ${marketId}: resolve TX failed — ${resp.errorMessage || resp.txStatus}`) + continue + } + + if (!resp.txHash) { + runtime.log(`Market ${marketId}: warning — transaction succeeded but no tx hash returned`) + } + const txHash = bytesToHex(resp.txHash || new Uint8Array(32)) + runtime.log(`Market ${marketId}: resolved! Price: $${priceScaled}, TX: ${txHash}`) + + resolved.push(`${marketId}`) + } + + if (resolved.length === 0) { + return "No markets were resolvable" + } + + return `Resolved markets: ${resolved.join(", ")}` +} + +// ─── Workflow Init ────────────────────────────────────────── +export function initWorkflow(config: Config) { + const cronTrigger = new cre.capabilities.CronCapability() + return [ + cre.handler( + cronTrigger.trigger({ schedule: config.schedule }), + onCronTrigger, + ), + ] +} diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.yaml new file mode 100644 index 00000000..bc2e156a --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.yaml @@ -0,0 +1,15 @@ +staging-settings: + user-workflow: + workflow-name: "prediction-market-ds-resolution-staging" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.staging.json" + secrets-path: "../secrets.yaml" + +production-settings: + user-workflow: + workflow-name: "prediction-market-ds-resolution-production" + workflow-artifacts: + workflow-path: "./main.ts" + config-path: "./config.production.json" + secrets-path: "../secrets.yaml" diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/project.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/project.yaml new file mode 100644 index 00000000..ae7135f8 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/project.yaml @@ -0,0 +1,15 @@ +# ========================================================================== +# CRE PROJECT SETTINGS FILE +# ========================================================================== +staging-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com + +# ========================================================================== +production-settings: + rpcs: + - chain-name: ethereum-testnet-sepolia + url: https://ethereum-sepolia-rpc.publicnode.com + - chain-name: ethereum-mainnet + url: https://ethereum-rpc.publicnode.com diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/secrets.yaml b/starter-templates/prediction-market/prediction-market-data-streams-ts/secrets.yaml new file mode 100644 index 00000000..e3b0fca9 --- /dev/null +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/secrets.yaml @@ -0,0 +1,5 @@ +secretsNames: + DATA_STREAMS_API_KEY: + - CRE_DS_API_KEY + DATA_STREAMS_API_SECRET: + - CRE_DS_API_SECRET diff --git a/starter-templates/prediction-market/prediction-market-ts/market-creation/.cre_build_tmp.js b/starter-templates/prediction-market/prediction-market-ts/market-creation/.cre_build_tmp.js index c36b8db8..5ed2dd64 100644 --- a/starter-templates/prediction-market/prediction-market-ts/market-creation/.cre_build_tmp.js +++ b/starter-templates/prediction-market/prediction-market-ts/market-creation/.cre_build_tmp.js @@ -30966,8 +30966,7 @@ var onCronTrigger = (runtime3, _payload) => { const defaults = runtime3.config.marketDefaults; const network495 = getNetwork({ chainFamily: "evm", - chainSelectorName: evmConfig.chainSelectorName, - isTestnet: true + chainSelectorName: evmConfig.chainSelectorName }); if (!network495) throw new Error(`Network not found: ${evmConfig.chainSelectorName}`); @@ -30989,6 +30988,9 @@ var onCronTrigger = (runtime3, _payload) => { if (resp.txStatus !== TxStatus.SUCCESS) { throw new Error(`Create market TX failed: ${resp.errorMessage || resp.txStatus}`); } + if (!resp.txHash) { + runtime3.log("Warning: transaction succeeded but no tx hash returned"); + } const txHash = bytesToHex(resp.txHash || new Uint8Array(32)); runtime3.log(`Market created! ID: ${nextMarketId}, TX: ${txHash}`); return safeJsonStringify({ From 58479668bc626255b0175414c2120fb6a0f6f837 Mon Sep 17 00:00:00 2001 From: Emmanuel Jacquier Date: Fri, 24 Apr 2026 14:52:30 -0400 Subject: [PATCH 2/2] updated config --- .../README.md | 15 ++-- .../market-dispute/config.production.json | 3 +- .../market-dispute/config.staging.json | 5 +- .../market-dispute/workflow.test.ts | 83 +++++++++++++---- .../market-dispute/workflow.ts | 1 - .../market-resolution/config.production.json | 3 +- .../market-resolution/config.staging.json | 5 +- .../market-resolution/workflow.test.ts | 88 +++++++++++++++---- .../market-resolution/workflow.ts | 1 - 9 files changed, 153 insertions(+), 51 deletions(-) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md b/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md index 3d08ebc6..21e638d3 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/README.md @@ -127,21 +127,21 @@ cd market-dispute && bun test && cd .. ```bash # Create a market -cre workflow simulate market-creation --target staging +cre workflow simulate market-creation --target staging-settings # Resolve expired markets (requires Data Streams API credentials) -cre workflow simulate market-resolution --target staging +cre workflow simulate market-resolution --target staging-settings # Handle a dispute (triggered by on-chain DisputeRaised event) -cre workflow simulate market-dispute --target staging +cre workflow simulate market-dispute --target staging-settings ``` ### 5. Broadcast (Sepolia Testnet) ```bash -cre workflow broadcast market-creation --target staging -cre workflow broadcast market-resolution --target staging -cre workflow broadcast market-dispute --target staging +cre workflow broadcast market-creation --target staging-settings +cre workflow broadcast market-resolution --target staging-settings +cre workflow broadcast market-dispute --target staging-settings ``` --- @@ -155,6 +155,7 @@ Creates a new binary prediction market every hour. The market asks: "Will BTC be **Config** (`market-creation/config.staging.json`): - `schedule`: Cron expression (default: hourly) - `evms[].predictionMarketAddress`: Deployed PredictionMarket contract +- `marketDefaults.question`: Market question template; `{expirationDate}` is replaced at creation - `marketDefaults.strikePriceUsd`: Price threshold in USD - `marketDefaults.durationSeconds`: Market duration (default: 24h) @@ -176,7 +177,7 @@ Checks markets every 10 minutes. For each resolvable market, fetches the latest **How it works:** 1. Calls `isResolvable(marketId)` on the contract 2. Fetches the latest report from the Data Streams REST API with HMAC authentication -3. Decodes the v3 (Crypto Advanced) report to extract the median price +3. Decodes the v3 (Crypto Advanced) report to extract the benchmark `price` 4. Converts from 18 decimal places (Data Streams) to 8 decimal places (on-chain) 5. Encodes a `RESOLVE` action and writes it on-chain via CRE report diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json index ab184684..da2d0bdd 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.production.json @@ -8,7 +8,6 @@ ], "dataStreams": { "apiUrl": "https://api.dataengine.chain.link", - "feedId": "0xYOUR_FEED_ID", - "owner": "" + "feedId": "0xYOUR_FEED_ID" } } diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json index 47ecdaf0..2475c8d0 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/config.staging.json @@ -7,8 +7,7 @@ } ], "dataStreams": { - "apiUrl": "https://api.dataengine.chain.link", - "feedId": "0x00021f1c95b33f5e56fa5c07968c566586d0e6cea93c9fb79127915892de430d", - "owner": "" + "apiUrl": "https://api.testnet-dataengine.chain.link", + "feedId": "0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00" } } diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts index da0b007b..2aa8a8ae 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.test.ts @@ -1,14 +1,14 @@ import { describe, expect } from 'bun:test' -import { cre, getNetwork, TxStatus } from '@chainlink/cre-sdk' -import { EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' -import type { Address } from 'viem' -import { PredictionMarket } from '../contracts/evm/ts/generated/PredictionMarket' +import { TxStatus } from '@chainlink/cre-sdk' +import { ConfidentialHttpMock, EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import { type Address, encodeAbiParameters, parseAbiParameters } from 'viem' import { newPredictionMarketMock } from '../contracts/evm/ts/generated/PredictionMarket_mock' import { initWorkflow, onDisputeRaised } from './workflow' const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia const PREDICTION_MARKET = '0xEb792aF46AB2c2f1389A774AB806423DB43aA425' as Address const DISPUTOR = '0x1f9840a85d5aF5bf1D1762F925BDADdC4201F984' as Address +const BTC_FEED_ID = '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00' const makeConfig = () => ({ evms: [ @@ -20,10 +20,70 @@ const makeConfig = () => ({ ], dataStreams: { apiUrl: 'https://api.testnet-dataengine.chain.link', - feedId: '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00', + feedId: BTC_FEED_ID, }, }) +const makeSecrets = () => { + const inner = new Map() + inner.set('DATA_STREAMS_API_KEY', 'test-api-key') + inner.set('DATA_STREAMS_API_SECRET', 'test-api-secret') + const outer = new Map>() + outer.set('default', inner) + return outer +} + +const encodeFullReport = (priceWith18Decimals: bigint): `0x${string}` => { + const reportData = encodeAbiParameters( + parseAbiParameters( + 'bytes32 feedId, uint32 validFromTimestamp, uint32 observationsTimestamp, uint192 nativeFee, uint192 linkFee, uint32 expiresAt, int192 price, int192 bid, int192 ask', + ), + [ + BTC_FEED_ID as `0x${string}`, + 1_700_000_000, + 1_700_000_000, + 0n, + 0n, + 1_800_000_000, + priceWith18Decimals, + priceWith18Decimals, + priceWith18Decimals, + ], + ) + return encodeAbiParameters( + parseAbiParameters( + 'bytes32[3] reportContext, bytes reportData, bytes32[] rawRs, bytes32[] rawSs, bytes32 rawVs', + ), + [ + [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + reportData, + [], + [], + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + ) +} + +const mockDataStreamsResponse = (priceWith18Decimals: bigint) => { + const body = JSON.stringify({ + report: { + feedID: BTC_FEED_ID, + validFromTimestamp: 1_700_000_000, + observationsTimestamp: 1_700_000_000, + fullReport: encodeFullReport(priceWith18Decimals), + }, + }) + const httpMock = ConfidentialHttpMock.testInstance() + httpMock.sendRequest = () => ({ + statusCode: 200, + body: new TextEncoder().encode(body), + }) +} + describe('market-dispute', () => { test('resolves dispute when market is in Disputed status', () => { const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) @@ -35,15 +95,10 @@ describe('market-dispute', () => { txHash: new Uint8Array(32), }) - const runtime = newTestRuntime() - ;(runtime as any).config = makeConfig() + mockDataStreamsResponse(105_000n * 10n ** 18n) - // Mock runtime.getSecret to return test credentials - ;(runtime as any).getSecret = (name: string) => { - if (name === 'DATA_STREAMS_API_KEY') return 'test-api-key' - if (name === 'DATA_STREAMS_API_SECRET') return 'test-api-secret' - return '' - } + const runtime = newTestRuntime(makeSecrets()) + ;(runtime as any).config = makeConfig() const payload = { topics: [], @@ -54,8 +109,6 @@ describe('market-dispute', () => { }, } - // Note: This test requires HTTP mock for Data Streams API response. - // The test verifies structure and contract interactions. const result = onDisputeRaised(runtime as any, payload as any) const parsed = JSON.parse(result) diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts index a0252fa0..2d8a55bf 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-dispute/workflow.ts @@ -32,7 +32,6 @@ export const configSchema = z.object({ dataStreams: z.object({ apiUrl: z.string(), feedId: z.string(), - owner: z.string(), }), }) type Config = z.infer diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json index 58e2ca04..50563f80 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.production.json @@ -9,8 +9,7 @@ ], "dataStreams": { "apiUrl": "https://api.dataengine.chain.link", - "feedId": "0xYOUR_FEED_ID", - "owner": "" + "feedId": "0xYOUR_FEED_ID" }, "marketIdsToCheck": [0, 1, 2] } diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json index 537bd071..dcf7e4af 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/config.staging.json @@ -8,9 +8,8 @@ } ], "dataStreams": { - "apiUrl": "https://api.dataengine.chain.link", - "feedId": "0x00021f1c95b33f5e56fa5c07968c566586d0e6cea93c9fb79127915892de430d", - "owner": "" + "apiUrl": "https://api.testnet-dataengine.chain.link", + "feedId": "0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00" }, "marketIdsToCheck": [0, 1, 2] } diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts index c09b448f..b9635e0d 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.test.ts @@ -1,12 +1,13 @@ import { describe, expect } from 'bun:test' -import { cre, getNetwork, TxStatus } from '@chainlink/cre-sdk' -import { EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' -import type { Address } from 'viem' +import { TxStatus } from '@chainlink/cre-sdk' +import { ConfidentialHttpMock, EvmMock, newTestRuntime, test } from '@chainlink/cre-sdk/test' +import { type Address, encodeAbiParameters, parseAbiParameters } from 'viem' import { newPredictionMarketMock } from '../contracts/evm/ts/generated/PredictionMarket_mock' import { initWorkflow, onCronTrigger } from './workflow' const CHAIN_SELECTOR = 16015286601757825753n // ethereum-testnet-sepolia const PREDICTION_MARKET = '0xEb792aF46AB2c2f1389A774AB806423DB43aA425' as Address +const BTC_FEED_ID = '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00' const makeConfig = () => ({ schedule: '0 */10 * * * *', @@ -19,11 +20,73 @@ const makeConfig = () => ({ ], dataStreams: { apiUrl: 'https://api.testnet-dataengine.chain.link', - feedId: '0x00027bbaff688c906a3e20a34fe951715d1018d262a5b66e38edd64e0fdd0d00', + feedId: BTC_FEED_ID, }, marketIdsToCheck: [0, 1, 2], }) +const makeSecrets = () => { + const inner = new Map() + inner.set('DATA_STREAMS_API_KEY', 'test-api-key') + inner.set('DATA_STREAMS_API_SECRET', 'test-api-secret') + const outer = new Map>() + outer.set('default', inner) + return outer +} + +// Build a valid v3 (Crypto Advanced) fullReport with the given price (18 decimals). +const encodeFullReport = (priceWith18Decimals: bigint): `0x${string}` => { + const reportData = encodeAbiParameters( + parseAbiParameters( + 'bytes32 feedId, uint32 validFromTimestamp, uint32 observationsTimestamp, uint192 nativeFee, uint192 linkFee, uint32 expiresAt, int192 price, int192 bid, int192 ask', + ), + [ + BTC_FEED_ID as `0x${string}`, + 1_700_000_000, + 1_700_000_000, + 0n, + 0n, + 1_800_000_000, + priceWith18Decimals, + priceWith18Decimals, + priceWith18Decimals, + ], + ) + return encodeAbiParameters( + parseAbiParameters( + 'bytes32[3] reportContext, bytes reportData, bytes32[] rawRs, bytes32[] rawSs, bytes32 rawVs', + ), + [ + [ + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + reportData, + [], + [], + '0x0000000000000000000000000000000000000000000000000000000000000000', + ], + ) +} + +const mockDataStreamsResponse = (priceWith18Decimals: bigint) => { + const fullReport = encodeFullReport(priceWith18Decimals) + const body = JSON.stringify({ + report: { + feedID: BTC_FEED_ID, + validFromTimestamp: 1_700_000_000, + observationsTimestamp: 1_700_000_000, + fullReport, + }, + }) + const httpMock = ConfidentialHttpMock.testInstance() + httpMock.sendRequest = () => ({ + statusCode: 200, + body: new TextEncoder().encode(body), + }) +} + describe('market-resolution', () => { test('resolves a market when resolvable', () => { const evmMock = EvmMock.testInstance(CHAIN_SELECTOR) @@ -35,20 +98,11 @@ describe('market-resolution', () => { txHash: new Uint8Array(32), }) - const runtime = newTestRuntime() - ;(runtime as any).config = makeConfig() + // BTC at $105,000 with 18 decimals + mockDataStreamsResponse(105_000n * 10n ** 18n) - // Mock runtime.getSecret to return test credentials - ;(runtime as any).getSecret = (name: string) => { - if (name === 'DATA_STREAMS_API_KEY') return 'test-api-key' - if (name === 'DATA_STREAMS_API_SECRET') return 'test-api-secret' - return '' - } - - // Note: In a full test, the HTTP client mock would intercept the Data Streams - // API call and return a mock fullReport. For unit tests, the HTTP capability - // needs to be mocked at the CRE SDK level. - // This test verifies the workflow structure and contract interactions. + const runtime = newTestRuntime(makeSecrets()) + ;(runtime as any).config = makeConfig() const result = onCronTrigger(runtime as any, { scheduledExecutionTime: new Date().toISOString() } as any) expect(result).toBe('Resolved markets: 0') diff --git a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts index 1e8813a7..32c3739e 100644 --- a/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts +++ b/starter-templates/prediction-market/prediction-market-data-streams-ts/market-resolution/workflow.ts @@ -29,7 +29,6 @@ export const configSchema = z.object({ dataStreams: z.object({ apiUrl: z.string(), feedId: z.string(), - owner: z.string(), }), marketIdsToCheck: z.array(z.number()), })