Skip to content

fix(platform-wallet): apply disabled-key flags to local cache after identity update#3915

Merged
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/wizardly-euler-f1f513
Jun 16, 2026
Merged

fix(platform-wallet): apply disabled-key flags to local cache after identity update#3915
QuantumExplorer merged 1 commit into
v3.1-devfrom
claude/wizardly-euler-f1f513

Conversation

@QuantumExplorer

Copy link
Copy Markdown
Member

Issue being fixed or feature implemented

The post-broadcast local-cache apply for the disable-key side of an identity update was an unimplemented TODO in update_identity_with_external_signer: it logged a warning and left the in-memory ManagedIdentity cache stale. As a result, a disabled key's disabled_at flag only flipped in SwiftData after a full network re-fetch (via IdentityDetailView.refreshIdentityData()).

This blocks promoting test case ID-12 (disable identity key) to a production UI action, where the Identity Keys list needs to reflect the disabled flag immediately.

What was done?

Added ManagedIdentity::disable_keys, the disable-side counterpart to the existing add_key:

  • Stamps disabled_at on the matching IdentityPublicKey records in the cached DPP Identity, so every signing / introspection path sees the disabled flag immediately.
  • Fires the persister with the same combined changeset shape add_key uses (scalar identity snapshot + IdentityKeysChangeSet upserts), so the client's PersistentPublicKey.disabledAt rows update without a network re-fetch. The FFI derives the Swift disabledAt from entry.public_key.disabled_at().
  • Each emitted key entry reuses the (wallet_id, identity_index, key_index) derivation breadcrumb that add_key carries — reconstructed from the managed identity's own slot. This matters because the Swift upsert wipes privateKeyKeychainIdentifier when derivationIndices is absent; carrying the breadcrumb makes the client re-derive idempotently and keep the key's private-key linkage instead of dropping it.
  • Skips (with a warning) key ids not present on the identity and emits no changeset when nothing matched.
  • Does not touch the identity revision — the revision bump is already handled by the caller alongside the update transition.

Wired it into the call site in update.rs, replacing the TODO/tracing::warn! block. The local wall-clock time is passed as a placeholder disabled_at; the next Platform refresh reconciles it to the authoritative on-chain block time.

How Has This Been Tested?

  • cargo check -p platform-wallet — passes.
  • cargo test -p platform-wallet --lib managed_identity21 passed, 0 failed, including the new test_disable_keys_stamps_disabled_at (asserts the targeted key is stamped, an untouched key stays enabled, and a non-existent key id is skipped without creating a phantom row).
  • cargo fmt --all — clean.

Breaking Changes

None. Adds a new public method to platform-wallet; no consensus or wire-format changes.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

…dentity update

The post-broadcast local-cache apply for the disable-key side of an
identity update was an unimplemented TODO: it logged a warning and left
the in-memory `ManagedIdentity` cache stale, so a disabled key's
`disabled_at` only flipped in SwiftData after a full network re-fetch.

Add `ManagedIdentity::disable_keys`, the disable-side counterpart to
`add_key`. It stamps `disabled_at` on the matching `IdentityPublicKey`
records in the cached DPP `Identity` and fires the persister with the
same combined changeset shape `add_key` uses, so the client's
`PersistentPublicKey.disabledAt` rows update immediately without a
re-fetch. Each emitted key entry reuses the `(wallet_id, identity_index,
key_index)` derivation breadcrumb so the disabled key keeps its
private-key linkage rather than getting wiped on the upsert. Revision
bump stays with the caller; missing key ids are skipped.

Follow-up to promoting test case ID-12 (disable identity key) to a
production UI action.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 16, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@QuantumExplorer, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 22 minutes and 56 seconds. Learn how PR review limits work.

Your organization has used up its prepaid credits, and credit purchases are no longer available. Enable the review add-on in the billing tab to keep reviews running — you're only billed for reviews past your plan's rate limits ($0.25/file).

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 452b16da-97dc-4681-9f87-64a099298f3f

📥 Commits

Reviewing files that changed from the base of the PR and between 60ee19f and 431d63b.

