Skip to content

fix(wallet): stop a duplicate identity index from making a wallet unloadable - #951

Open
Claudius-Maginificent wants to merge 5 commits into
v1.0-devfrom
fix/wallet-corrupted-after-migration
Open

fix(wallet): stop a duplicate identity index from making a wallet unloadable#951
Claudius-Maginificent wants to merge 5 commits into
v1.0-devfrom
fix/wallet-corrupted-after-migration

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

⚠️ Status update: further investigation against the reporting user's actual broken database showed this PR does not fix the reported incident's root cause — that turned out to be upstream, now fixed separately in #953. The identity-index hardening here is real and independently reproducible, just not what happened in that particular incident. All three commits (dfcb9065, 72de5aeb, dc547a14) have been independently adversarially reviewed — see Testing. Ready for review; remaining findings are non-blocking follow-ups, listed below.

TL;DR: Harden identity-index handling on a wallet: two independent, real bugs found while investigating a wallet-load failure, neither of which turned out to be that failure's actual cause (see status note above).

User story

As a Dash Evo Tool user with more than one identity on the same wallet, I want the app to refuse a duplicate identity index instead of silently colliding, and to keep a top-up account's registration on disk, so a restart never strands my funds or my saved wallet state.

Scenario

Base flow

A user adds identities to a wallet over time — including via "add existing identity," which lets the user pick the identity index themselves — and tops up an identity's funding from the wallet.

Actual behavior

  • Two identities on one wallet can end up at the same index. Upstream keys identities on (wallet_id, index); a second identity at a taken index silently displaces the first from the active set while both remain persisted, and the next launch's reload finds the displaced identity's now-ownerless data and rejects the entire wallet's saved state as fatally corrupted ("Saved wallet data appears damaged and cannot be loaded. Restore the wallet from its recovery phrase to keep using it.").
  • Separately, provisioning an identity's top-up funding account updates in-memory state but never persists the account's registration row — a restart between broadcasting an asset lock and consuming it cannot re-derive it, stranding the funds in that lock.

Expected behavior

A second identity at a taken index is refused up front on every path that can create one, not just some of them. A provisioned top-up account survives a restart.

Detailed discussion

What was NOT found here — the actual reported incident

The user's specific "Saved wallet data appears damaged" incident was traced (by loading their real, unmodified database) to an upstream bug in rs-platform-wallet-storage (dashpay/platform, pinned git dependency, not vendored in this repo): derive_spent_utxos fabricates an empty ScriptBuf for a spent input it has no prior-output record for; contrary to its own doc comment ("informational only"), that row is persisted (INSERTed when no existing row matches); load_used_addresses then scans every row's script on load, and Address::from_script on the empty script fails as UnrecognizedScript, which is treated as a fatal, whole-file-rejecting error. Fixed upstream and pulled in separately by #953 — not part of this PR.

What was done

WalletBackend::ensure_identity_managed now checks whether the target identity index already has an occupant before registering a new identity, returning a new typed TaskError::IdentityIndexAlreadyTaken { occupant_id, index } instead of an ambiguous insert. Separately, provision_identity_funding_account now persists the new account's AccountRegistrationEntry through the wallet persister (with rollback of the in-memory inserts on a persist failure) instead of leaving it memory-only.

Regression tests: a_second_identity_at_a_taken_index_is_refused_and_the_wallet_still_reloads, a_migrated_install_relaunches_without_damaged_wallet_data (confirmed RED before the fix against the duplicate-index mechanism specifically — this is not a reproduction of the user's actual incident, see status note), a_provisioned_identity_topup_account_survives_a_restart (RED before its fix).

Gaps found by independent review, now closed on the branch (commit pending push):

  • HIGH — the identity-index guard above originally only covered the boot reconciler and top-up call sites (WalletBackend::ensure_identity_managed). The primary identity-registration path (WalletBackend::register_identity → upstream register_identity_with_funding) reached the same unguarded upstream insert — the UI's own "used index" gate can't backstop it either, since it hydrates empty on every cold boot (pre-existing TODO already flagged this UI-side unreliability). WalletBackend::register_identity now pre-flights the same occupancy check before the asset lock is built.
  • MEDIUM — removing an identity freed its index DET-side but not upstream, letting the freed-looking index re-trigger the HIGH gap or surface a phantom "already taken" error naming an identity the user believes is gone. AppContext::remove_identity now also releases the index upstream (tombstone, not delete — no user data removed).
  • Residual, by design, named explicitly: on an install that already has two identities sharing an index before this fix (e.g. carried in by a migration), the boot reconciler still logs a warn and silently leaves the second identity unregistered rather than surfacing a banner — no new collision can be created anymore, so this only affects pre-existing duplicates, and a full reconcile-time banner wasn't judged worth the UX cost for a shrinking, non-growable case.

Testing

  • clippy --all-features --lib -- -D warnings — clean on every commit (implementer + independently reproduced by reviewer each time).
  • test --lib --all-features — 2310+ passed, 0 failed; all new/changed tests confirmed passing by name (not just aggregate count), independently reproduced by reviewer.
  • Independent adversarial review completed for all three commits:
    • dfcb9065 (identity-index guard): 1 HIGH + 2 MEDIUM found, both fixed in dc547a14.
    • 72de5aeb (top-up persistence): ship recommended; minor non-blocking rollback-error-classification follow-up.
    • dc547a14 (closes the HIGH+MEDIUM gaps): ship-with-followup. Both corruption routes independently proven closed by execution (offline reproductions, not just reading the diff) — reviewer wrote and ran an offline test proving the registration-path guard fires, and independently reproduced RED-before on the removal-path test. 3 MEDIUM + 3 LOW non-blocking findings, tracked as follow-up (memcan TODO, project=dash-evo-tool-2): the HIGH fix itself has no regression test despite the commit claiming it's untestable offline (reviewer's own test disproves that); a narrow TOCTOU window where the boot-reconciler subtask can still slip into the same collision; the new error message's remediation wording doesn't match how the picker actually works; a few lower-severity gaps in logging/test coverage.

