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.

PhaseWhenWhoCall
Dispute raisedt0 = raisedAtthe session's clientDisputeManager.raiseDispute(sessionId, evidenceURI)
Committ0 → t0 + commitPeriodany staked juror who is not a partycommit(disputeId, commitment)
Revealcommit deadline → + revealPeriodevery juror who committedreveal(disputeId, nodeAtFault, salt)
Finalizeafter the reveal deadlineanyonefinalize(disputeId) — needs quorum + strict majority
Escalateafter the reveal deadlinethe DAO (owner) onlyescalate(disputeId, nodeAtFault) — only when quorum failed or the vote tied
Penalize ghostsafter the reveal deadlineanyoneslashNoReveal(disputeId, juror)
Claim rewardafter resolutionwinning-side revealersclaimArbitratorReward(disputeId)
Timeout fallthrought0 + disputeTimeoutanyoneDisputeManager.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.

ParameterDefaultSet byNotes
minArbitratorStake5,000 ZAIsetMinArbitratorStakebelow this you cannot commit
quorum25,000 ZAIsetQuorumrevealed weight needed to auto-resolve
commitPeriod / revealPeriod1 day / 1 daysetPeriodsmust sum to less than disputeTimeout (3 days by default)
noRevealPenaltyBps2,000 (20%)setNoRevealPenaltyof committed weight, to the treasury
unbondingPeriod7 dayssetUnbondingPeriodmust exceed revealPeriod plus slack so penalties reach leavers
rewardToken / rewardPerResolutionunsetsetArbitratorRewardrewards off until governance funds them

How each number was chosen: design record's parameter table.

Per-dispute checklist

  1. caseOf says not resolved, and you are not a party.
  2. Read the evidence; run verify-evidence where a root was anchored.
  3. Generate the salt, save it with the dispute id and your vote, then commit.
  4. Set a reminder for the commit deadline; reveal inside the reveal window.
  5. After the reveal deadline: finalize if quorum was reached; claim if you were on the winning side.
  6. 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.