Skip to content

fix(#202): wait for the daemon reply before persisting a dispute - #275

Open
AndreaDiazCorreia wants to merge 9 commits into
mainfrom
fix/202-open-dispute-daemon-reply
Open

fix(#202): wait for the daemon reply before persisting a dispute#275
AndreaDiazCorreia wants to merge 9 commits into
mainfrom
fix/202-open-dispute-daemon-reply

Conversation

@AndreaDiazCorreia

@AndreaDiazCorreia AndreaDiazCorreia commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #202

Problem

open_dispute treated a successful relay publish as a successful dispute: it persisted Dispute { status: Open } right after publishing and never registered a pending-request waiter. The Dispute message also carried no request_id, so the daemon's CantDo rejection matched nothing in the dispatcher and was dropped (no matching pending request, ignoring event). The dispute stayed locally Open and the UI navigated to the dispute detail — a false success.

Fix

The Dispute message now carries a request_id nonce, which the daemon echoes in both DisputeInitiatedByYou and CantDo (verified in mostro src/app/dispute.rs and src/app.rs). open_dispute registers a pending record before publishing and waits up to 10 s, exactly like create_order / take_order / send_invoice. It persists only on a correlated acceptance; rejection and timeout return an error and write nothing.

The acceptance also carries the daemon's dispute UUID — the id its Kind 38386 event and the solver refer to — so the record is stored under it instead of a locally minted one.

No Dart changes: _openDispute already showed a localized SnackBar and rethrew without navigating, so propagating the error was enough.

Notes

The daemon replies CantDo only for MostroCantDo causes. A duplicate dispute or a daemon-side DB failure is an internal error it merely logs, so those surface as NoDaemonResponse rather than a precise reason.

Verification

cargo build, cargo clippy --locked -- -D warnings, cargo test (238 pass), flutter analyze, flutter test (195 pass). frb-generate.sh produces no drift — the public bridge surface is unchanged.

Tests cover the correlation layer, where the bug was: the kind/nonce gates of take_matching_dispute, a CantDo reaching the waiting caller through the dispatcher's lookup, and the dispute-id extraction. The end-to-end path needs a live daemon — pending manual check with the #203 repro.

Follow-ups (not in this PR)

  • A record created for a peer-opened dispute still gets a local UUID, so the two sides know the same dispute under different ids. Documented in data-model.md.
  • fiat_sent, release and cancel still send no nonce and await no reply — the same false-success shape as this issue, in other actions.

Summary by CodeRabbit

  • New Features

    • Dispute requests now wait for daemon confirmation and use the daemon-provided dispute ID.
    • Concurrent dispute attempts for the same trade are rejected.
    • Late acceptance responses can reconcile disputes after a timeout.
  • Bug Fixes

    • Improved handling of rejections, timeouts, malformed replies, publish failures, and duplicate disputes.
    • Prevented disputes from being recorded unless acceptance is confirmed.
    • Improved matching of dispute responses to their originating requests.
  • Documentation

    • Clarified request correlation, timeouts, dispute identifiers, and peer-opened disputes.

…ecord

Send Dispute action with correlation nonce, wait for daemon's DisputeInitiatedByYou or CantDo reply, and persist local record only on acceptance. Previously persisted on publish, leaving unreconciled Open disputes when daemon rejected with CantDo (#202). Add 10s timeout with late-reply handling, use daemon-assigned dispute UUID, and update error documentation.
…quest handling

Add tests for dispute_id_from_payload helper, verify take_matching_dispute only consumes Dispute records with exact nonce, and confirm CantDo rejections reach waiting open_dispute callers to prevent unreconciled Open disputes (#202).
Update dispute contract to explain request_id nonce correlation, 10s timeout, daemon UUID assignment, and no-persistence-on-rejection behavior. Note CantDo vs internal-error distinction and cross-reference orders.md nonce gate. Add id field note distinguishing daemon-assigned UUIDs (our disputes) from local UUIDs (peer disputes). Include open_dispute in orders.md correlation list.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 78f73cea-0263-4f11-92ff-fb7a12c52583

📥 Commits

Reviewing files that changed from the base of the PR and between 29504be and 184d09d.

📒 Files selected for processing (2)
  • rust/src/api/disputes.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • specs/004-mostro-p2p-client/contracts/disputes.md
  • rust/src/api/disputes.rs

Walkthrough

open_dispute now waits for a correlated daemon response before local persistence. The flow handles acceptance, rejection, timeout, unmatched replies, publish failures, daemon dispute IDs, and late acceptance reconciliation.

Changes

Dispute correlation flow

Layer / File(s) Summary
Correlated dispute message contract
rust/src/mostro/actions.rs, rust/src/api/orders.rs, specs/004-mostro-p2p-client/contracts/orders.md, specs/004-mostro-p2p-client/data-model.md
Dispute messages now include a nonzero request nonce. Pending requests support disputes. Acceptance replies can include the daemon-assigned dispute UUID.
Pending reply registration and dispatch
rust/src/api/orders.rs
The orders API registers and matches dispute waiters by request kind and nonce. Matching replies forward the parsed dispute ID. Late acceptances trigger reconciliation. Tests cover extraction, matching, and rejection handling.
Open dispute acceptance lifecycle
rust/src/api/disputes.rs, specs/004-mostro-p2p-client/contracts/disputes.md
open_dispute prevents concurrent openings for one trade and waits up to 10 seconds after publishing. It persists only accepted disputes with the daemon UUID. It reports rejection, timeout, protocol, and publish failures. Late accepted replies create unread local records when needed. Placeholder claims adopt the daemon ID.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant open_dispute
  participant orders_dispatch
  participant Daemon
  participant dispute_store
  Client->>open_dispute: Open dispute
  open_dispute->>orders_dispatch: Register request nonce
  open_dispute->>Daemon: Publish correlated Dispute action
  Daemon-->>orders_dispatch: DisputeInitiatedByYou or CantDo
  orders_dispatch-->>open_dispute: Matching response
  alt accepted with daemon dispute ID
    open_dispute->>dispute_store: Persist dispute and update status
  else rejected or timed out
    open_dispute-->>Client: Return error
  end
Loading

Possibly related PRs

  • MostroP2P/app#256: Both changes modify dispute persistence and placeholder handling in rust/src/api/disputes.rs.
  • MostroP2P/app#271: Both changes modify open_dispute behavior in rust/src/api/disputes.rs.

Suggested reviewers: ermeme

Poem

A rabbit sends a nonce through the night,
The daemon answers, wrong or right.
No record grows before reply,
Accepted IDs now safely tie.
Late replies mend the trail.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: waiting for the daemon reply before persisting a dispute.
Linked Issues check ✅ Passed The changes satisfy [#202] by correlating replies, persisting only accepted disputes, propagating rejections, and handling timeouts and late acceptance.
Out of Scope Changes check ✅ Passed The implementation, tests, and specification updates support the linked issue and stated dispute request lifecycle objectives.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/202-open-dispute-daemon-reply

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/src/api/disputes.rs`:
- Around line 297-302: Require DisputeAccepted handling in
rust/src/api/disputes.rs at lines 297-302 to extract dispute_id and return
ProtocolError before constructing or persisting Dispute when it is absent;
remove the local UUID fallback while preserving daemon-provided IDs. In
rust/src/api/orders.rs at lines 3308-3311, remove the fallback expectation and
add coverage verifying that an acceptance without a daemon dispute UUID is
rejected and persists nothing.

In `@rust/src/api/orders.rs`:
- Around line 1781-1814: The DisputeInitiatedByYou handling must not allow a
late acceptance with tx: None to fall through and persist only the Dispute trade
status. Update the take_matching_dispute branch in the reply handler to either
return immediately after logging late acceptances or create the
daemon-identified Dispute record before continuing; preserve normal processing
for active requests, and add a test covering timeout followed by acceptance.
- Around line 302-317: Update register_dispute_request to atomically reserve
each trade key before inserting its PendingRequest, rejecting a second
concurrent open_dispute attempt instead of replacing the existing waiter.
Release the reservation when the first attempt completes, including success,
failure, and timeout paths, and preserve the original sender/request
association. Add a concurrent-open test verifying the second attempt is rejected
while the first remains registered.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 50278ee6-694c-4360-8291-8dfaf3f04651

📥 Commits

Reviewing files that changed from the base of the PR and between a149b8f and 44944d2.

📒 Files selected for processing (6)
  • rust/src/api/disputes.rs
  • rust/src/api/orders.rs
  • rust/src/mostro/actions.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md
  • specs/004-mostro-p2p-client/contracts/orders.md
  • specs/004-mostro-p2p-client/data-model.md

Comment thread rust/src/api/disputes.rs Outdated
Comment thread rust/src/api/orders.rs
Comment thread rust/src/api/orders.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 44944d2129

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rust/src/api/disputes.rs Outdated
Prevent second open_dispute from replacing first attempt's pending record by checking insert freshness. Previously both concurrent calls would derive same trade key, second would overwrite first's pending entry, stranding first waiter on NoDaemonResponse. Add test coverage for in-flight guard rejection (#275).
…-reply

Two conflicts, both additive:

- rust/src/api/disputes.rs: the status gate from #203 and this branch's
  single-flight guard are adjacent in open_dispute and independent, so both
  stay, gate first — it is the cheap local check. Their tests likewise.
- contracts/disputes.md: main's stale "creates local Dispute record" side
  effect is the one this branch replaces; main's local-precondition paragraph
  is kept and the two layers are now documented together.

cargo test 246 pass, clippy clean, flutter analyze clean, flutter test 197
pass, frb-generate produces no drift.

@coderabbitai coderabbitai Bot 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
specs/004-mostro-p2p-client/contracts/orders.md (1)

255-264: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Scope deletion to taker-side rows.

These lines state that every Pending/WaitingBuyerInvoice/WaitingPayment row is deleted on Canceled. Line 279 requires a maker row to be resynced to Pending after a timeout republish. Keep the maker row and define the maker/taker condition explicitly. Otherwise, the cancellation handler can delete data that the republished order still needs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@specs/004-mostro-p2p-client/contracts/orders.md` around lines 255 - 264,
Update the cancellation semantics around the Canceled path to delete only
taker-side never-active rows, not every
Pending/WaitingBuyerInvoice/WaitingPayment row. In the orders contract text and
any matching cancellation handler logic, make the maker/taker distinction
explicit using the existing Canceled/TradeUpdate semantics so a maker row can
still be resynced to Pending after timeout republish, while taker-side rows are
deleted and active/history trades keep status Canceled.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@specs/004-mostro-p2p-client/contracts/orders.md`:
- Around line 255-264: Update the cancellation semantics around the Canceled
path to delete only taker-side never-active rows, not every
Pending/WaitingBuyerInvoice/WaitingPayment row. In the orders contract text and
any matching cancellation handler logic, make the maker/taker distinction
explicit using the existing Canceled/TradeUpdate semantics so a maker row can
still be resynced to Pending after timeout republish, while taker-side rows are
deleted and active/history trades keep status Canceled.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ea4b91eb-6250-448d-9ad8-333689627d02

📥 Commits

Reviewing files that changed from the base of the PR and between 44944d2 and c958333.

📒 Files selected for processing (5)
  • rust/src/api/disputes.rs
  • rust/src/api/orders.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md
  • specs/004-mostro-p2p-client/contracts/orders.md
  • specs/004-mostro-p2p-client/data-model.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • specs/004-mostro-p2p-client/data-model.md
  • specs/004-mostro-p2p-client/contracts/disputes.md
  • rust/src/api/disputes.rs
  • rust/src/api/orders.rs

… placeholder

When claiming a peer-initiated placeholder during the open_dispute race window, adopt the daemon's dispute ID instead of keeping the locally-minted placeholder ID. The peer path never sees the daemon's ID, while an accepted open carries it; discarding it would lose the authoritative identifier. Add test coverage for ID preservation during claim (#275 review).
…meout

When the daemon accepts open_dispute after the 10s timeout, the acceptance is genuine — the daemon opened the dispute and notified the counterparty — and its reply moves the trade to Disputed either way. Suppressing the record would leave a disputed trade with no dispute to open and no solver to reach. Record it now (unread, without the reason that went with the timed-out call).

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict review: changes requested

I would not approve this yet.

Blocking issue:

  • open_dispute still persists a locally generated dispute id when the accepted daemon reply does not carry a Payload::Dispute id. That contradicts the new daemon-id contract this PR introduces: the solver and Kind 38386 dispute events use the daemon UUID, so a locally generated fallback can recreate an unjoinable local dispute record. The late-acceptance reconciliation has the same fallback. Please fail closed instead: treat an acceptance without a daemon dispute UUID as a protocol error / unreconcilable malformed acceptance and persist nothing, and add a focused test for that case.

I verified the rest of the previously raised concurrency/late-acceptance concerns on the current head: the in-flight guard, placeholder id adoption, and late-acceptance reconciliation tests are present and pass locally.

Local verification run:

  • cargo test --lib → 248 passed, 8 ignored.

Reject malformed acceptances that carry no dispute_id in both open_dispute and record_late_acceptance. Dispute.id is contractually the daemon's identifier; minting a local fallback would create a record indistinguishable from a genuine one while being wrong. A conforming daemon always sends it (mostro src/app/dispute.rs notify_dispute_to_users), so this is a protocol-violation guard. Add test coverage for late acceptance rejection (#275

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@rust/src/api/disputes.rs`:
- Around line 475-491: The upsert update closure around upsert_or_update must
claim only an existing InReview, non-initiator, reason-less placeholder with a
solver, preserving its solver and status while replacing its local ID with the
daemon dispute ID and setting initiated_by_me to true. Leave all other records
unchanged, and update a_late_acceptance_leaves_an_existing_record_alone to cover
this race while retaining a separate completed-retry preservation test.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 81782226-691d-4391-b2c1-4eb5a5d2c3d3

📥 Commits

Reviewing files that changed from the base of the PR and between c958333 and 29504be.

📒 Files selected for processing (3)
  • rust/src/api/disputes.rs
  • rust/src/api/orders.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • rust/src/api/orders.rs
  • specs/004-mostro-p2p-client/contracts/disputes.md

Comment thread rust/src/api/disputes.rs

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict re-review: changes requested

The previous daemon-id fallback blocker is fixed on this head, but I would still not approve yet.

Blocking issue:

  • The late-acceptance reconciliation still leaves an admin-created placeholder unchanged. If open_dispute times out, then admin-took-dispute creates the peer-style placeholder before the correlated DisputeInitiatedByYou acceptance is processed, record_late_acceptance calls upsert_or_update(..., |_| Ok(())) and keeps the local placeholder id plus initiated_by_me: false. That loses the daemon dispute id and ownership proof from the correlated acceptance, so the local record still violates the daemon-id contract in this race.

Please make record_late_acceptance claim only the placeholder shape that proves this race (InReview, non-initiator, no reason, solver present): preserve the solver/status, replace the local id with the daemon dispute id, and set initiated_by_me = true. Leave completed retry records unchanged. Add a focused test for that interleaving; the current a_late_acceptance_leaves_an_existing_record_alone test actually codifies the buggy behavior.

Verification:

  • cargo test --lib passes on the current head: 249 passed, 8 ignored.
  • A temporary focused test for the placeholder interleaving fails on this head: the record keeps the placeholder UUID instead of adopting the daemon UUID.

…uard

Both the post-acceptance insert and the late-acceptance reconciliation test for the exact same placeholder shape before claiming it (InReview, not ours, no reason, solver known). Extract the predicate into a single function with a doc comment explaining what it guards. Update the late-acceptance reconciliation to claim the placeholder the same way the insert does, and add test coverage for the claim path.

@ermeme ermeme Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Strict re-review: approved

The latest head fixes the remaining daemon-id reconciliation blocker. I re-checked the prior concerns on the current commit:

  • Missing daemon dispute ID now fails closed and persists nothing.
  • Late acceptance with no local record creates a daemon-ID-backed dispute.
  • Late acceptance racing an admin-created placeholder now claims only the placeholder shape, preserves solver/status, and adopts the daemon dispute ID plus initiator ownership.
  • A successful retry record is left untouched.

Local verification:

  • cargo test --lib -- --nocapture → 250 passed, 8 ignored.
  • cargo clippy --locked -- -D warnings → passed.

No blocking issues remain from my review.

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.

open_dispute persists a local dispute optimistically — daemon CantDo rejection is never reconciled

1 participant