Breaking changes

None. All three fixes only reject/complete previously-silent, already-broken behavior.

Checklist

  • Regression tests added and confirmed RED before each fix
  • cargo fmt --all, clippy --all-features --all-targets -- -D warnings clean on touched scope
  • Independent adversarial review of the identity-index guard (found HIGH+MEDIUM, both fixed)
  • Independent adversarial review of the top-up persistence commit (ship recommended)
  • HIGH gap (registration path unguarded) fixed
  • MEDIUM gap (identity removal doesn't free the index upstream) fixed
  • Fix commit pushed (dc547a14)
  • Independent adversarial review of the HIGH/MEDIUM fix commit itself (ship-with-followup, 0 HIGH, 3 MEDIUM/3 LOW non-blocking)
  • Mark ready for review

Prior work

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

Summary by CodeRabbit

  • Bug Fixes
    • Prevented duplicate identity indexes before funds are moved, with clearer error messages.
    • Improved identity removal so indexes become available for reuse, including associated voter identities.
    • Ensured identity funding accounts are saved reliably and failures do not leave inconsistent wallet state.
    • Improved recovery when identities cannot be reconciled, while preserving existing retry behavior.
    • Fixed wallet reopening after migration and reduced the risk of damaged-data errors.

lklimek and others added 2 commits July 31, 2026 14:24
…oadable

A wallet whose saved state was migrated from an older install could come
back on the next launch as "Saved wallet data appears damaged and cannot
be loaded", with recovery-phrase restore as the only way forward.

The wallet backend buckets a wallet's identities on
`(wallet_id, identity_index)`. DET's identity index is user-entered (the
"add existing identity" screen) and carries no uniqueness, so one wallet
can hold two identities at the same index — the ordinary shape of an
install upgraded from a build that never used the index as a key.
Registering the second identity displaced the first from the active set
while both kept their saved records. The next launch replayed the same
collapse, could not reattach the displaced identity's saved keys, and
failed the whole wallet's load with `OrphanedIdentityEntry` — surfaced as
`TaskError::WalletLocalDataLoadFailed`.

`ensure_identity_managed` is the single DET path into that registration,
so it now refuses a second identity at a taken index with the dedicated
`TaskError::IdentityIndexAlreadyTaken`, naming the identity that holds it.
Nothing is written, the wallet stays loadable, and the identity reconciler
logs the permanent condition as a warning instead of a retryable defer.

Tests: `a_second_identity_at_a_taken_index_is_refused_and_the_wallet_still_reloads`
(RED before the guard with the exact user-facing failure) and
`a_migrated_install_relaunches_without_damaged_wallet_data`. The existing
`reconcile_managed_identities_registers_only_wallet_owned` probed a taken
index to prove an unrelated filter; it now probes a free one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A restart between an asset-lock broadcast and its consumption could strand the
lock and the funds in it: `resume_asset_lock` fails to re-derive the credit
output with "Funding account IdentityTopUp not found for re-derivation".

`load()` rebuilds `Wallet.accounts` from `account_registrations` alone.
`provision_identity_funding_account` created the account in both upstream
in-memory collections and persisted nothing, so upstream's own creator — the
only writer of that row — then hit its `contains_*` guards, concluded both
sides already existed, and took its early return, skipping the registration and
address-pool store its docs call load-bearing for crash recovery. The account
was live in memory and absent from disk on every launch.

DET's provisioning now writes the `AccountRegistrationEntry` through the
persister and flushes it, and rolls both in-memory inserts back on a store
failure so a retry re-creates and re-persists rather than short-circuiting on
the presence guards. Failures surface as the dedicated
`TaskError::IdentityFundingAccountPersistFailed`.

Residual: the paired address-pool snapshot upstream writes alongside the
registration is not reachable from here (`account_address_pool_entries` is
crate-private), so pool depth for that account re-warms on the next sync
instead of restoring. The account itself — what re-derivation needs — is
restored.

