fix(#335): replace-not-discard the session on a confirmed retake - #375
fix(#335): replace-not-discard the session on a confirmed retake#375Matobi98 wants to merge 3 commits into
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Team Run ID: 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. Comment |
There was a problem hiding this comment.
Reviewed at 23bed558, merged locally against current main (6261924). The branch is 5 commits behind and merges clean. On that merge: cargo test --locked 330 passed, cargo clippy --locked -- -D warnings clean, cargo check --locked --target wasm32-unknown-unknown clean.
The fix is the right one, and the manual regtest verification (two full trades, sell and buy, with mostro-cli as maker) is exactly what this needed. Three things to change before it goes in.
Blocking: the new test does not exercise the bug — verified by mutation
retake_replaces_stale_session_trade_key_index calls install_session twice directly. It never goes through take_order, which is where the bug lived.
I reverted the call site back to create_session — i.e. reinstated exactly the #335 part 1 bug, leaving install_session in place — and:
test result: ok. 330 passed; 0 failed
Nothing fails. The description says the test "reproduces the bug (fails without the fix, passes with it)"; that is only true in the compile sense (the method did not exist). With the method in the tree, anyone can move take_order back to create_session and CI stays green.
What would actually pin the fix is a test over take_order — which needs a daemon. A workable middle ground: assert the replacement semantics the caller depends on (two install_session calls with different indexes, the second winning even when peer material is already present). Failing that, the honest move is to say in the description that the take_order → install_session seam is covered only by the manual regtest run.
Blocking (small): create_session is left with no production caller
The description says "create_session keeps its reject-on-duplicate behavior — still used elsewhere and by its own idempotency test".
The first half is not true. After this PR, create_session has zero production callers — only the two tests (create_session_is_idempotent, new_session_has_no_peer_keys). On main its only caller was the very line this PR changes. So the "reject on duplicate" invariant no longer protects anything.
Not serious, but it should be a decision rather than a side effect: either delete create_session along with its tests, or say in the description that it is deliberately kept for future use. Dead code justified by an inaccurate claim is the part I would not merge as is.
Missing: the contract update
CLAUDE.md treats the specs as a living artifact — a behaviour change updates its contract in the same PR. This changes take-order behaviour and touches nothing in specs/. Two short edits:
1. contracts/orders.md, take_order → Side effects (~:122). The paragraph ends with "…the trade session/subscriptions start." It should say that a confirmed take installs the session:
…the order book entry is synced, and the trade session/subscriptions start.
A confirmed take **installs** that session, replacing whatever a prior
failed or timed-out attempt left behind: each attempt derives a fresh trade
key, so keeping the earlier session would leave chat key lookups reading a
superseded `trade_key_index` (#335).2. contracts/orders.md, the generation-gate bullet (:394-406). Worth one sentence recording which marker the gate reads, since it is the thing that made #335 part 2 a non-issue and it cost a full investigation to establish:
The gate compares against the persisted `trade_keys` binding — written by
`take_order` on every attempt (`store_trade_key_index`) — not against
`Session.trade_key_index`, which a retake could leave stale until #335. That
is why a superseded reply was already dropped even while the session held the
previous take's index.I would deliberately not add anything about a future deferred session removal carrying its generation: that documents a constraint on code which does not exist, and this repo consistently declines to do that (plan items 1.6 and 1.10 withdrawn rather than left "just in case"; #362 dropped the local_trade_status memoization "rather than adding pass-scoped state on spec"). If the bond work resumes, #197 is where that decision belongs.
Minor
-
take_orderstill discards the result withlet _ = .... What it now swallows is the only errorinstall_sessioncan return — theorder_id != order.idmismatch, i.e. a programming error.if let Err(e) = … { log::warn!(…) }costs one line. (Note: #347 rewrites this same line to log thecreate_sessionerror — see the coordination point.) -
install_sessionreplaces the whole session, so it resetspeer_pubkey,shared_keyandadmin_shared_keytoNone. For the case it fixes that is correct — a retake has a fresh trade key, so the old shared key is invalid — and it is not reachable today with peer material present, since the daemon will not confirm a second take of the same order. But the docstring should say it: "discards peer material; only for a confirmed take." If someone later calls it from elsewhere, losing the chat keys would be silent. -
Coordination: this conflicts with #345 and #347. Merging #347 then #375 gives a conflict in
rust/src/api/orders.rsandrust/src/mostro/session.rs. All three PRs rewrite session creation intake_order, and #347 addsupsert_peer_sessionwhile this one addsinstall_session— two new helpers in the same file with opposite merge semantics (one preserves role/index/order and touches only peer material; the other replaces everything). Worth deciding the order, and if both land, making sureSessionManagerdoes not end up with three ways to write a session and no written rule for choosing.
|
@Matobi98 hi, please, fix the conflicts with main |
…take
take_order created the session with create_session and discarded the
Err("SessionAlreadyExists") with `let _ = ...`. A retake of an order
that already had a session (first take timed out or was rejected,
retake succeeded with a fresh trade key) silently kept the stale
session and its old trade_key_index, which chat key lookups then read.
Add SessionManager::install_session, which always replaces whatever
session exists for the order instead of rejecting the write, and have
take_order call it. create_session keeps its reject-on-duplicate
behavior for callers that need it.
Manually verified against a local mostrod/regtest setup: a full sell
and a full buy trade both completed (Success) with the fix applied.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QqbQ5Kvxiw3ySA9p8pFEj3
…, contract Addresses Catrya's review on MostroP2P#375. The regression test asserted a tautology: it called install_session twice directly, so reverting take_order to create_session — reinstating the bug — left the suite green. Rewrite it to state what it actually pins, and add install_session_discards_previous_peer_material, which plants peer material on the first attempt's session and asserts the retake still wins and does not inherit it. That is the property that makes the replacement correct rather than merely last-write-wins, and the one install_session's docstring now promises. The take_order -> install_session seam remains out of reach without a daemon; both the test docstring and the PR description say so rather than implying coverage that does not exist. - Document that install_session discards peer material and is only for a confirmed take, so a future caller cannot drop chat keys silently. - Log the install_session error instead of discarding it with `let _`; the only error it can return is the order_id mismatch, a programming error. - Update contracts/orders.md: a confirmed take installs the session, and the generation gate reads the persisted trade_keys binding rather than Session.trade_key_index — which is why a superseded reply was already dropped while the session held the previous take's index.
23bed55 to
dd4a303
Compare
|
Rounds 1 and 2 — pushed in Round 2 first, because it changes the shape of the fix#345 and #347 merged in between. They added a second production path that creates the session for a take: So an unconditional The replacement is now gated on
Every take derives a fresh trade key, so a stale session always carries an older index and a peer-reveal session always carries the current one. The read and the write happen under one write lock: deciding outside it would let a reveal land in between and be discarded by a replacement decided when it did not yet exist. I considered carrying the peer material over on replacement instead, and rejected it: a Conflict resolution keeps both helpers. Round 1 — your reviewBlocking: the test didn't exercise the bug. You were right, and the mutation check is now part of how I write these. The new test, Blocking: Missing: the contract. Both edits are in, and the first one now records both branches rather than only the replacement. Minor 1 — VerifiedOn the toolchain CI pins (1.97.0): Unrelated, but noticed while verifying: adding Manual verification, redone on the rebased branchThe original PR's regtest run predated the rebase, so it no longer covered this code. Redone end to end against Polar + local relay + The chat step is what exercises this round specifically, and it is the one that would have caught a regression: if Subscribing to the local relay for kind 14 across the trade window, the message is there: a peer-to-peer event (neither author nor Two things worth recording from doing this, since neither is obvious and both cost me a wrong diagnosis first:
Part 2 of #335 — a decision I'd rather not take aloneThe PR adds no code for part 2, and I want that confirmed rather than assumed. The gate part 2 asks for already exists, at if let Some(bound) = lookup_trade_key_index(&oid).await {
if trade_index < bound {
// drop: addressed to superseded trade key (idx {} < bound {})
return;
}
}What matters is the source it compares against. The issue reads as though the two halves were one problem — with a stale session, a gate comparing against the session would compare against the wrong number. That coupling doesn't exist, because the gate never read the session. Which is also why I didn't tick the box myself: "already satisfied" is a different claim from "implemented", and it's yours to accept. If you'd rather have something concrete instead, there is one thing genuinely missing: a test pinning which source the gate reads, so a future refactor can't quietly move it onto |
Summary
Part 1 of #335.
take_ordercreated the session withcreate_sessionand discarded theErr("SessionAlreadyExists")withlet _ = ....A retake of an order that already had a session — the first take timed out or was rejected, and the retake succeeded with a fresh trade key — silently kept the stale session and its old
trade_key_index, which chat key lookups then read.Why a plain replacement is not enough
Since #345 and #347 merged, a session can already exist at that call site for two unrelated reasons, and they must not be treated alike:
trade_key_index, no peer materialapply_peer_reveal, when the daemon's first reply carried both trade pubkeyspeer_pubkeyandshared_keyAn unconditional replacement would wipe exactly the chat keys the peer-reveal path exists to establish.
main's own comment above the call site says the duplicate-create error there is now expected for that reason.The
trade_key_indexseparates the two exactly: every take derives a fresh trade key, so a stale session always carries an older index and a peer-reveal session always carries the current one.Changes
SessionManager::install_sessioninrust/src/mostro/session.rs.trade_key_index; returns the existing one untouched when the index matches.peer_pubkey,shared_keyandadmin_shared_key. That is correct rather than lossy: a shared key derived from the superseded trade key is invalid, so carrying it over would fail the chat silently instead of rebuilding it.take_ordercallsinstall_sessionand logs the error instead of discarding it. The only error it can return is an order-ID mismatch, i.e. a programming error.contracts/orders.md: both branches of the install, and the fact that the generation gate reads the persistedtrade_keysbinding rather thanSession.trade_key_index— which is why a superseded reply was already dropped even while the session held the previous take's index.create_sessionKept, with its reject-on-duplicate behaviour and its tests. It has a production caller again: #345/#347 added one at the peer-reveal path. The earlier concern that this PR would leave it callerless no longer applies.
Test coverage and its limit
retake_replaces_stale_session_trade_key_index— the retake wins, carrying its fresh index.install_session_discards_previous_peer_material— the retake wins even over a session holding peer material, and inherits none of it.install_session_keeps_this_takes_own_session_with_peer_material— the same-index case: the peer reveal's session survivestake_order, withpeer_pubkeyandshared_keyintact.The third is mutation-checked: removing the index gate fails it with
left: None, right: Some("aabbccdd").These pin
install_sessionitself. Reaching it throughtake_orderneeds a running daemon, so that seam is covered only by the manual run below — the test docstrings say so rather than implying coverage that does not exist.Test plan
cargo test --locked— 414 passedcargo clippy --locked -- -D warnings— cleancargo check --lockedand--target wasm32-unknown-unknown— cleanManual regtest runs on the rebased branch (Polar + local relay +
mostrod,mostro-clias maker, app as taker in Chrome). The run in the original PR predated the rebase ontomainand no longer covered this code, so it was redone — in two passes, because the first got the chat step wrong.IN_FLIGHT, not settled), fiat-sent, release, counterparty rated from the app. Settles in LND.ptag is the node.Had
install_sessionreplaced the peer reveal's session, there would be no shared key,send_messagewould fall back to storing local-only, and nothing would reach the relay — silently, since all four of its fallback paths onlylog::warn!and the UI renders the message as sent either way. That is why this was checked on the wire and not in the app.Worth recording: the chat only works once the trade is active. Before the hold is paid the node has not revealed the counterparty pubkeys (
payload=Order(..., buyer_pk=-, seller_pk=-)in the app log), so there is no peer material and a message sent then is correctly local-only. That was the mistake in the first pass.All of the above on the toolchain CI pins (1.97.0).
Part 2 of #335
No code here. The generation gate already exists (
rust/src/api/orders.rs:1710) and reads the persistedtrade_keysbinding rather thanSession.trade_key_index, so part 1's bug never reached it.contracts/orders.mdnow records that. Whether this ticks part 2's box is a maintainer call — left unticked, raised in the review thread.