Skip to content

feat(dispute): enforce bond slashing, winner refund, and reputation deltas on resolution - #1787

Merged
Olowodarey merged 2 commits into
Arena1X:mainfrom
Clevervikson:dispute
Aug 30, 2026
Merged

feat(dispute): enforce bond slashing, winner refund, and reputation deltas on resolution#1787
Olowodarey merged 2 commits into
Arena1X:mainfrom
Clevervikson:dispute

Conversation

@Clevervikson

Copy link
Copy Markdown
Contributor

Summary

Closes the economic and reputational griefing hole where a losing disputer could
file a bond-backed challenge with zero consequences. Every settlement path now
slashes the loser exactly once, makes the winner whole, and records a reputation
delta for both parties.


Problem

dispute.rs held the disputer's bond in escrow but the rejection path (uphold = false) called escrow::slash_funds — which only credited the protocol treasury —
without ever returning anything to the winner or penalising the loser's reputation.
An upheld dispute returned the disputer's bond but did not penalise the market
creator for having their resolution overturned. Neither path was guarded against
being called twice on the same dispute record.

Concretely:

  • A losing disputer forfeited funds but had no reputation cost.
  • A market creator whose resolution was overturned received no additional
    reputation penalty
    beyond the one already applied when the dispute was raised.
  • resolve_dispute and finalize_arbiter_vote could be called a second time on
    a dispute that had already been settled, risking double-refund or double-slash.
  • resolve_appeal could be called again after the appeal bond was cleared,
    silently doing nothing rather than reverting.

Changes

contracts/open-market/src/storage_types.rs

Field Type Purpose
is_resolved bool Set atomically before any fund movement; blocks re-entry on every settlement path
resolution_upheld Option<bool> Records the outcome for auditability after the dispute record is cleaned up

contracts/open-market/src/errors.rs

The #[contracterror] enum is hard-capped at 50 XDR cases and was already at
that limit. New error semantics are carried by existing variants, documented in
code:

Semantic Reused variant Discriminant
DisputeAlreadyResolved ZeroShareTransfer 112
NothingToSlash EscrowEmpty 32

contracts/open-market/src/escrow.rs

Added distribute_slashed_bond(env, winner, winner_refund, total_bond):

total_bond
  ├─ winner_refund  →  refund() to winner address
  └─ remainder      →  slash_funds()
                           ├─ insurance_pool_share_bps %  →  insurance pool
                           └─ remainder                   →  treasury
  • All arithmetic is checked (checked_sub, checked_mul, checked_div).
  • Returns InvalidInput if winner_refund > total_bond.
  • The winner refund uses the existing refund() path (reentrancy-guarded).

contracts/open-market/src/reputation.rs

Two new mutation hooks, consistent with the existing on_dispute_raised pattern:

  • on_dispute_upheld(env, creator) — applied when the disputer wins;
    increments the market creator's dispute_count a second time, compounding the
    penalty already applied when the dispute was raised.
  • on_dispute_rejected(env, disputer) — applied when the disputer loses;
    increments the disputer's dispute_count, reducing their reputation score
    according to the existing calculate_creator_reputation formula
    (min(dispute_count * 50, 200) penalty, capped at 200 pts).

contracts/open-market/src/dispute.rs

All three settlement paths updated uniformly:

resolve_dispute

  1. Load dispute → return ZeroShareTransfer if is_resolved
  2. Set is_resolved = true, resolution_upheld = Some(uphold) and persist — before any fund movement
  3. uphold = truedistribute_slashed_bond(winner = disputer, refund = bond, total = bond) + reopen market + on_dispute_upheld(creator)
  4. uphold = falsedistribute_slashed_bond(winner = None, refund = 0, total = bond) + on_dispute_rejected(disputer)

resolve_appeal

  1. Load appeal_bondreturn EscrowEmpty if appeal_bond == 0 (double-call guard)
  2. uphold = true → full refund to appealer + reopen market + on_dispute_upheld(creator)
  3. uphold = false → full slash + on_dispute_rejected(appealer)

finalize_arbiter_vote

  1. Load dispute → return ZeroShareTransfer if is_resolved
  2. Set is_resolved = true and persist — before slash-and-redistribute
  3. Same uphold/reject split as resolve_dispute with reputation deltas

Tests

contracts/open-market/tests/dispute_tests.rs — 10 new tests, all passing:

Test What it proves
test_resolve_dispute_reject_slashes_bond_correctly Bond is split exactly between insurance pool and treasury on rejection
test_resolve_dispute_uphold_refunds_disputer_full_bond Disputer receives 100 % of their bond back on uphold; treasury/insurance unchanged
test_resolve_dispute_cannot_be_resolved_twice Second call after removal returns DisputeNotFound
test_resolve_dispute_reject_penalizes_disputer_reputation Rejected disputer's dispute_count increments
test_resolve_dispute_uphold_penalizes_creator_reputation Uphold applies two increments to creator's dispute_count (raise + resolution) and score is bounded by the penalty formula
test_resolve_appeal_uphold_refunds_appealer_full_bond Appeal uphold refunds full bond; treasury unchanged
test_resolve_appeal_reject_slashes_appealer_bond Appeal rejection splits bond correctly
test_resolve_appeal_cannot_be_resolved_twice Second appeal resolution returns EscrowEmpty
test_complete_dispute_lifecycle_with_reputation_tracking Full raise → reject flow: bond splits correctly, disputer penalised, dispute removed
test_uphold_dispute_reopens_market_and_updates_all_parties Full raise → uphold flow: market reopened, full refund, creator double-penalised
test_checked_arithmetic_in_bond_distribution Odd bond amount (999 999 stroops): insurance + treasury shares sum exactly to bond with no rounding loss

All pre-existing dispute tests continue to pass (36/36). Reputation test suite
also green (7/7).


Acceptance criteria

Criterion Status
Winner is made whole distribute_slashed_bond refunds full bond to winner
Loser is slashed exactly once is_resolved flag blocks second settlement
Bond split: winner refund + protocol fee ✅ insurance pool share + treasury remainder
Reputation delta applied to both parties on_dispute_upheld / on_dispute_rejected hooks
Re-resolution reverts ZeroShareTransfer / EscrowEmpty / DisputeNotFound
Checked arithmetic throughout checked_sub, checked_mul, checked_div in distribute_slashed_bond
Covered by tests ✅ 10 new tests, 36/36 dispute suite passing

Notes for reviewers

  • Error code reuse: DisputeAlreadyResolved and NothingToSlash are new
    semantic concepts but reuse existing discriminants (112 and 32) because the
    50-case XDR cap is already exhausted. The reuse is documented in errors.rs and
    both call sites. If the cap is ever raised in a future SDK version these should
    be extracted into proper variants.
  • distribute_slashed_bond is pub(crate) — it is not exposed in the
    contract's public ABI; it is an internal escrow primitive used exclusively by
    the three dispute settlement paths.
  • The reputation formula (min(dispute_count * 50, 200)) and the insurance pool
    split (insurance_pool_share_bps, default 10 %) are both governance-configurable
    — no magic numbers were introduced.

closes #1761

@vercel

vercel Bot commented Aug 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
insight-arena-4rll Ready Ready Preview Aug 30, 2026 2:22pm

@Olowodarey
Olowodarey merged commit 61aafc0 into Arena1X:main Aug 30, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Contracts] open-market: Dispute bond slashing & reputation impact

2 participants