feat(#217): resync trade_key_index to the max recovered index - #239
feat(#217): resync trade_key_index to the max recovered index#239codaMW wants to merge 1 commit into
Conversation
WalkthroughThe restore flow computes the highest valid trade index across orders and disputes. It then raises the persisted identity trade-key index before returning restore data. Tests cover idempotency, invalid values, persistence, publication, and fresh derivation. ChangesRestore recovery flow
Estimated code review effort: 3 (Moderate) | ~20 minutes Sequence Diagram(s)sequenceDiagram
participant OrdersAPI
participant DaemonReply
participant IdentityAPI
OrdersAPI->>DaemonReply: receive restored orders and disputes
OrdersAPI->>OrdersAPI: compute maximum valid trade_index
OrdersAPI->>IdentityAPI: ensure trade-key index reaches recovered floor
IdentityAPI-->>OrdersAPI: persist and publish updated index
OrdersAPI-->>OrdersAPI: return RestoreSessionInfo
Possibly related issues
Possibly related PRs
Suggested reviewers: Poem
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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.
Actionable comments posted: 1
🧹 Nitpick comments (2)
rust/src/api/orders.rs (1)
2922-2953: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDoc comment for
restore_sessionis misattached torecovered_max_trade_index.Lines 2922-2929 describe
restore_session's send/await behavior and key-correlation design, but there's no blank line before Line 2930, so the whole block (2922-2936) becomes one contiguous rustdoc comment attached tofn recovered_max_trade_index(Line 2937) instead.pub async fn restore_session()(Line 2953) ends up with no doc comment of its own.♻️ Proposed fix
-/// Send a `RestoreSession` to the active daemon and return the user's active -/// trades/disputes. Mirrors create_order's send/await, minus the order payload. -/// -/// Correlation: the request is sent from a fresh TRADE key (event.sender) while -/// the Seal carries the IDENTITY key (event.identity). The daemon looks up -/// trades by identity/master key and replies to the trade key -/// (mostro restore_session.rs: master_key = event.identity, reply -> event.sender), -/// so we subscribe on the trade key and correlate the reply by that pubkey. -/// Highest trade-key index across all recovered orders and disputes (`#217`). +/// Highest trade-key index across all recovered orders and disputes (`#217`). /// /// The counter must be raised to this so the next `derive_trade_key()` cannot /// hand out an index a recovered trade already owns. Returns `None` when the /// restore carried no trades (nothing to resync to). Indexes are `i64` on the /// wire; a value that is negative or beyond `u32::MAX` is not a real trade /// index, so it is dropped rather than truncated into the counter. fn recovered_max_trade_index( info: &mostro_core::message::RestoreSessionInfo, ) -> Option<u32> {And restore the removed lines as the doc comment directly above
pub async fn restore_session()(Line 2952-2953).🤖 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 `@rust/src/api/orders.rs` around lines 2922 - 2953, Separate the restore_session-specific rustdoc from recovered_max_trade_index by ending it before the helper’s documentation, then restore that documentation immediately above pub async fn restore_session. Keep the recovered_max_trade_index explanation attached only to recovered_max_trade_index and preserve the existing restore_session send/await and key-correlation details on the public function.rust/src/api/identity.rs (1)
297-317: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winKeep
ensure_trade_key_index_at_leastout of the Dart-callable API.This setter is only used by the ignore-marked
orders::restore_session()path and by tests; make it non-pubinstead ofpub(crate)and regenerate FRB bindings if needed.♻️ Proposed fix
-pub async fn ensure_trade_key_index_at_least(floor: u32) -> Result<()> { +async fn ensure_trade_key_index_at_least(floor: u32) -> Result<()> {🤖 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 `@rust/src/api/identity.rs` around lines 297 - 317, Make ensure_trade_key_index_at_least private by removing its public visibility, since it is only used internally by orders::restore_session() and tests. Confirm the change does not expose it through Dart-callable or generated FRB bindings, and regenerate bindings only if required.Source: Path instructions
🤖 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/orders.rs`:
- Around line 2952-3033: Update the Action::CantDo rejection handling to
correlate restore requests with take_matching_restore(trade_pubkey_hex) instead
of the nonce-based take_matching_request path when the request kind is Restore.
Ensure the matched Restore waiter receives the rejection so restore_session()
returns the actual reason immediately, while preserving nonce-based correlation
for other request kinds.
---
Nitpick comments:
In `@rust/src/api/identity.rs`:
- Around line 297-317: Make ensure_trade_key_index_at_least private by removing
its public visibility, since it is only used internally by
orders::restore_session() and tests. Confirm the change does not expose it
through Dart-callable or generated FRB bindings, and regenerate bindings only if
required.
In `@rust/src/api/orders.rs`:
- Around line 2922-2953: Separate the restore_session-specific rustdoc from
recovered_max_trade_index by ending it before the helper’s documentation, then
restore that documentation immediately above pub async fn restore_session. Keep
the recovered_max_trade_index explanation attached only to
recovered_max_trade_index and preserve the existing restore_session send/await
and key-correlation details on the public function.
🪄 Autofix (Beta)
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: e7ce263d-b70c-4593-98a1-8b519886f74c
📒 Files selected for processing (3)
rust/src/api/identity.rsrust/src/api/orders.rsrust/src/mostro/actions.rs
8b4ef14 to
e7471ce
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
rust/src/api/identity.rs (1)
1-1: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winUn-rolled-back persist failure in
ensure_trade_key_index_at_leastlets a retried restore silently report success without ever persisting the raised counter. The root cause is inidentity.rs;orders.rsonly surfaces the downstream effect.
rust/src/api/identity.rs#L414-434:state.identity_info.trade_key_index = raisedis set beforedb.save_identityand never rolled back on failure; combined with theraised == currentno-op short-circuit, a retry with the same floor silently returnsOk(())without retrying the persist. Also missing apublish_indexcall so the secure-storage mirror (issue#249) never learns of the raised index. Roll backtrade_key_indextocurrenton save failure and callpublish_index(trade_key_index_tx(), raised)after a successful save.rust/src/api/orders.rs#L3040-3051: because of the above, a retriedrestore_session()call can returnOk(info)even though the DB'strade_key_indexwas never durably raised — no local change needed once the identity.rs fix lands, but flagging so the contract is understood.🤖 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 `@rust/src/api/identity.rs` at line 1, Update ensure_trade_key_index_at_least to restore state.identity_info.trade_key_index to current when db.save_identity fails, allowing retries to persist the requested floor instead of taking the raised == current no-op path; after a successful save, call publish_index(trade_key_index_tx(), raised) to update the secure-storage mirror.
🤖 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/identity.rs`:
- Around line 414-434: Update ensure_trade_key_index_at_least to restore the
in-memory index if save_identity fails, allowing a retry with the same floor to
persist it again. After a successful persistence, publish the raised index
through the existing trade-key index channel, such as
publish_index/trade_key_index_tx, and preserve the no-op behavior when the
current index already meets the floor. Extend the lifecycle test to assert
publication and add coverage for persistence failure followed by a successful
retry.
In `@rust/src/api/orders.rs`:
- Around line 3040-3051: The restore path in the match arm handling
DaemonReply::Restored depends on ensure_trade_key_index_at_least retrying
persistence when the stored counter is already high enough. Update
ensure_trade_key_index_at_least in identity.rs so each call verifies or retries
the durable counter update instead of returning early solely because the
in-memory/current value meets the floor, while preserving monotonicity and
propagating persistence failures to the restore caller.
---
Outside diff comments:
In `@rust/src/api/identity.rs`:
- Line 1: Update ensure_trade_key_index_at_least to restore
state.identity_info.trade_key_index to current when db.save_identity fails,
allowing retries to persist the requested floor instead of taking the raised ==
current no-op path; after a successful save, call
publish_index(trade_key_index_tx(), raised) to update the secure-storage mirror.
🪄 Autofix (Beta)
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: b4e4403f-d7c9-4a2d-abc8-afb81a0494a7
📒 Files selected for processing (2)
rust/src/api/identity.rsrust/src/api/orders.rs
There was a problem hiding this comment.
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/identity.rs`:
- Around line 414-415: Update ensure_trade_key_index_at_least to require a
durable database handle from app_db::db() before calling
ensure_trade_key_index_at_least_with; return an error when storage is
unavailable so neither the in-memory trade_key_index nor its publisher is
changed. Add coverage verifying the None-storage path preserves both states
unchanged.
🪄 Autofix (Beta)
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: 292f1b3b-80a6-45f8-97d0-377a30a34947
📒 Files selected for processing (1)
rust/src/api/identity.rs
|
Fixed, mirroring derive_trade_key rather than the proposed one-liner, since require_durable_storage returns () (a guard), not a handle, and the unconditional version would break web. Now require_durable_storage(db)? on native (refuses the resync when there's no store, so we never bump+publish without persisting), web exempt with the same rationale as derive_trade_key (no init_db on web, IndexedDB has no save_identity yet, so the Flutter mirror is web's durable record until #233). The None-refuses path is covered by the existing require_durable_storage test that derive_trade_key also relies on. |
Refs MostroP2P#217 (sub-issue of MostroP2P#142). After a restore, the local trade_key_index counter is still at its post-install value while recovered trades already occupy higher indexes, so the next order reuses a trade key already bound to a recovered trade — the daemon rejects the reused index with CantDo(InvalidTradeIndex), and two trades would share a key. When a valid RestoreData is processed, raise trade_key_index to the maximum recovered index across orders and disputes. Monotonic (a restore never rewinds the counter) and idempotent. - identity::ensure_trade_key_index_at_least(floor) bumps the counter to max(current, floor) under the identity write lock; persists with the same discipline as derive_trade_key (rolls back the in-memory bump on a persist failure so a bumped-but-unpersisted counter can't regress on restart and reopen the bug), and requires durable storage on native (web exempt, same rationale as derive_trade_key). - orders::recovered_max_trade_index(info) — the max trade_index over restore_orders and restore_disputes; u32::try_from drops negatives and out-of-range values rather than truncating garbage. None when the restore carried no trades. - Wired into restore_session's Restored arm: resync before returning the info. A resync failure fails the restore rather than returning success with a counter that could hand out a reused key.
7b97e5a to
39c6492
Compare
|
Rebased onto current main now that #225 (the #215 RestoreSession handshake this was stacked on) has merged. Squashed the four iterative review-fix commits into one clean commit. The only conflict was additive test-module overlap in |
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
rust/src/api/identity.rs (1)
424-433: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy liftPersist the WebAssembly resynchronization before returning success.
On WebAssembly, Lines 424-433 pass
Noneto the core. The core updates only memory and callspublish_index, which ignoresSender::sendfailures. Tokio broadcast sends only to active receivers, and a successful send does not confirm that a receiver observed the value. (docs.rs)
restore_session()can succeed without a durabletrade_key_index. If the application stops before the Flutter mirror writes the event, a later startup can reuse the old index. Persist this Rust-owned protocol state through IndexedDB before returning success.As per coding guidelines, Rust owns protocol-layer persistence and must use
indexed_db_futureson web.#!/usr/bin/env bash set -euo pipefail # Confirm the resolved Tokio version and locate every durable consumer of this index. rg -n -C 3 '(^tokio\s*=|name = "tokio")' --glob 'Cargo.toml' --glob 'Cargo.lock' . rg -n -C 6 \ 'publish_index|trade_key_index_tx|TradeKeyIndexStream|trade_key_index|indexed_db_futures|save_identity' \ --glob '*.rs' --glob '*.dart' .🤖 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 `@rust/src/api/identity.rs` around lines 424 - 433, Update the WebAssembly path around ensure_trade_key_index_at_least_with so the resynchronized trade-key index is durably persisted through indexed_db_futures/IndexedDB before returning success, rather than relying on publish_index or the Flutter mirror. Keep the existing non-WebAssembly flow unchanged, and propagate persistence failures instead of treating the in-memory update as successful.Source: Coding guidelines
🤖 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/identity.rs`:
- Around line 452-465: The resync failure path must preserve the raised
trade_key_index and track that persistence is pending instead of restoring
current. Update the resync logic around save_identity and the same-floor
short-circuit to retry or reconcile pending durability rather than return
success without writing, and prevent derive_trade_key_with from issuing keys
until the floor is persisted or reconciled. Add a targeted failing-store test
covering failure, recovery, and attempted derivation.
- Around line 447-450: Update recovered_max_trade_index to reject u32::MAX
before ensure_trade_key_index_at_least_with can persist it, using the existing
error path and an i64::from(u32::MAX) + 1 limit. Preserve the no-op behavior in
the raised == current branch for valid recovered indices.
---
Duplicate comments:
In `@rust/src/api/identity.rs`:
- Around line 424-433: Update the WebAssembly path around
ensure_trade_key_index_at_least_with so the resynchronized trade-key index is
durably persisted through indexed_db_futures/IndexedDB before returning success,
rather than relying on publish_index or the Flutter mirror. Keep the existing
non-WebAssembly flow unchanged, and propagate persistence failures instead of
treating the in-memory update as successful.
🪄 Autofix (Beta)
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: db232fb6-c95d-4ca6-8f92-082a6cb20895
📒 Files selected for processing (2)
rust/src/api/identity.rsrust/src/api/orders.rs
🚧 Files skipped from review as they are similar to previous changes (1)
- rust/src/api/orders.rs
| let raised = current.max(floor); | ||
| if raised == current { | ||
| // Already ahead of (or level with) the recovered set — no-op, no write. | ||
| return Ok(()); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
ast-grep outline rust/src/api/orders.rs --items all
rg -n -C 8 \
'recovered_max_trade_index|u32::MAX|try_from|trade_index' \
rust/src/api/orders.rs rust/src/api/identity.rsRepository: MostroP2P/app
Length of output: 50369
🏁 Script executed:
#!/usr/bin/env bash
set -euo pipefail
echo "== identity.rs relevant lines =="
sed -n '340,390p' rust/src/api/identity.rs
sed -n '420,460p' rust/src/api/identity.rs
echo
echo "== orders.rs recovery max lines =="
sed -n '3070,3104p' rust/src/coderabbit-shell-logs/shell-output-iujecI.log 2>/dev/null || sed -n '3070,3104p' rust/src/api/orders.rs
echo
echo "== recovery tests around out-of-range =="
sed -n '3125,3195p' rust/src/api/orders.rs
echo
echo "== exact boundary/overflow references and u32 casts =="
rg -n -C 4 'u32::MAX|checked_add|overflow|identity::.*derive_trade_key|trade_key_index\s*\+' rust/src/api rust/src --glob '*.rs'Repository: MostroP2P/app
Length of output: 21588
Reserve the terminal trade-key index.
recovered_max_trade_index accepts u32::MAX, and ensure_trade_key_index_at_least_with can store it in identity_info.trade_key_index. The next derive_trade_key_with computes candidate_index = state.identity_info.trade_key_index + 1, so a restored terminal index causes derivation to fail or wrap depending on overflow configuration. Reject u32::MAX with i64::from(u32::MAX) + 1 in recovered_max_trade_index, or use checked increment with a stable exhaustion marker.
🤖 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 `@rust/src/api/identity.rs` around lines 447 - 450, Update
recovered_max_trade_index to reject u32::MAX before
ensure_trade_key_index_at_least_with can persist it, using the existing error
path and an i64::from(u32::MAX) + 1 limit. Preserve the no-op behavior in the
raised == current branch for valid recovered indices.
| state.identity_info.trade_key_index = raised; | ||
| if let Some(db) = db { | ||
| if let Err(e) = db.save_identity(&state.identity_info).await { | ||
| // Roll back the in-memory bump on a failed persist. Without this, a | ||
| // retried restore with the same floor would see `raised == current`, | ||
| // take the no-op short-circuit above, and return Ok(()) WITHOUT ever | ||
| // re-attempting the write — silently leaving the durable counter | ||
| // un-raised and reopening the key-reuse bug this closes. (Unlike | ||
| // derive_trade_key_with, which safely keeps its forward mutation | ||
| // because it has no idempotency short-circuit to defeat.) | ||
| state.identity_info.trade_key_index = current; | ||
| return Err(anyhow!( | ||
| "StorageError: failed to persist resynced trade_key_index {raised}: {e}" | ||
| )); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Do not roll back the in-memory safety floor after a save error.
Line 462 restores current after save_identity fails. If current is 22 and the recovered floor is 23, a transient failure leaves the state at 22. If storage recovers before a later derivation, derive_trade_key_with calculates 23 at Line 370 and can issue an index already present in the recovered set. The restore error does not block that later derivation.
Do not only remove the rollback. Lines 448-450 would then let a same-floor retry report success without another write. Keep a pending-durability state, retain the non-regressing floor, and block derivation until the floor is persisted or reconciled. Add a failing-store test for this sequence.
As per coding guidelines, add targeted tests when expanding complex asynchronous workflows.
🤖 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 `@rust/src/api/identity.rs` around lines 452 - 465, The resync failure path
must preserve the raised trade_key_index and track that persistence is pending
instead of restoring current. Update the resync logic around save_identity and
the same-floor short-circuit to retry or reconcile pending durability rather
than return success without writing, and prevent derive_trade_key_with from
issuing keys until the floor is persisted or reconciled. Add a targeted
failing-store test covering failure, recovery, and attempted derivation.
Source: Coding guidelines
Refs #217 sub-issue of #142. Stacked on #215 (consumes the
RestoreDatahandling that PR introduces).
Problem
After a restore, the local
trade_key_indexcounter is still at itspost-install value (0/1) while the recovered trades already occupy higher
indexes. The next order the user creates therefore reuses a trade key already
bound to a recovered trade a correctness bug (the daemon rejects the reused
index with
CantDo(InvalidTradeIndex), and worse, two trades would share a key).What it does
When a valid
RestoreDatais processed, raisetrade_key_indexto the maximumrecovered index across both orders and disputes. Monotonic a restore never
rewinds the counter.
identity::ensure_trade_key_index_at_least(floor)bumps the counter tomax(current, floor)under the identity write lock. No-op when already ahead(idempotent). Persists with the same discipline as
derive_trade_key: if thecounter moves, the write must succeed or the call fails a bumped-but-
unpersisted counter would regress on the next restart and reopen this bug.
orders::recovered_max_trade_index(info)the maxtrade_indexoverrestore_ordersandrestore_disputes. Indexes arei64on the wire;u32::try_fromdrops negatives and anything beyondu32::MAXrather thantruncating a garbage value into the counter. Returns
Nonewhen the restorecarried no trades (nothing to resync to).
restore_session'sRestoredarm: resync before returning theinfo. A resync failure fails the restore rather than returning "success" with
a counter that could hand out a reused key.
Lands on the payload shape available today (
trade_indexis already present)does not wait for the snapshot contract in #216.
Acceptance criteria
trade_key_indexraised tomax(order indexes, dispute indexes)on avalid restore
derive_trade_key()returns a fresh index, not a recovered one
RestoreDatatwice leaves the counterunchanged
Tests
load_derive_then_delete_identity_lifecycleextended (kept in the onestateful test so parallel threads never race the
identity_locksingleton):a floor below current is a no-op (never lowers), a higher floor raises,
re-applying the same floor is idempotent, and the next
derive_trade_key()returns a fresh index past the recovered set.
recovered_max_trade_index:Nonewhen empty, max spans both orders anddisputes, negatives and out-of-range values dropped.
Restoredarm is exercised end-to-end by Restore: RestoreSession handshake (send, correlate reply, subscribe) #215'srestore_e2e_tests(needs a live regtest daemon).cargo test --lib115 passed / clippy-D warningsclean /flutter analyzeclean.
Merge order
Stacked on #215 depends on its
RestoreDatahandling. Since #215 isn't inmain yet, this PR's diff currently includes #215's commits; the resync itself is
commit
aa7409b(identity.rs+orders.rs, ~153 lines). Once #215 merges,I'll rebase this onto main and the diff will show only the resync.
Summary by CodeRabbit
Bug Fixes
Tests