fix(escrow): allow multi-sig escrows to enter the Disputed state - #556
Merged
MaryammAli merged 1 commit intoAug 19, 2026
Merged
Conversation
`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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #550
Summary
dispute()read onlyEscrowEntry.arbiterand returnedNoArbiterwhen it wasNone. Escrows created bydeposit_with_arbitersdeliberately leavearbiterasNoneand carry their arbiters inarbiters+arbiter_threshold— so every multi-sig escrow was rejected.Since
dispute()is the only transition intoDisputed, and bothvote_for_disputeandresolve_dispute_multi_sigrequire 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. TheEscrowDisputedevent still carries one representative arbiter — the assigned one for single-arbiter escrows, otherwise the first listed arbiter, matching the deterministic fallbackdispute::resolve_expiry_recipientalready uses.Why the existing tests missed it
test_deposit_with_arbiters_creates_escrow_and_is_disputablenever callsdispute(). It asserts only that the escrow isPending, 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 withdeposit(..., &Some(arbiter))— a single-arbiter escrow, not a multi-sig one — then assertis_err(). They passed for the wrong reason.Tests added
lifecycle_test.rscovers the transition that was broken:multi_sig_escrow_can_be_disputed— adeposit_with_arbitersescrow reachesDisputedmulti_sig_arbiters_can_vote_once_disputed— an assigned arbiter can then voteBoth fail on
mainand pass here.reentrancy_test.rsdrives the money paths with a hostile token that calls back into the contract duringtransfer.deposit_with_commitmentandpartial_paymenttransfer before writing state, unlike the other eight money paths inescrow.rs, which would let a nested call observe stale storage and slip past theCommitmentAlreadyExistsandOverpaymentguards. 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
Pending → Disputedtransition; duplicate-settlement guards proven intact under re-entryVerification
Repo-wide
cargo fmt --checkreports 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:
cleanup_escrowcallsentry.arbiter.unwrap()after anis_some()check (clippy flags it) — pre-existing, unrelated.