📒 Files selected for processing (3)
  • packages/rs-platform-wallet/src/wallet/identity/network/update.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs
  • packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch claude/wizardly-euler-f1f513

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 and usage tips.

@thepastaclaw

thepastaclaw commented Jun 16, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 431d63b)

@QuantumExplorer QuantumExplorer left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Reviewed

@QuantumExplorer QuantumExplorer merged commit 85eba06 into v3.1-dev Jun 16, 2026
16 of 17 checks passed
@QuantumExplorer QuantumExplorer deleted the claude/wizardly-euler-f1f513 branch June 16, 2026 09:09

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

Code Review

The PR cleanly mirrors add_key for the disable side and is largely correct, but it introduces one consequential side effect: stamping disabled_at in the local cache means the existing MASTER-key selector in update_identity_with_external_signer can now pick a disabled master after a rotation. Test coverage of the persistence contract (the actual fix payload — derivation breadcrumb + wallet_id) is also missing.

🔴 1 blocking | 🟡 2 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 `packages/rs-platform-wallet/src/wallet/identity/network/update.rs`:
- [BLOCKING] packages/rs-platform-wallet/src/wallet/identity/network/update.rs:138-151: MASTER signer selector must skip disabled keys now that the cache stamps `disabled_at`
  This PR newly stamps `disabled_at` on cached `IdentityPublicKey`s in `disable_keys`, so the cached identity loaded at lines 117–120 can now contain disabled keys. The MASTER selector here iterates `public_keys()` and picks the first match by id without checking `disabled_at`. A valid master-key rotation disables the old MASTER and adds a new MASTER in the same update; after this PR applies that rotation locally, a subsequent update on the same identity will deterministically select the old (lower-id) MASTER even though `sign_external`/DPP reject keys with `disabled_at` set, and the update will fail locally despite the cache containing a valid enabled MASTER that could sign. This is a direct regression caused by making the local cache accurate. Add `&& key.disabled_at().is_none()` to the predicate.

In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs:360-406: Test never exercises the breadcrumb — the load-bearing part of the fix
  The PR's stated purpose is that each emitted `IdentityKeyEntry` carries `(wallet_id, identity_index, key_index)` so the Swift upsert preserves `privateKeyKeychainIdentifier`. `test_disable_keys_stamps_disabled_at` constructs a `ManagedIdentity::new(identity, 0)` (leaving `wallet_id == None`) and uses `noop_persister()`, so the `match (self.wallet_id, self.identity_index)` always falls into the `None` arm and the persister discards any changeset. A regression that drops the breadcrumb, computes the wrong `key_index`, or stops calling `persister.store(...)` entirely would still pass this test. Add a recording persister, set `managed.wallet_id = Some(..)` and `identity_index = Some(..)`, then assert the captured `PlatformWalletChangeSet` contains one upsert with `wallet_id` set, `derivation_indices == Some(IdentityKeyDerivationIndices { identity_index, key_index: key_id })`, and the matching disabled public key. Cover the no-match early-return at lines 388–390 in the same pass.

In `packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs`:
- [SUGGESTION] packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs:362-371: Document the `key_index == key_id` cross-module invariant
  `disable_keys` reconstructs the breadcrumb as `IdentityKeyDerivationIndices { identity_index, key_index: key_id }`, equating the DPP `KeyID` with the DIP-9 hardened derivation index. This holds today because every `add_key` call site passes `key_index = key.id()` (e.g. `update.rs:231`, registration), and `MASTER_KEY_INDEX == 0` happens to equal the master key id. But this is an implicit cross-module invariant, not a typed one — if a future wallet adds keys with non-monotonic IDs (revoke/re-add, imports), the persisted breadcrumb will point the Swift Keychain at the wrong derivation slot, silently defeating the PR's stated goal. Either cache the derivation index per key at `add_key` time and read it back here, or at minimum add a doc-comment and a `debug_assert` recording the invariant and the failure mode.

Comment on lines +360 to +406
#[test]
fn test_disable_keys_stamps_disabled_at() {
use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0};
use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0;
use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0;
use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel};
use dpp::platform_value::BinaryData;

