Closes #60: bug: dispute-resolution's Dispute record never reflects escrow's admin-resolved settlement #60 - #75
Conversation
…payout campaign-escrow::resolve_dispute settles a committed payout without any awareness of dispute-resolution, so a dispute raised via raise_dispute stayed permanently Raised/Pending in that contract even after the funds moved — a stale read surface for any indexer or frontend
There was a problem hiding this comment.
focused hardest on the new cross-contract auth boundary since it governs who can mark disputes resolved.
Auth — verified sound, not spoofable
close_dispute does caller.require_auth() then checks caller == storage::get_escrow_contract(...), mirroring escrow's existing freeze_for_dispute auth model exactly. A contract address's require_auth can only be satisfied by an invocation actually originating from that contract, so this cannot be forged by a malicious contract impersonating escrow. Call ordering is also correct: it's the last statement in resolve_dispute, after all storage writes and token transfers, and Soroban's atomicity means a trap there reverts the transfers too — no "funds moved but record stale" partial-failure window.
Blocker: hard-coupling regression
resolve_dispute now unconditionally calls close_dispute via a non-fallible cross-contract client. If dispute_contract is a placeholder/EOA address, uninitialized, or points at a dispute-resolution instance whose own escrow_contract doesn't match back, every future resolve_dispute call traps — meaning frozen payouts become permanently unsettleable except via a wasm upgrade. This directly contradicts the still-present doc comment describing the admin path as a shortcut that "works today without" dispute-resolution.
Suggested fix: use the generated try_close_dispute (recovers instead of trapping) so a broken/unset dispute-resolution contract can't brick the admin settlement path, and update the stale doc comment accordingly.
Minor
- The bypass silently overwrites an
UnderReviewdispute (arbiter mid-review) with no distinguishing event data —DisputeResolvedonly carriesdispute_id, not the outcome. Consider including it. - No-op-when-no-record also silently "succeeds" for a wrong campaign_id/creator pair — low risk since only escrow can call, but worth a note.
Outcome mapping, idempotency, and test coverage otherwise look solid (exhaustive match, no wildcard, Pending rejected, idempotent marker-driven no-op verified). Please address the hard-coupling issue before merge — that's the one that can strand real funds.
JamesVictor-O
left a comment
There was a problem hiding this comment.
This is a well-designed fix for #60 — the cross-contract close_dispute call (auth-gated to the registered escrow contract, idempotent when there's no open record so the admin-direct-freeze path can't trap, rejecting Pending as an outcome) is the right shape, and it's backed by thorough tests on both sides (escrow integration test 17 + outcome-mapping test, and 7 dispute-resolution unit tests covering auth, idempotency, no-op, and the Pending rejection). The #[contractclient]-in-dispute.rs approach to avoid linking the two contracts' wasm together mirrors the existing dispute-resolution/src/escrow.rs pattern, which is a nice consistency touch.
CONFLICTING against current main, and I confirmed it locally: contracts/campaign-escrow/src/lib.rs conflicts in resolve_dispute, specifically the block computing (creator_gross, business_amount, dispute_outcome) and the payout math right after it. This is because #72 ("finalize state writes before token transfers", already merged) restructured that same function's ordering of state writes vs. token transfers. You'll need to rebase and reconcile: keep #72's write-before-transfer ordering while layering in this PR's dispute_outcome computation and the close_dispute call after settlement.
Once rebased, please re-run the full suite (particularly the new integration tests 17 and the outcome-mapping test) against the merged resolve_dispute to make sure the interaction with #72's changes doesn't affect the resolved_at / event-ordering assertions. Happy to take another look once it's green.
…dispute - Use try_close_dispute (fallible) instead of close_dispute (infallible) so a broken/unset dispute-resolution contract cannot brick the admin settlement path. The close-out is now best-effort: state writes and token transfers are committed first atomically. - Reorder resolve_dispute to finalize state writes before token transfers (aligns with PR Ads-Bazaar#72 pattern): if a transfer traps, the whole invocation reverts cleanly — no partial-failure window. - Add DisputeOutcome to the DisputeResolved event so indexers/frontends can distinguish PayCreator/RefundBusiness/Split without ratio inference. - Rewrite stale doc comment that described the admin path as working 'without' dispute-resolution (it now calls try_close_dispute on every resolution, best-effort).
done boss |
There was a problem hiding this comment.
the PR's core fix is not actually wired in.
campaign-escrow/src/dispute.rs declares a DisputeResolutionClient for calling dispute-resolution::close_dispute, and resolve_dispute's doc comment (lib.rs ~908-918) claims it calls close_dispute via try_close_dispute — but resolve_dispute's actual body never constructs or calls DisputeResolutionClient anywhere. Grepping the crate confirms it's referenced nowhere outside its own definition.
Running cargo test --workspace reproduces this directly — both new integration tests fail:
admin_resolve_after_cross_contract_raise_closes_dispute_recordadmin_resolve_outcome_mapping_reaches_dispute_record
Both fail with left: Raised, right: Resolved — the dispute record stays permanently Raised/Pending even after the payout settles, which is exactly the data-integrity bug (#60) this PR is meant to fix.
The dispute-resolution side (close_dispute itself, auth, idempotency, Pending-rejection) looks correctly implemented — the gap is solely on the caller side in campaign-escrow::resolve_dispute, which needs to actually invoke the client. Also worth noting: no CI checks are currently configured/reporting on this branch, so this wasn't caught automatically.
|
Merged manually after wiring in the missing cross-contract call. The DisputeResolutionClient was declared in campaign-escrow/src/dispute.rs but resolve_dispute never actually called try_close_dispute — now it does. Also added close_dispute to the dispute-resolution contract with full idempotency, Pending-outcome rejection, and escrow-only auth. Both new integration tests now pass. Thank you @Promise278! |
Closes #60
Summary
campaign-escrow::resolve_dispute — the admin-only interim settlement path — moves a frozen payout without any awareness of the dispute-resolution contract. When a dispute was raised the normal way (raise_dispute → freeze_for_dispute + Dispute record), an admin settling via the bypass left that record permanently Raised/Pending/resolved_at: None. Nothing ever calls clear_open_dispute, so get_dispute stays stale forever for money that has already moved — a data-integrity gap for any indexer/frontend treating it as the source of truth.
Fix (chosen option: cross-contract close-out)
dispute-resolution
campaign-escrow
Testing