Test: `a_provisioned_identity_topup_account_survives_a_restart`, confirmed RED
before the fix (0 persisted rows, expected 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The change validates identity index ownership, persists identity funding accounts with rollback handling, releases indexes during identity removal, updates reconciliation behavior, and adds lifecycle and v0.9.3 relaunch regression coverage.

Changes

Identity lifecycle safeguards

Layer / File(s) Summary
Identity registration and funding persistence
src/backend_task/error.rs, src/wallet_backend/identity_ops.rs
Identity registration rejects occupied wallet indexes. Derived funding-account registrations are persisted and rolled back on persistence failure.
Identity removal and index release
src/backend_task/identity/..., src/wallet_backend/identity_ops.rs, src/context/wallet_lifecycle/tests.rs
Identity removal awaits asynchronous index release for the identity and associated voter identity before local cleanup. Tests verify index reuse.
Lifecycle reconciliation and regression tests
src/app_dir.rs, src/context/wallet_lifecycle/bootstrap.rs, src/context/wallet_lifecycle/tests.rs
Reconciliation logs occupied-index conflicts without retrying them. Tests cover persisted top-up accounts, duplicate indexes, detached identities, and copied data directories.
Migration relaunch regression
src/backend_task/migration/v093_upgrade.rs
The migration test reopens a copied v0.9.3 installation and verifies that wallet data loads successfully.

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

Sequence Diagram(s)

sequenceDiagram
  participant IdentityTask
  participant WalletBackend
  participant IdentityStore
  IdentityTask->>WalletBackend: release identity indexes
  WalletBackend-->>IdentityTask: release result
  IdentityTask->>IdentityStore: delete local identity
  IdentityStore-->>IdentityTask: removal result
Loading

Possibly related PRs

🚥 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 describes the primary change: preventing duplicate identity indexes from making a wallet unloadable.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ 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/wallet-corrupted-after-migration

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.

…wallet

The occupancy guard added in dfcb906 covered only the reconcile and top-up
paths. Two routes to the same fatal corruption stayed open, both found by
independent review.

Registration: `WalletBackend::register_identity` handed off to upstream
`register_identity_with_funding`, whose Step 4 calls `add_identity` with no
occupancy check and then deliberately swallows that add's failure so the spent
asset lock is still consumed — a collision there is persisted, never reported.
The UI gate that was assumed to cover this is inert: `wallet.identities`
hydrates empty on every cold boot, so after a restart the index picker marks
nothing as used and recommends index 0, the very index a first identity
usually holds. A pre-flight check now refuses before the asset lock is built,
so a collision costs the user nothing. Per CLAUDE.md the backend task is the
authoritative enforcement layer; a combo box was never enough.

Removal: `AppContext::remove_identity` only cleared DET's own k/v store and
vault, so the wallet backend kept the identity and its index. The slot stayed
occupied by an identity the user believes is gone — blocking re-use of that
index with a phantom occupant they cannot act on, and leaving the index looking
free to the picker while a registration there would still collide. Removal now
releases the index upstream, which records a tombstone rather than deleting:
the rows stay on disk and any orphaned key rows become safe to skip at load,
which is what keeps a removal from becoming the damaged-data failure itself.

Both call sites share one `index_occupant` helper so the invariant cannot drift
between them, and the error message now names controls that exist ("pick an
identity index that is not marked as used, or remove identity X first").

Test `removing_an_identity_frees_its_index_for_reuse` drives the real
`BackendTask::RemoveIdentity` dispatch and was confirmed RED before the fix,
failing with the exact phantom-occupant `IdentityIndexAlreadyTaken`. The
registration pre-flight is not unit-testable offline — it needs funds and a
live Platform — so it is verified by code trace against the pinned upstream
revision, not by a run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@lklimek
lklimek marked this pull request as ready for review July 31, 2026 15:11
@thepastaclaw

thepastaclaw commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

⛔ Blockers found — Sonnet deferred (commit ff26767)
Canonical validated blockers: 2

@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)
src/wallet_backend/identity_ops.rs (1)

356-441: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Rollback clears an already-persisted account, not just the freshly-derived one.

in_wallet is true when kw.accounts.* was rebuilt from the persisted manifest at wallet load (the cold-boot re-provision case this function's header comment describes at lines 328-335). In that branch, the if !in_wallet block at lines 373-396 is skipped, so persist_account_registration at line 422 is a redundant re-write of an already-durable row.

If that redundant write fails, the .inspect_err closure at lines 423-440 still clears kw.accounts.identity_registration (or removes the top-up entry) unconditionally. This discards an in-memory account whose on-disk state never changed and was already confirmed durable, degrading funding-account availability for the rest of the session until the next call re-derives it. HD derivation is deterministic, so a subsequent call self-heals by re-deriving the same xpub, but the invariant assumed by the rollback comment ("so a retry re-creates and re-persists") only holds for the freshly-created (!in_wallet) case.

Track whether the kw-side account was newly derived this call, and only roll back that side when it was.

🐛 Proposed fix: only clear the wallet-side entry that was actually created this call
         if in_wallet && in_managed {
             return Ok(());
         }
 
+        let newly_added_to_wallet = !in_wallet;
         if !in_wallet {
             let account_type = match funding {
@@
         self.persist_account_registration(&wallet_id, account_type, account_xpub)
             .inspect_err(|_| {
                 // Roll back both sides so a retry re-creates and re-persists,
                 // rather than the `in_wallet && in_managed` guard above short-
                 // circuiting a persist that never happened.
                 match funding {
                     Funding::Registration => {
-                        kw.accounts.identity_registration = None;
+                        if newly_added_to_wallet {
+                            kw.accounts.identity_registration = None;
+                        }
                         info.core_wallet.accounts.identity_registration = None;
                     }
                     Funding::TopUp(registration_index) => {
-                        kw.accounts.identity_topup.remove(&registration_index);
+                        if newly_added_to_wallet {
+                            kw.accounts.identity_topup.remove(&registration_index);
+                        }
                         info.core_wallet
                             .accounts
                             .identity_topup
                             .remove(&registration_index);
                     }
                 }
             })
🤖 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 `@src/wallet_backend/identity_ops.rs` around lines 356 - 441, Track whether the
funding account was newly added in the !in_wallet branch, and update the
persist_account_registration error rollback so the wallet-side entry is cleared
only when that account was created during this call. Always preserve the
managed-side rollback as appropriate, but leave an already-persisted kw account
intact when persistence is merely a redundant rewrite.
🤖 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 `@src/wallet_backend/identity_ops.rs`:
- Around line 356-441: Track whether the funding account was newly added in the
!in_wallet branch, and update the persist_account_registration error rollback so
the wallet-side entry is cleared only when that account was created during this
call. Always preserve the managed-side rollback as appropriate, but leave an
already-persisted kw account intact when persistence is merely a redundant
rewrite.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b4ddc8d6-74f2-42a6-9608-f16844cff399

📥 Commits

Reviewing files that changed from the base of the PR and between 2ee9fa6 and 72de5ae.

📒 Files selected for processing (6)
  • src/app_dir.rs
  • src/backend_task/error.rs
  • src/backend_task/migration/v093_upgrade.rs
  • src/context/wallet_lifecycle/bootstrap.rs
  • src/context/wallet_lifecycle/tests.rs
  • src/wallet_backend/identity_ops.rs

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The focused duplicate-index and funding-account restart tests pass, but the production identity-registration seams still bypass the new slot guard and can spend funds before recreating the collision the PR is intended to prevent. The persistence rollback also mishandles transient store failures by discarding in-memory state while the changeset remains buffered; identity removal itself is intentionally DET-local and is not a defect in this PR. Source: reviewers gpt-5.6-sol (general, rust-quality); verifier gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 1 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/wallet_backend/identity_ops.rs`:
- [BLOCKING] src/wallet_backend/identity_ops.rs:69-76: Enforce identity-index occupancy before every paid registration
  The new uniqueness check protects only `ensure_identity_managed`, while wallet-funded creation calls `register_identity_with_funding` directly after provisioning. At the pinned upstream revision, `IdentityManager::add_identity` checks only for a duplicate identity ID and then uses `BTreeMap::insert(identity_index, managed_identity)`, which replaces an existing occupant; the orchestrator performs that insertion only after Platform has accepted and consumed the registration funding. This path is reachable after a cold boot because DET's `Wallet.identities` map starts empty and is not hydrated from stored qualified identities, so the UI's used-index list can miss an existing upstream slot. Address-funded creation at `src/backend_task/identity/register_identity.rs:334-379` likewise submits payment before checking the slot, then saves an identity that reconciliation cannot manage alongside the occupant; add-existing at `src/backend_task/identity/load_identity_from_wallet.rs:250-267` also admits duplicate local ownership. Centralize an atomic occupancy reservation and use it before broadcasting, submitting address-funded creation, or saving an existing identity. A check that is released before the asynchronous registration is insufficient because concurrent registrations could both pass it.
- [SUGGESTION] src/wallet_backend/identity_ops.rs:462-465: Retry the retained changeset before rolling back memory
  DET uses the default immediate-flush `SqlitePersister`. When its inline flush fails transiently, `store` restores the account-registration changeset to the per-wallet buffer and returns a transient `PersistenceError`, as required by `PlatformWalletPersistence`. The `and_then` here skips the subsequent bare `flush`, and the caller's `inspect_err` removes both in-memory account entries even though the registration remains queued. A later unrelated store can therefore commit that retained registration while the running wallet has no corresponding account in either in-memory collection. Handle `source.is_transient()` by retrying the existing buffer through `flush` without re-submitting the changeset; roll back the in-memory inserts only after a terminal failure for which the backend has dropped the buffered delta. Add an injected transient-failure test covering the two account maps, buffered state, durable row, and retry behavior.

Comment on lines +462 to +465
self.inner
.wallet_persister
.store(*wallet_id, changeset)
.and_then(|()| self.inner.wallet_persister.flush(*wallet_id))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Retry the retained changeset before rolling back memory

DET uses the default immediate-flush SqlitePersister. When its inline flush fails transiently, store restores the account-registration changeset to the per-wallet buffer and returns a transient PersistenceError, as required by PlatformWalletPersistence. The and_then here skips the subsequent bare flush, and the caller's inspect_err removes both in-memory account entries even though the registration remains queued. A later unrelated store can therefore commit that retained registration while the running wallet has no corresponding account in either in-memory collection. Handle source.is_transient() by retrying the existing buffer through flush without re-submitting the changeset; roll back the in-memory inserts only after a terminal failure for which the backend has dropped the buffered delta. Add an injected transient-failure test covering the two account maps, buffered state, durable row, and retry behavior.

source: ['codex']

@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: 2

🧹 Nitpick comments (1)
src/context/wallet_lifecycle/tests.rs (1)

4224-4248: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Prove that the replacement identity is distinct and newly managed.

Line 4242 accepts Ok(false). If test_identity_with_key() returns the same identity ID for both fixtures, the test can pass while the original manager entry remains. Assert that both IDs differ. Assert that the second call returns true.

Proposed test strengthening
 let removed = test_identity_with_key();
 let replacement = test_identity_with_key();
+assert_ne!(removed.id(), replacement.id());
 ...
-backend
-    .ensure_identity_managed(&seed_hash, &replacement, 0)
-    .await
-    .expect(...);
+assert!(
+    backend
+        .ensure_identity_managed(&seed_hash, &replacement, 0)
+        .await
+        .expect(...)
+);
🤖 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 `@src/context/wallet_lifecycle/tests.rs` around lines 4224 - 4248, Strengthen
the identity replacement test around test_identity_with_key by asserting that
removed.id and replacement.id are different, then capture the result of the
second ensure_identity_managed call and assert it is true rather than merely
accepting Ok(false). Preserve the existing removal-success assertion and failure
context.
🤖 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 `@src/backend_task/identity/remove_identity.rs`:
- Around line 54-65: The remove-identity flow must not delete the local identity
or report success when release_identity_index fails. Change
release_identity_index to return a typed Result using the backend task’s
TaskError conventions, propagate the forget_identity failure from the removal
operation, and perform local deletion only after successful index release.

In `@src/wallet_backend/identity_ops.rs`:
- Around line 88-96: The pre-flight check in reject_taken_identity_index is racy
because registration proceeds after the lock is released, allowing concurrent
tasks to claim the same wallet identity_index. Serialize registration for each
wallet/index or reserve the index before asset-lock creation, retain that
reservation through registration, and release it only on a terminal outcome; add
a regression test covering concurrent registration attempts.

---

Nitpick comments:
In `@src/context/wallet_lifecycle/tests.rs`:
- Around line 4224-4248: Strengthen the identity replacement test around
test_identity_with_key by asserting that removed.id and replacement.id are
different, then capture the result of the second ensure_identity_managed call
and assert it is true rather than merely accepting Ok(false). Preserve the
existing removal-success assertion and failure context.
🪄 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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 0eb9c30d-39d2-4e35-9c33-cacecb01cbd2

📥 Commits

Reviewing files that changed from the base of the PR and between 72de5ae and dc547a1.

📒 Files selected for processing (5)
  • src/backend_task/error.rs
  • src/backend_task/identity/mod.rs
  • src/backend_task/identity/remove_identity.rs
  • src/context/wallet_lifecycle/tests.rs
  • src/wallet_backend/identity_ops.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/backend_task/error.rs

Comment on lines +54 to +65
async fn release_identity_index(&self, identity_id: &Identifier) {
let Ok(backend) = self.wallet_backend() else {
return;
};
if let Err(error) = backend.forget_identity(identity_id).await {
tracing::warn!(
%identity_id,
%error,
"Identity index could not be released in the wallet backend; \
adding an identity at that index may be refused"
);
}

@coderabbitai coderabbitai Bot Jul 31, 2026

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Do not report removal success when index release fails.

forget_identity can fail after the upstream identity remains persisted. Lines 58-64 only log that failure, and Line 20 then deletes the local identity. The wallet can retain a phantom index occupant with no local identity available to retry the tombstone.

Return the typed release error before local deletion, or persist a durable retry record before reporting successful removal.

As per coding guidelines, backend tasks are the authoritative enforcement layer and must return typed Result<T, TaskError> values.

🤖 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 `@src/backend_task/identity/remove_identity.rs` around lines 54 - 65, The
remove-identity flow must not delete the local identity or report success when
release_identity_index fails. Change release_identity_index to return a typed
Result using the backend task’s TaskError conventions, propagate the
forget_identity failure from the removal operation, and perform local deletion
only after successful index release.

Source: Coding guidelines

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The finding is valid, but propagating forget_identity errors alone is insufficient. At the pinned upstream revision, IdentityManager::remove_identity swallows persister.store failures and returns success after mutating memory, so DET must establish a durable tombstone itself or persist a durable cleanup-retry record before deleting the local identity.

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.

@thepastaclaw, agreed. The current forget_identity result cannot prove that the upstream tombstone is durable.

DET must persist a durable tombstone or a durable cleanup-retry record before it deletes the local identity and returns RemovedIdentities. DET must also keep the local identity when that persistence step fails.

The review finding remains valid with this stronger requirement.

🐇🔍


✏️ Learnings added
Learnt from: thepastaclaw
Repo: dashpay/dash-evo-tool PR: 951
File: src/backend_task/identity/remove_identity.rs:54-65
Timestamp: 2026-07-31T17:46:22.061Z
Learning: At the pinned upstream `rs-platform-wallet` revision used by Dash Evo Tool, `IdentityManager::remove_identity` can swallow `persister.store` failures after it mutates in-memory state and returns success. In `src/backend_task/identity/remove_identity.rs`, a successful `wallet_backend().forget_identity(identity_id).await` result does not prove that identity-index release is durable. Before `AppContext::remove_identity` deletes the local identity or returns removal success, DET must persist a durable upstream tombstone itself or persist a durable cleanup-retry record.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

Comment on lines +88 to +96
// Refuse before the asset lock is built, so a collision costs nothing:
// upstream registers the identity at `identity_index` without an
// occupancy check and deliberately swallows that add's failure to
// guarantee the spent lock is consumed, so a collision there is
// persisted rather than reported. A pre-flight, not a mutex — DET
// cannot hold the manager lock across the network round-trip.
self.reject_taken_identity_index(seed_hash, identity_index)
.await?;

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.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Make the identity-index check atomic with registration.

Lines 88-95 release the read lock before asset-lock creation and network registration. Two registration tasks can both pass this check for the same wallet and index. Upstream can then accept the second registration and displace the first managed identity.

Serialize registration per wallet/index, or reserve the index before funding starts. Release the reservation only after the registration reaches a terminal outcome. Add a concurrent-registration regression test.

🤖 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 `@src/wallet_backend/identity_ops.rs` around lines 88 - 96, The pre-flight
check in reject_taken_identity_index is racy because registration proceeds after
the lock is released, allowing concurrent tasks to claim the same wallet
identity_index. Serialize registration for each wallet/index or reserve the
index before asset-lock creation, retain that reservation through registration,
and release it only on a terminal outcome; add a regression test covering
concurrent registration attempts.

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The focused duplicate-index, removal, and funding-account restart tests pass, and the new preflight closes the ordinary sequential wallet-funded collision path. The identity-index invariant is still bypassed by address-funded and local-load paths and remains non-atomic under concurrent registration or reconciliation; identity removal can also discard its only retry anchor before the upstream tombstone is durable. The transient account-registration persistence issue from the prior review is unchanged.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 💬 1 nitpick(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/wallet_backend/identity_ops.rs`:
- [BLOCKING] src/wallet_backend/identity_ops.rs:88-95: Reserve the identity index across every registration path
  The new preflight covers only wallet-funded registration and releases the manager read lock before account provisioning, asset-lock creation, and the Platform round trip. Backend tasks and unlock-time reconciliation can run concurrently, so two registrations—or registration racing `reconcile_managed_identities`—can observe a free slot before either records its identity. At the pinned upstream revision, `IdentityManager::add_identity` checks only for a duplicate identity ID and then uses `BTreeMap::insert(identity_index, ...)`, replacing an existing slot occupant after Platform has already accepted payment. The same invariant is bypassed entirely by `register_identity_from_platform_addresses`, which spends address credits at `src/backend_task/identity/register_identity.rs:334-356` before saving the wallet/index association, and by `load_user_identity_from_wallet`, which persists that association at `src/backend_task/identity/load_identity_from_wallet.rs:250-267` without consulting the guard. Use a shared per-`(wallet_id, identity_index)` reservation for wallet-funded registration, address-funded registration, reconciliation, and existing-identity attachment, and retain it until the operation reaches its terminal bookkeeping outcome.

In `src/backend_task/identity/remove_identity.rs`:
- [BLOCKING] src/backend_task/identity/remove_identity.rs:19-20: Keep the local identity until index release is durable
  `release_identity_index` is best-effort, but the local identity is deleted unconditionally immediately afterward. If backend initialization was deferred, `wallet_backend()` fails and release becomes a no-op. Even with an initialized backend, the pinned upstream `IdentityManager::remove_identity` mutates memory, calls `persister.store`, logs and swallows any persistence error, and still returns success. A transient failure leaves the tombstone only in the persister buffer, while a terminal failure drops it; exiting before a retained write commits lets the old occupant reappear on restart after DET has deleted the identity and its only cleanup retry anchor. Reusing the apparently free slot can then recreate duplicate persisted identity rows or leave a paid replacement unmanaged. Require an observably durable tombstone, or save a durable cleanup-retry record, before deleting the local identity and reporting removal success.

Comment on lines +88 to +95
// Refuse before the asset lock is built, so a collision costs nothing:
// upstream registers the identity at `identity_index` without an
// occupancy check and deliberately swallows that add's failure to
// guarantee the spent lock is consumed, so a collision there is
// persisted rather than reported. A pre-flight, not a mutex — DET
// cannot hold the manager lock across the network round-trip.
self.reject_taken_identity_index(seed_hash, identity_index)
.await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Reserve the identity index across every registration path

The new preflight covers only wallet-funded registration and releases the manager read lock before account provisioning, asset-lock creation, and the Platform round trip. Backend tasks and unlock-time reconciliation can run concurrently, so two registrations—or registration racing reconcile_managed_identities—can observe a free slot before either records its identity. At the pinned upstream revision, IdentityManager::add_identity checks only for a duplicate identity ID and then uses BTreeMap::insert(identity_index, ...), replacing an existing slot occupant after Platform has already accepted payment. The same invariant is bypassed entirely by register_identity_from_platform_addresses, which spends address credits at src/backend_task/identity/register_identity.rs:334-356 before saving the wallet/index association, and by load_user_identity_from_wallet, which persists that association at src/backend_task/identity/load_identity_from_wallet.rs:250-267 without consulting the guard. Use a shared per-(wallet_id, identity_index) reservation for wallet-funded registration, address-funded registration, reconciliation, and existing-identity attachment, and retain it until the operation reaches its terminal bookkeeping outcome.

source: ['codex']

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Resolved in this update — Reserve the identity index across every registration path no longer present.

Auto-resolved by the review system based on the latest commit diff. If you believe this was closed in error, reopen the thread.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Correction: the preceding auto-resolve reply was incorrect. This finding remains open at 4c1013a1672e7e8617f87731801e7eb28042aea7 and is one of the two blockers retained by the preliminary review: #951 (review)

No thread resolution occurred; please treat the earlier auto-resolve message as superseded by this correction.

Comment on lines +19 to 20
self.release_identity_index(&identity_id).await;
self.delete_local_qualified_identity(&identity_id)?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: Keep the local identity until index release is durable

release_identity_index is best-effort, but the local identity is deleted unconditionally immediately afterward. If backend initialization was deferred, wallet_backend() fails and release becomes a no-op. Even with an initialized backend, the pinned upstream IdentityManager::remove_identity mutates memory, calls persister.store, logs and swallows any persistence error, and still returns success. A transient failure leaves the tombstone only in the persister buffer, while a terminal failure drops it; exiting before a retained write commits lets the old occupant reappear on restart after DET has deleted the identity and its only cleanup retry anchor. Reusing the apparently free slot can then recreate duplicate persisted identity rows or leave a paid replacement unmanaged. Require an observably durable tombstone, or save a durable cleanup-retry record, before deleting the local identity and reporting removal success.

source: ['codex']

Comment on lines +4224 to +4248
let removed = test_identity_with_key();
let replacement = test_identity_with_key();
assert!(
backend
.ensure_identity_managed(&seed_hash, &removed, 0)
.await
.expect("first identity registers at a free index"),
);

ctx.run_backend_task(
BackendTask::IdentityTask(IdentityTask::RemoveIdentity {
identity_id: removed.id(),
}),
sender,
)
.await
.expect("removing an identity must succeed");

backend
.ensure_identity_managed(&seed_hash, &replacement, 0)
.await
.expect(
"the index of a removed identity must be free again; a phantom occupant \
means the removal did not reach the wallet backend",
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

💬 Nitpick: Assert that removal admits a distinct replacement identity

The test ignores the boolean returned by the second ensure_identity_managed call, so Ok(false) is accepted even though it means no replacement was newly registered. The fixtures currently generate random IDs, making accidental equality extremely unlikely, but the test should state both assumptions explicitly: the identities are distinct, and the replacement was newly admitted after removal.

Suggested change
let removed = test_identity_with_key();
let replacement = test_identity_with_key();
assert!(
backend
.ensure_identity_managed(&seed_hash, &removed, 0)
.await
.expect("first identity registers at a free index"),
);
ctx.run_backend_task(
BackendTask::IdentityTask(IdentityTask::RemoveIdentity {
identity_id: removed.id(),
}),
sender,
)
.await
.expect("removing an identity must succeed");
backend
.ensure_identity_managed(&seed_hash, &replacement, 0)
.await
.expect(
"the index of a removed identity must be free again; a phantom occupant \
means the removal did not reach the wallet backend",
);
let removed = test_identity_with_key();
let replacement = test_identity_with_key();
assert_ne!(removed.id(), replacement.id());
assert!(
backend
.ensure_identity_managed(&seed_hash, &removed, 0)
.await
.expect("first identity registers at a free index"),
);
ctx.run_backend_task(
BackendTask::IdentityTask(IdentityTask::RemoveIdentity {
identity_id: removed.id(),
}),
sender,
)
.await
.expect("removing an identity must succeed");
assert!(
backend
.ensure_identity_managed(&seed_hash, &replacement, 0)
.await
.expect(
"the index of a removed identity must be free again; a phantom occupant \
means the removal did not reach the wallet backend",
),
);

source: ['coderabbit']

@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Follow-up from a fresh repro against the reporting user's actual DB, done just now on the current v1.0-dev pin (which already includes #953):

The status-update note above is stale. Loading the user's real platform-wallet.sqlite on the current pin does not hit UnrecognizedScript — it hits OrphanedIdentityEntry, the exact duplicate-(wallet_id, identity_index) mechanism this PR hardens against:

WalletLocalDataLoadFailed { source: PersisterLoad(Backend { kind: Fatal,
    source: OrphanedIdentityEntry { owner: 63c48e0f...977303 } }) }

Querying the DB directly confirms it: two identity rows share (wallet_id c59da1...67e0b0, identity_index 1)63c48e0f...977303 and e7fc8f3e...14fb5f5, both tombstoned = 0. So either #953 masked an earlier failure and this duplicate-index corruption was already sitting underneath it, or the original investigation moved on before re-testing against #953. Either way, this PR's own bug report is what's currently blocking that wallet.

However, merging this PR as-is will not unblock it. All three fixes here (ensure_identity_managed, register_identity, AppContext::remove_identity) are DET-side guards against creating a new collision. The fatal error happens earlier and lower — inside upstream rs-platform-wallet-storage's load_from_persistor()merge_contacts_and_keys(), before any DET code (including this PR's guards, or the boot-reconciler leniency the "residual, by design" note refers to) ever runs. Pre-existing on-disk duplicates — e.g. carried in by a migration, as here — still hard-fail wallet load with no recovery path.

Logged the upstream side of this as a platform TODO (memcan 4fbe235c): rs-platform-wallet-storage needs either a write-time uniqueness constraint on (wallet_id, identity_index), or merge_contacts_and_keys needs to treat an orphaned entry as skip-and-warn (like it already does for tombstoned identities) instead of rejecting the whole wallet's saved state. This DET-side PR is still correct and worth keeping — it stops new collisions — but the upstream fix is the one that actually recovers a wallet already in this state.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The dependency-only delta from the previously reviewed head updates empty-script UTXO handling; the identity lifecycle files are unchanged, and the relevant upstream identity-manager and persister code is identical across the old and current platform pins. Two blockers remain: identity indices are not reserved across concurrent and alternative entry paths, and local deletion can outrun a durable upstream tombstone; transient funding-account persistence handling and regression-test gaps also remain open.

Source: reviewers gpt-5.6-sol (general, rust-quality); verifier gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 1 suggestion(s)

4 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/context/wallet_lifecycle/tests.rs`:
- [SUGGESTION] src/context/wallet_lifecycle/tests.rs:4294-4304: Exercise the paid registration guard rather than only the reconciler helper
  The collision regression calls `ensure_identity_managed` directly for both identities, so it never executes the new `WalletBackend::register_identity` preflight. Removing the paid-path check at lines 94-95 leaves this test green. Contrary to the commit message, this path is testable offline: seed an occupied manager slot and call `register_identity`; the occupancy error is returned before the secret session, funding-account provisioning, asset-lock work, or any network request. Add that direct regression, plus barrier-controlled concurrency coverage and equivalent tests for the address-funded and existing-identity paths when the shared reservation is introduced.

Comment on lines +4294 to +4304
assert!(
backend
.ensure_identity_managed(&seed_hash, &resident, 0)
.await
.expect("the first identity at a free index must register"),
);

let error = backend
.ensure_identity_managed(&seed_hash, &colliding, 0)
.await
.expect_err("a second identity at a taken index must be refused");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: Exercise the paid registration guard rather than only the reconciler helper

The collision regression calls ensure_identity_managed directly for both identities, so it never executes the new WalletBackend::register_identity preflight. Removing the paid-path check at lines 94-95 leaves this test green. Contrary to the commit message, this path is testable offline: seed an occupied manager slot and call register_identity; the occupancy error is returned before the secret session, funding-account provisioning, asset-lock work, or any network request. Add that direct regression, plus barrier-controlled concurrency coverage and equivalent tests for the address-funded and existing-identity paths when the shared reservation is introduced.

source: ['codex']

Three resolutions, one textual and two silent:

- src/wallet_backend/identity_ops.rs — both sides inserted after
  `ensure_identity_managed`. Kept #951's `reject_taken_identity_index` /
  `forget_identity` and #954's test-only `resolved_managed_identity_id`,
  plus #954's rewritten `top_up_identity` doc (#951 left that doc at the
  base text, so theirs is the current one).
- The `Funding` rollback match in `provision_identity_funding_account`
  (#951) gained #954's `TopUpNotBound` variant. Auto-merge left it
  non-exhaustive; the arm clears both the key-wallet and managed copies,
  mirroring the other two.
- `a_provisioned_identity_topup_account_survives_a_restart` (#951) opened
  the wallet database at its pre-#954 location inside the disposable SPV
  cache. It now goes through `wallet_database_path()`, as every other
  call site in the file already does.

Side effect worth knowing: #954's index-less top-up funding account now
persists its account registration, because it provisions through the path
#951 taught to write the manifest row.
@Claudius-Maginificent

Copy link
Copy Markdown
Collaborator Author

Merged `v1.0-dev` to resolve the mergeable-conflict state — it was pure adjacency (both this PR and #954 inserting a method at the same anchor line), now resolved with both additions kept.

Two conflicts auto-merged without markers and needed a closer look:

Both fixed, verified: full `wallet_lifecycle` suite green (75/75), clippy clean, all four PRs' new tests confirmed passing by name.

Flagging for review: the added `TopUpNotBound` rollback arm is untested — it only fires if `persist_account_registration` fails, and no existing test exercises that failure path. Worth a look before merge.

🤖 Co-authored by Claudius the Magnificent AI Agent

@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 (2)
src/context/wallet_lifecycle/tests.rs (2)

3781-3846: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Drain subtasks before copying the data directory.

This test calls backend.shutdown().await; and then immediately calls copy_dir_recursive(source_dir.path(), cold_dir.path());. Sibling tests in this file that copy a directory after registering wallet state (for example issue7_fresh_persistor_bip44_xpub_matches_det_bridge at line 754 and cold_boot_skips_corrupt_fvk_for_one_wallet_and_restores_healthy_wallet at line 3942) additionally call let _ = ctx.subtasks.shutdown_async().await; before the copy. Without draining subtasks here, a pending background write from ensure_identity_funding_accounts could still be in flight when the directory is copied, producing an inconsistent snapshot and a flaky assertion on persisted_topup_rows.

Add the missing drain to match the established pattern in this file.

🔧 Proposed fix
         backend
             .ensure_identity_funding_accounts(&seed_hash, &seed, registration_index)
             .await
             .expect("provision identity funding accounts");
         backend.shutdown().await;
+        let _ = ctx.subtasks.shutdown_async().await;
         seed_hash
     };
🤖 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 `@src/context/wallet_lifecycle/tests.rs` around lines 3781 - 3846, In
a_provisioned_identity_topup_account_survives_a_restart, drain the context’s
background subtasks after backend.shutdown().await and before
copy_dir_recursive. Use the established ctx.subtasks.shutdown_async() pattern so
all persistence writes complete before the data directory snapshot is copied.

4746-4786: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the top-up error type before trusting the displacement check.

This test discards the dispatch error with let _error = .... The sibling test cross_wallet_top_up_leaves_the_paying_wallets_identity_state_intact (lines 4713-4721) explicitly asserts the error is not TaskError::IdentityNotWalletOwned or TaskError::IdentityIndexMismatch, to prove the operation reached funding rather than being rejected by an early routing guard.

This test lacks that assertion. If a routing guard rejects the top-up before the code ever touches the identity manager, own.identity was never at risk, and the final assertion on resolved_managed_identity_id passes without exercising the displacement path the test is meant to guard against. Add the same error-type assertion here so the test proves the displacement-prevention logic actually ran.

🔧 Proposed fix
-    let _error = dispatch_wallet_funded_top_up(&ctx, &sender, &foreign, &payer_arc, 0).await;
+    let error = dispatch_wallet_funded_top_up(&ctx, &sender, &foreign, &payer_arc, 0).await;
+    assert!(
+        !matches!(
+            error,
+            TaskError::IdentityNotWalletOwned { .. } | TaskError::IdentityIndexMismatch { .. }
+        ),
+        "the displacement risk must be tested against a real funding attempt, not an early \
+         routing rejection: {error:?}"
+    );
🤖 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 `@src/context/wallet_lifecycle/tests.rs` around lines 4746 - 4786, Update
cross_wallet_top_up_never_displaces_the_paying_wallets_own_identity to inspect
the result from dispatch_wallet_funded_top_up and assert its error is not
TaskError::IdentityNotWalletOwned or TaskError::IdentityIndexMismatch, matching
cross_wallet_top_up_leaves_the_paying_wallets_identity_state_intact. Retain the
existing resolved_managed_identity_id assertion after confirming the operation
reached the intended funding path.
🤖 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 `@src/context/wallet_lifecycle/tests.rs`:
- Around line 3781-3846: In
a_provisioned_identity_topup_account_survives_a_restart, drain the context’s
background subtasks after backend.shutdown().await and before
copy_dir_recursive. Use the established ctx.subtasks.shutdown_async() pattern so
all persistence writes complete before the data directory snapshot is copied.
- Around line 4746-4786: Update
cross_wallet_top_up_never_displaces_the_paying_wallets_own_identity to inspect
the result from dispatch_wallet_funded_top_up and assert its error is not
TaskError::IdentityNotWalletOwned or TaskError::IdentityIndexMismatch, matching
cross_wallet_top_up_leaves_the_paying_wallets_identity_state_intact. Retain the
existing resolved_managed_identity_id assertion after confirming the operation
reached the intended funding path.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 82cc11a6-871d-4676-8884-5ba81a678223

📥 Commits

Reviewing files that changed from the base of the PR and between dc547a1 and ff26767.

📒 Files selected for processing (4)
  • src/backend_task/error.rs
  • src/backend_task/identity/mod.rs
  • src/context/wallet_lifecycle/tests.rs
  • src/wallet_backend/identity_ops.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/backend_task/identity/mod.rs
  • src/backend_task/error.rs
  • src/wallet_backend/identity_ops.rs

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Preliminary review — Codex only

The merged delta adds the index-less foreign-top-up funding branch, but it does not change the non-atomic identity-index preflight, best-effort removal ordering, or transient persistence chain. Two blocking data-integrity issues remain, and the paid-registration, unbound-account restart, and removal regressions still leave important behavior unproven.

Source: reviewers gpt-5.6-sol (general, rust-quality); verifier gpt-5.6-sol.

Validated blockers were found in the Codex precheck. Sonnet is deferred until a fresh Codex revalidation clears the blocker gate.

Review provenance

  • Codex reviewers: gpt-5.6-sol — general (completed), gpt-5.6-sol — rust-quality (completed)
  • Verifier: gpt-5.6-sol — verifier
  • Sonnet: not run (deferred by blocker gate)

🔴 2 blocking | 🟡 1 suggestion(s)

1 additional finding(s) omitted (not in diff).

5 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `src/context/wallet_lifecycle/tests.rs`:
- [SUGGESTION] src/context/wallet_lifecycle/tests.rs:4574-4581: Prove the unbound top-up account survives a restart
  The merged branch routes `Funding::TopUpNotBound` through this PR's account-registration persistence path, but this test calls `ensure_unbound_topup_funding_account` twice in the same process. That proves only that the two in-memory account collections were populated; it does not assert the `identity_topup_unbound` manifest row or verify that a cold boot reconstructs the account needed to resume a foreign-identity asset lock. The existing restart regression covers only the indexed `IdentityTopUp { registration_index }` variant. Add a cold-boot regression that provisions the unbound account, verifies its account-registration row, reloads the wallet, and confirms the account remains available for unbound top-up recovery.

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.

3 participants