Spec — DisputeArbitration Program
Owner: anchor-engineer
Depends on: 03 (AgentRegistry), 07 (TaskMarket), Switchboard VRF v3, NXSStaking (arbitrator stake).
Blocks: M2 TaskMarket unfreeze on Disputed status; frontend /governance dispute panel.
References: backend PDF §2.5 (full spec), §2.1 (CU budget — target raise_dispute 40k / commit_vote 25k / reveal_vote 35k / tally_round 120k / resolve_dispute 180k), §2.6 (7-day upgrade timelock), §5.1 (Re-entrancy — CPIs into TaskMarket, Authorization, Integer Safety, Oracle/VRF Safety, Slashing Safety).
Goal
On-chain dispute resolution for TaskMarket. A client who lost trust in a verified result raises a dispute within the 24h dispute window; a staked arbitrator pool selected by Switchboard VRF renders a binding verdict via commit-reveal; on majority decision, DisputeArbitration CPIs back into TaskMarket to execute release-to-agent, refund-to-client, or a programmable split. Appeals promote the case to a 5-arbitrator second round. Bad-faith voters are slashed with a 10% per-incident cap under a 30-day timelock, matching AgentRegistry slash invariants.
Every transition is signed, seeded, and event-logged so the indexer can replay any dispute deterministically and the portal can surface live arbitration status.
State
DisputeConfig PDA — singleton (extends existing scaffold struct)
- Seeds:
[b"dispute_config"] - Fields:
authority: Pubkeytask_market: Pubkeyagent_registry: Pubkeynxs_staking: Pubkey— arbitrator stake lives in NXSStakingswitchboard_program: Pubkeystake_mint: Pubkey— SAEP mintmin_arbitrator_stake: u64— default10_000 * 10^decimalsper §2.5round1_size: u8— 3round2_size: u8— 5round1_window_secs: i64—48 * 3600round2_window_secs: i64—72 * 3600commit_reveal_split_bps: u16— fraction of the round window allocated to the commit phase (default 5000 = 50%)appeal_collateral_bps: u16— extra collateral multiplier for appeal (default 15000 = 1.5× the losing-side stake-sum)max_slash_bps: u16— 1000 (10% per §2.5)slash_timelock_secs: i64—30 * 86400(mirrors AgentRegistry §5.1 Slashing Safety)vrf_stale_slots: u64— VRF result rejected if older than this (default 150 slots ≈ 60s)paused: boolbump: u8
ArbitratorAccount PDA
- Seeds:
[b"arbitrator", operator.as_ref()] - Fields:
operator: Pubkeystake_pda: Pubkey— NXSStaking stake account this arbitrator draws weight fromeffective_stake: u64— snapshotted at register + anyrefresh_stakeixdisputes_handled: u64minority_votes: u64— count of rounds where this arbitrator voted with the losing sidebad_faith_strikes: u32— incremented when a minority vote is slashedstatus: ArbitratorStatus—Active | Paused | Slashed | Withdrawingwithdraw_unlock_slot: u64— 0 if not withdrawingbump: u8
DisputePool PDA — per-epoch snapshot of eligible arbitrators
Seeds:
[b"dispute_pool", epoch.to_le_bytes()]Fields:
epoch: u64arbitrators: Vec<Pubkey>— max 256 per pool; seeding ix admits new arbitrators once per epochtotal_weight: u128— sum ofeffective_stakebump: u8
Epoch length = 7 days. Pool rebuilds on
snapshot_pool, permissionless crank. Selection reads the pool for the current epoch only — ensures VRF randomness commits against a fixed set.
DisputeCase PDA
- Seeds:
[b"dispute", task_id.as_ref()]— 1-to-1 withTaskMarket::TaskContract.task_id - Fields (backend §2.5 mapping):
task_id: [u8; 32]task_market_account: Pubkey— PDA of the TaskContractclient: Pubkeyagent_did: [u8; 32]escrow_amount: u64— snapshot at raise-timepayment_mint: Pubkeyround: u8— 1 or 2status: DisputeStatusarbitrators: [Pubkey; 5]— round-1 uses first 3 slots, round-2 uses all 5arbitrator_count: u8— 3 or 5vrf_request: Pubkey— Switchboard VRF accountvrf_fulfilled_at_slot: u64commit_deadline: i64reveal_deadline: i64verdict: DisputeVerdict—None | AgentWins | ClientWins | Split { agent_bps: u16 }votes_for_agent: u8votes_for_client: u8votes_for_split: u8raised_at: i64resolved_at: i64bump: u8
VoteRecord PDA
- Seeds:
[b"vote", dispute_case.as_ref(), arbitrator.as_ref()] - Fields:
dispute_case: Pubkeyarbitrator: Pubkeycommit_hash: [u8; 32]—keccak256(vote_tag || nonce || arbitrator)revealed: Option<DisputeVerdict>stake_weight: u64— snapshot at commitbump: u8
AppealRecord PDA
- Seeds:
[b"appeal", dispute_case.as_ref()] - Fields:
dispute_case: Pubkeyappellant: Pubkey— client or agent operatorcollateral_amount: u64— locked at propose-time, slashed on losing appealcollateral_escrow: Pubkey— token accountproposed_at: i64bump: u8
PendingSlash PDA (mirrors AgentRegistry pattern)
- Seeds:
[b"pending_slash", arbitrator.as_ref()] - Fields:
arbitrator,amount,reason_code: u8,proposed_at: i64,executable_at: i64,bump. Single outstanding slash per arbitrator at a time.
Existing scaffold state (keep)
ReentrancyGuard+AllowedCallers— already inguard.rsat4ac3da3+. Used at every inbound CPI from TaskMarket (appeal flow) and outbound CPI into TaskMarket (resolution flow).
§State sweep — reconciliation to scaffold (cycle 177)
Spec §State above is the pre-scaffold intent document. Scaffold (programs/dispute_arbitration/src/state.rs:1-258 + guard.rs:1-130) extends it across 6 typed PDAs + 16 module-level constants + 2 helper fns. Pairs with §Events reconciliation 2b1adae + §Instructions reconciliation 0fb31a9 — together the three deltas blocks form the M2 dispute-program reviewer-handoff substrate. Scaffold landed 5029c6a (M2 full build) over c759a7b guard primitives + cd5b594 reset_guard helper extract.
- Guard cohort: dispute_arbitration =
2-PDA (ReentrancyGuard + AllowedCallers), no separateGuardConfig. Matches cycle-176 treasury_standard finding; confirms cycle-175 prediction "3-PDA guard triplet" wrong for both. 5-program §State matrix: 3-of-5 M1-in-scope landings (capability_registryN/A+ treasury_standard2-PDA+ dispute_arbitration2-PDA); remaining (al-3) agent_registry + (al-6) proof_verifier + (al-4) task_market TBD. DisputeConfigdeltas (11 absent fields, 1 spec field absent in scaffold):pending_authority: Pubkey(5029c6a:79, two-step auth slot — parallels treasury cycle-176 + capability cycle-175);fee_collector: Pubkey(:83, slash-proceeds destination);emergency_council: Pubkey(:86, 4-of-7 pause authority perspecs/ops-squads-multisig.md);commit_window_secs+reveal_window_secs+appeal_window_secs: i64(:89-91, three independent config values rather than spec'sround1_window_secs × commit_reveal_split_bpsderivation);min_stake: u64(:95, renames specmin_arbitrator_stake);min_lock_secs: i64(:96, minimum NXSStaking lock arbitrators commit);bad_faith_threshold: u8+bad_faith_lookback: u8(:99-100, strike-window bounds — absent from spec entirely);next_case_id: u64(:101, monotonicDisputeCase.case_idsource — spec keys disputes viatask_idonly). Spec fieldstake_mint: Pubkeyabsent (NXSStaking owns the mint reference; not duplicated).ArbitratorAccountdeltas (2 absent + 4 drifts + 1 absent-in-scaffold): absent —effective_lock_end: i64(:112, snapshot of NXSStaking lock end at register-time),registered_at: i64(:117). Drifts — specstake_pda→ scaffoldstake_account; specdisputes_handled: u64→cases_participated: u32; specwithdraw_unlock_slot: u64→withdraw_unlock_time: i64(slot-vs-unix semantic shift); specbad_faith_strikes: u32→u8(matchesbad_faith_threshold: u8ceiling). Specminority_votes: u64absent in scaffold (folded intocases_participated+bad_faith_strikes).DisputePooldeltas (3 absent + 2 renames): absent —snapshot_time: i64(:125, wall-time staleness check),arbitrator_count: u16(:127, derivable fromarbitrators.len()but stored explicit for cheaper reads),cumulative_stakes: Vec<u64>cap 256 (:131, load-bearing forweighted_selectbinary-search walk — spec describes "weighted VRF draw over cumulative-stake walk" but doesn't enumerate substrate). Renames — specepoch→snapshot_epoch; spectotal_weight: u128→total_staked: u128.DisputeCasedeltas (4 absent + 5 drifts + 1 absent-in-scaffold + 1 rename): absent —case_id: u64(:138, sourced fromDisputeConfig.next_case_id— spec usestask_idas 1-to-1 PDA seed);vrf_result: [u8; 32](:150, VRF payload bytes — replaces specvrf_fulfilled_at_slot: u64placeholder);total_revealed_weight: u128(:157, denominator for verdict-tally percentage);snapshot_pool: Pubkey(:160, whichDisputePoolwas used). Drifts — spectask_id: [u8;32]→ scaffoldtask_id: u64(cross-program ID-width consistency callout: task_market cycle-167 §Events also usesu64, but spec contracts assume 32-byte hash — surfaces reviewer-handoff item); specagent_did: [u8;32]→ scaffoldagent_operator: Pubkey(different identity surface — operator wallet vs DID hash); specarbitrators: [Pubkey; 5]→Vec<Pubkey>cap 5 (Anchor-friendlier serialization); specvotes_for_*: u8→u128(vote weight not vote count — matchestotal_revealed_weightdenominator); specverdict::Split { agent_bps: u16 }→ scaffoldSplitno embedded data (split-ratio computed from per-voterevealed_verdict+revealed_weight, reviewer open-Q below). Spectask_market_account: Pubkeyabsent (resolvable fromtask_idviafind_program_address). Rename — specraised_at→created_at.VoteRecord→DisputeVoteRecordrename + 5 absent + commit-hash format drift: scaffold name diverges (SEED_DISPUTE_VOTE = b"dispute_vote"atstate.rs:9vs spec[b"vote", ...]). Absent —round: u8(:169, disambiguates round-1 vs round-2);committed_at: i64(:171);revealed_verdict: DisputeVerdict+revealed: bool(:172-173, 2-field separation vs spec'sOption<DisputeVerdict>);revealed_at: i64(:175);revealed_weight: u128(:174, replaces specstake_weight: u64, type widening). Commit-hash format drift (security-relevant; not patched here): spec line 91 specifieskeccak256(vote_tag || nonce || arbitrator)— 3-field binding. Scaffoldcompute_commit_hashatstate.rs:202-211computessha256(verdict_byte || salt)— 2-field binding, sha256 not keccak, no arbitrator pubkey. Held for separate cycle: (a) hash-fn decision (sha256 cheaper viasolana_sha256_hasherbut warrants audit re-scope), (b) arbitrator-binding decision (without arbitrator pubkey a leaked salt allows commit-replay across arbitrators in the same case).AppealRecorddeltas (1 absent + 3 drifts): absent —round: u8(:184, which round the appeal is filed against). Drifts — specdispute_case: Pubkey→case_id: u64(escapes spec's PDA-keyed model); speccollateral_escrow: Pubkey(token account) →collateral_mint: Pubkey(mint; token-account derived as ATA — semantic shift); specproposed_at: i64→filed_at: i64.PendingSlashdeltas (1 absent + 1 absent-in-scaffold): absent —case_id: u64(:195, which case triggered the slash). Specproposed_at: i64absent in scaffold (onlyexecutable_atretained — reviewer flag: withoutproposed_atthe spec §5.1 "single outstanding slash per arbitrator" temporal invariant cannot be enforced from this state alone; held for separate cycle).- Module-level constants (16 absent): 7
SEED_*constants (:3-11) +MAX_ALLOWED_CALLERS = 8+MAX_CPI_STACK_HEIGHT = 3+ADMIN_RESET_TIMELOCK_SECS = 24h+MAX_POOL_SIZE = 256+MAX_ROUND2_ARBITRATORS = 5+MAX_ROUND1_ARBITRATORS = 3+DEFAULT_COMMIT_WINDOW_SECS = 86_400+DEFAULT_REVEAL_WINDOW_SECS = 86_400+DEFAULT_APPEAL_WINDOW_SECS = 86_400+DEFAULT_APPEAL_COLLATERAL_BPS = 15_000+DEFAULT_MAX_SLASH_BPS = 1_000+DEFAULT_SLASH_TIMELOCK_SECS = 30d+DEFAULT_VRF_STALE_SLOTS = 150+DEFAULT_BAD_FAITH_THRESHOLD = 3+DEFAULT_BAD_FAITH_LOOKBACK = 10+BPS_DENOMINATOR = 10_000. Drift surfaced (not patched here):DEFAULT_VRF_STALE_SLOTS = 150vs spec line 34default 2000 slots ≈ 13 min— order-of-magnitude tighter (150 slots ≈ 60s). Held for value-reconciliation cycle. - Helper fns (2 absent from spec):
compute_commit_hashat:202-211(format drift flagged above);weighted_selectat:213-257(cumulative-stake walk for VRF arbitrator draw — takesvrf_bytes+cumulative_stakes+count+offset; binary-search seek + linear-probe collision resolution; load-bearing for round-1 + round-2 selection per spec §State machine). - Did NOT patch: commit-hash format drift, VRF-stale-slots magnitude drift,
PendingSlash.proposed_atabsence, cross-program task_id width consistency. Each warrants its own cycle per single-section discipline.
§State-intro-refresh (cycle 185, 2026-04-19)
Reconciles the vrf_stale_slots default magnitude drift surfaced in the cycle-177 deltas block (line above, DEFAULT_VRF_STALE_SLOTS = 150 vs spec-prior default 2000 slots ≈ 13 min). Spec §State line 34 now reads default 150 slots ≈ 60s to match scaffold state.rs:25. The tighter default reflects the scaffold author's threat-model pick: a VRF callback that hasn't landed inside a ~1-minute window is stale enough that a permissionless cancel_stale_vrf crank refunds the client rather than trapping funds against a possibly-manipulated randomness seed; §Open-questions-for-reviewer line 389 VRF-replacement-path open-Q carries the production-tuning decision forward, including whether 150 slots is too tight for Switchboard's empirical callback latency at M2 launch and should ratchet via set_params governance. This refresh is editorial — no scaffold edit, no program source change, no audit-scope adjustment; the set_params surface already permits governance to widen the default if Neodyme flags it. Remaining cycle-177 held drifts (commit-hash format keccak-vs-sha256, PendingSlash.proposed_at absence, cross-program task_id width) unchanged this cycle — security-relevant and multi-part, warrant their own supervised cycles.
Enums
enum ArbitratorStatus { Active, Paused, Slashed, Withdrawing }
enum DisputeStatus {
RequestedVrf, // case raised, VRF request in flight
SelectionReady, // VRF fulfilled, arbitrators assigned
Committing, // commit window open
Revealing, // reveal window open
Tallied, // verdict computed, awaiting resolve
Appealed, // round 2 triggered, same flow re-runs with 5 arbitrators
Resolved, // CPIed back into TaskMarket
Cancelled, // VRF failure → refund client, mark task Released
}
enum DisputeVerdict { None, AgentWins, ClientWins, Split { agent_bps: u16 } }
Resolved and Cancelled are terminal. Appealed resets round = 2, re-enters RequestedVrf with arbitrator_count = 5.
State machine
raise_dispute (TaskMarket CPI)
|
v
RequestedVrf
|
(Switchboard VRF callback)
v
SelectionReady
|
(start_commit)
v
Committing
|
(commit_reveal_split window elapses)
v
Revealing
|
(tally_round)
v
+---- majority clean? ----+
| yes | no (2-of-3 tie)
v v
Tallied --appeal?--> Appealed --> RequestedVrf (round 2, 5 arbitrators)
| |
v v
Resolved <---- tally_round ----+ (round 2 decides; no further appeal)
OR
RequestedVrf --vrf stale--> Cancelled (permissionless crank)
Invariant: illegal transitions rejected by status gate. Round 2 tally is final.
Instructions
init_config(task_market, agent_registry, nxs_staking, switchboard_program, stake_mint, params) — one-shot, deployer.
register_arbitrator(stake_pda_bump)
- Signers:
operator - Validation:
- CPI-read
NXSStaking::StakeAccountforoperator,status == Active,amount >= min_arbitrator_stake,lock_unlock_slot > now + round2_window_secs / 0.4(stake lock must outlast the longest dispute window). ArbitratorAccountdoes not already exist.
- CPI-read
- Effect: initializes
ArbitratorAccount { status = Active, effective_stake = stake }. - Emits:
ArbitratorRegistered
refresh_stake()
- Signers:
operator - Effect: re-reads NXSStaking amount, re-snapshots
effective_stake. Drops status toPausedif stake falls below min.
snapshot_pool(epoch) — permissionless crank
- Validation:
epoch == current_epoch_from_clock(now)ORepoch == current_epoch + 1(pre-seed). Pool does not already exist. - Effect: initializes
DisputePoolforepoch. Ix takesremaining_accounts: Vec<ArbitratorAccount>and filters forstatus == Active && effective_stake >= min_arbitrator_stake. Pushes up to 256 pubkeys intoarbitrators. Sum intototal_weight. - Emits:
PoolSnapshotted { epoch, count, total_weight }
raise_dispute(task_nonce) — CPI-invoked from TaskMarket
- Signers:
client(via TaskMarket) - Validation:
- Caller = TaskMarket (CPI identity check against
config.task_market). - TaskMarket
status == Disputed(TaskMarket set this pre-CPI per §5.1). !config.paused.- Current epoch
DisputePoolexists andarbitrator_count >= round1_size.
- Caller = TaskMarket (CPI identity check against
- Effect: creates
DisputeCase { round: 1, arbitrator_count: 3, status: RequestedVrf }. Submits Switchboard VRF request;vrf_requestaddress stored.raised_at = now. - Emits:
DisputeRaised { task_id, client, agent_did, escrow_amount } - CU target: 40k
consume_vrf(task_id) — permissionless crank
- Validation:
DisputeCase.status == RequestedVrf. Switchboard VRF callback fulfilled onvrf_requestwithinvrf_stale_slots. Current epoch pool unchanged since raise. - Effect: deterministic weighted selection — for each of the N seats, derive
offset = vrf_bytes[i*8..(i+1)*8] % total_weight, walk the pool's cumulative-stake array, assign the arbitrator at that offset. Reject duplicate selection (re-draw with next 8 bytes). Store pubkeys inarbitrators[0..N]. Status →SelectionReady. Kick off commit window via implicitstart_commit(single-ix coupling avoids an extra round-trip). commit_deadline = now + round_window_secs * commit_reveal_split_bps / 10000reveal_deadline = now + round_window_secs- Emits:
ArbitratorsSelected { case, arbitrators } - CU target: 120k (VRF decode + cumulative walk for up to 256-entry pool)
cancel_stale_vrf(task_id) — permissionless crank
- Validation:
status == RequestedVrf,now_slot > request_slot + vrf_stale_slots. - Effect:
status = Cancelled. CPI TaskMarketforce_release(new TaskMarket ix added in M2 alongside this spec) — refunds client and closes the case; task returns toReleasedwithdisputed=trueflag for analytics. No arbitrator slashing (this is infrastructure-fault, not misbehavior). - Emits:
DisputeCancelled { task_id, reason: "vrf_stale" }
commit_vote(task_id, commit_hash)
- Signers: one of
DisputeCase.arbitrators[0..arbitrator_count] - Validation:
status == Committing,now <= commit_deadline, no priorVoteRecordfor(case, arbitrator). - Effect: creates
VoteRecord { commit_hash, stake_weight = current effective_stake, revealed: None }. - Emits:
VoteCommitted - CU target: 25k
reveal_vote(task_id, verdict, nonce)
- Signers: arbitrator
- Validation:
status == RevealingOR (status == CommittingANDnow > commit_deadline).keccak256(verdict_tag || nonce || arbitrator) == vote_record.commit_hash. - Effect: sets
vote_record.revealed = Some(verdict). Increments the per-verdict counter onDisputeCase. - Emits:
VoteRevealed - CU target: 35k
tally_round(task_id)
- Signers: any (permissionless)
- Validation:
status == Revealing,now > reveal_deadline. - Effect:
- Count stake-weighted votes per verdict across revealed
VoteRecords. - Majority rule: any verdict with
> total_revealed_weight / 2wins. - Unrevealed votes auto-slashed (pre-commits bad-faith strike →
PendingSlashwith 30-day timelock). The arbitrator's weight is dropped from the denominator — no quorum gaming. - If no clean majority (tie or split-three-ways) and
round == 1:status = Appealed. Reset for round 2. EmitAppealAutoTriggered. - If clean majority OR
round == 2:status = Tallied,verdict = winner.
- Count stake-weighted votes per verdict across revealed
- Emits:
RoundTallied { case, round, verdict, votes_for_agent, votes_for_client, votes_for_split } - CU target: 120k
escalate_appeal(task_id)
- Signers: losing party (client if
verdict == AgentWins, agent operator ifClientWins). - Validation:
status == Tallied,round == 1,now < resolved_at + 86400(1-day appeal window). Appellant has not already appealed. - Effect: locks
appeal_collateral_bps * escrow / 10000intoAppealRecord.collateral_escrow. Status →Appealed. Next crank callsraise_dispute-equivalent path internally to re-request VRF for 5 arbitrators. - Emits:
AppealEscalated { appellant, collateral }
resolve_dispute(task_id)
- Signers: any (permissionless)
- Validation:
status == Tallied,verdict != None. For round 2 terminals, or round 1 when appeal window has elapsed without escalate. - Effect (state-before-CPI per §5.1): set
status = Resolved,resolved_at = now. Then CPI into TaskMarketexecute_dispute_verdict(task_id, verdict)— TaskMarket performs the actual token movements (release / refund / split). Release appeal collateral back to appellant ifround == 2 && appellant_won; otherwise collateral is slashed intofee_collector. - Outbound CPI guarded by
check_callee_preconditions— reentrancy flag flipped before CPI, unset on return. - Emits:
DisputeResolved { task_id, verdict } - CU target: 180k
slash_arbitrator(task_id, arbitrator, reason_code)
- Signers: any (permissionless — reason is derived from
VoteRecord+ verdict) - Validation:
DisputeCase.status ∈ {Resolved}.VoteRecord.revealed.is_none()(unrevealed) ORVoteRecord.revealed != verdict(minority) AND arbitrator hasminority_votes_in_last_N >= 3(bad-faith pattern per §2.5).- No existing
PendingSlashfor this arbitrator. amount = min(effective_stake * max_slash_bps / 10000, effective_stake).
- Effect: creates
PendingSlash { executable_at = now + slash_timelock_secs }. Arbitratorstatus = Paused. Incrementsbad_faith_strikes. - Emits:
SlashProposed
execute_slash(arbitrator)
- Signers: any (permissionless crank)
- Validation:
now >= PendingSlash.executable_at. - Effect: CPI into NXSStaking to transfer the slashed amount to
fee_collector. ClosePendingSlash. Reset arbitrator toActiveifbad_faith_strikes < 5, else permanentlySlashed. - Emits:
SlashExecuted
cancel_slash(arbitrator)
- Signers:
authority - Validation:
PendingSlashexists, timelock not yet elapsed. - Effect: closes
PendingSlash. Arbitratorstatus = Active. - Emits:
SlashCancelled
begin_withdraw() / complete_withdraw()
- Two-step arbitrator exit.
begin_withdrawsetsstatus = Withdrawing,withdraw_unlock_slot = now + round2_window_secs. Arbitrator is excluded from future pool snapshots immediately but stays bound for any already-selected case.complete_withdrawclosesArbitratorAccountafter the unlock slot; stake becomes unlockable via NXSStaking.
set_params, set_paused — standard governance surface.
Guard-admin block — reentrancy-guard + CPI-caller allowlist admin.
init_guard(initial_callers: Vec<Pubkey>)— one-shot init. CreatesReentrancyGuard+AllowedCallersPDAs. Signer =authority(config-bound).set_allowed_callers(callers: Vec<Pubkey>)— governance setter. Caps atMAX_ALLOWED_CALLERS. Signer =authority.propose_guard_reset()— opens 24h timelock window for admin-reset. Setsadmin_reset_proposed_at = now. Signer =authority.admin_reset_guard()— executes the timelock-elapsed reset. Validation:now >= admin_reset_proposed_at + ADMIN_RESET_TIMELOCK_SECS(24h). Effect: clearsReentrancyGuard.active, resetsadmin_reset_proposed_at = 0. Signer =authority.- All 4 live in
programs/dispute_arbitration/src/instructions/guard_admin.rsalongsideinitialize_handler(theinit_configimpl). No events emitted by any guard-admin ix at M1 — indexer-side, guard-admin state is visible only via post-emit account reads on the 2 guard PDAs. Cross-spec parity withtreasury_standard's guard-admin block (cycle 163 spec callout) andtask_market's (cycle 164); contrast withagent_registry'sGuardInitialized/GuardAdminReset/AllowedCallersUpdatedlive-emit trio (cycle 161 spec) — guard-vocabulary normalization is a deferred cross-spec cycle.
Scaffold-vs-spec deltas (reconciliation notes against programs/dispute_arbitration/src/instructions/*.rs).
- Spec
init_config(...)→ codeinitialize_handler(params: InitConfigParams). Signature packs the 6 deployer args (task_market,agent_registry,nxs_staking,switchboard_program,stake_mint,params) into a singleInitConfigParamsstruct. Name drift intentional; IDL emitsinitialize. - Spec
slash_arbitrator(task_id, arbitrator, reason_code)→ codeslash_arbitrator_handler(reason_code: u8).task_idis derived from thedispute_casePDA account (viacase_id);arbitratoris theArbitratorAccountPDA (seeds on operator pubkey). Onlyreason_code: u8is a true ix arg. - No
transfer_authority/accept_authorityhandlers in the scaffold — the pre-cycle-166 line "authority two-step — standard governance surface" was spec-ahead-of-code. Two-step authority transfer is deferred to an M2+ governance sweep alongside the other 8 programs' governance surfaces; not in this scaffold. - Scaffold ix-module count: 7 files (
arbitrator.rs,dispute.rs,guard_admin.rs,params.rs,resolution.rs,slashing.rs,voting.rs) +mod.rs, 23pub fn *_handlertotal. Spec §Instructions enumerates 17 headings (pre-cycle 166: 16, counting the bundledbegin_withdraw / complete_withdrawas one); +4 guard-admin block this cycle closes the delta.
Events
Emitted events (15, per programs/dispute_arbitration/src/events.rs + emit! call sites in instructions/*): ArbitratorRegistered, PoolSnapshotted, DisputeRaised, ArbitratorsSelected, DisputeCancelled, VoteCommitted, VoteRevealed, RoundTallied, AppealEscalated, DisputeResolved, SlashProposed, SlashExecuted, SlashCancelled, ParamsUpdated, PausedSet.
5 struct-only guard events ship in the IDL but are never emit!'d at M1 — GuardEntered, ReentrancyRejected, GuardInitialized, GuardAdminReset, AllowedCallersUpdated. Scaffold parity with the guard modules in fee_collector + nxs_staking; wire-up lands when the guard-admin ixs go beyond their init/reset shapes.
Case-scoped events carry case_id: u64 (primary case identifier) — present on 10 of 15 emitted events (dispute lifecycle / vote / slash). task_id: u64 rides alongside case_id on the 3 TaskMarket-bridge events (DisputeRaised, DisputeCancelled, DisputeResolved) so the indexer + TaskMarket caller can correlate without an extra account-read. Operator-scoped events carry arbitrator: Pubkey (Vote × 2 + Slash × 3) or operator: Pubkey (ArbitratorRegistered — pre-registration the arbitrator PDA does not yet exist). PoolSnapshotted carries epoch: u64; ParamsUpdated + PausedSet carry authority: Pubkey. All 15 emitted events carry timestamp: i64; none carry slot in the event body — the indexer resolves slot from the containing transaction, same convention as fee_collector + nxs_staking. The indexer can replay any dispute deterministically off (case_id, task_id?, timestamp) plus the per-event payload.
Errors
Unauthorized, Paused, PoolMissing, PoolTooSmall, VrfStale, VrfNotFulfilled, WrongStatus, CommitWindowClosed, RevealWindowClosed, CommitHashMismatch, DuplicateVote, ArbitratorNotSelected, AppealWindowClosed, AppealCollateralInsufficient, TooManyAppeals, SlashAlreadyPending, SlashTimelockNotElapsed, NoMajority, VerdictEncodingInvalid, StakeInsufficient, StakeLockTooShort, ArithmeticOverflow, CallerNotTaskMarket, ReentrancyDetected, UnauthorizedCaller, CpiDepthExceeded. (Reentrancy / caller / CPI depth errors reuse existing scaffold enum.)
CU budget (§2.1 targets; reviewer may tighten)
| Instruction | Target |
|---|---|
register_arbitrator |
60k |
snapshot_pool |
variable — 10k + 2k × pool_size, 200k hard cap |
raise_dispute |
40k |
consume_vrf |
120k |
cancel_stale_vrf |
60k |
commit_vote |
25k |
reveal_vote |
35k |
tally_round |
120k |
escalate_appeal |
60k |
resolve_dispute |
180k (CPI dominated) |
slash_arbitrator |
50k |
execute_slash |
80k |
resolve_dispute sits adjacent to TaskMarket release / expire in CU cost — same CPI-to-TaskMarket shape, state-before-CPI contract, token-movement path on the TaskMarket side.
Invariants
- At most one
DisputeCasepertask_idover the task's lifetime (seed-enforced). arbitrator_count ∈ {round1_size, round2_size}only.arbitrators[0..arbitrator_count]are pairwise distinct (enforced atconsume_vrf).- Each selected arbitrator has exactly one
VoteRecordfor a given(case, arbitrator). tally_roundruns at most once per(case, round). Second call on same round rejected by status gate.round == 1 && verdict == Noneis only legal whilestatus ∈ {RequestedVrf, SelectionReady, Committing, Revealing}.round == 2is terminal — no further appeal, irrespective of outcome.- Slash cap:
sum(pending + executed slashes in one dispute) <= effective_stake * max_slash_bps / 10000. Single-outstandingPendingSlashper arbitrator enforces this operationally. SlashExecutedcannot fire beforeslash_timelock_secselapse.status == Resolved⇒TaskMarketreceived exactly oneexecute_dispute_verdictCPI fortask_id.- Appeal collateral is either returned (appellant won round 2) or sent to
fee_collector(appellant lost) — never trapped. effective_stakeat vote weight = stake atcommit_vote, not attally_round. Prevents mid-case stake inflation.- VRF result reused within a case only (round 1 and round 2 re-request independently).
Security checks (backend §5.1)
- Account Validation: Anchor seeds + bumps on
DisputeConfig,ArbitratorAccount,DisputePool,DisputeCase,VoteRecord,AppealRecord,PendingSlash. Discriminator enforced. CPI identities for NXSStaking / AgentRegistry / TaskMarket / Switchboard read fromDisputeConfig— hard equality, never caller-supplied. - Re-entrancy: inbound CPI (
raise_disputefrom TaskMarket) goes throughcheck_callee_preconditions— caller guard must be active,DisputeArbitration's guard must be inactive pre-entry. Outbound CPI (resolve_dispute→ TaskMarket) setsstatus = Resolvedbefore the CPI, so even a malicious TaskMarket upgrade cannot re-enter and double-settle. - Integer Safety: stake-weighted tally via
u128; cumulative-weight walk rejects modular overflow;checked_*on slash amounts and timelock deadlines. - Authorization: arbitrator-signed for commit/reveal; permissionless for tally / resolve / consume_vrf / cancel_stale_vrf / slash proposals (all status-gated); client-signed through TaskMarket CPI for raise; losing-party signed for escalate.
- Slashing Safety: 30-day timelock + 10% cap + single-outstanding PendingSlash per arbitrator. Mirrors AgentRegistry §5.1 contract.
cancel_slashavailable to authority until timelock elapses. - Oracle / VRF Safety: Switchboard program ID hard-pinned at init. VRF staleness check (
vrf_stale_slots) prevents replay against an old randomness seed if someone delaysconsume_vrfacross a pool change. VRF failure →cancel_stale_vrfrefunds the client rather than trapping funds — matches §5.1 "oracle failure does not trap value". - Upgrade Safety: Squads 4-of-7, 7-day timelock per §2.6 (not critical-path, so same window as AgentRegistry/TreasuryStandard).
- Token Safety: Slashed tokens move via NXSStaking's existing transfer path (Token-2022
transfer_checked); appeal collateral uses Token-2022transfer_checkedwith the case'spayment_mint. No rawtransferanywhere. - Pause: blocks
raise_dispute,commit_vote,reveal_vote,escalate_appeal. Leavestally_round,resolve_dispute,execute_slash,cancel_stale_vrfunblocked so an in-flight case cannot be trapped by a pause. - Jito bundle assumption: none. Dispute flow is multi-step across hours; no bundle atomicity required.
- DOS surface:
snapshot_poolcaps at 256 arbitrators — once the pool grows past that, the reviewer-tightened version splits pool into sharded PDAs. Out of M2 scope; add when arbitrator count exceeds ~200.
CPI contract with TaskMarket
DisputeArbitration depends on two TaskMarket instructions not in spec 07's M1 surface:
execute_dispute_verdict(task_id, verdict: DisputeVerdict)— called by DisputeArbitration onresolve_dispute. TaskMarket validates caller = DisputeArbitration, validates case isDisputed, and transitions toResolvedwith token movements per verdict (release-to-agent / refund-to-client / split).force_release(task_id, reason_code)— called by DisputeArbitration oncancel_stale_vrf. TaskMarket treats as an expedited release withdisputed=trueflag onrecord_job_outcome.
Both added to TaskMarket in the M2 cycle that lands DisputeArbitration. Spec 07 reserves the Disputed → Resolved transition; this spec fills in the caller.
Open questions for reviewer
- Pool snapshot cadence. 7-day epoch matches backend §2.5 cadence but drifts against arbitrator churn. Reviewer may want a hot-path refresh on
raise_dispute(O(log N) index update) versus the whole-pool rebuild. - Minority-pattern threshold. §2.5 says "repeated pattern"; spec picks
>= 3 minority votes in the last N rounds. Reviewer sets N (default proposal: 10). - Appeal collateral default 1.5×. §2.5 says "additional collateral"; the multiplier is a judgment call. 1.5× of loser-side stake-sum rounds up to a non-trivial cost without pricing out honest appellants.
- VRF replacement path. If Switchboard VRF is unavailable at M2 launch, fallback to recent-blockhash + slot-hash-based lottery is unacceptable (manipulable by leader). Reviewer may require a second VRF provider (Chainlink VRF on Solana is in preview as of 2026-04). Deferred to a separate decision doc if Switchboard signal weakens.
- Stake lock coupling to NXSStaking. Registration requires stake lock
> round2_window_secs / 0.4— assumes slot ~= 400ms, coarse. Reviewer may tighten to a per-slot-rate config read from a slot-rate oracle.
Done-checklist
- Full state machine implemented; illegal transitions rejected
-
register_arbitratorreads NXSStaking via CPI; rejects under-staked / short-locked operators -
snapshot_poolfilters inactive arbitrators; caps at 256 -
raise_disputeonly callable from TaskMarket via CPI identity check -
consume_vrfweighted-draw matches the cumulative-stake walk; duplicates rejected -
cancel_stale_vrfrefund path exercised in integration test -
commit_vote/reveal_votecommit-reveal scheme: reveal fails on mismatched hash; fails afterreveal_deadline -
tally_roundround-1 no-majority triggersAppealed; round-2 no-majority locksverdict = Nonewith reviewer-specified fallback -
escalate_appeallocks collateral viatransfer_checked; refund path green on round-2 win -
resolve_disputeCPIsTaskMarket::execute_dispute_verdictonce; state-before-CPI verified by re-entrancy audit -
slash_arbitratorrespectsmax_slash_bpscap andslash_timelock_secswindow;cancel_slashworks during timelock -
execute_slashCPIs NXSStaking; slashed tokens routed tofee_collector - Every CPI site annotated with the pre-CPI state write
- Golden-path integration test (localnet): register 5 arbitrators → fund a task → raise dispute → VRF fulfill → commit/reveal/tally → majority agent wins → resolve → agent balance increases, dispute recorded on TaskMarket
- Appeal path integration test: round 1 no-majority → round 2 selects 5 → round 2 terminal
- Slash path integration test: minority voter hit with 3-strike threshold → slash proposed → wait 30 days (bankrun warp) → slash executed
- Reentrancy test: malicious TaskMarket upgrade attempts re-entry on
resolve_dispute— rejected - CU measurements per instruction in
reports/dispute-arbitration-anchor.md - IDL at
target/idl/dispute_arbitration.json - Security auditor pass (§5.1); findings closed
- Reviewer gate green; spec ready for Neodyme M2 queue