Serve as a juror — commit-reveal arbitration runbook
One event moves staked ZAI against its owner's will: a node loses a dispute and DisputeManager
calls slash. Disputes between a client and a service node are decided by an open, staked
jury: anyone who stakes ZAI in the CommitRevealArbitrator can vote, votes are sealed during a
commit window and disclosed during a reveal window, and the weighted result goes to the
DisputeManager, which slashes the operator or forfeits the client's bond. For the person who
handed an agent a budget, this jury is the last line behind it. Day to day, quality polices itself:
after every settled session the agent rates the operator on-chain, 0–100, from the errors and slow
answers it saw, and operators who answer badly stop being chosen. When an answer was bad enough
that money should move, the agent disputes it with the record of what it was served, and you, the
juror, decide. Serving is the second way ZAI earns. The procedure runs on cast; the design and
its reasoning: Arbitration: commit-reveal quorum. The app's Jury
tab on the ZAI page (/zai?tab=jury) performs staking, commit, reveal,
finalize and unstake with a wallet, including the salt handling in step 3; claiming a reward and
penalising a non-revealer are the cast calls in steps 6 and 7.
Read this first. A commit you never reveal costs you
noRevealPenaltyBps(20%) of the weight you committed. A lost salt makes a commit unrevealable. Save the salt before the commit transaction is broadcast, and keep a backup outside the browser.
The lifecycle at a glance
Every dispute passes through the same phases, each with its exact call.
| Phase | When | Who | Call |
|---|---|---|---|
| Dispute raised | t0 = raisedAt | the session's client | DisputeManager.raiseDispute(sessionId, evidenceURI) |
| Commit | t0 → t0 + commitPeriod | any staked juror who is not a party | commit(disputeId, commitment) |
| Reveal | commit deadline → + revealPeriod | every juror who committed | reveal(disputeId, nodeAtFault, salt) |
| Finalize | after the reveal deadline | anyone | finalize(disputeId) — needs quorum + strict majority |
| Escalate | after the reveal deadline | the DAO (owner) only | escalate(disputeId, nodeAtFault) — only when quorum failed or the vote tied |
| Penalize ghosts | after the reveal deadline | anyone | slashNoReveal(disputeId, juror) |
| Claim reward | after resolution | winning-side revealers | claimArbitratorReward(disputeId) |
| Timeout fallthrough | t0 + disputeTimeout | anyone | DisputeManager.resolveStale(disputeId) — refund, no slash; the DAO backstop exists so this should not happen |
The windows are snapshotted per dispute at its first commit, from the parameters in force then.
disputeTimeout on the DisputeManager must exceed commitPeriod + revealPeriod; the contract
enforces that when governance changes either.
0. Find the contracts
Everything starts from the DisputeManager, which knows which arbitrator is installed and hands
you its address:
export RPC=http://127.0.0.1:8545 # your RPC
cd offchain
export DISPUTE=$(node -p "require('./deployments.json').dispute")
export ZAI=$(node -p "require('./deployments.json').aioum")
export ARB=$(cast call $DISPUTE "arbitrator()(address)" --rpc-url $RPC)
export ME=<your juror address>
cast call $ARB "minArbitratorStake()(uint256)" --rpc-url $RPC # e.g. 5000000000000000000000 = 5,000 ZAI
cast call $ARB "quorum()(uint256)" --rpc-url $RPC
If arbitrator() returns a plain wallet or multisig rather than a contract with quorum(), that
deployment uses a single arbitrator and there is nothing to vote on. The jury contract is
installed when the deploy script runs with USE_COMMIT_REVEAL_ARBITRATOR=1 (on the local
development network scripts/devstack.sh review sets it; plain devstack.sh up does not); its
address is also written to deployments.json as arbitrator, and the app's
Jury tab of the ZAI page reads it from the DisputeManager the same
way.
On your local development network (scripts/devstack.sh review) a case is already waiting: the
seed opens a fresh session on the service the demo agent used and disputes it while still open,
as a "paid, got nothing" claim (cd offchain && npm run demo:dispute does it again), with its
evidence package at http://localhost:3003/.devstack/evidence/<sessionId>.json.
1. Stake
Your voting weight is your live stake at the moment you commit, and you need at least
minArbitratorStake to commit at all, so staking comes first.
cast send $ZAI "approve(address,uint256)" $ARB 10000ether --rpc-url $RPC --private-key $JUROR_KEY
cast send $ARB "stake(uint256)" 10000ether --rpc-url $RPC --private-key $JUROR_KEY
cast call $ARB "stakeOf(address)(uint256)" $ME --rpc-url $RPC
On the AIOUM network you acquire ZAI first; on your local development network the ZAI token is a
mock with an open mint(address,uint256). Staked ZAI is held by the arbitrator contract, apart
from service-node stake in the StakingManager, so a juror's stake and an operator's never answer
for each other.
2. Watch for disputes
A session's client, usually an agent acting for its owner, raises a dispute while the session is
open or inside the post-settlement challenge window. Follow
DisputeRaised(disputeId, sessionId, client, operator) on the DisputeManager:
cast logs --from-block latest --address $DISPUTE "DisputeRaised(uint256,uint256,address,address)" --rpc-url $RPC
cast call $DISPUTE "disputes(uint256)" $ID --rpc-url $RPC # includes evidenceURI, raisedAt, stakeAtRaise
cast call $ARB "caseOf(uint256)(uint64,uint64,uint256,uint256,uint256,bool)" $ID --rpc-url $RPC
caseOf returns (commitDeadline, revealDeadline, committedWeight, forFault, againstFault, resolved); deadlines are 0 until the first commit.
Judging the evidence. The evidenceURI points at an evidence package, the record of what the
agent was served. Where the node anchored a results root, the package is verifiable:
npm run verify-evidence from offchain/ checks the transcript against the on-chain root, the
signatures on the receipts, and (with --exec) a deterministic re-execution. What it can and
cannot prove: dispute verifiability and the
evidence format. Your vote answers one question: was the
node at fault? true slashes the operator and refunds the client; false forfeits the
client's bond to the operator.
3. Commit — generate the salt first, save it, then send
The commitment is keccak256(abi.encode(disputeId, juror, nodeAtFault, salt)). It binds your
address, so nobody can copy it. The vote is one bit, so the salt must be 32 random bytes; a
guessable salt makes the commitment brute-forceable.
export VOTE=true # your verdict: node at fault?
export SALT=0x$(openssl rand -hex 32)
echo "$ID $VOTE $SALT" >> ~/aioum-juror-salts.txt # SAVE IT NOW — before the transaction exists
export COMMITMENT=$(cast call $ARB "commitmentOf(uint256,address,bool,bytes32)(bytes32)" $ID $ME $VOTE $SALT --rpc-url $RPC)
cast send $ARB "commit(uint256,bytes32)" $ID $COMMITMENT --rpc-url $RPC --private-key $JUROR_KEY
The app does the same: it draws the salt with crypto.getRandomValues, writes it to the
browser's local storage before broadcasting, and offers an export of all outstanding
commitments. Export after every commit; a browser reset between commit and reveal otherwise turns
your vote into a penalty.
commit reverts with party recused if you are the client or operator of the session, stake below min under the minimum, commit window closed after the deadline, and already committed
on a second attempt. Committing locks your stake: initiateUnstake is refused until the reveal
deadline of every dispute you have committed to has passed (lockedUntil).
4. Reveal
Between the commit deadline and the reveal deadline, open your sealed vote:
cast send $ARB "reveal(uint256,bool,bytes32)" $ID $VOTE $SALT --rpc-url $RPC --private-key $JUROR_KEY
bad reveal means the vote or salt does not match what you committed; check the saved line. You
cannot reveal a different vote than the one you committed, which is the point of sealing: nobody,
including you, can wait to see how the others lean.
5. Finalize, or escalate
Once the reveal deadline passes, anyone may finalize. It succeeds when revealed weight
reached quorum and one side has a strict majority; the decision is forwarded to the
DisputeManager, which slashes and refunds, or forfeits the bond.
cast send $ARB "finalize(uint256)" $ID --rpc-url $RPC --private-key $ANY_KEY
If revealed weight is below quorum or the vote tied, finalize reverts with no quorum. The
dispute then does not fall to the no-fault timeout: the DAO rules it with
escalate(disputeId, nodeAtFault), and jurors who revealed on the side the DAO chose still count
as winners for rewards. Only if the DAO also fails to act before disputeTimeout does
resolveStale refund the client without slashing.
6. Rewards
Governance can set a reward token and rewardPerResolution. When a dispute resolves, a pot up to
that amount is reserved from the contract's free balance (ZAI held as juror stake is never
spent) and split among winning-side revealers pro rata to committed weight, so the jurors who
read the evidence and judged correctly are the ones paid.
cast send $ARB "claimArbitratorReward(uint256)" $ID --rpc-url $RPC --private-key $JUROR_KEY
not a winning voter means you did not reveal, or revealed the losing side. no reward means the
pot is empty: rewards stay off until governance sets them and the treasury funds the contract
from its share of slashes (a governance action). On the local development network they are unset.
7. Penalties and leaving
A commit is a promise. If you do not reveal, after the reveal deadline anyone can call
slashNoReveal(disputeId, you), and 20% of the weight you committed goes to the treasury, from
live stake first and then from your unbonding queue, so unstaking right after the deadline is no
escape. Recusal is address-level: the contract refuses the two parties to the session, and the
minimum stake plus a quorum set high relative to any single actor make it uneconomic for a party
to vote through a separately staked address. To leave, call initiateUnstake(amount) once no
reveal deadline is pending for you, wait unbondingPeriod, then withdrawUnstaked(). At most 32
unbonding entries can be open.
cast send $ARB "initiateUnstake(uint256)" 10000ether --rpc-url $RPC --private-key $JUROR_KEY
# ... unbondingPeriod later ...
cast send $ARB "withdrawUnstaked()" --rpc-url $RPC --private-key $JUROR_KEY
Parameters (defaults)
These are the values the deploy script sets; governance changes each one with the setter listed.
| Parameter | Default | Set by | Notes |
|---|---|---|---|
minArbitratorStake | 5,000 ZAI | setMinArbitratorStake | below this you cannot commit |
quorum | 25,000 ZAI | setQuorum | revealed weight needed to auto-resolve |
commitPeriod / revealPeriod | 1 day / 1 day | setPeriods | must sum to less than disputeTimeout (3 days by default) |
noRevealPenaltyBps | 2,000 (20%) | setNoRevealPenalty | of committed weight, to the treasury |
unbondingPeriod | 7 days | setUnbondingPeriod | must exceed revealPeriod plus slack so penalties reach leavers |
rewardToken / rewardPerResolution | unset | setArbitratorReward | rewards off until governance funds them |
How each number was chosen: design record's parameter table.
Per-dispute checklist
caseOfsays not resolved, and you are not a party.- Read the evidence; run
verify-evidencewhere a root was anchored. - Generate the salt, save it with the dispute id and your vote, then commit.
- Set a reminder for the commit deadline; reveal inside the reveal window.
- After the reveal deadline: finalize if quorum was reached; claim if you were on the winning side.
- If you hold commits, do not start unstaking until every reveal deadline you are bound to has passed.
That closes the circle: an agent with a budget buys from operators it finds on its own, the operators' stake answers for what they sell, and a staked jury decides when the two disagree. All of it also runs on your own machine, jury included.
Next: Try it on your computer — the same contracts and services on a private chain with mock USDC, so every step runs without spending anything.