Binary YES/NO prediction markets on Stellar (Soroban).
Testnet preview: v0.1.0 · contract on Stellar Expert
This repository is the source of truth for financial state and protocol rules. Applications should treat on-chain balances and claim results as authoritative; indexers and UIs are caches and conveniences only.
Users deposit a SEP-41 token into YES or NO for a market. After the staking deadline, a designated resolver reports the outcome. Winners divide the full market pool proportional to their winning-side stake. If a market cannot be resolved in time, anyone may invalidate it so participants can recover deposits.
Wallet → resolve-app → resolve-sdk → Stellar RPC → Resolve Contract → Token (SEP-41)
↑
resolve-indexer (events only; not settlement authority)
Sibling repositories:
| Repo | Role |
|---|---|
| resolve-sdk | TypeScript client |
| resolve-indexer | Event indexer + query API |
| resolve-app | Reference web application |
create_market
│
▼
Open ──(ledger time ≥ close_at)──► Awaiting resolution
│ │
│ ├─ resolve(Yes|No|Invalid) [resolver]
│ │
│ └─ invalidate [anyone, after timeout]
▼ ▼
stake() allowed only while Resolved | Invalid
status=Open AND now < close_at │
▼
claim()
Awaiting resolution is not a stored status: status remains Open until resolve or invalidate finalizes the market.
A single address may stake on both YES and NO. Sides are tracked independently. On a normal Yes/No resolution, only the winning side is paid; the losing side is forfeited. On invalid/refund settlement, both sides are returned.
For a resolved market with a non-empty winning pool:
payout = floor(user_winning_stake * (yes_pool + no_pool) / winning_pool)
- All amounts are
i128integers (no floating point). - Division floors toward zero for positive amounts.
- Residual dust stays in the contract and is not claimable. This guarantees
sum(successful claims) ≤ total pool. - Claim order does not change any user's payout (each claim is independent floor division).
YES pool = 1000, NO pool = 500, outcome = YES. Alice staked 250 YES:
payout = floor(250 * 1500 / 1000) = 375
If the market resolves YES but yes_pool == 0 (only NO was staked), or resolves NO with no_pool == 0, there are no winners. Settlement switches to full refund of every position's deposits. This avoids division by zero and undefined payouts.
| Path | Who | When | Funds |
|---|---|---|---|
resolve(..., Invalid) |
Designated resolver | After close_at |
Full refund of deposits |
invalidate(...) |
Any authorized caller | After close_at + resolution_timeout |
Full refund of deposits |
resolution_timeout is configured per market at creation (minimum 1 hour, maximum 30 days). A market may close at most 120 days after creation. These limits fit the full lifecycle inside the contract's 180-day persistent-storage TTL window and prevent a missing resolver from trapping funds forever.
| Function | Auth | Description |
|---|---|---|
__constructor |
deploy | Initializes market id counter |
create_market(creator, resolver, question, description, token, close_at, resolution_timeout) -> u64 |
creator |
Creates market, returns id |
stake(user, market_id, side, amount) |
user |
Transfers tokens in, updates pools/position |
resolve(market_id, outcome) |
resolver |
Finalizes Yes/No/Invalid after close |
invalidate(caller, market_id) |
caller |
Permissionless finalize after timeout |
claim(user, market_id) -> i128 |
user |
Pays payout or refund once |
get_market / get_position / get_claimable / next_market_id |
none | Views |
- Creators authorize market creation.
- Stakers authorize deposits (and the nested token
transfer). - Only the market's
resolveraddress canresolve. - Anyone can
invalidateafter the resolution timeout (must authorize ascaller). - Claimants authorize withdrawals to themselves.
- The contract never moves escrowed tokens except via
stake(in) andclaim(out).
- Settlement token implements SEP-41 correctly and is not adversarial in ways that break escrow (fee-on-transfer, rebasing, blacklist mid-flight). Prefer standard SAC assets.
- Resolvers are trusted for timely honest outcomes; economic recourse for bad resolution is off-chain / social. Invalidation only covers non-resolution, not disputed wrong outcomes.
- Ledger timestamp is the clock; validators set it within protocol rules.
- Contract is not upgradeable in v0.1.0 (no admin upgrade path). Redeploy for changes.
- This code is unaudited. Do not use with mainnet funds at scale without an audit.
- Rust 1.84+ (tested with 1.98)
wasm32v1-nonetarget:rustup target add wasm32v1-none- Stellar CLI for WASM builds and deploy
cargo test --manifest-path contracts/resolve/Cargo.toml
stellar contract build
# WASM: target/wasm32v1-none/release/resolve.wasmWithout Stellar CLI, native tests still run via cargo test.
See .env.example for deploy/invoke helper variables (not required for unit tests).
stellar keys generate deployer --network testnet --fund
stellar contract build
stellar contract deploy \
--wasm target/wasm32v1-none/release/resolve.wasm \
--source-account deployer \
--network testnetRecord the contract id and set it in resolve-sdk / resolve-app / resolve-indexer env files.
For a reproducible deployment that also records the WASM hash and shared configuration:
./scripts/deploy-testnet.ps1 -SourceAccount deployer -SettlementTokenId C...
./scripts/verify-testnet.ps1 -SourceAccount deployerCommit deployments/testnet.json after verifying it. The manifest is consumed by the SDK and provides one reviewable source for app and indexer configuration.
The repository also records a completed create → stake → resolve → claim lifecycle in deployments/testnet-e2e.json, including public Stellar testnet transaction hashes.
cargo test --manifest-path contracts/resolve/Cargo.tomlCoverage includes creation validation, staking, both-sides positions, deadlines, resolution, invalidation, zero-sided refunds, rounding dust, double-claim, and pool invariants.
Apache-2.0 — see LICENSE.
See CONTRIBUTING.md and SECURITY.md.