let make_key = |id: u32| {
IdentityPublicKey::V0(IdentityPublicKeyV0 {
id,
key_type: KeyType::ECDSA_SECP256K1,
purpose: Purpose::AUTHENTICATION,
security_level: SecurityLevel::HIGH,
contract_bounds: None,
read_only: false,
data: BinaryData::new(vec![0x02; 33]),
disabled_at: None,
})
};

let mut identity = create_test_identity();
let mut keys = BTreeMap::new();
keys.insert(0, make_key(0));
keys.insert(1, make_key(1));
identity.set_public_keys(keys);

let mut managed = ManagedIdentity::new(identity, 0);
let p = noop_persister();

// Disable key 1 plus a non-existent key 9 (must be skipped).
managed.disable_keys(&[1, 9], 1_700_000_000, &p);

let pk0 = managed.identity.get_public_key_by_id(0).unwrap();
let pk1 = managed.identity.get_public_key_by_id(1).unwrap();
assert_eq!(pk0.disabled_at(), None, "untouched key must stay enabled");
assert_eq!(
pk1.disabled_at(),
Some(1_700_000_000),
"targeted key must be stamped disabled"
);
// The skipped key id must not have materialized a phantom row.
assert!(
managed.identity.get_public_key_by_id(9).is_none(),
"non-existent key id must not be created"
);
}

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: Test never exercises the breadcrumb — the load-bearing part of the fix

The PR's stated purpose is that each emitted IdentityKeyEntry carries (wallet_id, identity_index, key_index) so the Swift upsert preserves privateKeyKeychainIdentifier. test_disable_keys_stamps_disabled_at constructs a ManagedIdentity::new(identity, 0) (leaving wallet_id == None) and uses noop_persister(), so the match (self.wallet_id, self.identity_index) always falls into the None arm and the persister discards any changeset. A regression that drops the breadcrumb, computes the wrong key_index, or stops calling persister.store(...) entirely would still pass this test. Add a recording persister, set managed.wallet_id = Some(..) and identity_index = Some(..), then assert the captured PlatformWalletChangeSet contains one upsert with wallet_id set, derivation_indices == Some(IdentityKeyDerivationIndices { identity_index, key_index: key_id }), and the matching disabled public key. Cover the no-match early-return at lines 388–390 in the same pass.

source: ['claude', 'codex']

Comment on lines +362 to +371
let (wallet_id, derivation_indices) = match breadcrumb {
Some((wallet_id, identity_index)) => (
Some(wallet_id),
Some(crate::changeset::IdentityKeyDerivationIndices {
identity_index,
key_index: key_id,
}),
),
None => (None, None),
};

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: Document the key_index == key_id cross-module invariant

disable_keys reconstructs the breadcrumb as IdentityKeyDerivationIndices { identity_index, key_index: key_id }, equating the DPP KeyID with the DIP-9 hardened derivation index. This holds today because every add_key call site passes key_index = key.id() (e.g. update.rs:231, registration), and MASTER_KEY_INDEX == 0 happens to equal the master key id. But this is an implicit cross-module invariant, not a typed one — if a future wallet adds keys with non-monotonic IDs (revoke/re-add, imports), the persisted breadcrumb will point the Swift Keychain at the wrong derivation slot, silently defeating the PR's stated goal. Either cache the derivation index per key at add_key time and read it back here, or at minimum add a doc-comment and a debug_assert recording the invariant and the failure mode.

source: ['claude']

@codecov

codecov Bot commented Jun 16, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 72.19%. Comparing base (e2039e5) to head (431d63b).
⚠️ Report is 19 commits behind head on v3.1-dev.

Additional details and impacted files
@@             Coverage Diff              @@
##           v3.1-dev    #3915      +/-   ##
============================================
+ Coverage     71.20%   72.19%   +0.99%     
============================================
  Files            20       21       +1     
  Lines          2837     2946     +109     
============================================
+ Hits           2020     2127     +107     
- Misses          817      819       +2     
Components Coverage Δ
dpp ∅ <ø> (∅)
drive ∅ <ø> (∅)
drive-abci ∅ <ø> (∅)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value ∅ <ø> (∅)
platform-wallet ∅ <ø> (∅)
drive-proof-verifier ∅ <ø> (∅)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.

2 participants