Skip to content

Closes #60: bug: dispute-resolution's Dispute record never reflects escrow's admin-resolved settlement #60 - #75

Closed
Promise278 wants to merge 3 commits into
Ads-Bazaar:mainfrom
Promise278:feat/dispute-resolution-dispute-record
Closed

Closes #60: bug: dispute-resolution's Dispute record never reflects escrow's admin-resolved settlement #60#75
Promise278 wants to merge 3 commits into
Ads-Bazaar:mainfrom
Promise278:feat/dispute-resolution-dispute-record

Conversation

@Promise278

Copy link
Copy Markdown

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

  • New close_dispute(caller, campaign_id, creator, outcome) — authorized for the registered escrow contract only (mirror of escrow's freeze_for_dispute auth model).
  • Marks the record Resolved with the DisputeOutcome matching the admin's resolution, stamps resolved_at, clears the open marker, publishes DisputeResolved.
  • Idempotent no-op when no record exists, so the admin's direct-freeze path can't trap.
  • Rejects Pending as an outcome; get_dispute doc comment documents the reconcile + the read-only detection rule for the edge case (still-Raised dispute + get_application(...).frozen == false / Paid ⇒ bypass used).

campaign-escrow

  • resolve_dispute now maps PayCreator/RefundBusiness/Split → CreatorFavored/BusinessFavored/Split and calls close_dispute cross-contract after settlement, via a new local #[contractclient] (src/dispute.rs, mirroring dispute-resolution/src/escrow.rs) so neither crate's wasm links the other.

Testing

  • 7 new unit tests: escrow-only auth, marker cleared, idempotent double-close, no-op without a record, Pending rejected, and the end-to-end raise → escrow resolve_dispute → record Resolved.
  • 2 new integration tests (test 17 + outcome-mapping): raise → wait window → resolve_dispute → get_dispute asserts Resolved + matching outcome + resolved_at; escrow bootstrap now registers a real initialized dispute-resolution contract.
  • Full workspace: 139 tests passing, clippy and rustfmt clean.

…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

@JamesVictor-O JamesVictor-O 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.

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 UnderReview dispute (arbiter mid-review) with no distinguishing event data — DisputeResolved only carries dispute_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 JamesVictor-O 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.

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.

⚠️ Merge conflict. GitHub reports 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).
@Promise278

Copy link
Copy Markdown
Author

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.

⚠️ Merge conflict. GitHub reports 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.

done boss

@JamesVictor-O JamesVictor-O 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.

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_record
  • admin_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.

@JamesVictor-O

Copy link
Copy Markdown
Contributor

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!

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.

bug: dispute-resolution's Dispute record never reflects escrow's admin-resolved settlement

2 participants