Skip to content

fix(escrow): allow multi-sig escrows to enter the Disputed state - #556

Merged
MaryammAli merged 1 commit into
BlockDash-Studios:mainfrom
ThatCodeBabe:fix/multi-sig-dispute-lifecycle-550
Aug 19, 2026
Merged

fix(escrow): allow multi-sig escrows to enter the Disputed state#556
MaryammAli merged 1 commit into
BlockDash-Studios:mainfrom
ThatCodeBabe:fix/multi-sig-dispute-lifecycle-550

Conversation

@ThatCodeBabe

Copy link
Copy Markdown
Contributor

Closes #550

Summary

dispute() read only EscrowEntry.arbiter and returned NoArbiter when it was None. Escrows created by deposit_with_arbiters deliberately leave arbiter as None and carry their arbiters in arbiters + arbiter_threshold — so every multi-sig escrow was rejected.

Since dispute() is the only transition into Disputed, and both vote_for_dispute and resolve_dispute_multi_sig require that status, the entire multi-arbiter dispute path was unreachable. A multi-sig escrow could only be withdrawn or refunded after expiry; its arbiters could never act on it.

dispute() now accepts either escrow shape. The EscrowDisputed event still carries one representative arbiter — the assigned one for single-arbiter escrows, otherwise the first listed arbiter, matching the deterministic fallback dispute::resolve_expiry_recipient already uses.

Why the existing tests missed it

  • test_deposit_with_arbiters_creates_escrow_and_is_disputable never calls dispute(). It asserts only that the escrow is Pending, despite its name.
  • test_multi_sig_invalid_signer_cannot_vote, test_multi_sig_insufficient_votes_cannot_resolve, and the single-vote test all construct their escrow with deposit(..., &Some(arbiter)) — a single-arbiter escrow, not a multi-sig one — then assert is_err(). They passed for the wrong reason.

Tests added

lifecycle_test.rs covers the transition that was broken:

  • multi_sig_escrow_can_be_disputed — a deposit_with_arbiters escrow reaches Disputed
  • multi_sig_arbiters_can_vote_once_disputed — an assigned arbiter can then vote

Both fail on main and pass here.

reentrancy_test.rs drives the money paths with a hostile token that calls back into the contract during transfer. deposit_with_commitment and partial_payment transfer before writing state, unlike the other eight money paths in escrow.rs, which would let a nested call observe stale storage and slip past the CommitmentAlreadyExists and Overpayment guards. These tests pin down that the Soroban host refuses the re-entrant frame, so the guards hold. If that platform guarantee ever changes — a host upgrade, or a refactor reaching an entry point through some other indirection — these fail instead of allowing silent duplicate settlement.

Acceptance criteria

Criterion Status
State transitions invalid if they break lifecycle assumptions or attempt duplicate settlement ✅ multi-sig escrows can now make the legitimate Pending → Disputed transition; duplicate-settlement guards proven intact under re-entry
Dispute and release flows deterministic, no ambiguous fund states ✅ representative-arbiter selection is deterministic and matches the existing expiry fallback
Test coverage for dispute resolution, release, cancellation, partially settled edges ⚠️ partial — see below

Verification

cargo test     407 → 411 passing, 0 failed
cargo clippy   clean on all changed files

Repo-wide cargo fmt --check reports 326 pre-existing diffs (which is presumably why the fmt and clippy steps are commented out in .github/workflows/contract.yml), so I formatted only the two new files rather than bury this change in an unrelated repo-wide reformat.

Scope note

I kept this focused on the reachability bug rather than sweeping every lifecycle path, so it stays reviewable. Two things I found but deliberately left alone, happy to take either in a follow-up:

  1. cleanup_escrow calls entry.arbiter.unwrap() after an is_some() check (clippy flags it) — pre-existing, unrelated.
  2. There are 37 ignored tests in the suite. Worth a look at whether any cover the paths this issue is about.

`dispute()` read only `EscrowEntry.arbiter`, returning `NoArbiter` when it was
`None`. Escrows created by `deposit_with_arbiters` deliberately leave `arbiter`
as `None` and carry their arbiters in `arbiters` + `arbiter_threshold`, so every
multi-sig escrow was rejected.

Because `dispute()` is the only transition into `Disputed`, and both
`vote_for_dispute` and `resolve_dispute_multi_sig` require that status, the
entire multi-arbiter dispute path was unreachable. A multi-sig escrow could only
ever be withdrawn or refunded after expiry; its arbiters could never act.

`dispute()` now accepts either shape. The emitted `EscrowDisputed` event still
carries a single representative arbiter: the assigned one for single-arbiter
escrows, otherwise the first listed arbiter — the same deterministic fallback
`dispute::resolve_expiry_recipient` already uses.

Why this was not caught:

- `test_deposit_with_arbiters_creates_escrow_and_is_disputable` never calls
  `dispute()`; it only asserts the escrow is `Pending`.
- `test_multi_sig_invalid_signer_cannot_vote`,
  `test_multi_sig_insufficient_votes_cannot_resolve` and the single-vote test
  all build their escrow with `deposit(..., Some(arbiter))` — a single-arbiter
  escrow, not a multi-sig one — then assert `is_err()`, which passed for the
  wrong reason.

Adds `lifecycle_test.rs` covering the real transition: a multi-sig escrow
reaching `Disputed`, and an assigned arbiter voting once it is.

Also adds `reentrancy_test.rs`, which drives the money paths with a hostile
token that calls back into the contract. `deposit_with_commitment` and
`partial_payment` transfer before writing state, unlike the other eight money
paths; the tests pin down that the Soroban host refuses the re-entrant frame, so
the duplicate-commitment and overpayment guards cannot be bypassed. If that
platform guarantee ever changes, these fail rather than silently allowing
duplicate settlement.

Tests: 407 -> 411, all passing. clippy clean on the changed files. Repo-wide
`cargo fmt` is not clean (326 pre-existing diffs, which is why it is disabled in
CI), so only the two new files were formatted.

@MaryammAli MaryammAli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@MaryammAli
MaryammAli merged commit a247dca into BlockDash-Studios:main Aug 19, 2026
2 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.

Harden escrow lifecycle and dispute settlement semantics

2 participants