Optional per environment. Skip all of this if markets are resolved by the operator key — that is the default and nothing here is required for it.
UmaCtfAdapter lets a market’s result come from UMA’s Optimistic Oracle instead of from
you. Its point is that payouts stop depending on the operator being present or honest:
resolve is permissionless, so an absent operator cannot strand anyone’s winnings.
Which document do you need? This is the real-oracle runbook — deploying to Sepolia, the staging lifecycle, real disputes, the fork harness. To walk disputes on your own machine with the mock jury (no WETH, buttons in the browser), go to uma-local-demo.md instead.
Read this before deploying. A CTF condition’s id is
keccak256(oracle, questionId, 2), so the resolver’s address is part of every market’s identity. That has three consequences that shape everything below:
- the adapter must exist before any market that uses it;
- a live market can never be repointed at a different oracle — doing so computes a different
conditionId, i.e. a different market holding none of the original positions;- so the oracle is chosen once, per market, at creation, and there is no edit screen.
| Backbone deployed + recorded | deployments.json has a <target> entry (deploy.md §2) |
| Operator gas | ≥ 0.01 ETH — deploy is ~0.003 at 2 gwei, each market’s initialize costs more |
| Bond currency | WETH, ≥ 0.011 per market you intend to resolve |
| foundry | forge, cast on PATH |
WETH is the one that catches people. UMA only accepts bond currencies on its
AddressWhitelist, and Verex’s MockUSDC is not on it. Sepolia WETH is, and is
self-service:
set -a; source packages/contracts/.env; set +a
cast send 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9 "deposit()" \
--value 0.05ether --private-key $VEREX_OPERATOR_KEY --rpc-url $VEREX_RPC_URL
Per proposal a proposer posts finalFee + bond = 0.001 + 0.01 = 0.011 WETH. That is a
deposit, not a cost — an honest proposer gets it back at settlement. 0.05 WETH covers
about four concurrent proposals.
Sepolia addresses, verified 2026-08-03 (../tasks/aug-03-plan.md, G4 result):
| Contract | Address |
|---|---|
OptimisticOracleV2 |
0x9f1263B8f0355673619168b5B8c0248f1d03e88C |
Store (final fee) |
0x39e7FFA77A4ac4D34021C6BbE4C8778d47F684F2 |
AddressWhitelist |
0xE8DE4bcE27f6214dcE18D8a7629f233C66A97B84 |
| WETH | 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9 |
DRY_RUN=1 ./scripts/deploy-uma-adapter.sh staging # preflight only, broadcasts nothing
./scripts/deploy-uma-adapter.sh staging # deploy + record
The script pairs the two steps that must happen in one sitting — the forge deploy and
save-uma-adapter — because forge’s broadcast/run-latest.json is per chain id and
staging/prod share Sepolia. Leave a gap and the next deploy on that chain overwrites the
artifact the recorder reads.
Preflight (DRY_RUN=1) reports, before spending anything:
| Check | Behaviour |
|---|---|
existing umaAdapter for this target |
stops unless FORCE=1 — a new adapter cannot inherit the old one’s markets |
| operator ETH | warns under 0.01 |
| operator WETH | warns at zero, prints the deposit() command |
The forge script itself refuses to deploy without code at CTF_ADDR, without an oracle
that answers defaultLiveness() (so a wrong address can’t pass as an oracle), or with the
Sepolia default address on any other chain. A wrong constructor argument is not fixable in
place — it’s a redeploy plus re-creating every market bound to it.
save-uma-adapter then verifies, before writing: the broadcast’s deployer is
$VEREX_OPERATOR_KEY; the address isn’t already recorded under the other target;
adapter.ctf() matches this target’s recorded CTF; and the operator is admin() — only
admin can initialize questions, so an adapter admin’d by a key you don’t hold is unusable.
Then review and commit the deployments.json diff — the seed reads it from git.
./scripts/deploy-uma-adapter.sh prod # same, when you want it there
Redeploying the backbone invalidates the adapter — it is bound to one CTF by its constructor.
save-deploymenthandles this: it keepsumaAdapterwhen the CTF is unchanged and drops it with a warning when it isn’t. If it drops, redeploy the adapter.
Re-seed so the environment gets its UMA-resolved market:
VEREX_DEPLOY_TARGET=staging pnpm --filter @verex/api seed
The seed creates one UMA market (uma-eth-above-6k-2026), and only where the manifest
carries a umaAdapter. Without one it logs [4b] no UMA adapter in the manifest — skipping
and seeds normally — the adapter is not a dependency of seeding.
Everything else the seed creates stays operator-resolved. The UMA market exists so a fresh environment has something to exercise the propose → resolve path against without anyone hand-crafting a market first.
Once recorded, /create offers Resolution source → UMA oracle. Three rules the form
enforces rather than documents:
| Rule | Why |
|---|---|
| Hidden unless this environment has an adapter | asks GET /config first — better than offering it and failing |
| Binary Yes/No only | each member of a group would be an independent UMA question, with nothing enforcing that exactly one settles Yes |
| Resolution criteria required (≥20 chars) | that text is the entire basis a voter decides on; without it the likely settlement is unresolvable, which pays both sides half |
Deploying the adapter creates no market, and creating a market does not resolve it.
initialize — admin-only, happens inside market creation. Prepares the CTF
condition and opens the UMA request.reward = 0 (our default) nobody is paid to propose, so on
staging you propose. 1e18 = YES, 0 = NO, 0.5e18 = unresolvable.POST /markets/<slug>/uma-resolve. Permissionless; returns 409 while
UMA hasn’t settled.# 2. propose YES
ADAPTER=$(node -p "require('./packages/contracts/deployments.json').staging.umaAdapter")
# read the request's timestamp + exact ancillary bytes back from the adapter
cast call $ADAPTER \
'getQuestion(bytes32)((uint256,address,address,uint256,uint256,bytes,bool))' \
<questionId> --rpc-url $VEREX_RPC_URL
cast send 0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9 \
'approve(address,uint256)' 0x9f1263B8f0355673619168b5B8c0248f1d03e88C 11000000000000000 \
--private-key $VEREX_OPERATOR_KEY --rpc-url $VEREX_RPC_URL
cast send 0x9f1263B8f0355673619168b5B8c0248f1d03e88C \
'proposePrice(address,bytes32,uint256,bytes,int256)' \
$ADAPTER $(cast format-bytes32-string "YES_OR_NO_QUERY") \
<requestTimestamp> <ancillaryDataHex> 1000000000000000000 \
--private-key $VEREX_OPERATOR_KEY --rpc-url $VEREX_RPC_URL
# 3–4. after liveness
cast call $ADAPTER 'isSettleable(bytes32)(bool)' <questionId> --rpc-url $VEREX_RPC_URL
curl -X POST "$API_URL/markets/<slug>/uma-resolve"
questionId is Market.questionId; the ancillary hex is toHex(Market.umaAncillaryData).
Check
isSettleable, never the request’ssettledflag.settledmeans “someone already calledsettleAndGetPrice” — it is false for the entire window in which resolving is possible and only flips as a side effect of resolving. Gating on it makesresolveunreachable. This was a real bug, caught only against the live oracle.
The operator endpoint refuses UMA markets outright. An operator reportPayouts on an
adapter-owned condition does not revert — the CTF derives the condition from msg.sender,
so it would silently report on a different condition, leaving the real one unresolved
forever.
The dispute mechanism has two halves, and only the first half is simulatable:
| Half | Simulatable? |
|---|---|
| propose → dispute → market frozen | yes — commands below |
| the DVM verdict that unfreezes it | no — needs real staked voters in a ~2-day commit/reveal round; on Sepolia’s test DVM a disputed request may simply never settle |
⚠️ Never dispute the seeded
uma-eth-above-6k-2026— a disputed market stays frozen until the DVM rules, which on a testnet can be forever. Create a THROWAWAY UMA market on/createand dispute that one.
The disputer must be a different wallet than the proposer — demo wallet #1 works. It was funded 0.01 ETH at setup, which is not enough for the 0.011 WETH bond plus gas, so top it up first:
set -a; source packages/contracts/.env; set +a
MNEMONIC=$(gcloud secrets versions access latest --secret=verex-demo-mnemonic-verex)
W1_KEY=$(cast wallet private-key --mnemonic "$MNEMONIC" --mnemonic-index 1)
W1=$(cast wallet address $W1_KEY)
# 1. top up wallet #1 from the operator (bond 0.01 + final fee 0.001 + gas)
cast send $W1 --value 0.02ether --private-key $VEREX_OPERATOR_KEY --rpc-url $VEREX_RPC_URL
# 2. wrap ETH → WETH and approve the oracle for bond + final fee
WETH=0x7b79995e5f793A07Bc00c21412e50Ecae098E7f9
OO=0x9f1263B8f0355673619168b5B8c0248f1d03e88C
cast send $WETH 'deposit()' --value 0.011ether --private-key $W1_KEY --rpc-url $VEREX_RPC_URL
cast send $WETH 'approve(address,uint256)' $OO 11000000000000000 \
--private-key $W1_KEY --rpc-url $VEREX_RPC_URL
# 3. dispute — same request params as the propose step (§4): the adapter is the
# requester, and timestamp/ancillary come from getQuestion(<questionId>)
ADAPTER=$(node -p "require('./packages/contracts/deployments.json').staging.umaAdapter")
cast send $OO 'disputePrice(address,bytes32,uint256,bytes)' \
$ADAPTER $(cast format-bytes32-string "YES_OR_NO_QUERY") \
<requestTimestamp> <ancillaryDataHex> \
--private-key $W1_KEY --rpc-url $VEREX_RPC_URL
What you should observe afterwards — this IS the worst case, working as designed:
isSettleable(<questionId>) → false, indefinitelyPOST /markets/<slug>/uma-resolve → 409, indefinitelymarket is not resolved yet) — nobody can cash out on a contested answerEconomics: whichever side the DVM eventually rules against loses its bond, half of which pays the winning side. Proposing falsely risks 0.01 WETH against a 1-hour window watched by anyone; disputing frivolously risks the same. Honesty is the cheap strategy on both sides — that is the entire design.
For reference — this is mainnet UMA governance, not something the demo needs:
Verex never talks to the DVM directly: the adapter only reads the settled answer
through settleAndGetPrice. Dispute UX likewise belongs to UMA’s own dApp
(oracle.uma.xyz) — real deployments (Polymarket included)
link out to it rather than rebuilding dispute flows in-app.
Section 4b’s limitation — the DVM verdict can’t be simulated — is real only against the real oracle. Local environments deploy a MockOptimisticOracleV2 whose DVM is a jury of the demo wallets, so all three dispute endings (defeated / upheld / dead end) can be walked in a browser, with 10 USDC bonds and 5-minute liveness. The full walkthrough lives in its own runbook: uma-local-demo.md.
The mock demo above proves the adapter is self-consistent, not that it matches UMA. The real oracle has no plain-anvil deployment, so testing against its actual bytecode means forking Sepolia — the mock encodes our understanding of the oracle, and only a fork can catch the places where that understanding is wrong.
# 1. fork Sepolia on a spare port
set -a; source packages/contracts/.env; set +a
anvil --fork-url $VEREX_RPC_URL --chain-id 11155111 --port 8546 --silent &
# 2. give the operator fork ETH (fork balances are real, top up to be safe)
OP=$(cast wallet address $VEREX_OPERATOR_KEY)
cast rpc anvil_setBalance $OP 0x8AC7230489E80000 --rpc-url http://127.0.0.1:8546
# 3. deploy the adapter onto the fork
cd packages/contracts
CTF_ADDR=$(node -p "require('./deployments.json').staging.ctf") \
forge script script/DeployUmaAdapter.s.sol --rpc-url http://127.0.0.1:8546 --broadcast
cd ../..
# 4. run the lifecycle
cd packages/api
ADAPTER=<address printed above> \
VEREX_RPC_URL=http://127.0.0.1:8546 VEREX_CHAIN_ID=11155111 \
pnpm exec tsx scripts/uma-e2e-fork.ts
It creates a market through the API’s own path, proposes YES on the real oracle, warps past liveness, resolves, and checks the verdict landed in the DB. Expected tail:
✓ proposed YES on the real OptimisticOracleV2
✓ isSettleable true after liveness, before resolving
✓ payout vector [1,0] read back from the CTF, not assumed
✓ Yes settled at 1.00
✓ all UMA end-to-end checks passed
It is destructive to the local DB — it rewrites ChainConfig and creates a market. Run
./scripts/reset.sh afterwards to get local dev back (it needs plain anvil on 8545).
Why keep this around rather than trust the unit tests: the isSettled bug above passed 14
green tests, because the mock encoded the same misunderstanding as the contract. A mock can
only confirm its author’s beliefs.
Same fork, but drive it from the browser:
# after steps 1–3 above, record the fork adapter in the manifest
VEREX_RPC_URL=http://127.0.0.1:8546 \
pnpm --filter @verex/api save-uma-adapter staging
VEREX_DEPLOY_TARGET=staging VEREX_RPC_URL=http://127.0.0.1:8546 \
pnpm --filter @verex/api seed
VEREX_RPC_URL=http://127.0.0.1:8546 pnpm --filter @verex/api dev
pnpm --filter @verex/web dev
Then open /create — Resolution source → UMA oracle should be selectable, and the
seeded uma-eth-above-6k-2026 market should be present.
Revert the manifest afterwards. Step 2 writes a fork address into the committed
deployments.json:git checkout packages/contracts/deployments.json. Forgetting this is how a fork address reaches staging.
To skip the 1-hour wait when driving it manually, warp the fork forward:
cast rpc evm_increaseTime 4000 --rpc-url http://127.0.0.1:8546
cast rpc evm_mine --rpc-url http://127.0.0.1:8546
pnpm --filter @verex/contracts test # 63 tests; 29 cover the adapter + mock oracle
No chain, no fork. Covers the payout mapping, admin gating, permissionless resolve, the
isSettleable window, and a full lifecycle including redemption — but against a mock
oracle, so it proves the adapter is self-consistent, not that it matches UMA. That is what
5a is for.
| Symptom | Cause |
|---|---|
initialize reverts AlreadyInitialized |
questionId is keccak256(ancillaryData), so identical question text = the same on-chain question. The slug is folded into the ancillary data to prevent this — a collision means two markets share a slug and text |
initialize reverts on transfer |
reward > 0 and the adapter doesn’t hold the reward token — requestPrice pulls from the caller, and the caller is the adapter, not you |
requestPrice reverts on currency |
reward token isn’t on UMA’s AddressWhitelist — MockUSDC never is |
uma-resolve returns 409 |
no proposal yet, or liveness hasn’t expired. Check isSettleable |
resolve reverts UnsupportedPrice |
UMA settled something other than 1e18 / 0 / 0.5e18. Deliberate: coercing it would resolve a market on a value nobody voted for |
| Market settles “unresolvable” (both sides get half) | the ancillary data gave voters no basis to decide. This is a question-writing failure, not a bug |
UMA option missing from /create |
this environment has no adapter recorded — check GET /config |
packages/contracts/src/UmaCtfAdapter.sol — the trust-model comments live at the top