Prediction market on FX.
Binary markets like "USD/JPY > 158 by Friday close" using Sera's reference rate as the settlement oracle. Markets settle in stablecoins; payouts route through Sera FX so winners can withdraw in whatever currency they want.
A deployed binary-market contract on Ethereum that resolves against Sera's reference FX rate, plus an off-chain "resolver" service that posts the final rate at expiry.
Prediction markets are regulated in most jurisdictions. This tutorial shows the technical pattern. Treat it as infrastructure for a permitted product (internal employee market, on-chain airdrop game, sanctioned jurisdiction), not a public consumer offering without legal review.
Architecture
┌──────────────┐ ┌────────────────┐ ┌─────────────────┐
│ User funds │───▶│ Market contract│ │ Resolver svc │
│ YES or NO │ │ (escrows USDC)│ │ (off-chain) │
└──────────────┘ └────────┬───────┘ └────────┬────────┘
│ │
│ At expiry: │
│ ┌─────────────────┘
│ │
│ ▼
│ sera.get_fx_rate({USD,JPY})
│ │
│ ▼
│ resolver.postRate(rate, sig)
▼
┌─────────────────┐
│ Market resolves│
│ Winners claim │
│ USDC → Sera │
│ → any currency │
└─────────────────┘
Steps
1The market contract (minimal)
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
contract FxBinaryMarket {
IERC20 public immutable usdc;
address public immutable resolver;
string public question; // "USD/JPY > 158 at 2026-05-20T16:00Z"
uint256 public immutable threshold;
uint256 public immutable expiry;
enum Outcome { Pending, Yes, No }
Outcome public outcome;
mapping(address => uint256) public yesShares;
mapping(address => uint256) public noShares;
uint256 public totalYes;
uint256 public totalNo;
function bet(bool yes, uint256 amount) external {
require(block.timestamp < expiry, "closed");
usdc.transferFrom(msg.sender, address(this), amount);
if (yes) { yesShares[msg.sender] += amount; totalYes += amount; }
else { noShares[msg.sender] += amount; totalNo += amount; }
}
function resolve(uint256 finalRate) external {
require(msg.sender == resolver, "!resolver");
require(block.timestamp >= expiry, "early");
require(outcome == Outcome.Pending, "resolved");
outcome = finalRate > threshold ? Outcome.Yes : Outcome.No;
}
function claim() external {
require(outcome != Outcome.Pending, "pending");
uint256 total = totalYes + totalNo;
uint256 winning = outcome == Outcome.Yes ? yesShares[msg.sender] : noShares[msg.sender];
uint256 pool = outcome == Outcome.Yes ? totalYes : totalNo;
uint256 payout = (winning * total) / pool;
if (outcome == Outcome.Yes) yesShares[msg.sender] = 0;
else noShares[msg.sender] = 0;
usdc.transfer(msg.sender, payout);
}
}
2The off-chain resolver
A small Node service that wakes at expiry, calls sera.get_fx_rate, and posts the result.
import { spawnSeraMcp } from './mcpClient';
import { ethers } from 'ethers';
const sera = spawnSeraMcp({ env: { SERA_NETWORK: 'mainnet' } });
const wallet = new ethers.Wallet(process.env.RESOLVER_KEY!, provider);
const market = new ethers.Contract(MARKET_ADDR, ABI, wallet);
async function resolveAtExpiry() {
const expiry = await market.expiry();
const now = Math.floor(Date.now() / 1000);
if (now < expiry) return;
const r = await sera.call('get_fx_rate', { base: 'USD', quote: 'JPY' });
const finalRate = ethers.parseUnits(r.rate.toString(), 8);
await market.resolve(finalRate);
}
3(Better) Median over a resolution window
A single rate read at expiry is manipulable. In production, average over a resolution window (e.g. last 30 min before expiry) using sera.fx_history + multi_source_mid as a sanity check.
const series = await sera.call('fx_history', {
pair: 'USD/JPY', hours: 0.5
});
const median = series.map(s => s.rate).sort()[Math.floor(series.length / 2)];
const ext = await sera.call('multi_source_mid', { base: 'USD', quote: 'JPY' });
if (Math.abs(median - ext.median) / ext.median > 0.005) {
// More than 50bps disagreement — pause resolution, alert humans.
throw new Error('Sera vs external mid disagree');
}
4Multi-currency payouts
Winners claim USDC from the contract. To let them withdraw in MYR, JPYC, or whatever, point them to a Sera-powered swap UI right after they claim. Use the payment widget pattern with recipient = winner_address.
Common gotchas
- Single-source oracles get gamed. Always cross-check Sera's reference against
multi_source_midat resolution time. - Resolver key custody. If the resolver wallet is compromised, attacker resolves favorably. Multi-sig, time-locks, or commit-reveal are common defenses.
- Decimal scale. Sera returns rates as floats. On-chain math needs fixed-point. Pick a scale (8 or 18 decimals) and stick with it.
- Expiry timezone. "Friday close" is meaningless without a timezone. Resolve to UTC timestamps.
Next: Treasury rebalancer uses Sera as state-mutation infra rather than oracle, or x402-paid API shows how to monetize the resolver itself.