From 3cf342fc0ba39c6d0728f2cb72af64228e5a5407 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:21:01 +0300 Subject: [PATCH 01/72] feat(platform-wallet): update an identity at a revision the caller names An identity update carries the cached identity's revision plus one, read when the update starts. A caller that records the revision before it submits, to tell later whether the update can still land (Drive takes an identity update only at one above the identity's revision), cannot see that read, and a refresh of the cache in between moves it. `update_identity_with_external_signer_at_revision` takes the revision the transition must carry and fails with `IdentityRevisionMismatch` before signing when the cache would give it another one, so the recorded revision is the only one the update can land at. The existing entry point passes `None` and behaves as before. Exposed as `platform_wallet_update_identity_with_signer_at_revision` in the FFI (a new symbol, so the Kotlin JNI binding is untouched) and as `ManagedPlatformWallet.updateIdentity(..., revision:)` in Swift. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 61 +++++++++++- packages/rs-platform-wallet/src/error.rs | 14 +++ .../src/wallet/identity/network/update.rs | 93 ++++++++++++++++++- .../ManagedPlatformWallet.swift | 40 ++++---- 4 files changed, 187 insertions(+), 21 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 32b7de4c8bb..f7f244a2eb3 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -311,6 +311,58 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, signer_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + update_identity_with_signer( + wallet_handle, + identity_id, + add_public_keys, + add_public_keys_count, + disable_public_key_ids, + disable_public_key_ids_count, + None, + signer_handle, + ) +} + +/// [`platform_wallet_update_identity_with_signer`], with the revision the +/// update must carry. When the cached identity would give the transition +/// another revision (it moved since the caller read it), the call fails +/// before anything is signed or broadcast, so a caller that recorded +/// `revision` knows the only revision this update can land at. +#[no_mangle] +#[allow(clippy::too_many_arguments)] +pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision( + wallet_handle: Handle, + identity_id: *const u8, + add_public_keys: *const IdentityPubkeyFFI, + add_public_keys_count: usize, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + revision: u64, + signer_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + update_identity_with_signer( + wallet_handle, + identity_id, + add_public_keys, + add_public_keys_count, + disable_public_key_ids, + disable_public_key_ids_count, + Some(revision), + signer_handle, + ) +} + +#[allow(clippy::too_many_arguments)] +unsafe fn update_identity_with_signer( + wallet_handle: Handle, + identity_id: *const u8, + add_public_keys: *const IdentityPubkeyFFI, + add_public_keys_count: usize, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + revision: Option, + signer_handle: *mut SignerHandle, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); @@ -375,7 +427,14 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( block_on_worker(async move { let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); identity_wallet - .update_identity_with_external_signer(&id, add_keys, disable_ids, signer, None) + .update_identity_with_external_signer_at_revision( + &id, + add_keys, + disable_ids, + revision, + signer, + None, + ) .await }) }); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7f171b1ab98..fedb0b02e93 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -77,6 +77,20 @@ pub enum PlatformWalletError { #[error("Invalid identity data: {0}")] InvalidIdentityData(String), + /// An identity update was asked to carry a revision the cached identity + /// would not give it: the cache moved since the caller read the + /// revision, or the caller read it elsewhere. Raised before the + /// transition is signed, so nothing was broadcast. + #[error( + "identity {identity_id}: the update was asked to carry revision {expected}, \ + but the cached identity would give it {actual}; nothing was broadcast" + )] + IdentityRevisionMismatch { + identity_id: Identifier, + expected: u64, + actual: u64, + }, + /// A DashPay invitation link's voucher key is for the other network (a /// testnet link opened in a mainnet wallet, or the reverse). Definitive: /// the claim refuses the link before any network work. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9806b257692..8cdc62ea960 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -95,6 +95,42 @@ impl IdentityWallet { signer: &S, settings: Option, ) -> Result<(), PlatformWalletError> + where + S: Signer + Send + Sync, + { + self.update_identity_with_external_signer_at_revision( + identity_id, + add_public_keys, + disable_public_keys, + None, + signer, + settings, + ) + .await + } + + /// [`Self::update_identity_with_external_signer`], with the revision + /// the transition must carry. + /// + /// The transition takes the cached identity's revision plus one, read + /// when the update starts. A caller that records the revision before + /// submitting (to tell later whether the update can still land: Drive + /// takes an identity update only at one above the identity's revision) + /// cannot see that read, and a refresh in between moves it. With + /// `revision` set, an update that would carry another one fails with + /// [`PlatformWalletError::IdentityRevisionMismatch`] before anything is + /// signed or broadcast, so the revision the caller recorded is the only + /// one this update can land at. `None` takes whatever the cache gives, + /// as [`Self::update_identity_with_external_signer`] does. + pub async fn update_identity_with_external_signer_at_revision( + &self, + identity_id: &Identifier, + add_public_keys: Vec, + disable_public_keys: Vec, + revision: Option, + signer: &S, + settings: Option, + ) -> Result<(), PlatformWalletError> where S: Signer + Send + Sync, { @@ -131,8 +167,7 @@ impl IdentityWallet { }; // Increment revision for the update transition. - let original_revision = identity.revision(); - identity.set_revision(original_revision + 1); + identity.set_revision(update_revision(identity_id, identity.revision(), revision)?); // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. @@ -332,3 +367,57 @@ impl IdentityWallet { Ok(result) } } + +/// The revision an update of an identity cached at `cached` carries: one +/// above it. With `expected` set, any other value is refused. +fn update_revision( + identity_id: &Identifier, + cached: u64, + expected: Option, +) -> Result { + let next = cached.checked_add(1).ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "identity {identity_id} is at the last revision" + )) + })?; + match expected { + Some(expected) if expected != next => Err(PlatformWalletError::IdentityRevisionMismatch { + identity_id: *identity_id, + expected, + actual: next, + }), + _ => Ok(next), + } +} + +#[cfg(test)] +mod update_revision_tests { + use super::*; + + #[test] + fn should_carry_one_above_the_cached_revision() { + let id = Identifier::from([7u8; 32]); + assert_eq!(update_revision(&id, 4, None).unwrap(), 5); + assert_eq!(update_revision(&id, 4, Some(5)).unwrap(), 5); + } + + #[test] + fn should_refuse_an_update_asked_for_another_revision() { + let id = Identifier::from([7u8; 32]); + // The cache moved past the revision the caller read. + match update_revision(&id, 5, Some(5)) { + Err(PlatformWalletError::IdentityRevisionMismatch { + expected, actual, .. + }) => assert_eq!((expected, actual), (5, 6)), + other => panic!("unexpected: {other:?}"), + } + assert!(matches!( + update_revision(&id, 3, Some(5)), + Err(PlatformWalletError::IdentityRevisionMismatch { .. }) + )); + assert!(matches!( + update_revision(&id, u64::MAX, None), + Err(PlatformWalletError::InvalidIdentityData(_)) + )); + } +} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 548e2d19c83..403251065a7 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3729,10 +3729,19 @@ extension ManagedPlatformWallet { /// otherwise subsequent operations that try to sign with the /// newly-added keys will fail. The signer here only signs the /// update transition itself with an existing MASTER key. + /// + /// The transition carries the cached identity's revision plus one, + /// read when the Rust side starts the update. With `revision` set, an + /// update that would carry another one throws before anything is + /// signed or broadcast (`IdentityRevisionMismatch` in the message), so + /// a caller that recorded `revision` beforehand knows the only revision + /// this update can land at: Drive takes an identity update only at one + /// above the identity's revision. `nil` takes whatever the cache gives. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], disablePublicKeyIds: [UInt32] = [], + revision: UInt64? = nil, signer: KeychainSigner ) async throws { let handle = self.handle @@ -3751,30 +3760,25 @@ extension ManagedPlatformWallet { let result = idBytes.withUnsafeBufferPointer { idBp -> PlatformWalletFFIResult in disableIds.withUnsafeBufferPointer { disableBp in + let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in + let disable = disableIds.isEmpty ? nil : disableBp.baseAddress + guard let revision else { + return platform_wallet_update_identity_with_signer( + handle, idBp.baseAddress!, rows, UInt(count), + disable, UInt(disableIds.count), signerHandle) + } + return platform_wallet_update_identity_with_signer_at_revision( + handle, idBp.baseAddress!, rows, UInt(count), + disable, UInt(disableIds.count), revision, signerHandle) + } if addPubkeys.isEmpty { - return platform_wallet_update_identity_with_signer( - handle, - idBp.baseAddress!, - nil, - 0, - disableIds.isEmpty ? nil : disableBp.baseAddress, - UInt(disableIds.count), - signerHandle - ) + return update(nil, 0) } return ManagedPlatformWallet.withPubkeyFFIArray( addPubkeys, buffers: pubkeyBuffers ) { ffiRowsPtr, ffiRowsCount in - platform_wallet_update_identity_with_signer( - handle, - idBp.baseAddress!, - ffiRowsPtr, - UInt(ffiRowsCount), - disableIds.isEmpty ? nil : disableBp.baseAddress, - UInt(disableIds.count), - signerHandle - ) + update(ffiRowsPtr, ffiRowsCount) } } } From fde97b1084cc40ec4bfede6de7669441c8147718 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:29:35 +0300 Subject: [PATCH 02/72] fix(platform-wallet): bump the cached revision to the one the update landed at After a broadcast the cache took its own revision plus one. A refresh between the snapshot and that apply had already stored the landed revision, so the cache went one past the chain, and the next update asked for at that revision would be refused by Drive. It now takes the higher of what it holds and the transition's revision. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 19 +++++++++++++++---- .../ManagedPlatformWallet.swift | 10 ++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 8cdc62ea960..aba82e08b2a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -167,7 +167,8 @@ impl IdentityWallet { }; // Increment revision for the update transition. - identity.set_revision(update_revision(identity_id, identity.revision(), revision)?); + let transition_revision = update_revision(identity_id, identity.revision(), revision)?; + identity.set_revision(transition_revision); // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. @@ -258,10 +259,15 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - // Bump the cached revision so a subsequent update - // doesn't reuse the pre-broadcast value. + // The identity is at the transition's revision now, so a + // subsequent update must not reuse the pre-broadcast value. + // The cache may have been refreshed since the snapshot (to + // this revision or past it), so it takes the higher of the + // two instead of adding one to whatever it holds. let cached_revision = managed.identity.revision(); - managed.identity.set_revision(cached_revision + 1); + managed + .identity + .set_revision(cached_revision.max(transition_revision)); for key in added_keys_for_local_apply { let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); @@ -415,6 +421,11 @@ mod update_revision_tests { update_revision(&id, 3, Some(5)), Err(PlatformWalletError::IdentityRevisionMismatch { .. }) )); + } + + #[test] + fn should_refuse_an_identity_at_the_last_revision() { + let id = Identifier::from([7u8; 32]); assert!(matches!( update_revision(&id, u64::MAX, None), Err(PlatformWalletError::InvalidIdentityData(_)) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 403251065a7..1ad8d402495 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3733,10 +3733,12 @@ extension ManagedPlatformWallet { /// The transition carries the cached identity's revision plus one, /// read when the Rust side starts the update. With `revision` set, an /// update that would carry another one throws before anything is - /// signed or broadcast (`IdentityRevisionMismatch` in the message), so - /// a caller that recorded `revision` beforehand knows the only revision - /// this update can land at: Drive takes an identity update only at one - /// above the identity's revision. `nil` takes whatever the cache gives. + /// signed or broadcast (as `.unknown`, with a message that says the + /// update was asked for another revision and nothing was broadcast), + /// so a caller that recorded `revision` beforehand knows the only + /// revision this update can land at: Drive takes an identity update + /// only at one above the identity's revision. `nil` takes whatever the + /// cache gives. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], From 7ecb917c0655ecf3350fbe330f971b3a1bb2f7c1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:03:48 +0300 Subject: [PATCH 03/72] test(platform-wallet): cover the revision refusal and the landed-update cache step - Move the post-broadcast local apply into `apply_landed_update` and test it: a transition at 5 leaves a cache at 4, 5 or 6 at 5, 5 and 6, for an add-only and a disable-only update. The revision step now runs for every landed update, not only one that adds or disables keys. - Test `update_identity_with_external_signer_at_revision` itself on the mock SDK: a mismatch in either direction returns the structured (expected, actual), never consults the signer and leaves the cache unchanged; at the matching revision the same call reaches the signer and the SDK. - Document the pointer, signer and threading contract of the identity update FFI entry points, and correct the stale cache note on `update_identity_with_external_signer`. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 24 ++ .../src/wallet/identity/network/update.rs | 354 ++++++++++++++---- 2 files changed, 297 insertions(+), 81 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index f7f244a2eb3..33164e00f2e 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -301,6 +301,26 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// Update an identity by adding new public keys and/or disabling /// existing key IDs, signing the resulting `IdentityUpdateTransition` /// with the supplied `signer_handle`. +/// +/// # Safety +/// - `wallet_handle` must come from the platform-wallet handle registry. +/// - `identity_id` must be null (refused) or point at a 32-byte buffer. +/// - When `add_public_keys_count` is non-zero, `add_public_keys` must +/// point at a valid, aligned `[IdentityPubkeyFFI; add_public_keys_count]` +/// array whose rows satisfy the contract documented on +/// [`IdentityPubkeyFFI`]. +/// - When `disable_public_key_ids_count` is non-zero, +/// `disable_public_key_ids` must point at a valid, aligned +/// `[u32; disable_public_key_ids_count]` array. +/// - `signer_handle` must be a valid, non-destroyed `SignerHandle` from +/// rs-sdk-ffi's signer constructors (typically `KeychainSigner.handle`, +/// made by `dash_sdk_signer_create_with_ctx`). It is borrowed until the +/// call returns and this function does NOT destroy it. Its callbacks run +/// on a runtime worker thread while the calling thread blocks, so they +/// must be safe to call from any thread, must not wait on the calling +/// thread, and must not call back into this FFI. +/// - Every buffer above stays owned by the caller and must stay valid and +/// unmodified until the call returns; none is retained after it. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( @@ -329,6 +349,10 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( /// another revision (it moved since the caller read it), the call fails /// before anything is signed or broadcast, so a caller that recorded /// `revision` knows the only revision this update can land at. +/// +/// # Safety +/// Same pointer, length and lifetime rules as +/// [`platform_wallet_update_identity_with_signer`]. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index aba82e08b2a..2ac814b4d38 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -14,6 +14,7 @@ use dpp::identity::SecurityLevel; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::ProtocolError; +use std::time::{SystemTime, UNIX_EPOCH}; use dpp::identity::signer::Signer; @@ -77,16 +78,16 @@ impl IdentityWallet { /// in the typical case). The signer here only signs the update /// transition itself; it does not derive the new keys. /// - /// CACHE INVARIANT: this function does NOT refresh the in-process - /// `IdentityManager` after a successful broadcast. The local - /// cached `Identity` keeps the pre-update revision and key set - /// until the caller invokes [`Self::refresh_identity`] (or the - /// next sync round). A subsequent call to this function for the - /// same identity without an intervening refresh will reuse the - /// stale revision and Platform will reject the duplicate. It is - /// documented here rather than fixed because the refresh requires - /// a wallet-manager write lock that may already be held higher in - /// the call stack. + /// CACHE: after a successful broadcast the in-process + /// `IdentityManager` takes the update locally (see + /// `apply_landed_update`): the cached revision moves to the + /// higher of its own and the transition's, and the added and disabled + /// keys are applied, so a follow-up update needs no refresh in + /// between. A refresh that fetched the identity before the update + /// landed and writes it after this step still puts the cache back + /// to the pre-update state until the next refresh. When the call fails + /// after the broadcast (the proof wait timed out, say), the update may + /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -146,7 +147,7 @@ impl IdentityWallet { let (mut identity, identity_index) = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( + PlatformWalletError::WalletNotFound( "Wallet info not found in wallet manager".to_string(), ) })?; @@ -231,77 +232,92 @@ impl IdentityWallet { )) })?; - // Post-broadcast local apply. - // - // Drive accepted the transition, so the on-chain identity - // now carries the new keys / disabled flags. If we leave - // the local `ManagedIdentity` cache untouched here: - // - The next state transition for this identity reuses - // the stale revision and Platform rejects it as a - // duplicate. - // - The Swift `PersistentPublicKey` rows never receive - // the new keys (rows arrive via the - // `IdentityKeysChangeSet` persister callback that - // `add_key` fires). - // - The Identity Keys list in the iOS app keeps showing - // only the pre-update key set. - // - // `ManagedIdentity::add_key` handles both the in-memory - // mutation AND the `IdentityKeysChangeSet` upsert. Run it - // on each new key under a write lock; the breadcrumb lets - // the iOS Keychain re-derive the matching private bytes - // from the wallet seed + DIP-9 path. - if !added_keys_for_local_apply.is_empty() || !disabled_ids_for_local_apply.is_empty() { - let mut wm = self.wallet_manager.write().await; - let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { - crate::error::PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })?; - if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - // The identity is at the transition's revision now, so a - // subsequent update must not reuse the pre-broadcast value. - // The cache may have been refreshed since the snapshot (to - // this revision or past it), so it takes the higher of the - // two instead of adding one to whatever it holds. - let cached_revision = managed.identity.revision(); - managed - .identity - .set_revision(cached_revision.max(transition_revision)); - - for key in added_keys_for_local_apply { - let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); - managed - .add_key(key, breadcrumb, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - })?; - } + self.apply_landed_update( + identity_id, + identity_index, + transition_revision, + added_keys_for_local_apply, + disabled_ids_for_local_apply, + ) + .await + } - if !disabled_ids_for_local_apply.is_empty() { - // Disable-side counterpart to the `add_key` loop - // above: stamp `disabled_at` on the matching cached - // keys and fire the persister so the Swift - // `PersistentPublicKey.disabledAt` rows flip without - // a network re-fetch. The local wall-clock timestamp - // is a placeholder — the next Platform refresh - // reconciles it to the authoritative on-chain block - // time. `disable_keys` reuses the same derivation - // breadcrumb `add_key` carries, so the disabled key - // keeps its private-key linkage. - let disabled_at = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); - managed.disable_keys( - &disabled_ids_for_local_apply, - disabled_at, - &self.persister, - ); - } - } + /// Post-broadcast local apply of an update Drive accepted at + /// `transition_revision`. + /// + /// Drive accepted the transition, so the on-chain identity + /// now carries the new keys / disabled flags. If we leave + /// the local `ManagedIdentity` cache untouched here: + /// - The next state transition for this identity reuses + /// the stale revision and Platform rejects it as a + /// duplicate. + /// - The Swift `PersistentPublicKey` rows never receive + /// the new keys (rows arrive via the + /// `IdentityKeysChangeSet` persister callback that + /// `add_key` fires). + /// - The Identity Keys list in the iOS app keeps showing + /// only the pre-update key set. + /// + /// `ManagedIdentity::add_key` handles both the in-memory + /// mutation AND the `IdentityKeysChangeSet` upsert. Run it + /// on each new key under a write lock; the breadcrumb lets + /// the iOS Keychain re-derive the matching private bytes + /// from the wallet seed + DIP-9 path. + async fn apply_landed_update( + &self, + identity_id: &Identifier, + identity_index: Option, + transition_revision: u64, + added_keys: Vec, + disabled_ids: Vec, + ) -> Result<(), PlatformWalletError> { + let mut wm = self.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { + return Ok(()); + }; + + // The identity is at the transition's revision now, so a + // subsequent update must not reuse the pre-broadcast value. + // The cache may have been refreshed since the snapshot (to + // this revision or past it), so it takes the higher of the + // two instead of adding one to whatever it holds. + let cached_revision = managed.identity.revision(); + managed + .identity + .set_revision(cached_revision.max(transition_revision)); + + for key in added_keys { + let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); + managed + .add_key(key, breadcrumb, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + })?; + } + + if !disabled_ids.is_empty() { + // Disable-side counterpart to the `add_key` loop + // above: stamp `disabled_at` on the matching cached + // keys and fire the persister so the Swift + // `PersistentPublicKey.disabledAt` rows flip without + // a network re-fetch. The local wall-clock timestamp + // is a placeholder — the next Platform refresh + // reconciles it to the authoritative on-chain block + // time. `disable_keys` rebuilds the derivation breadcrumb + // from the managed identity's own wallet slot, so the + // disabled key keeps its private-key linkage. + let disabled_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + managed.disable_keys(&disabled_ids, disabled_at, &self.persister); } Ok(()) @@ -432,3 +448,179 @@ mod update_revision_tests { )); } } + +#[cfg(test)] +mod update_identity_at_revision_tests { + use super::super::signing_key::tests::wallet_with_signing_keys; + use super::*; + use crate::PlatformWallet; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// Counts every call the update makes into it. + #[derive(Debug, Default)] + struct RecordingSigner(AtomicUsize); + + #[async_trait] + impl Signer for RecordingSigner { + async fn sign(&self, _: &IdentityPublicKey, _: &[u8]) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Err(ProtocolError::Generic("recording signer".to_string())) + } + async fn sign_create_witness( + &self, + _: &IdentityPublicKey, + _: &[u8], + ) -> Result { + self.0.fetch_add(1, Ordering::SeqCst); + Err(ProtocolError::Generic("recording signer".to_string())) + } + fn can_sign_with(&self, _: &IdentityPublicKey) -> bool { + self.0.fetch_add(1, Ordering::SeqCst); + true + } + } + + async fn cached_identity(wallet: &IdentityWallet, identity_id: &Identifier) -> Identity { + let wm = wallet.wallet_manager.read().await; + wm.get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(identity_id) + .unwrap() + .identity + .clone() + } + + /// A wallet whose cached identity is at `revision` and also holds a + /// signable MASTER key (id 0), so an update that got past the revision + /// check would consult the signer and reach the mock SDK. + async fn wallet_at_revision(revision: u64) -> Arc { + let wallet = wallet_with_signing_keys().await; + { + let identity_wallet = wallet.identity(); + let mut wm = identity_wallet.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&identity_wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap(); + managed.identity.add_public_key( + IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![0; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(), + ); + managed.identity.set_revision(revision); + } + wallet + } + + /// The mock SDK has no expectations, so the update fails if it gets as + /// far as the nonce fetch; the refusal must come before it and before + /// the signer is consulted. + #[tokio::test] + async fn should_refuse_another_revision_before_signing_or_reaching_platform() { + let identity_id = Identifier::default(); + // Cache moved past the recorded revision (5 → 6), or is behind it (3 → 4). + for (cached, expected, actual) in [(5, 5, 6), (3, 5, 4)] { + let wallet = wallet_at_revision(cached).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + let signer = RecordingSigner::default(); + let error = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(expected), + &signer, + None, + ) + .await + .unwrap_err(); + match error { + PlatformWalletError::IdentityRevisionMismatch { + identity_id: id, + expected: e, + actual: a, + } => assert_eq!((id, e, a), (identity_id, expected, actual)), + other => panic!("unexpected: {other:?}"), + } + assert_eq!(signer.0.load(Ordering::SeqCst), 0); + assert_eq!(cached_identity(wallet, &identity_id).await, before); + } + + // Control: at the matching revision the same update gets past the + // check, consults the signer and then fails at the mock SDK, so the + // zero count above comes from the refusal, not from the fixture. + let wallet = wallet_at_revision(4).await; + let signer = RecordingSigner::default(); + let error = wallet + .identity() + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(5), + &signer, + Some(PutSettings { + request_settings: dash_sdk::RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); + assert!(signer.0.load(Ordering::SeqCst) > 0); + } + + #[tokio::test] + async fn should_leave_the_cache_at_the_higher_of_its_revision_and_the_landed_one() { + let identity_id = Identifier::default(); + // A transition at 5 landed; the cache is still at 4, was refreshed + // to 5, or was refreshed past it to 6. + for (cached, settled) in [(4, 5), (5, 5), (6, 6)] { + // An update that only added a key. + let wallet = wallet_at_revision(cached).await; + let wallet = wallet.identity(); + let added: IdentityPublicKey = IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), settled); + assert_eq!(identity.public_keys().get(&3), Some(&added)); + + // An update that only disabled a key. + let wallet = wallet_at_revision(cached).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), settled); + assert!(identity.public_keys()[&1].disabled_at().is_some()); + } + } +} From d0205753c5309917a99edace4c02b44c4ca3c6db Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:43:31 +0300 Subject: [PATCH 04/72] fix(platform-wallet): keep a newer cached identity from older updates and fetches A landed update whose transition is older than the cached identity (the cache was refreshed past it) no longer applies its added and disabled keys: the cache already holds them and what later updates did to them, and the payload's upserts could re-enable a key a newer update had disabled. A fetched identity at a lower revision than the cache's no longer replaces it, in refresh_identity and in the index loader. Such a fetch was answered before an update the cache already took, and could put the cache back to the pre-update revision and keys. The loader still adds its breadcrumbs, riding on the cache's newer keys. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 89 +++++++++++++-- .../wallet/identity/network/signing_key.rs | 10 +- .../src/wallet/identity/network/update.rs | 107 +++++++++++++----- .../state/managed_identity/identity_ops.rs | 42 +++++++ 4 files changed, 212 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 3133c1e2bea..1acc727e21a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -1,6 +1,7 @@ //! Load identities by HD index / DPNS name, refresh state. use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; @@ -206,7 +207,7 @@ impl IdentityWallet { }; // Query Platform for an identity registered with this key hash. - let identity = match Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await { + let mut identity = match Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await { Ok(Some(identity)) => identity, Ok(None) => return Ok(None), Err(e) => { @@ -257,10 +258,25 @@ impl IdentityWallet { // new ones, and without this a re-load kept serving the // stale key set (keys added from another device never // appeared, and contact requests referencing them failed - // key-index validation). Same replace `refresh_identity` - // does; the `set_status` below persists the full snapshot - // changeset, so the updated keys reach the store. - managed.identity = identity.clone(); + // key-index validation). Same fold `refresh_identity` + // does: a fetch older than the cache is dropped. The + // `set_status` below persists the full snapshot changeset, + // so the updated keys reach the store. + let key_decisions = if managed.take_fetched_identity(&identity) { + key_decisions + } else { + // The breadcrumbs still hold (a key's derivation never + // changes), but each rides on the cache's newer key so + // its upsert does not take that key back either. + identity = managed.identity.clone(); + key_decisions + .into_iter() + .filter_map(|mut decision| { + decision.key = identity.public_keys().get(&decision.key.id())?.clone(); + Some(decision) + }) + .collect() + }; managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (was MASTER-only). A @@ -306,9 +322,12 @@ impl IdentityWallet { /// /// The identity must already exist in the [`IdentityManager`]. Its /// on-chain state (keys, balance, revision) is replaced with the latest - /// version from Platform and the status is set to `Active`. + /// version from Platform and the status is set to `Active`. A fetch at + /// a lower revision than the cache's was answered before an update the + /// cache already holds, and leaves the cache as it is + /// ([`ManagedIdentity::take_fetched_identity`]). /// - /// Returns the refreshed identity. + /// Returns the identity the cache holds afterwards. /// /// # Errors /// @@ -337,7 +356,7 @@ impl IdentityWallet { } // Fetch the latest state from Platform. - let identity = Identity::fetch(&self.sdk, *identity_id) + let mut identity = Identity::fetch(&self.sdk, *identity_id) .await .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( @@ -361,7 +380,9 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - managed.identity = identity.clone(); + if !managed.take_fetched_identity(&identity) { + identity = managed.identity.clone(); + } managed.set_status(IdentityStatus::Active, &self.persister); } } @@ -511,6 +532,56 @@ mod tests { use key_wallet::wallet::Wallet; use key_wallet::Network; + /// A refresh answered before an update the cache already took (the + /// cache at 6, Platform's answer at 5) must not take the cache back; a + /// newer answer replaces it. Either way the call returns what the cache + /// holds afterwards. + #[tokio::test] + async fn should_not_take_the_cache_back_with_an_older_refresh() { + use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on}; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::prelude::Identifier; + + let identity_id = Identifier::default(); + for (cached, answered, settled) in [(6, 5, 6), (6, 6, 6), (6, 7, 7)] { + let mut answer = identity( + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + ); + answer.set_revision(answered); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(identity_id, Some(answer)) + .await + .unwrap(); + let wallet = wallet_with_signing_keys_on(sdk).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .identity + .set_revision(cached); + } + let returned = wallet.refresh_identity(&identity_id).await.unwrap(); + let wm = wallet.wallet_manager.read().await; + let held = &wm + .get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(&identity_id) + .unwrap() + .identity; + assert_eq!(held.revision(), settled, "answered at {answered}"); + assert_eq!(&returned, held); + } + } + /// English BIP-39 test vector (all-zero entropy). Same fixture the /// FFI-side derive tests and the `identity_handle` tests use, so the /// derivations here can be cross-checked against those if a diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 5e09f7af239..3e6dfe2c419 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -365,6 +365,14 @@ pub(super) mod tests { } pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { + wallet_with_signing_keys_on(dash_sdk::SdkBuilder::new_mock().build().unwrap()).await + } + + /// [`wallet_with_signing_keys`] on a given SDK, for a test that sets + /// mock expectations on it first. + pub(crate) async fn wallet_with_signing_keys_on( + sdk: dash_sdk::Sdk, + ) -> std::sync::Arc { use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletManager; @@ -375,7 +383,7 @@ pub(super) mod tests { impl EventHandler for Events {} impl PlatformEventHandler for Events {} let manager = PlatformWalletManager::new( - Arc::new(dash_sdk::SdkBuilder::new_mock().build().unwrap()), + Arc::new(sdk), Arc::new(NoPlatformPersistence), Arc::new(Events), ); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 2ac814b4d38..7539d5592f3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -81,11 +81,12 @@ impl IdentityWallet { /// CACHE: after a successful broadcast the in-process /// `IdentityManager` takes the update locally (see /// `apply_landed_update`): the cached revision moves to the - /// higher of its own and the transition's, and the added and disabled - /// keys are applied, so a follow-up update needs no refresh in - /// between. A refresh that fetched the identity before the update - /// landed and writes it after this step still puts the cache back - /// to the pre-update state until the next refresh. When the call fails + /// transition's and the added and disabled keys are applied, so a + /// follow-up update needs no refresh in between. A cache already + /// refreshed past the transition is left as it is. A refresh that + /// fetched the identity before the update landed and finishes after + /// this step does not take the cache back: a fetched identity at a + /// lower revision than the cache's is dropped. When the call fails /// after the broadcast (the proof wait timed out, say), the update may /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( @@ -283,13 +284,16 @@ impl IdentityWallet { // The identity is at the transition's revision now, so a // subsequent update must not reuse the pre-broadcast value. - // The cache may have been refreshed since the snapshot (to - // this revision or past it), so it takes the higher of the - // two instead of adding one to whatever it holds. - let cached_revision = managed.identity.revision(); - managed - .identity - .set_revision(cached_revision.max(transition_revision)); + // A cache refreshed past the transition already holds its keys + // and whatever later updates did to them: applying this payload + // would take those back, so it is left as it is. A cache still + // below the transition, or refreshed to exactly it, takes the + // transition's revision and payload (applying a payload the + // refresh already holds changes nothing). + if managed.identity.revision() > transition_revision { + return Ok(()); + } + managed.identity.set_revision(transition_revision); for key in added_keys { let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); @@ -588,27 +592,27 @@ mod update_identity_at_revision_tests { #[tokio::test] async fn should_leave_the_cache_at_the_higher_of_its_revision_and_the_landed_one() { let identity_id = Identifier::default(); - // A transition at 5 landed; the cache is still at 4, was refreshed - // to 5, or was refreshed past it to 6. - for (cached, settled) in [(4, 5), (5, 5), (6, 6)] { + let added: IdentityPublicKey = IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(); + // A transition at 5 landed; the cache is still at 4 or was + // refreshed to 5: it takes the revision and the payload. + for cached in [4, 5] { // An update that only added a key. let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); - let added: IdentityPublicKey = IdentityPublicKeyV0 { - id: 3, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(); wallet .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), settled); + assert_eq!(identity.revision(), 5); assert_eq!(identity.public_keys().get(&3), Some(&added)); // An update that only disabled a key. @@ -619,8 +623,59 @@ mod update_identity_at_revision_tests { .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), settled); + assert_eq!(identity.revision(), 5); assert!(identity.public_keys()[&1].disabled_at().is_some()); } + + // Refreshed past it to 6: the cache already holds this update and + // what came after, and is left as it is. + let wallet = wallet_at_revision(6).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) + .await + .unwrap(); + assert_eq!(cached_identity(wallet, &identity_id).await, before); + } + + /// An update at 5 added key 3, a later one at 6 disabled it, and the + /// cache was refreshed to 6 before the confirmation of 5 came back: + /// key 3 stays disabled. + #[tokio::test] + async fn should_not_take_back_a_newer_cache_with_an_older_landed_payload() { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + + let identity_id = Identifier::default(); + let added: IdentityPublicKey = IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(); + let wallet = wallet_at_revision(6).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap(); + let mut disabled = added.clone(); + disabled.set_disabled_at(1); + managed.identity.add_public_key(disabled); + } + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![added], vec![]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 6); + assert_eq!(identity.public_keys()[&3].disabled_at(), Some(1)); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 0e380b080e6..c946f4d1153 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -381,6 +381,21 @@ impl ManagedIdentity { } } + /// Take an identity fetched from Platform as the cached one, unless the + /// cache is at a higher revision. Revisions only grow on Platform, so + /// such a fetch was answered before an update the cache already holds + /// (one applied locally after it landed, or a newer refresh), and + /// taking it would put the cache back. Returns whether it was taken. + /// + /// Only the in-memory identity changes here; the caller persists. + pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { + if fetched.revision() < self.identity.revision() { + return false; + } + self.identity = fetched.clone(); + true + } + /// Layer an `IdentityPublicKey` onto this identity's /// `public_keys` map and emit a single-key /// [`IdentityKeysChangeSet`] upsert carrying the full derivation @@ -698,6 +713,33 @@ mod tests { }) } + /// A fetch at a lower revision than the cache's was answered before an + /// update the cache holds, and must not take it back; one at the same + /// revision or higher replaces it. + #[test] + fn should_take_a_fetched_identity_only_when_it_is_not_older() { + fn at(revision: u64, disabled: bool) -> Identity { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + let mut key = key(3); + if disabled { + key.set_disabled_at(1); + } + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(3, key)]), + balance: revision, + revision, + }) + } + // The cache took an update at 6 that disabled key 3. + let mut managed = ManagedIdentity::new(at(6, true), 0); + assert!(!managed.take_fetched_identity(&at(5, false))); + assert_eq!(managed.identity, at(6, true)); + assert!(managed.take_fetched_identity(&at(6, true))); + assert!(managed.take_fetched_identity(&at(7, true))); + assert_eq!(managed.identity, at(7, true)); + } + /// A batch of fetched names lands as one snapshot carrying every name, /// skips labels already held, and persists nothing when all are known. /// One snapshot per name made the first read as the complete owned set. From 7331388c470954762a160838c64810c91e4ddaee Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 17:47:52 +0300 Subject: [PATCH 05/72] feat(platform-wallet-ffi): report a revision mismatch with its own result code IdentityRevisionMismatch is the expected refusal of the revision-qualified update, but it reached hosts as ErrorUnknown, so they could only tell it from an SDK failure by its text. It now maps to ErrorIdentityRevisionMismatch (62; 61 is held by open #5395), mirrored as a typed error in Swift (identityRevisionMismatch) and Kotlin (PlatformWallet.IdentityRevisionMismatch), each with a pin test, and recorded in the error-code registry. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 10 +++++++ .../dashsdk/errors/DashSdkErrorTest.kt | 10 +++++++ .../ERROR_CODE_REGISTRY.md | 7 +++-- packages/rs-platform-wallet-ffi/src/error.rs | 29 +++++++++++++++++++ .../PlatformWallet/PlatformWalletResult.swift | 14 ++++++++- .../ErrorHandlingTests.swift | 14 +++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 3227c3d606b..4f4eb0fb4ce 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -240,6 +240,15 @@ sealed class DashSdkError( class AssetLockTooManyInputs(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorIdentityRevisionMismatch` (native code 62). An identity update + * asked for a revision the cached identity would not give it. Nothing + * was signed or broadcast; the expected and actual revisions are in + * [message]. Not retryable as is: refresh the identity and decide again. + */ + class IdentityRevisionMismatch(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -768,6 +777,7 @@ sealed class DashSdkError( 27 -> PlatformWallet.ShutdownIncomplete(message, cause) // ErrorShutdownIncomplete 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds 60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs + 62 -> PlatformWallet.IdentityRevisionMismatch(message, cause) // ErrorIdentityRevisionMismatch // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index fd09ae4051a..d3ccbc34b28 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -19,6 +19,16 @@ class DashSdkErrorTest { assertFalse(mapped.isRetryable) } + @Test + fun shouldPreserveIdentityRevisionMismatchAsItsOwnError() { + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 62, "asked 5, would give 6") + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.IdentityRevisionMismatch) + assertEquals(native.message, mapped.message) + assertEquals(native, mapped.cause) + assertFalse(mapped.isRetryable) + } + @Test fun mapsNativeCodesToHierarchy() { // Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index a916a4eb433..3f33d9e6bdf 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 63** — 27–62 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -123,7 +123,9 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — -renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, +renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed +by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 proposed by #5487 for +`ErrorIdentityRevisionMismatch`; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -177,6 +179,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | +| 62 | `ErrorIdentityRevisionMismatch` | #5487 | Proposed — 61 is held by open #5395 (`ErrorCoreFundsAwaitingNetwork`), so this takes 62 (#5395 records its own row). An identity update asked for a revision (`update_identity_with_external_signer_at_revision`) the cached identity would not give it; refused before signing, nothing broadcast, the expected and actual revisions in the message. Not retryable as is: the host refreshes and decides again. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `identityRevisionMismatch` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.IdentityRevisionMismatch` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 0bc67f532c9..4bc0223346e 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -614,6 +614,14 @@ pub enum PlatformWalletFFIResultCode { /// host must not silently substitute a partial drain. ErrorAssetLockTooManyInputs = 60, + /// Maps `PlatformWalletError::IdentityRevisionMismatch`: an identity + /// update asked for a revision the cached identity would not give it. + /// Refused before signing; nothing was broadcast. The host's recorded + /// revision is stale (or the cache is): refresh the identity and decide + /// again, instead of retrying the same call. `expected` and `actual` + /// are in the message. 61 is held by an open PR (#5395). + ErrorIdentityRevisionMismatch = 62, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1028,6 +1036,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockTooManyInputs { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockTooManyInputs } + PlatformWalletError::IdentityRevisionMismatch { .. } => { + PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. @@ -1671,6 +1682,24 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } + #[test] + fn should_preserve_identity_revision_mismatch_code_and_message() { + let error = PlatformWalletError::IdentityRevisionMismatch { + identity_id: Identifier::from([7; 32]), + expected: 5, + actual: 6, + }; + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch + ); + assert_eq!(result.code as i32, 62, "the host ABI code must stay stable"); + assert_eq!(message_of(&result), rendered); + } + #[test] fn should_preserve_shielded_identity_debit_pending_code_and_message() { let error = PlatformWalletError::ShieldedIdentityDebitPending { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index e7e746105e6..2ec67466028 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -264,6 +264,9 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorSpvProcessRestartRequired = 59 /// A whole-wallet asset lock exceeds the input cap; nothing was broadcast. case errorAssetLockTooManyInputs = 60 + /// An identity update asked for a revision the cached identity would not + /// give it; refused before signing, nothing was broadcast. + case errorIdentityRevisionMismatch = 62 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -395,6 +398,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorSpvProcessRestartRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_TOO_MANY_INPUTS: self = .errorAssetLockTooManyInputs + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH: + self = .errorIdentityRevisionMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -556,6 +561,11 @@ public enum PlatformWalletError: LocalizedError { case identityNotFound(String) /// A managed identity has no balance in the Platform response; the read may be retried. case identityBalanceUnavailable(String) + /// `updateIdentity(…, revision:)` was asked for a revision the cached + /// identity would not give the update. Nothing was signed or broadcast; + /// the expected and actual revisions are in the message. Refresh the + /// identity and decide again rather than retrying the same call. + case identityRevisionMismatch(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -799,7 +809,8 @@ public enum PlatformWalletError: LocalizedError { switch self { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), - .identityNotFound(let m), .identityBalanceUnavailable(let m), .contactNotFound(let m), .utf8Conversion(let m), + .identityNotFound(let m), .identityBalanceUnavailable(let m), .identityRevisionMismatch(let m), + .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), .coreInsufficientFunds(let m), @@ -911,6 +922,7 @@ public enum PlatformWalletError: LocalizedError { case .errorWalletOperation: self = .walletOperation(detail) case .errorIdentityNotFound: self = .identityNotFound(detail) case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) + case .errorIdentityRevisionMismatch: self = .identityRevisionMismatch(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f18b44a9fce..2b42a2fac11 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,6 +33,20 @@ final class ErrorHandlingTests: XCTestCase { ) } + func testIdentityRevisionMismatchFFIResultMapping() { + XCTAssertEqual( + PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH), + .errorIdentityRevisionMismatch + ) + XCTAssertEqual(PlatformWalletResultCode.errorIdentityRevisionMismatch.rawValue, 62) + let error = PlatformWalletError(code: .errorIdentityRevisionMismatch, message: "asked 5, would give 6") + guard case .identityRevisionMismatch(let detail) = error else { + return XCTFail("the revision refusal lost its typed case") + } + XCTAssertEqual(detail, "asked 5, would give 6") + XCTAssertEqual(error.errorDescription, "asked 5, would give 6") + } + func testAssetLockInsufficientFundsFFIResultMapping() { // The asset-lock coin-selection shortfall (dashpay/platform#4073). // Swift could not decode code 29 at all before this mirror existed — From d58a809db98d61626f3611e88343b1c67401e395 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:11:06 +0300 Subject: [PATCH 06/72] fix(platform-wallet): store a newer cache's keys when an older update lands A landed update whose cache was refreshed to its revision or past it skipped the payload but stored nothing: a refresh stores no keys, so the added keys lost their derivation breadcrumbs and the disabled keys never reached the store. The touched keys are now stored from the cache's own copies (on-chain disabled_at included), with their breadcrumbs. A cache at exactly the transition's revision is treated the same way, since the refresh already holds the payload as Platform has it. The tests read what reached the store, and the fixture takes a store and an SDK for that. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 10 +- .../wallet/identity/network/signing_key.rs | 25 +- .../src/wallet/identity/network/update.rs | 227 +++++++++++++----- .../state/managed_identity/identity_ops.rs | 5 + 4 files changed, 189 insertions(+), 78 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 1acc727e21a..c8cb7ab7b55 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -260,8 +260,8 @@ impl IdentityWallet { // appeared, and contact requests referencing them failed // key-index validation). Same fold `refresh_identity` // does: a fetch older than the cache is dropped. The - // `set_status` below persists the full snapshot changeset, - // so the updated keys reach the store. + // `set_status` below persists the identity's scalar + // snapshot; the keys reach the store through `add_keys`. let key_decisions = if managed.take_fetched_identity(&identity) { key_decisions } else { @@ -556,7 +556,11 @@ mod tests { .expect_fetch(identity_id, Some(answer)) .await .unwrap(); - let wallet = wallet_with_signing_keys_on(sdk).await; + let wallet = wallet_with_signing_keys_on( + sdk, + std::sync::Arc::new(crate::wallet::persister::NoPlatformPersistence), + ) + .await; let wallet = wallet.identity(); { let mut wm = wallet.wallet_manager.write().await; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 3e6dfe2c419..a21bc7b6779 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -365,16 +365,23 @@ pub(super) mod tests { } pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { - wallet_with_signing_keys_on(dash_sdk::SdkBuilder::new_mock().build().unwrap()).await + wallet_with_signing_keys_on( + dash_sdk::SdkBuilder::new_mock().build().unwrap(), + std::sync::Arc::new(crate::wallet::persister::NoPlatformPersistence), + ) + .await } - /// [`wallet_with_signing_keys`] on a given SDK, for a test that sets - /// mock expectations on it first. - pub(crate) async fn wallet_with_signing_keys_on( + /// [`wallet_with_signing_keys`] on a given SDK and store, for a test + /// that sets mock expectations first or reads what was persisted. + pub(crate) async fn wallet_with_signing_keys_on

( sdk: dash_sdk::Sdk, - ) -> std::sync::Arc { + persistence: std::sync::Arc

, + ) -> std::sync::Arc + where + P: crate::changeset::PlatformWalletPersistence + 'static, + { use crate::events::{EventHandler, PlatformEventHandler}; - use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWalletManager; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use std::sync::Arc; @@ -382,11 +389,7 @@ pub(super) mod tests { struct Events; impl EventHandler for Events {} impl PlatformEventHandler for Events {} - let manager = PlatformWalletManager::new( - Arc::new(sdk), - Arc::new(NoPlatformPersistence), - Arc::new(Events), - ); + let manager = PlatformWalletManager::new(Arc::new(sdk), persistence, Arc::new(Events)); let wallet = manager .create_wallet_from_seed_bytes( key_wallet::Network::Testnet, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 7539d5592f3..590be46e8c4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -83,7 +83,8 @@ impl IdentityWallet { /// `apply_landed_update`): the cached revision moves to the /// transition's and the added and disabled keys are applied, so a /// follow-up update needs no refresh in between. A cache already - /// refreshed past the transition is left as it is. A refresh that + /// refreshed to the transition or past it keeps its keys, and only + /// stores them. A refresh that /// fetched the identity before the update landed and finishes after /// this step does not take the cache back: a fetched identity at a /// lower revision than the cache's is dropped. When the call fails @@ -282,17 +283,40 @@ impl IdentityWallet { return Ok(()); }; + // A cache refreshed to the transition or past it already holds + // this update's keys as Platform has them (on-chain `disabled_at` + // included) and whatever later updates did to them: applying the + // payload would take those back. A refresh persists no keys, + // though, so the touched keys are stored here from the cache's own + // copies, with their derivation breadcrumbs. + if managed.identity.revision() >= transition_revision { + let slot = managed.wallet_id.zip(managed.identity_index); + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let keys = added_ids + .iter() + .map(|&id| (id, identity_index.map(|idx| (self.wallet_id, idx, id)))) + .chain( + disabled_ids + .iter() + .filter(|id| !added_ids.contains(id)) + .map(|&id| (id, slot.map(|(wallet_id, idx)| (wallet_id, idx, id)))), + ) + .filter_map(|(id, breadcrumb)| { + Some(crate::changeset::KeyWithBreadcrumb { + key: managed.identity.public_keys().get(&id)?.clone(), + breadcrumb, + }) + }) + .collect(); + return managed.add_keys(keys, &self.persister).map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }); + } + // The identity is at the transition's revision now, so a // subsequent update must not reuse the pre-broadcast value. - // A cache refreshed past the transition already holds its keys - // and whatever later updates did to them: applying this payload - // would take those back, so it is left as it is. A cache still - // below the transition, or refreshed to exactly it, takes the - // transition's revision and payload (applying a payload the - // refresh already holds changes nothing). - if managed.identity.revision() > transition_revision { - return Ok(()); - } managed.identity.set_revision(transition_revision); for key in added_keys { @@ -455,12 +479,18 @@ mod update_revision_tests { #[cfg(test)] mod update_identity_at_revision_tests { - use super::super::signing_key::tests::wallet_with_signing_keys; + use super::super::signing_key::tests::wallet_with_signing_keys_on; use super::*; + use crate::changeset::{ + ClientStartState, IdentityKeyEntry, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, + }; + use crate::wallet::platform_wallet::WalletId; use crate::PlatformWallet; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; + use std::sync::Mutex; /// Counts every call the update makes into it. #[derive(Debug, Default)] @@ -497,11 +527,62 @@ mod update_identity_at_revision_tests { .clone() } + /// Records every changeset, so a test can read what reached the store. + #[derive(Default)] + struct CapturingStore(Mutex>); + + impl PlatformWalletPersistence for CapturingStore { + fn store( + &self, + _: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + impl CapturingStore { + /// The last key row stored for `key_id`. + fn stored_key(&self, identity_id: Identifier, key_id: u32) -> Option { + self.0.lock().unwrap().iter().rev().find_map(|changeset| { + changeset + .identity_keys + .as_ref()? + .upserts + .get(&(identity_id, key_id)) + .cloned() + }) + } + } + /// A wallet whose cached identity is at `revision` and also holds a /// signable MASTER key (id 0), so an update that got past the revision /// check would consult the signer and reach the mock SDK. async fn wallet_at_revision(revision: u64) -> Arc { - let wallet = wallet_with_signing_keys().await; + wallet_at_revision_on( + revision, + Arc::new(crate::wallet::persister::NoPlatformPersistence), + ) + .await + } + + /// [`wallet_at_revision`] over a given store. + async fn wallet_at_revision_on( + revision: u64, + persistence: Arc

, + ) -> Arc { + let wallet = wallet_with_signing_keys_on( + dash_sdk::SdkBuilder::new_mock().build().unwrap(), + persistence, + ) + .await; { let identity_wallet = wallet.identity(); let mut wm = identity_wallet.wallet_manager.write().await; @@ -590,73 +671,62 @@ mod update_identity_at_revision_tests { } #[tokio::test] - async fn should_leave_the_cache_at_the_higher_of_its_revision_and_the_landed_one() { + async fn should_take_a_landed_update_only_into_a_cache_behind_it() { let identity_id = Identifier::default(); - let added: IdentityPublicKey = IdentityPublicKeyV0 { - id: 3, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(); - // A transition at 5 landed; the cache is still at 4 or was - // refreshed to 5: it takes the revision and the payload. - for cached in [4, 5] { - // An update that only added a key. - let wallet = wallet_at_revision(cached).await; - let wallet = wallet.identity(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) - .await - .unwrap(); - let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), 5); - assert_eq!(identity.public_keys().get(&3), Some(&added)); + let added = key_3(); + // A transition at 5 landed and the cache is still at 4: it takes + // the revision and the payload. + let wallet = wallet_at_revision(4).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5); + assert_eq!(identity.public_keys().get(&3), Some(&added)); + + let wallet = wallet_at_revision(4).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5); + assert!(identity.public_keys()[&1].disabled_at().is_some()); - // An update that only disabled a key. + // A cache refreshed to 5 or past it already holds the update and + // what came after, and is left as it is. + for cached in [5, 6] { let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) .await .unwrap(); - let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), 5); - assert!(identity.public_keys()[&1].disabled_at().is_some()); + assert_eq!( + cached_identity(wallet, &identity_id).await, + before, + "cached at {cached}" + ); } - - // Refreshed past it to 6: the cache already holds this update and - // what came after, and is left as it is. - let wallet = wallet_at_revision(6).await; - let wallet = wallet.identity(); - let before = cached_identity(wallet, &identity_id).await; - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) - .await - .unwrap(); - assert_eq!(cached_identity(wallet, &identity_id).await, before); } /// An update at 5 added key 3, a later one at 6 disabled it, and the /// cache was refreshed to 6 before the confirmation of 5 came back: - /// key 3 stays disabled. + /// key 3 stays disabled, and is stored that way with its breadcrumb (a + /// refresh stores no keys). A key the update disabled is stored as the + /// refresh has it, with the on-chain `disabled_at`. #[tokio::test] - async fn should_not_take_back_a_newer_cache_with_an_older_landed_payload() { + async fn should_store_the_newer_cache_keys_instead_of_an_older_landed_payload() { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; let identity_id = Identifier::default(); - let added: IdentityPublicKey = IdentityPublicKeyV0 { - id: 3, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(); - let wallet = wallet_at_revision(6).await; + let added = key_3(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; let wallet = wallet.identity(); { let mut wm = wallet.wallet_manager.write().await; @@ -669,13 +739,42 @@ mod update_identity_at_revision_tests { let mut disabled = added.clone(); disabled.set_disabled_at(1); managed.identity.add_public_key(disabled); + let mut key_1 = managed.identity.public_keys()[&1].clone(); + key_1.set_disabled_at(2); + managed.identity.add_public_key(key_1); } wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added], vec![]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added], vec![1]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; assert_eq!(identity.revision(), 6); assert_eq!(identity.public_keys()[&3].disabled_at(), Some(1)); + assert_eq!(identity.public_keys()[&1].disabled_at(), Some(2)); + + let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); + assert_eq!(stored.public_key.disabled_at(), Some(1)); + assert_eq!(stored.wallet_id, Some(wallet.wallet_id)); + assert_eq!( + stored + .derivation_indices + .map(|indices| (indices.identity_index, indices.key_index)), + Some((0, 3)) + ); + let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); + assert_eq!(stored.public_key.disabled_at(), Some(2)); + } + + /// The key the landed-update tests add. + fn key_3() -> IdentityPublicKey { + IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into() } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index c946f4d1153..2888b235731 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -387,6 +387,11 @@ impl ManagedIdentity { /// (one applied locally after it landed, or a newer refresh), and /// taking it would put the cache back. Returns whether it was taken. /// + /// The revision orders keys, not the balance, which moves without it: + /// a fetch at the cache's revision is taken whole, as before, and a + /// dropped one takes no balance with it. Balances read by height go + /// through their own watermark. + /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { if fetched.revision() < self.identity.revision() { From aa2b6a7030b4031c664d89d92012ce2dbdab41f0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:23:24 +0300 Subject: [PATCH 07/72] fix(platform-wallet): keep stored breadcrumbs and cover the third fetch site Discovery layered the keys of a fetch onto a known identity without looking at the cache's revision, so an older discovery answer could still re-enable a key a newer update had disabled. It now carries its breadcrumbs on the cache's keys, through the same helper the index loader uses (ManagedIdentity::key_decisions_on_cached_keys). When a landed update meets a cache at or past its revision, a disabled key's row is stored without a breadcrumb, which keeps the one it holds instead of writing an unverified one; a store failure for disabled keys only is logged, as disable_keys does, and an added key missing from the cache is logged. The updateIdentity(revision:) doc names the typed identityRevisionMismatch error, and take_fetched_identity says what the revision does not order (key limits, the balance). Tests: the index loader keeps a newer cache in memory and in what it stores, and the refresh test tells a taken answer from a dropped one. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 8 + .../src/wallet/identity/network/loading.rs | 138 ++++++++++++++++-- .../src/wallet/identity/network/update.rs | 67 ++++++--- .../state/managed_identity/identity_ops.rs | 26 +++- .../ManagedPlatformWallet.swift | 4 +- 5 files changed, 203 insertions(+), 40 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 11011b4b884..fbd21c324dc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -472,6 +472,14 @@ impl IdentityWallet { // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after // restart, so surface it (matching `add_identity` above). + // A fetch older than a known identity's cache carries + // its breadcrumbs on the cache's newer keys. + let key_decisions = if identity.revision() < managed.identity.revision() + { + managed.key_decisions_on_cached_keys(key_decisions) + } else { + key_decisions + }; managed .add_keys(key_decisions, &self.persister) .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index c8cb7ab7b55..bf08a5caf9e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -1,7 +1,6 @@ //! Load identities by HD index / DPNS name, refresh state. use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::prelude::Identifier; @@ -265,17 +264,8 @@ impl IdentityWallet { let key_decisions = if managed.take_fetched_identity(&identity) { key_decisions } else { - // The breadcrumbs still hold (a key's derivation never - // changes), but each rides on the cache's newer key so - // its upsert does not take that key back either. identity = managed.identity.clone(); - key_decisions - .into_iter() - .filter_map(|mut decision| { - decision.key = identity.public_keys().get(&decision.key.id())?.clone(); - Some(decision) - }) - .collect() + managed.key_decisions_on_cached_keys(key_decisions) }; managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); @@ -540,6 +530,7 @@ mod tests { async fn should_not_take_the_cache_back_with_an_older_refresh() { use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on}; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; @@ -583,6 +574,131 @@ mod tests { .identity; assert_eq!(held.revision(), settled, "answered at {answered}"); assert_eq!(&returned, held); + // The cache's keys are CRITICAL, the answer's HIGH: a dropped + // answer leaves the cache's. + let level = held.public_keys()[&1].security_level(); + let expected = if answered < cached { + SecurityLevel::CRITICAL + } else { + SecurityLevel::HIGH + }; + assert_eq!(level, expected, "answered at {answered}"); + } + } + + /// A load by index answered before an update the cache already took + /// leaves the cache's keys, in memory and in what it stores, and returns + /// the cache; an answer at the cache's revision is taken. + #[tokio::test] + async fn should_keep_a_newer_cache_when_a_load_answers_older() { + use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on}; + use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, + }; + use crate::wallet::platform_wallet::WalletId; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::prelude::Identifier; + use std::sync::{Arc, Mutex}; + + #[derive(Default)] + struct Store(Mutex>); + impl PlatformWalletPersistence for Store { + fn store( + &self, + _: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + // The probe the loader sends for index 0 of the fixture's seed. + let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); + let probe = derive_load_probe_hash( + ResolvedLoadKeyHashSource::Master(&master), + Network::Testnet, + 0, + ) + .unwrap(); + + let identity_id = Identifier::default(); + for (answered, taken) in [(5, false), (6, true)] { + let mut answer = identity( + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + ); + answer.set_revision(answered); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(answer)) + .await + .unwrap(); + let store = Arc::new(Store::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .identity + .set_revision(6); + } + store.0.lock().unwrap().clear(); + + let returned = wallet + .load_identity_by_index_from_master(0, &master) + .await + .unwrap() + .unwrap(); + let expected = if taken { + SecurityLevel::HIGH + } else { + SecurityLevel::CRITICAL + }; + assert_eq!(returned.revision(), 6, "answered at {answered}"); + assert_eq!( + returned.public_keys()[&1].security_level(), + expected, + "answered at {answered}" + ); + let wm = wallet.wallet_manager.read().await; + let held = &wm + .get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(&identity_id) + .unwrap() + .identity; + assert_eq!(held, &returned); + // It stored key rows, each carrying the key the cache holds. + let stored = store.0.lock().unwrap(); + let rows: Vec<_> = stored + .iter() + .flat_map(|changeset| changeset.identity_keys.iter()) + .flat_map(|keys| keys.upserts.values()) + .collect(); + assert!(!rows.is_empty(), "answered at {answered}"); + for entry in rows { + assert_eq!( + entry.public_key.security_level(), + expected, + "answered at {answered}" + ); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 590be46e8c4..9a14cb06a99 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -288,31 +288,47 @@ impl IdentityWallet { // included) and whatever later updates did to them: applying the // payload would take those back. A refresh persists no keys, // though, so the touched keys are stored here from the cache's own - // copies, with their derivation breadcrumbs. + // copies: an added key with its derivation breadcrumb (the store + // may not have its row yet), a disabled one with none, which keeps + // the breadcrumb its row already holds. if managed.identity.revision() >= transition_revision { - let slot = managed.wallet_id.zip(managed.identity_index); let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let keys = added_ids - .iter() - .map(|&id| (id, identity_index.map(|idx| (self.wallet_id, idx, id)))) - .chain( - disabled_ids - .iter() - .filter(|id| !added_ids.contains(id)) - .map(|&id| (id, slot.map(|(wallet_id, idx)| (wallet_id, idx, id)))), - ) - .filter_map(|(id, breadcrumb)| { - Some(crate::changeset::KeyWithBreadcrumb { - key: managed.identity.public_keys().get(&id)?.clone(), - breadcrumb, - }) - }) - .collect(); - return managed.add_keys(keys, &self.persister).map_err(|e| { - PlatformWalletError::Persistence(format!( + let mut keys = Vec::new(); + for &id in &added_ids { + let Some(key) = managed.identity.public_keys().get(&id) else { + tracing::warn!( + identity = %identity_id, + key_id = id, + "landed update: an added key is missing from a cache at its revision; not stored", + ); + continue; + }; + keys.push(crate::changeset::KeyWithBreadcrumb { + key: key.clone(), + breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), + }); + } + for id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { + if let Some(key) = managed.identity.public_keys().get(id) { + keys.push(crate::changeset::KeyWithBreadcrumb { + key: key.clone(), + breadcrumb: None, + }); + } + } + // A failure to store an added key is an error, as on the path + // below; one for disabled keys only is logged, as + // `disable_keys` does. + return match managed.add_keys(keys, &self.persister) { + Err(e) if !added_ids.is_empty() => Err(PlatformWalletError::Persistence(format!( "identity key not persisted after update: {e}" - )) - }); + ))), + Err(e) => { + tracing::error!("Failed to persist changeset: {}", e); + Ok(()) + } + Ok(()) => Ok(()), + }; } // The identity is at the transition's revision now, so a @@ -718,7 +734,8 @@ mod update_identity_at_revision_tests { /// cache was refreshed to 6 before the confirmation of 5 came back: /// key 3 stays disabled, and is stored that way with its breadcrumb (a /// refresh stores no keys). A key the update disabled is stored as the - /// refresh has it, with the on-chain `disabled_at`. + /// refresh has it, with the on-chain `disabled_at`, and without a + /// breadcrumb, so its row keeps the one it holds. #[tokio::test] async fn should_store_the_newer_cache_keys_instead_of_an_older_landed_payload() { use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; @@ -763,6 +780,10 @@ mod update_identity_at_revision_tests { ); let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); assert_eq!(stored.public_key.disabled_at(), Some(2)); + assert_eq!( + stored.derivation_indices, None, + "the row keeps the breadcrumb it holds" + ); } /// The key the landed-update tests add. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 2888b235731..e351edf4ae0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -387,10 +387,11 @@ impl ManagedIdentity { /// (one applied locally after it landed, or a newer refresh), and /// taking it would put the cache back. Returns whether it was taken. /// - /// The revision orders keys, not the balance, which moves without it: - /// a fetch at the cache's revision is taken whole, as before, and a - /// dropped one takes no balance with it. Balances read by height go - /// through their own watermark. + /// The revision orders the keys an identity update adds and disables. + /// It does not order a key-limits update or the balance, which change + /// without it: a fetch at the cache's revision is taken whole, as + /// before, and a dropped one takes no balance with it. Balances read by + /// height go through their own watermark. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { @@ -401,6 +402,23 @@ impl ManagedIdentity { true } + /// `decisions` built from a fetch older than the cache, carried on the + /// cache's copies of their keys instead, so storing their breadcrumbs + /// (a key's derivation never changes) does not take back what the cache + /// holds newer. A key the cache lacks is left out. + pub fn key_decisions_on_cached_keys( + &self, + decisions: Vec, + ) -> Vec { + decisions + .into_iter() + .filter_map(|mut decision| { + decision.key = self.identity.public_keys().get(&decision.key.id())?.clone(); + Some(decision) + }) + .collect() + } + /// Layer an `IdentityPublicKey` onto this identity's /// `public_keys` map and emit a single-key /// [`IdentityKeysChangeSet`] upsert carrying the full derivation diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 1ad8d402495..67ed8d6ef7d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3733,8 +3733,8 @@ extension ManagedPlatformWallet { /// The transition carries the cached identity's revision plus one, /// read when the Rust side starts the update. With `revision` set, an /// update that would carry another one throws before anything is - /// signed or broadcast (as `.unknown`, with a message that says the - /// update was asked for another revision and nothing was broadcast), + /// signed or broadcast (`PlatformWalletError.identityRevisionMismatch`, + /// whose message carries the expected and actual revisions), /// so a caller that recorded `revision` beforehand knows the only /// revision this update can land at: Drive takes an identity update /// only at one above the identity's revision. `nil` takes whatever the From 8182dfce8a101ba7fa5953ebac90dc8b26e68eae Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:37:17 +0300 Subject: [PATCH 08/72] fix(platform-wallet): give stored disabled keys the wallet slot's breadcrumb When a landed update meets a cache at or past its revision, a disabled key is now stored with the breadcrumb disable_keys gives it (the wallet slot's), not none: the SQLite store replaces a key's whole record, and a buffered changeset merge keeps the last upsert, so a row stored without a breadcrumb loses the one it had. Whether a store failure is an error now follows whether the batch held an added key, not whether one was asked. Discovery takes a newer fetch of a known identity as the loader and refresh_identity do, so its revision moves with the keys it lays on, and it folds before persisting the snapshot. The tests share one capturing store and an armable failing one, and cover a store failure for disabled keys only and for an added key. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 16 +-- .../src/wallet/identity/network/loading.rs | 62 ++------ .../wallet/identity/network/signing_key.rs | 86 ++++++++++- .../src/wallet/identity/network/update.rs | 133 ++++++++++-------- 4 files changed, 175 insertions(+), 122 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index fbd21c324dc..d91b21d8247 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -462,6 +462,14 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(&identity_id) { + // A known identity takes the fetch as the loader does; + // one older than its cache carries its breadcrumbs on + // the cache's newer keys. + let key_decisions = if managed.take_fetched_identity(&identity) { + key_decisions + } else { + managed.key_decisions_on_cached_keys(key_decisions) + }; managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (not just the @@ -472,14 +480,6 @@ impl IdentityWallet { // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after // restart, so surface it (matching `add_identity` above). - // A fetch older than a known identity's cache carries - // its breadcrumbs on the cache's newer keys. - let key_decisions = if identity.revision() < managed.identity.revision() - { - managed.key_decisions_on_cached_keys(key_decisions) - } else { - key_decisions - }; managed .add_keys(key_decisions, &self.persister) .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index bf08a5caf9e..23937ac0fe3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -515,12 +515,20 @@ mod tests { use super::super::identity_handle::{ derive_identity_auth_key_hash, derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, }; + use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on, CapturingStore}; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; + use crate::wallet::persister::NoPlatformPersistence; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; + use std::sync::Arc; /// A refresh answered before an update the cache already took (the /// cache at 6, Platform's answer at 5) must not take the cache back; a @@ -528,12 +536,6 @@ mod tests { /// holds afterwards. #[tokio::test] async fn should_not_take_the_cache_back_with_an_older_refresh() { - use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on}; - use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::prelude::Identifier; - let identity_id = Identifier::default(); for (cached, answered, settled) in [(6, 5, 6), (6, 6, 6), (6, 7, 7)] { let mut answer = identity( @@ -547,11 +549,7 @@ mod tests { .expect_fetch(identity_id, Some(answer)) .await .unwrap(); - let wallet = wallet_with_signing_keys_on( - sdk, - std::sync::Arc::new(crate::wallet::persister::NoPlatformPersistence), - ) - .await; + let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; let wallet = wallet.identity(); { let mut wm = wallet.wallet_manager.write().await; @@ -591,37 +589,6 @@ mod tests { /// the cache; an answer at the cache's revision is taken. #[tokio::test] async fn should_keep_a_newer_cache_when_a_load_answers_older() { - use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on}; - use crate::changeset::{ - ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, - }; - use crate::wallet::platform_wallet::WalletId; - use dash_sdk::platform::types::identity::PublicKeyHash; - use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::prelude::Identifier; - use std::sync::{Arc, Mutex}; - - #[derive(Default)] - struct Store(Mutex>); - impl PlatformWalletPersistence for Store { - fn store( - &self, - _: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - self.0.lock().unwrap().push(changeset); - Ok(()) - } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - Ok(ClientStartState::default()) - } - } - // The probe the loader sends for index 0 of the fixture's seed. let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); let probe = derive_load_probe_hash( @@ -644,7 +611,7 @@ mod tests { .expect_fetch(PublicKeyHash(probe), Some(answer)) .await .unwrap(); - let store = Arc::new(Store::default()); + let store = Arc::new(CapturingStore::default()); let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; let wallet = wallet.identity(); { @@ -657,7 +624,7 @@ mod tests { .identity .set_revision(6); } - store.0.lock().unwrap().clear(); + store.clear(); let returned = wallet .load_identity_by_index_from_master(0, &master) @@ -685,12 +652,7 @@ mod tests { .identity; assert_eq!(held, &returned); // It stored key rows, each carrying the key the cache holds. - let stored = store.0.lock().unwrap(); - let rows: Vec<_> = stored - .iter() - .flat_map(|changeset| changeset.identity_keys.iter()) - .flat_map(|keys| keys.upserts.values()) - .collect(); + let rows = store.key_rows(); assert!(!rows.is_empty(), "answered at {answered}"); for entry in rows { assert_eq!( diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index a21bc7b6779..2ee77cf0035 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -117,10 +117,17 @@ pub(super) fn credit_signing_key<'a>( #[cfg(test)] pub(super) mod tests { use super::*; + use crate::changeset::{ + ClientStartState, IdentityKeyEntry, PersistenceError, PlatformWalletChangeSet, + PlatformWalletPersistence, + }; + use crate::wallet::persister::NoPlatformPersistence; + use crate::wallet::platform_wallet::WalletId; use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::KeyID; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; @@ -364,10 +371,85 @@ pub(super) mod tests { } } + /// Records every changeset, so a test can read what reached the store. + #[derive(Default)] + pub(crate) struct CapturingStore(std::sync::Mutex>); + + impl PlatformWalletPersistence for CapturingStore { + fn store( + &self, + _: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + self.0.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + impl CapturingStore { + /// Every key row stored, oldest first. + pub(crate) fn key_rows(&self) -> Vec { + self.0 + .lock() + .unwrap() + .iter() + .flat_map(|changeset| changeset.identity_keys.iter()) + .flat_map(|keys| keys.upserts.values().cloned()) + .collect() + } + + /// The last key row stored for `key_id`. + pub(crate) fn stored_key( + &self, + identity_id: Identifier, + key_id: KeyID, + ) -> Option { + self.key_rows() + .into_iter() + .rev() + .find(|row| row.identity_id == identity_id && row.key_id == key_id) + } + + pub(crate) fn clear(&self) { + self.0.lock().unwrap().clear(); + } + } + + /// Takes every changeset until armed, then refuses each. + #[derive(Default)] + pub(crate) struct FailingStore(std::sync::atomic::AtomicBool); + + impl FailingStore { + pub(crate) fn arm(&self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + } + + impl PlatformWalletPersistence for FailingStore { + fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + if self.0.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend("store armed to fail")); + } + Ok(()) + } + fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { wallet_with_signing_keys_on( dash_sdk::SdkBuilder::new_mock().build().unwrap(), - std::sync::Arc::new(crate::wallet::persister::NoPlatformPersistence), + std::sync::Arc::new(NoPlatformPersistence), ) .await } @@ -379,7 +461,7 @@ pub(super) mod tests { persistence: std::sync::Arc

, ) -> std::sync::Arc where - P: crate::changeset::PlatformWalletPersistence + 'static, + P: PlatformWalletPersistence + 'static, { use crate::events::{EventHandler, PlatformEventHandler}; use crate::PlatformWalletManager; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9a14cb06a99..492559f5c00 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -20,6 +20,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; +use crate::changeset::KeyWithBreadcrumb; use crate::error::PlatformWalletError; use super::signing_key::AvailableSigningKey; @@ -265,6 +266,10 @@ impl IdentityWallet { /// on each new key under a write lock; the breadcrumb lets /// the iOS Keychain re-derive the matching private bytes /// from the wallet seed + DIP-9 path. + /// + /// A cache already refreshed to `transition_revision` or past it + /// holds the update as Platform has it: the payload is not applied, + /// and only the cache's copies of the touched keys are stored. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -288,11 +293,12 @@ impl IdentityWallet { // included) and whatever later updates did to them: applying the // payload would take those back. A refresh persists no keys, // though, so the touched keys are stored here from the cache's own - // copies: an added key with its derivation breadcrumb (the store - // may not have its row yet), a disabled one with none, which keeps - // the breadcrumb its row already holds. + // copies, with the breadcrumbs the path below would give them: an + // added key the caller's, a disabled key the wallet slot's, as + // `disable_keys` builds it. if managed.identity.revision() >= transition_revision { let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let slot = managed.wallet_id.zip(managed.identity_index); let mut keys = Vec::new(); for &id in &added_ids { let Some(key) = managed.identity.public_keys().get(&id) else { @@ -303,16 +309,17 @@ impl IdentityWallet { ); continue; }; - keys.push(crate::changeset::KeyWithBreadcrumb { + keys.push(KeyWithBreadcrumb { key: key.clone(), breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), }); } - for id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { - if let Some(key) = managed.identity.public_keys().get(id) { - keys.push(crate::changeset::KeyWithBreadcrumb { + let stores_an_added_key = !keys.is_empty(); + for &id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { + if let Some(key) = managed.identity.public_keys().get(&id) { + keys.push(KeyWithBreadcrumb { key: key.clone(), - breadcrumb: None, + breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), }); } } @@ -320,7 +327,7 @@ impl IdentityWallet { // below; one for disabled keys only is logged, as // `disable_keys` does. return match managed.add_keys(keys, &self.persister) { - Err(e) if !added_ids.is_empty() => Err(PlatformWalletError::Persistence(format!( + Err(e) if stores_an_added_key => Err(PlatformWalletError::Persistence(format!( "identity key not persisted after update: {e}" ))), Err(e) => { @@ -495,18 +502,17 @@ mod update_revision_tests { #[cfg(test)] mod update_identity_at_revision_tests { - use super::super::signing_key::tests::wallet_with_signing_keys_on; - use super::*; - use crate::changeset::{ - ClientStartState, IdentityKeyEntry, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, + use super::super::signing_key::tests::{ + wallet_with_signing_keys_on, CapturingStore, FailingStore, }; - use crate::wallet::platform_wallet::WalletId; + use super::*; + use crate::changeset::PlatformWalletPersistence; + use crate::wallet::persister::NoPlatformPersistence; use crate::PlatformWallet; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; - use std::sync::Mutex; /// Counts every call the update makes into it. #[derive(Debug, Default)] @@ -543,50 +549,11 @@ mod update_identity_at_revision_tests { .clone() } - /// Records every changeset, so a test can read what reached the store. - #[derive(Default)] - struct CapturingStore(Mutex>); - - impl PlatformWalletPersistence for CapturingStore { - fn store( - &self, - _: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - self.0.lock().unwrap().push(changeset); - Ok(()) - } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - Ok(ClientStartState::default()) - } - } - - impl CapturingStore { - /// The last key row stored for `key_id`. - fn stored_key(&self, identity_id: Identifier, key_id: u32) -> Option { - self.0.lock().unwrap().iter().rev().find_map(|changeset| { - changeset - .identity_keys - .as_ref()? - .upserts - .get(&(identity_id, key_id)) - .cloned() - }) - } - } - /// A wallet whose cached identity is at `revision` and also holds a /// signable MASTER key (id 0), so an update that got past the revision /// check would consult the signer and reach the mock SDK. async fn wallet_at_revision(revision: u64) -> Arc { - wallet_at_revision_on( - revision, - Arc::new(crate::wallet::persister::NoPlatformPersistence), - ) - .await + wallet_at_revision_on(revision, Arc::new(NoPlatformPersistence)).await } /// [`wallet_at_revision`] over a given store. @@ -734,12 +701,10 @@ mod update_identity_at_revision_tests { /// cache was refreshed to 6 before the confirmation of 5 came back: /// key 3 stays disabled, and is stored that way with its breadcrumb (a /// refresh stores no keys). A key the update disabled is stored as the - /// refresh has it, with the on-chain `disabled_at`, and without a - /// breadcrumb, so its row keeps the one it holds. + /// refresh has it, with the on-chain `disabled_at` and the wallet + /// slot's breadcrumb. #[tokio::test] async fn should_store_the_newer_cache_keys_instead_of_an_older_landed_payload() { - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; - let identity_id = Identifier::default(); let added = key_3(); let store = Arc::new(CapturingStore::default()); @@ -781,8 +746,52 @@ mod update_identity_at_revision_tests { let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); assert_eq!(stored.public_key.disabled_at(), Some(2)); assert_eq!( - stored.derivation_indices, None, - "the row keeps the breadcrumb it holds" + stored + .derivation_indices + .map(|indices| (indices.identity_index, indices.key_index)), + Some((0, 1)), + "the wallet slot's breadcrumb, as disable_keys gives it" + ); + } + + /// A cache at or past the transition stores the touched keys. A store + /// failure is an error when it held an added key, as on the path that + /// applies the payload, and only logged for disabled keys, as + /// `disable_keys` does: the update is on chain either way. + #[tokio::test] + async fn should_report_a_store_failure_only_for_an_added_key() { + let identity_id = Identifier::default(); + let store = Arc::new(FailingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + store.arm(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap(); + // An added key the cache lacks is not stored, so the batch holds + // disabled keys only. + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) + .await + .unwrap(); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .identity + .add_public_key(key_3()); + } + let error = wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::Persistence(_)), + "{error:?}" ); } From bb4380b7b641026cce7c60b89873103ba2517d9d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:50:18 +0300 Subject: [PATCH 09/72] fix(platform-wallet): keep keys an older answer has and the cache lacks Platform never removes a key, so a key in an older fetch that the cache lacks is on the identity: carrying the fetch's breadcrumbs on the cache's keys now keeps the fetch's copy of such a key instead of dropping it. The same holds for a landed update meeting a cache at its revision that lacks an added key: the update's copy is stored and taken into the cache, and a store failure for it is an error. Discovery leaves a known identity's revision and balance alone again, as before this PR, and only rebases an older fetch's breadcrumbs: taking an equal-revision fetch whole could put back an older balance under a newer balance watermark. The loader skips the fold for an identity it just added. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 16 ++--- .../src/wallet/identity/network/loading.rs | 5 +- .../src/wallet/identity/network/update.rs | 72 ++++++++++++------- .../state/managed_identity/identity_ops.rs | 22 +++--- 4 files changed, 71 insertions(+), 44 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index d91b21d8247..fbd21c324dc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -462,14 +462,6 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(&identity_id) { - // A known identity takes the fetch as the loader does; - // one older than its cache carries its breadcrumbs on - // the cache's newer keys. - let key_decisions = if managed.take_fetched_identity(&identity) { - key_decisions - } else { - managed.key_decisions_on_cached_keys(key_decisions) - }; managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (not just the @@ -480,6 +472,14 @@ impl IdentityWallet { // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after // restart, so surface it (matching `add_identity` above). + // A fetch older than a known identity's cache carries + // its breadcrumbs on the cache's newer keys. + let key_decisions = if identity.revision() < managed.identity.revision() + { + managed.key_decisions_on_cached_keys(key_decisions) + } else { + key_decisions + }; managed .add_keys(key_decisions, &self.persister) .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 23937ac0fe3..0af0ab56358 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -242,7 +242,8 @@ impl IdentityWallet { "Wallet info not found in wallet manager".to_string(), ) })?; - if info.identity_manager.identity(&identity_id).is_none() { + let is_new = info.identity_manager.identity(&identity_id).is_none(); + if is_new { info.identity_manager.add_identity( identity.clone(), identity_index, @@ -261,7 +262,7 @@ impl IdentityWallet { // does: a fetch older than the cache is dropped. The // `set_status` below persists the identity's scalar // snapshot; the keys reach the store through `add_keys`. - let key_decisions = if managed.take_fetched_identity(&identity) { + let key_decisions = if is_new || managed.take_fetched_identity(&identity) { key_decisions } else { identity = managed.identity.clone(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 492559f5c00..fd087648bc4 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -300,17 +300,17 @@ impl IdentityWallet { let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); let slot = managed.wallet_id.zip(managed.identity_index); let mut keys = Vec::new(); - for &id in &added_ids { - let Some(key) = managed.identity.public_keys().get(&id) else { - tracing::warn!( - identity = %identity_id, - key_id = id, - "landed update: an added key is missing from a cache at its revision; not stored", - ); - continue; - }; + // An added key the cache lacks is stored, and taken into the + // cache, as the update registered it. + for key in &added_keys { + let id = key.id(); keys.push(KeyWithBreadcrumb { - key: key.clone(), + key: managed + .identity + .public_keys() + .get(&id) + .unwrap_or(key) + .clone(), breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), }); } @@ -684,6 +684,16 @@ mod update_identity_at_revision_tests { for cached in [5, 6] { let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .identity + .add_public_key(added.clone()); + } let before = cached_identity(wallet, &identity_id).await; wallet .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) @@ -769,22 +779,6 @@ mod update_identity_at_revision_tests { .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) .await .unwrap(); - // An added key the cache lacks is not stored, so the batch holds - // disabled keys only. - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) - .await - .unwrap(); - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap() - .identity - .add_public_key(key_3()); - } let error = wallet .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) .await @@ -795,6 +789,32 @@ mod update_identity_at_revision_tests { ); } + /// A cache at the transition's revision that lacks a key the update + /// added takes the update's copy, in memory and in the store, with its + /// breadcrumb: Platform has it either way. + #[tokio::test] + async fn should_store_an_added_key_a_cache_at_its_revision_lacks() { + let identity_id = Identifier::default(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(5, store.clone()).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![]) + .await + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5); + assert_eq!(identity.public_keys().get(&3), Some(&key_3())); + let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); + assert_eq!(stored.public_key, key_3()); + assert_eq!( + stored + .derivation_indices + .map(|indices| (indices.identity_index, indices.key_index)), + Some((0, 3)) + ); + } + /// The key the landed-update tests add. fn key_3() -> IdentityPublicKey { IdentityPublicKeyV0 { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index e351edf4ae0..70bd5594faf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -2,7 +2,9 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; -use crate::changeset::{IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet}; +use crate::changeset::{ + IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, +}; use crate::wallet::persister::WalletPersister; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -405,16 +407,20 @@ impl ManagedIdentity { /// `decisions` built from a fetch older than the cache, carried on the /// cache's copies of their keys instead, so storing their breadcrumbs /// (a key's derivation never changes) does not take back what the cache - /// holds newer. A key the cache lacks is left out. + /// holds newer. A key the cache lacks keeps the fetch's copy: Platform + /// never removes a key, so it is on the identity, and storing it adds it + /// to the cache too. pub fn key_decisions_on_cached_keys( &self, - decisions: Vec, - ) -> Vec { + decisions: Vec, + ) -> Vec { decisions .into_iter() - .filter_map(|mut decision| { - decision.key = self.identity.public_keys().get(&decision.key.id())?.clone(); - Some(decision) + .map(|mut decision| { + if let Some(cached) = self.identity.public_keys().get(&decision.key.id()) { + decision.key = cached.clone(); + } + decision }) .collect() } @@ -675,6 +681,7 @@ mod tests { }; use crate::wallet::identity::PaymentEntry; use crate::wallet::platform_wallet::WalletId; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; @@ -742,7 +749,6 @@ mod tests { #[test] fn should_take_a_fetched_identity_only_when_it_is_not_older() { fn at(revision: u64, disabled: bool) -> Identity { - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; let mut key = key(3); if disabled { key.set_disabled_at(1); From f8d3f77a5f21b140858cb14b1091e561980b2ec9 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 18:59:33 +0300 Subject: [PATCH 10/72] fix(platform-wallet): return the keys a dropped load took into the cache When a load's fetch is older than the cache, the loader returned the cache as it was before storing the fetch's keys, so a key the fetch had and the cache lacked went into the cache and the store but not into what the call returned. It now returns the cache after storing them. The fold rule lives in one method, ManagedIdentity::key_decisions_for_fetch, used by the loader and discovery; the wallet-slot breadcrumb comes from ManagedIdentity::wallet_slot, shared with disable_keys. A disabled key id the cache lacks is logged on the at-or-past path as disable_keys logs it, and the docs say an added key the cache lacks is taken from the update. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 8 +--- .../src/wallet/identity/network/loading.rs | 39 +++++++++++++++---- .../src/wallet/identity/network/update.rs | 30 ++++++++------ .../state/managed_identity/identity_ops.rs | 30 ++++++++------ 4 files changed, 71 insertions(+), 36 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index fbd21c324dc..093fc3abb22 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -474,12 +474,8 @@ impl IdentityWallet { // restart, so surface it (matching `add_identity` above). // A fetch older than a known identity's cache carries // its breadcrumbs on the cache's newer keys. - let key_decisions = if identity.revision() < managed.identity.revision() - { - managed.key_decisions_on_cached_keys(key_decisions) - } else { - key_decisions - }; + let key_decisions = + managed.key_decisions_for_fetch(&identity, key_decisions); managed .add_keys(key_decisions, &self.persister) .map_err(|e| { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 0af0ab56358..f274bda784d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -262,12 +262,8 @@ impl IdentityWallet { // does: a fetch older than the cache is dropped. The // `set_status` below persists the identity's scalar // snapshot; the keys reach the store through `add_keys`. - let key_decisions = if is_new || managed.take_fetched_identity(&identity) { - key_decisions - } else { - identity = managed.identity.clone(); - managed.key_decisions_on_cached_keys(key_decisions) - }; + let key_decisions = managed.key_decisions_for_fetch(&identity, key_decisions); + let taken = is_new || managed.take_fetched_identity(&identity); managed.set_status(IdentityStatus::Active, &self.persister); managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (was MASTER-only). A @@ -280,6 +276,11 @@ impl IdentityWallet { "identity keys not persisted during load: {e}" )) })?; + // A dropped fetch returns the cache, with the keys it just + // took from the fetch. + if !taken { + identity = managed.identity.clone(); + } } } @@ -522,7 +523,8 @@ mod tests { use dash_sdk::platform::types::identity::PublicKeyHash; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - use dpp::identity::{KeyType, Purpose, SecurityLevel}; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; @@ -607,6 +609,17 @@ mod tests { KeyType::ECDSA_SECP256K1, ); answer.set_revision(answered); + // Key 4 is on the identity, but the cache does not have it. + let key_4: IdentityPublicKey = IdentityPublicKeyV0 { + id: 4, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MEDIUM, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![4; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(); + answer.add_public_key(key_4.clone()); let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); sdk.mock() .expect_fetch(PublicKeyHash(probe), Some(answer)) @@ -652,13 +665,23 @@ mod tests { .unwrap() .identity; assert_eq!(held, &returned); + assert_eq!( + returned.public_keys().get(&4), + Some(&key_4), + "answered at {answered}" + ); // It stored key rows, each carrying the key the cache holds. let rows = store.key_rows(); assert!(!rows.is_empty(), "answered at {answered}"); for entry in rows { + let level = if entry.key_id == 4 { + SecurityLevel::MEDIUM + } else { + expected + }; assert_eq!( entry.public_key.security_level(), - expected, + level, "answered at {answered}" ); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index fd087648bc4..d26cf529078 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -84,8 +84,8 @@ impl IdentityWallet { /// `apply_landed_update`): the cached revision moves to the /// transition's and the added and disabled keys are applied, so a /// follow-up update needs no refresh in between. A cache already - /// refreshed to the transition or past it keeps its keys, and only - /// stores them. A refresh that + /// refreshed to the transition or past it keeps its keys and stores + /// them, taking from the update only an added key it lacks. A refresh that /// fetched the identity before the update landed and finishes after /// this step does not take the cache back: a fetched identity at a /// lower revision than the cache's is dropped. When the call fails @@ -269,7 +269,9 @@ impl IdentityWallet { /// /// A cache already refreshed to `transition_revision` or past it /// holds the update as Platform has it: the payload is not applied, - /// and only the cache's copies of the touched keys are stored. + /// and the touched keys are stored from the cache's copies (an added + /// key the cache lacks from the payload, taken into the cache too). A + /// failure to store an added key is an error, as on the other path. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -298,7 +300,7 @@ impl IdentityWallet { // `disable_keys` builds it. if managed.identity.revision() >= transition_revision { let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let slot = managed.wallet_id.zip(managed.identity_index); + let slot = managed.wallet_slot(); let mut keys = Vec::new(); // An added key the cache lacks is stored, and taken into the // cache, as the update registered it. @@ -314,14 +316,20 @@ impl IdentityWallet { breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), }); } - let stores_an_added_key = !keys.is_empty(); + let stores_an_added_key = !added_keys.is_empty(); for &id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { - if let Some(key) = managed.identity.public_keys().get(&id) { - keys.push(KeyWithBreadcrumb { - key: key.clone(), - breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), - }); - } + let Some(key) = managed.identity.public_keys().get(&id) else { + tracing::warn!( + identity = %identity_id, + key_id = id, + "landed update: a disabled key id is not on the cached identity; skipping", + ); + continue; + }; + keys.push(KeyWithBreadcrumb { + key: key.clone(), + breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), + }); } // A failure to store an added key is an error, as on the path // below; one for disabled keys only is logged, as diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 70bd5594faf..b6021a027a8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -404,16 +404,21 @@ impl ManagedIdentity { true } - /// `decisions` built from a fetch older than the cache, carried on the - /// cache's copies of their keys instead, so storing their breadcrumbs - /// (a key's derivation never changes) does not take back what the cache - /// holds newer. A key the cache lacks keeps the fetch's copy: Platform - /// never removes a key, so it is on the identity, and storing it adds it - /// to the cache too. - pub fn key_decisions_on_cached_keys( + /// The key decisions to store for `decisions` built from `fetched`. A + /// fetch older than the cache has them carried on the cache's copies of + /// their keys, so storing their breadcrumbs (a key's derivation never + /// changes) does not take back what the cache holds newer; a key the + /// cache lacks keeps the fetch's copy, since Platform never removes a + /// key, and storing it adds it to the cache too. Any other fetch's + /// decisions are stored as they are. + pub fn key_decisions_for_fetch( &self, + fetched: &Identity, decisions: Vec, ) -> Vec { + if fetched.revision() >= self.identity.revision() { + return decisions; + } decisions .into_iter() .map(|mut decision| { @@ -425,6 +430,12 @@ impl ManagedIdentity { .collect() } + /// The wallet slot `(wallet_id, identity_index)` a wallet-owned + /// identity's key breadcrumbs are built from, or `None` out of wallet. + pub fn wallet_slot(&self) -> Option<(crate::wallet::platform_wallet::WalletId, u32)> { + self.wallet_id.zip(self.identity_index) + } + /// Layer an `IdentityPublicKey` onto this identity's /// `public_keys` map and emit a single-key /// [`IdentityKeysChangeSet`] upsert carrying the full derivation @@ -607,10 +618,7 @@ impl ManagedIdentity { // wallet/identity slot. Wallet-owned identities carry both // halves; out-of-wallet ones have neither (and no private key // to preserve), so they fall through to a watch-only entry. - let breadcrumb = match (self.wallet_id, self.identity_index) { - (Some(wallet_id), Some(identity_index)) => Some((wallet_id, identity_index)), - _ => None, - }; + let breadcrumb = self.wallet_slot(); // Operate on a clone of the key map, mirroring `add_key`, then // write it back once all matching keys have been stamped. From 24c62da194889d34352fdae0b5418e1113beaf8d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:11:10 +0300 Subject: [PATCH 11/72] style(platform-wallet): import at the top in the new code and fixtures Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/error.rs | 2 +- .../wallet/identity/network/signing_key.rs | 20 ++++++++++--------- .../state/managed_identity/identity_ops.rs | 3 ++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 4bc0223346e..9ba8b38384d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -619,7 +619,7 @@ pub enum PlatformWalletFFIResultCode { /// Refused before signing; nothing was broadcast. The host's recorded /// revision is stale (or the cache is): refresh the identity and decide /// again, instead of retrying the same call. `expected` and `actual` - /// are in the message. 61 is held by an open PR (#5395). + /// are in the message. ErrorIdentityRevisionMismatch = 62, /// The named thing does not exist. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 2ee77cf0035..e56af9d7b88 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -123,6 +123,7 @@ pub(super) mod tests { }; use crate::wallet::persister::NoPlatformPersistence; use crate::wallet::platform_wallet::WalletId; + use crate::PlatformWallet; use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; @@ -131,6 +132,8 @@ pub(super) mod tests { use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, Mutex}; /// Test signer that can sign with the keys `F` accepts; selection must never sign. pub(crate) struct KeyFilter bool>(pub F); @@ -373,7 +376,7 @@ pub(super) mod tests { /// Records every changeset, so a test can read what reached the store. #[derive(Default)] - pub(crate) struct CapturingStore(std::sync::Mutex>); + pub(crate) struct CapturingStore(Mutex>); impl PlatformWalletPersistence for CapturingStore { fn store( @@ -423,17 +426,17 @@ pub(super) mod tests { /// Takes every changeset until armed, then refuses each. #[derive(Default)] - pub(crate) struct FailingStore(std::sync::atomic::AtomicBool); + pub(crate) struct FailingStore(AtomicBool); impl FailingStore { pub(crate) fn arm(&self) { - self.0.store(true, std::sync::atomic::Ordering::SeqCst); + self.0.store(true, Ordering::SeqCst); } } impl PlatformWalletPersistence for FailingStore { fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { - if self.0.load(std::sync::atomic::Ordering::SeqCst) { + if self.0.load(Ordering::SeqCst) { return Err(PersistenceError::backend("store armed to fail")); } Ok(()) @@ -446,10 +449,10 @@ pub(super) mod tests { } } - pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { + pub(crate) async fn wallet_with_signing_keys() -> Arc { wallet_with_signing_keys_on( dash_sdk::SdkBuilder::new_mock().build().unwrap(), - std::sync::Arc::new(NoPlatformPersistence), + Arc::new(NoPlatformPersistence), ) .await } @@ -458,15 +461,14 @@ pub(super) mod tests { /// that sets mock expectations first or reads what was persisted. pub(crate) async fn wallet_with_signing_keys_on

( sdk: dash_sdk::Sdk, - persistence: std::sync::Arc

, - ) -> std::sync::Arc + persistence: Arc

, + ) -> Arc where P: PlatformWalletPersistence + 'static, { use crate::events::{EventHandler, PlatformEventHandler}; use crate::PlatformWalletManager; use key_wallet::wallet::initialization::WalletAccountCreationOptions; - use std::sync::Arc; struct Events; impl EventHandler for Events {} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index b6021a027a8..093d052443d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -6,6 +6,7 @@ use crate::changeset::{ IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, }; use crate::wallet::persister::WalletPersister; +use crate::wallet::platform_wallet::WalletId; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; @@ -432,7 +433,7 @@ impl ManagedIdentity { /// The wallet slot `(wallet_id, identity_index)` a wallet-owned /// identity's key breadcrumbs are built from, or `None` out of wallet. - pub fn wallet_slot(&self) -> Option<(crate::wallet::platform_wallet::WalletId, u32)> { + pub fn wallet_slot(&self) -> Option<(WalletId, u32)> { self.wallet_id.zip(self.identity_index) } From b0d364fac3d26864a6341bd093f5bd5ffea82ddf Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:55:49 +0300 Subject: [PATCH 12/72] fix(platform-wallet): fold older fetches per field and store the landed revision An identity fetched at a lower revision than the cache's was dropped whole, losing its balance and key limits, which change without a revision bump. It is now folded per field: the cache keeps its revision, key set and each key's disabled state, and takes the fetch's balance and each shared key's limits, as a whole fetch set them before. An older fetch's key decisions carry the same fold, and a key only it has is left out: an older answer cannot show whether a later update disabled it, and a fetch at least as new brings it. apply_landed_update applies every step in memory, stores each, stores the identity's scalar snapshot on its own (a payload whose keys the cache lacks wrote no row, so the landed revision stayed in memory only), and returns the first store failure as an update that landed but was not stored. The at-or-past path returns its store failure too. disable_keys gains a try_disable_keys that returns the failure. The identity update FFI exports run under the crate's catch_unwind guard, moved out of the shielded-only module into panic_guard: a panic surfaces as ErrorUnknown with guidance that the update may have been broadcast, never as a definitive pre-broadcast failure. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 66 ++++++ packages/rs-platform-wallet-ffi/src/lib.rs | 1 + .../rs-platform-wallet-ffi/src/panic_guard.rs | 53 +++++ .../src/shielded_send.rs | 51 +---- .../src/shielded_wallet_drain.rs | 5 +- .../src/wallet/identity/network/loading.rs | 205 ++++++++--------- .../wallet/identity/network/signing_key.rs | 14 ++ .../src/wallet/identity/network/update.rs | 133 +++++++---- .../state/managed_identity/identity_ops.rs | 213 +++++++++++++----- 9 files changed, 489 insertions(+), 252 deletions(-) create mode 100644 packages/rs-platform-wallet-ffi/src/panic_guard.rs diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 33164e00f2e..62f4e395193 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -34,6 +34,7 @@ use crate::handle::*; use crate::identity_registration_with_signer::{ decode_contract_bounds, key_with_row_limits, IdentityPubkeyFFI, }; +use crate::panic_guard::catch_panic_to_code; use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; @@ -377,6 +378,16 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision ) } +/// Post-panic guidance for the identity update exports. A panic can strike +/// after the transition was broadcast, so the outcome is ambiguous. +const UPDATE_PANIC_GUIDANCE: &str = "The update may or may not have been broadcast: refresh the \ + identity before deciding again. An update asked at a revision cannot land twice there."; + +/// The shared body of both update exports, under `catch_unwind`: a panic +/// (most concretely `block_on_worker`'s `.expect` on a panicking worker) +/// must not reach the `extern "C"` frame, where it would abort the process. +/// It surfaces as `ErrorUnknown`, never as a definitive pre-broadcast +/// failure, since the transition may already be on Platform. #[allow(clippy::too_many_arguments)] unsafe fn update_identity_with_signer( wallet_handle: Handle, @@ -387,6 +398,41 @@ unsafe fn update_identity_with_signer( disable_public_key_ids_count: usize, revision: Option, signer_handle: *mut SignerHandle, +) -> PlatformWalletFFIResult { + guard_update(|| { + update_identity_with_signer_unguarded( + wallet_handle, + identity_id, + add_public_keys, + add_public_keys_count, + disable_public_key_ids, + disable_public_key_ids_count, + revision, + signer_handle, + ) + }) +} + +/// `body` under the update exports' panic guard. +fn guard_update(body: impl FnOnce() -> PlatformWalletFFIResult) -> PlatformWalletFFIResult { + catch_panic_to_code( + "identity update", + PlatformWalletFFIResultCode::ErrorUnknown, + UPDATE_PANIC_GUIDANCE, + body, + ) +} + +#[allow(clippy::too_many_arguments)] +unsafe fn update_identity_with_signer_unguarded( + wallet_handle: Handle, + identity_id: *const u8, + add_public_keys: *const IdentityPubkeyFFI, + add_public_keys_count: usize, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + revision: Option, + signer_handle: *mut SignerHandle, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); @@ -477,6 +523,26 @@ mod tests { use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + /// A panic in an update body comes back as `ErrorUnknown` with the + /// ambiguous-outcome guidance, not as a definitive failure, and never + /// unwinds into the C frame. + #[test] + fn should_report_a_panicking_update_as_an_ambiguous_outcome() { + let result = guard_update(|| panic!("worker gone")); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); + let message = unsafe { std::ffi::CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert!( + message.contains("identity update panicked: worker gone"), + "{message}" + ); + assert!(message.contains(UPDATE_PANIC_GUIDANCE), "{message}"); + + let ok = guard_update(PlatformWalletFFIResult::ok); + assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); + } + const YAPPR_TAGLESS_IDENTITY_UPDATE_FIXTURE_HEX: &str = concat!( "0089fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae72", "01010200060200020000145e24e38a86e720f61757647996957e322686abb70000", diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 6b7b6f5f258..01f158fd611 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -64,6 +64,7 @@ pub mod masternode_update_service; pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; +pub(crate) mod panic_guard; pub mod parse_state_transition; pub mod persistence; pub mod platform_address_sync; diff --git a/packages/rs-platform-wallet-ffi/src/panic_guard.rs b/packages/rs-platform-wallet-ffi/src/panic_guard.rs new file mode 100644 index 00000000000..3f062bdb759 --- /dev/null +++ b/packages/rs-platform-wallet-ffi/src/panic_guard.rs @@ -0,0 +1,53 @@ +//! Keeping a panic in an FFI export body from unwinding into the C ABI. + +use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; + +/// Render a caught panic payload as a human-readable string. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the +/// operation's contract-appropriate result `code` (with `guidance` appended to the message) +/// instead of letting it reach the `extern "C"` frame. +/// +/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer +/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but +/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the +/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it +/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, +/// note bookkeeping, the SDK) re-panics right here inside the export. +/// +/// The `code` must be chosen per operation to preserve that operation's result contract — a +/// panic can strike after side effects (note reservation, a broadcast) have happened, so the +/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See +/// `shielded_send`'s `catch_spend_panic` and the per-export call sites. +/// +/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and +/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles +/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size +/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the +/// process before this guard can see it. +pub(crate) fn catch_panic_to_code( + operation: &str, + code: PlatformWalletFFIResultCode, + guidance: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { + Ok(result) => result, + Err(payload) => PlatformWalletFFIResult::err( + code, + format!( + "{operation} panicked: {}. {guidance}", + panic_payload_message(payload.as_ref()) + ), + ), + } +} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 01f385d75b5..9feb2cd8394 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -64,6 +64,7 @@ use crate::core_wallet_types::OutPointFFI; use crate::error::*; use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; +use crate::panic_guard::catch_panic_to_code; use crate::runtime::{block_on_worker, runtime}; use crate::shielded_types::{ShieldedIdentityDebitRecoveryRecordFFI, ShieldedShieldPreflightFFI}; use crate::types::read_identifier; @@ -681,56 +682,6 @@ fn is_preserved_signer_key_unavailable(error: &PlatformWalletError) -> bool { ) } -/// Render a caught panic payload as a human-readable string. -fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { - if let Some(s) = payload.downcast_ref::<&'static str>() { - (*s).to_string() - } else if let Some(s) = payload.downcast_ref::() { - s.clone() - } else { - "".to_string() - } -} - -/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the -/// operation's contract-appropriate result `code` (with `guidance` appended to the message) -/// instead of letting it reach the `extern "C"` frame. -/// -/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer -/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but -/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the -/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it -/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, -/// note bookkeeping, the SDK) re-panics right here inside the export. -/// -/// The `code` must be chosen per operation to preserve that operation's result contract — a -/// panic can strike after side effects (note reservation, a broadcast) have happened, so the -/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See -/// [`catch_spend_panic`] and the per-export call sites. -/// -/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and -/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles -/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size -/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the -/// process before this guard can see it. -pub(crate) fn catch_panic_to_code( - operation: &str, - code: PlatformWalletFFIResultCode, - guidance: &str, - body: impl FnOnce() -> PlatformWalletFFIResult, -) -> PlatformWalletFFIResult { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { - Ok(result) => result, - Err(payload) => PlatformWalletFFIResult::err( - code, - format!( - "{operation} panicked: {}. {guidance}", - panic_payload_message(payload.as_ref()) - ), - ), - } -} - /// Post-panic guidance for the note-spending exports. Paired with /// `ErrorShieldedSpendUnconfirmed` in [`catch_spend_panic`]. const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast — do NOT \ diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs index 52463b4c307..3f4a8ce4aee 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -16,10 +16,9 @@ use crate::check_ptr; use crate::core_wallet_types::OutPointFFI; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::Handle; +use crate::panic_guard::catch_panic_to_code; use crate::runtime::block_on_worker; -use crate::shielded_send::{ - catch_funding_panic, catch_panic_to_code, resolve_wallet_and_coordinator, -}; +use crate::shielded_send::{catch_funding_panic, resolve_wallet_and_coordinator}; use std::sync::Arc; diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index f274bda784d..4dc903f6c24 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -518,13 +518,17 @@ mod tests { derive_identity_auth_key_hash, derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, }; use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on, CapturingStore}; + use super::super::IdentityWallet; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; use crate::wallet::persister::NoPlatformPersistence; use dash_sdk::platform::types::identity::PublicKeyHash; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, + }; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::identity::Identity; + use dpp::identity::{KeyType, Purpose, SecurityLevel}; use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; @@ -533,63 +537,97 @@ mod tests { use key_wallet::Network; use std::sync::Arc; + /// Platform's answer in the fold tests: keys 1 and 2 live, key 4 (which + /// the cache never saw), a balance of 777, at `revision`. + fn answer_at(revision: u64) -> Identity { + let mut answer = identity( + Purpose::AUTHENTICATION, + SecurityLevel::HIGH, + KeyType::ECDSA_SECP256K1, + ); + answer.set_revision(revision); + answer.set_balance(777); + answer.add_public_key( + IdentityPublicKeyV0 { + id: 4, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MEDIUM, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![4; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(), + ); + answer + } + + /// Put the fixture's cached identity at revision 6, with key 1 disabled + /// by an update the answer below 6 predates. + async fn cache_at_6_with_key_1_disabled(wallet: &IdentityWallet) { + let mut wm = wallet.wallet_manager.write().await; + let identity = &mut wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap() + .identity; + identity.set_revision(6); + let mut key_1 = identity.public_keys()[&1].clone(); + key_1.set_disabled_at(9); + identity.add_public_key(key_1); + } + + async fn held(wallet: &IdentityWallet) -> Identity { + let wm = wallet.wallet_manager.read().await; + wm.get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(&Identifier::default()) + .unwrap() + .identity + .clone() + } + /// A refresh answered before an update the cache already took (the - /// cache at 6, Platform's answer at 5) must not take the cache back; a - /// newer answer replaces it. Either way the call returns what the cache - /// holds afterwards. + /// cache at 6, Platform's answer at 5) keeps the cache's revision, key + /// set and disabled states, and takes the answer's balance; an answer + /// at 6 or above replaces the cache. Either way the call returns what + /// the cache holds afterwards. #[tokio::test] async fn should_not_take_the_cache_back_with_an_older_refresh() { let identity_id = Identifier::default(); - for (cached, answered, settled) in [(6, 5, 6), (6, 6, 6), (6, 7, 7)] { - let mut answer = identity( - Purpose::AUTHENTICATION, - SecurityLevel::HIGH, - KeyType::ECDSA_SECP256K1, - ); - answer.set_revision(answered); + for (answered, settled) in [(5, 6), (6, 6), (7, 7)] { let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); sdk.mock() - .expect_fetch(identity_id, Some(answer)) + .expect_fetch(identity_id, Some(answer_at(answered))) .await .unwrap(); let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; let wallet = wallet.identity(); - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap() - .identity - .set_revision(cached); - } + cache_at_6_with_key_1_disabled(wallet).await; + let returned = wallet.refresh_identity(&identity_id).await.unwrap(); - let wm = wallet.wallet_manager.read().await; - let held = &wm - .get_wallet_info(&wallet.wallet_id) - .unwrap() - .identity_manager - .identity(&identity_id) - .unwrap() - .identity; + let held = held(wallet).await; + assert_eq!(returned, held, "answered at {answered}"); assert_eq!(held.revision(), settled, "answered at {answered}"); - assert_eq!(&returned, held); - // The cache's keys are CRITICAL, the answer's HIGH: a dropped - // answer leaves the cache's. - let level = held.public_keys()[&1].security_level(); - let expected = if answered < cached { - SecurityLevel::CRITICAL - } else { - SecurityLevel::HIGH - }; - assert_eq!(level, expected, "answered at {answered}"); + assert_eq!(held.balance(), 777, "answered at {answered}"); + let older = answered < 6; + assert_eq!( + held.public_keys()[&1].is_disabled(), + older, + "answered at {answered}" + ); + assert_eq!( + held.public_keys().contains_key(&4), + !older, + "answered at {answered}" + ); } } - /// A load by index answered before an update the cache already took - /// leaves the cache's keys, in memory and in what it stores, and returns - /// the cache; an answer at the cache's revision is taken. + /// The same fold through a load by index, in memory, in the returned + /// identity and in the key rows it stores. #[tokio::test] async fn should_keep_a_newer_cache_when_a_load_answers_older() { // The probe the loader sends for index 0 of the fixture's seed. @@ -601,43 +639,16 @@ mod tests { ) .unwrap(); - let identity_id = Identifier::default(); - for (answered, taken) in [(5, false), (6, true)] { - let mut answer = identity( - Purpose::AUTHENTICATION, - SecurityLevel::HIGH, - KeyType::ECDSA_SECP256K1, - ); - answer.set_revision(answered); - // Key 4 is on the identity, but the cache does not have it. - let key_4: IdentityPublicKey = IdentityPublicKeyV0 { - id: 4, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::MEDIUM, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![4; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(); - answer.add_public_key(key_4.clone()); + for answered in [5, 6] { let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); sdk.mock() - .expect_fetch(PublicKeyHash(probe), Some(answer)) + .expect_fetch(PublicKeyHash(probe), Some(answer_at(answered))) .await .unwrap(); let store = Arc::new(CapturingStore::default()); let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; let wallet = wallet.identity(); - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap() - .identity - .set_revision(6); - } + cache_at_6_with_key_1_disabled(wallet).await; store.clear(); let returned = wallet @@ -645,46 +656,36 @@ mod tests { .await .unwrap() .unwrap(); - let expected = if taken { - SecurityLevel::HIGH - } else { - SecurityLevel::CRITICAL - }; - assert_eq!(returned.revision(), 6, "answered at {answered}"); + let held = held(wallet).await; + assert_eq!(returned, held, "answered at {answered}"); + assert_eq!(held.revision(), 6, "answered at {answered}"); + assert_eq!(held.balance(), 777, "answered at {answered}"); + let older = answered < 6; assert_eq!( - returned.public_keys()[&1].security_level(), - expected, + held.public_keys()[&1].is_disabled(), + older, "answered at {answered}" ); - let wm = wallet.wallet_manager.read().await; - let held = &wm - .get_wallet_info(&wallet.wallet_id) - .unwrap() - .identity_manager - .identity(&identity_id) - .unwrap() - .identity; - assert_eq!(held, &returned); assert_eq!( - returned.public_keys().get(&4), - Some(&key_4), + held.public_keys().contains_key(&4), + !older, "answered at {answered}" ); - // It stored key rows, each carrying the key the cache holds. + let rows = store.key_rows(); assert!(!rows.is_empty(), "answered at {answered}"); - for entry in rows { - let level = if entry.key_id == 4 { - SecurityLevel::MEDIUM - } else { - expected - }; + for row in &rows { assert_eq!( - entry.public_key.security_level(), - level, + row.public_key, + held.public_keys()[&row.key_id], "answered at {answered}" ); } + assert_eq!( + rows.iter().any(|row| row.key_id == 4), + !older, + "answered at {answered}" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index e56af9d7b88..bd085d84069 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -419,6 +419,20 @@ pub(super) mod tests { .find(|row| row.identity_id == identity_id && row.key_id == key_id) } + /// The last revision stored for `identity_id`. + pub(crate) fn stored_revision(&self, identity_id: Identifier) -> Option { + self.0.lock().unwrap().iter().rev().find_map(|changeset| { + Some( + changeset + .identities + .as_ref()? + .identities + .get(&identity_id)? + .revision, + ) + }) + } + pub(crate) fn clear(&self) { self.0.lock().unwrap().clear(); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index d26cf529078..808da62fa1d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -20,7 +20,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::changeset::KeyWithBreadcrumb; +use crate::changeset::{KeyWithBreadcrumb, PersistenceError}; use crate::error::PlatformWalletError; use super::signing_key::AvailableSigningKey; @@ -316,7 +316,6 @@ impl IdentityWallet { breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), }); } - let stores_an_added_key = !added_keys.is_empty(); for &id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { let Some(key) = managed.identity.public_keys().get(&id) else { tracing::warn!( @@ -331,55 +330,57 @@ impl IdentityWallet { breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), }); } - // A failure to store an added key is an error, as on the path - // below; one for disabled keys only is logged, as - // `disable_keys` does. - return match managed.add_keys(keys, &self.persister) { - Err(e) if stores_an_added_key => Err(PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - ))), - Err(e) => { - tracing::error!("Failed to persist changeset: {}", e); - Ok(()) - } - Ok(()) => Ok(()), - }; + return managed + .add_keys(keys, &self.persister) + .map_err(landed_but_not_stored); } // The identity is at the transition's revision now, so a // subsequent update must not reuse the pre-broadcast value. managed.identity.set_revision(transition_revision); - for key in added_keys { - let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); - managed - .add_key(key, breadcrumb, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - })?; + // Each step below applies in memory before it stores, so the cache + // takes the whole update even when a store fails; the first failure + // is returned once every step ran. + let mut failure = None; + let added = added_keys + .into_iter() + .map(|key| { + let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); + KeyWithBreadcrumb { key, breadcrumb } + }) + .collect(); + if let Err(e) = managed.add_keys(added, &self.persister) { + failure.get_or_insert(e); } if !disabled_ids.is_empty() { - // Disable-side counterpart to the `add_key` loop - // above: stamp `disabled_at` on the matching cached - // keys and fire the persister so the Swift - // `PersistentPublicKey.disabledAt` rows flip without - // a network re-fetch. The local wall-clock timestamp - // is a placeholder — the next Platform refresh - // reconciles it to the authoritative on-chain block - // time. `disable_keys` rebuilds the derivation breadcrumb - // from the managed identity's own wallet slot, so the - // disabled key keeps its private-key linkage. + // Disable-side counterpart to `add_keys` above: stamp + // `disabled_at` on the matching cached keys and fire the + // persister so the Swift `PersistentPublicKey.disabledAt` rows + // flip without a network re-fetch. The local wall-clock + // timestamp is a placeholder — the next Platform refresh + // reconciles it to the authoritative on-chain block time. + // `try_disable_keys` rebuilds the derivation breadcrumb from the + // managed identity's own wallet slot, so the disabled key keeps + // its private-key linkage. let disabled_at = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or_default(); - managed.disable_keys(&disabled_ids, disabled_at, &self.persister); + if let Err(e) = managed.try_disable_keys(&disabled_ids, disabled_at, &self.persister) { + failure.get_or_insert(e); + } } - Ok(()) + // The steps above store the revision only alongside a key row they + // write, and a payload whose keys the cache lacks writes none: the + // revision is stored on its own as well. + if let Err(e) = managed.store_snapshot(&self.persister) { + failure.get_or_insert(e); + } + + failure.map_or(Ok(()), |e| Err(landed_but_not_stored(e))) } /// Update an identity using an externally-provided identity and signer. @@ -471,6 +472,14 @@ fn update_revision( } } +/// The error for a landed update the wallet could not store: the update is +/// on Platform either way, so the caller must not resubmit it. +fn landed_but_not_stored(e: PersistenceError) -> PlatformWalletError { + PlatformWalletError::Persistence(format!( + "identity update landed, but the wallet could not store it: {e}" + )) +} + #[cfg(test)] mod update_revision_tests { use super::*; @@ -772,21 +781,17 @@ mod update_identity_at_revision_tests { ); } - /// A cache at or past the transition stores the touched keys. A store - /// failure is an error when it held an added key, as on the path that - /// applies the payload, and only logged for disabled keys, as - /// `disable_keys` does: the update is on chain either way. + /// A store failure after a landed update is returned on both paths, + /// as an update that landed but was not stored, and the cache keeps the + /// whole update in memory. #[tokio::test] - async fn should_report_a_store_failure_only_for_an_added_key() { + async fn should_report_a_store_failure_after_a_landed_update() { let identity_id = Identifier::default(); + // The cache behind the transition takes the payload. let store = Arc::new(FailingStore::default()); - let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet_at_revision_on(4, store.clone()).await; let wallet = wallet.identity(); store.arm(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) - .await - .unwrap(); let error = wallet .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) .await @@ -795,6 +800,42 @@ mod update_identity_at_revision_tests { matches!(error, PlatformWalletError::Persistence(_)), "{error:?}" ); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5); + assert_eq!(identity.public_keys().get(&3), Some(&key_3())); + assert!(identity.public_keys()[&1].disabled_at().is_some()); + + // A cache at or past it only stores, and a disabled-only failure is + // reported too. + let store = Arc::new(FailingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + store.arm(); + let error = wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::Persistence(_)), + "{error:?}" + ); + } + + /// The landed revision reaches the store even when the update writes no + /// key row: here it disables a key the cache does not have. + #[tokio::test] + async fn should_store_the_landed_revision_on_its_own() { + let identity_id = Identifier::default(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + let wallet = wallet.identity(); + store.clear(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![7]) + .await + .unwrap(); + assert!(store.key_rows().is_empty()); + assert_eq!(store.stored_revision(identity_id), Some(5)); } /// A cache at the transition's revision that lacks a key the update diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 093d052443d..dc3f434e478 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -8,8 +8,10 @@ use crate::changeset::{ use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::Identity; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; +use dpp::identity::{Identity, IdentityPublicKey}; use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; use dpp::util::hash::ripemd160_sha256; @@ -384,34 +386,44 @@ impl ManagedIdentity { } } - /// Take an identity fetched from Platform as the cached one, unless the - /// cache is at a higher revision. Revisions only grow on Platform, so - /// such a fetch was answered before an update the cache already holds - /// (one applied locally after it landed, or a newer refresh), and - /// taking it would put the cache back. Returns whether it was taken. + /// Fold an identity fetched from Platform into the cached one. /// - /// The revision orders the keys an identity update adds and disables. - /// It does not order a key-limits update or the balance, which change - /// without it: a fetch at the cache's revision is taken whole, as - /// before, and a dropped one takes no balance with it. Balances read by - /// height go through their own watermark. + /// A fetch at the cache's revision or above is taken whole, as before. + /// Revisions only grow on Platform, so a fetch at a lower revision was + /// answered before an update the cache already holds (one applied + /// locally after it landed, or a newer refresh). The revision orders + /// only the keys an identity update adds and disables, so such a fetch + /// is folded per field: the cache keeps its revision, its key set and + /// each key's disabled state, and takes the fetch's balance and each + /// shared key's limits, which change without a revision bump (as a + /// whole fetch set them before). Returns whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { - if fetched.revision() < self.identity.revision() { - return false; + if fetched.revision() >= self.identity.revision() { + self.identity = fetched.clone(); + return true; + } + self.identity.set_balance(fetched.balance()); + let mut keys = self.identity.public_keys().clone(); + for (key_id, cached) in keys.iter_mut() { + if let Some(fetched_key) = fetched.public_keys().get(key_id) { + *cached = with_disabled_state_of(fetched_key, cached); + } } - self.identity = fetched.clone(); - true + self.identity.set_public_keys(keys); + false } - /// The key decisions to store for `decisions` built from `fetched`. A - /// fetch older than the cache has them carried on the cache's copies of - /// their keys, so storing their breadcrumbs (a key's derivation never - /// changes) does not take back what the cache holds newer; a key the - /// cache lacks keeps the fetch's copy, since Platform never removes a - /// key, and storing it adds it to the cache too. Any other fetch's - /// decisions are stored as they are. + /// The key decisions to store for `decisions` built from `fetched`. + /// Any fetch at the cache's revision or above has its decisions stored + /// as they are. For an older fetch each key is folded as + /// [`Self::take_fetched_identity`] folds it (the cache's disabled state, + /// the fetch's limits), so storing its breadcrumb (a key's derivation + /// never changes) does not take back a disable the cache holds; a key + /// the cache lacks is left out, since an older answer cannot show + /// whether a later update disabled it, and a fetch at least as new will + /// bring it. pub fn key_decisions_for_fetch( &self, fetched: &Identity, @@ -422,11 +434,10 @@ impl ManagedIdentity { } decisions .into_iter() - .map(|mut decision| { - if let Some(cached) = self.identity.public_keys().get(&decision.key.id()) { - decision.key = cached.clone(); - } - decision + .filter_map(|mut decision| { + let cached = self.identity.public_keys().get(&decision.key.id())?; + decision.key = with_disabled_state_of(&decision.key, cached); + Some(decision) }) .collect() } @@ -610,6 +621,19 @@ impl ManagedIdentity { disabled_at: TimestampMillis, persister: &WalletPersister, ) { + if let Err(e) = self.try_disable_keys(key_ids, disabled_at, persister) { + tracing::error!("Failed to persist changeset: {}", e); + } + } + + /// [`Self::disable_keys`], returning the store's failure instead of + /// logging it. + pub fn try_disable_keys( + &mut self, + key_ids: &[KeyID], + disabled_at: TimestampMillis, + persister: &WalletPersister, + ) -> Result<(), crate::changeset::PersistenceError> { use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; @@ -666,7 +690,7 @@ impl ManagedIdentity { // Nothing matched — leave state untouched rather than churning a // no-op changeset. if keys_cs.upserts.is_empty() { - return; + return Ok(()); } self.identity.set_public_keys(keys); @@ -676,12 +700,33 @@ impl ManagedIdentity { identity_keys: Some(keys_cs), ..Default::default() }; - if let Err(e) = persister.store(cs) { - tracing::error!("Failed to persist changeset: {}", e); - } + persister.store(cs) + } + + /// Store the identity's scalar snapshot (revision, balance, status…) + /// on its own, returning the store's failure. + pub fn store_snapshot( + &self, + persister: &WalletPersister, + ) -> Result<(), crate::changeset::PersistenceError> { + persister.store(self.snapshot_changeset().into()) } } +/// `fetched`'s copy of a key with `cached`'s disabled state: the key's +/// limits change without a revision bump, its disabled state does not. +fn with_disabled_state_of( + fetched: &IdentityPublicKey, + cached: &IdentityPublicKey, +) -> IdentityPublicKey { + let mut key = fetched.clone(); + match cached.disabled_at() { + Some(disabled_at) => key.set_disabled_at(disabled_at), + None => key.remove_disabled_at(), + } + key +} + #[cfg(test)] mod tests { use super::*; @@ -691,7 +736,9 @@ mod tests { use crate::wallet::identity::PaymentEntry; use crate::wallet::platform_wallet::WalletId; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; use std::collections::BTreeMap; @@ -753,34 +800,98 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds, and must not take it back; one at the same - /// revision or higher replaces it. + /// update the cache holds: it takes the cache's revision, key set and + /// disabled states back only per field, keeping those and taking the + /// balance and key limits, which change without a revision. A fetch at + /// the same revision or higher replaces the cache. #[test] - fn should_take_a_fetched_identity_only_when_it_is_not_older() { - fn at(revision: u64, disabled: bool) -> Identity { - let mut key = key(3); - if disabled { - key.set_disabled_at(1); - } + fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { + fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { + IdentityPublicKey::V1(IdentityPublicKeyV1 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![id as u8; 33]), + disabled_at, + total_budget: Some(budget), + expires_at: None, + }) + } + fn at(revision: u64, balance: u64, keys: Vec) -> Identity { Identity::V0(IdentityV0 { id: Identifier::from([1u8; 32]), - public_keys: BTreeMap::from([(3, key)]), - balance: revision, + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance, revision, }) } - // The cache took an update at 6 that disabled key 3. - let mut managed = ManagedIdentity::new(at(6, true), 0); - assert!(!managed.take_fetched_identity(&at(5, false))); - assert_eq!(managed.identity, at(6, true)); - assert!(managed.take_fetched_identity(&at(6, true))); - assert!(managed.take_fetched_identity(&at(7, true))); - assert_eq!(managed.identity, at(7, true)); + // The cache took an update at 6 that disabled key 3; the fetch at 5 + // still shows key 3 live, a raised budget, a newer balance and key + // 4, which the cache never saw. + let cache = at(6, 10, vec![limited(3, 100, Some(9))]); + let older = at(5, 20, vec![limited(3, 500, None), limited(4, 1, None)]); + let mut managed = ManagedIdentity::new(cache, 0); + assert!(!managed.take_fetched_identity(&older)); + assert_eq!(managed.identity, at(6, 20, vec![limited(3, 500, Some(9))])); + assert_eq!(managed.identity.public_keys()[&3].total_budget(), Some(500)); + + let same = at(6, 30, vec![limited(3, 600, Some(9)), limited(4, 1, None)]); + assert!(managed.take_fetched_identity(&same)); + assert_eq!(managed.identity, same); + let newer = at(7, 40, vec![limited(3, 600, Some(9))]); + assert!(managed.take_fetched_identity(&newer)); + assert_eq!(managed.identity, newer); + } + + /// An older fetch's key decisions carry the cache's disabled state and + /// the fetch's limits, and a key only the fetch has is left out. + #[test] + fn should_fold_an_older_fetch_s_key_decisions_into_the_cache_s_keys() { + let mut disabled = key(3); + disabled.set_disabled_at(9); + let managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(3, disabled.clone())]), + balance: 0, + revision: 6, + }), + 0, + ); + let fetched = |revision| { + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(3, key(3)), (4, key(4))]), + balance: 0, + revision, + }) + }; + let decisions = || { + vec![ + crate::changeset::KeyWithBreadcrumb { + key: key(3), + breadcrumb: None, + }, + crate::changeset::KeyWithBreadcrumb { + key: key(4), + breadcrumb: None, + }, + ] + }; + let folded = managed.key_decisions_for_fetch(&fetched(5), decisions()); + assert_eq!(folded.len(), 1); + assert_eq!(folded[0].key, disabled); + assert_eq!( + managed + .key_decisions_for_fetch(&fetched(6), decisions()) + .len(), + 2 + ); } - /// A batch of fetched names lands as one snapshot carrying every name, - /// skips labels already held, and persists nothing when all are known. - /// One snapshot per name made the first read as the complete owned set. #[test] fn merge_dpns_names_persists_one_complete_snapshot() { let identity = Identity::V0(IdentityV0 { From dab42a490c56af00d53c3f9204a5c07d903621bb Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:05:51 +0300 Subject: [PATCH 13/72] fix(platform-wallet): store a landed update in one changeset and keep any disable A landed update is now applied in memory and stored with the landed revision as one changeset (ManagedIdentity::apply_landed_keys), instead of up to three separate stores, so a store cannot keep the revision without the key rows. The error for a landed update the wallet could not store says it is on Platform and in the cache, and must not be resubmitted. Folding an older fetch keeps a disable from either copy: Platform never re-enables a key, so a disable only the fetch shows is not erased. Docs on the fold and the landed-update error are brought up to date, the helpers use top-level imports, and a test doc comment lost in the previous commit is restored. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 4 +- .../src/wallet/identity/network/loading.rs | 9 +- .../wallet/identity/network/signing_key.rs | 4 + .../src/wallet/identity/network/update.rs | 91 ++++++------ .../state/managed_identity/identity_ops.rs | 130 +++++++++++------- 5 files changed, 133 insertions(+), 105 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 093fc3abb22..9975fc5fe7b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -472,8 +472,8 @@ impl IdentityWallet { // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after // restart, so surface it (matching `add_identity` above). - // A fetch older than a known identity's cache carries - // its breadcrumbs on the cache's newer keys. + // A fetch older than a known identity's cache stores + // only the keys the cache has, with their disables. let key_decisions = managed.key_decisions_for_fetch(&identity, key_decisions); managed diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 4dc903f6c24..9763c25f956 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -259,7 +259,8 @@ impl IdentityWallet { // stale key set (keys added from another device never // appeared, and contact requests referencing them failed // key-index validation). Same fold `refresh_identity` - // does: a fetch older than the cache is dropped. The + // does: a fetch older than the cache keeps the cache's + // revision, key set and disabled states. The // `set_status` below persists the identity's scalar // snapshot; the keys reach the store through `add_keys`. let key_decisions = managed.key_decisions_for_fetch(&identity, key_decisions); @@ -276,8 +277,7 @@ impl IdentityWallet { "identity keys not persisted during load: {e}" )) })?; - // A dropped fetch returns the cache, with the keys it just - // took from the fetch. + // A fetch not taken whole returns the cache as folded. if !taken { identity = managed.identity.clone(); } @@ -316,7 +316,8 @@ impl IdentityWallet { /// on-chain state (keys, balance, revision) is replaced with the latest /// version from Platform and the status is set to `Active`. A fetch at /// a lower revision than the cache's was answered before an update the - /// cache already holds, and leaves the cache as it is + /// cache already holds: the cache keeps its revision, key set and + /// disabled states, and takes the fetch's balance and key limits /// ([`ManagedIdentity::take_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index bd085d84069..50d92202451 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -433,6 +433,10 @@ pub(super) mod tests { }) } + pub(crate) fn changeset_count(&self) -> usize { + self.0.lock().unwrap().len() + } + pub(crate) fn clear(&self) { self.0.lock().unwrap().clear(); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 808da62fa1d..1c3dc0b86ba 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -88,9 +88,12 @@ impl IdentityWallet { /// them, taking from the update only an added key it lacks. A refresh that /// fetched the identity before the update landed and finishes after /// this step does not take the cache back: a fetched identity at a - /// lower revision than the cache's is dropped. When the call fails - /// after the broadcast (the proof wait timed out, say), the update may - /// have landed without this step: refresh before the next update. + /// lower revision than the cache's keeps the cache's revision, key set + /// and disabled states (see `ManagedIdentity::take_fetched_identity`). + /// When the call fails after the broadcast (the proof wait timed out, + /// say), the update may have landed without this step: refresh before + /// the next update. A `Persistence` error means it landed and is in the + /// cache, but was not stored: do not resubmit it. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -261,17 +264,18 @@ impl IdentityWallet { /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// - /// `ManagedIdentity::add_key` handles both the in-memory - /// mutation AND the `IdentityKeysChangeSet` upsert. Run it - /// on each new key under a write lock; the breadcrumb lets - /// the iOS Keychain re-derive the matching private bytes + /// `ManagedIdentity::apply_landed_keys` applies the added and + /// disabled keys in memory and stores them with the landed + /// revision as one changeset, under a write lock; the breadcrumb + /// lets the iOS Keychain re-derive the matching private bytes /// from the wallet seed + DIP-9 path. /// /// A cache already refreshed to `transition_revision` or past it /// holds the update as Platform has it: the payload is not applied, /// and the touched keys are stored from the cache's copies (an added - /// key the cache lacks from the payload, taken into the cache too). A - /// failure to store an added key is an error, as on the other path. + /// key the cache lacks from the payload, taken into the cache too). + /// Either way a store failure is returned as an update that landed + /// but was not stored. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -339,10 +343,12 @@ impl IdentityWallet { // subsequent update must not reuse the pre-broadcast value. managed.identity.set_revision(transition_revision); - // Each step below applies in memory before it stores, so the cache - // takes the whole update even when a store fails; the first failure - // is returned once every step ran. - let mut failure = None; + // The added and disabled keys and the landed revision go to the + // store as one changeset, which holds the revision even when no key + // row matched. Disabled keys get the wallet slot's breadcrumb and a + // local wall-clock `disabled_at` placeholder, which the next + // Platform refresh reconciles to the on-chain block time. Memory + // holds the whole update even when the store fails. let added = added_keys .into_iter() .map(|key| { @@ -350,37 +356,13 @@ impl IdentityWallet { KeyWithBreadcrumb { key, breadcrumb } }) .collect(); - if let Err(e) = managed.add_keys(added, &self.persister) { - failure.get_or_insert(e); - } - - if !disabled_ids.is_empty() { - // Disable-side counterpart to `add_keys` above: stamp - // `disabled_at` on the matching cached keys and fire the - // persister so the Swift `PersistentPublicKey.disabledAt` rows - // flip without a network re-fetch. The local wall-clock - // timestamp is a placeholder — the next Platform refresh - // reconciles it to the authoritative on-chain block time. - // `try_disable_keys` rebuilds the derivation breadcrumb from the - // managed identity's own wallet slot, so the disabled key keeps - // its private-key linkage. - let disabled_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); - if let Err(e) = managed.try_disable_keys(&disabled_ids, disabled_at, &self.persister) { - failure.get_or_insert(e); - } - } - - // The steps above store the revision only alongside a key row they - // write, and a payload whose keys the cache lacks writes none: the - // revision is stored on its own as well. - if let Err(e) = managed.store_snapshot(&self.persister) { - failure.get_or_insert(e); - } - - failure.map_or(Ok(()), |e| Err(landed_but_not_stored(e))) + let disabled_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + managed + .apply_landed_keys(added, &disabled_ids, disabled_at, &self.persister) + .map_err(landed_but_not_stored) } /// Update an identity using an externally-provided identity and signer. @@ -472,11 +454,13 @@ fn update_revision( } } -/// The error for a landed update the wallet could not store: the update is -/// on Platform either way, so the caller must not resubmit it. +/// The error for a landed update the wallet could not store. The update is +/// on Platform and in the cache either way, so the caller must not resubmit +/// it; the next refresh or load stores the identity again. fn landed_but_not_stored(e: PersistenceError) -> PlatformWalletError { PlatformWalletError::Persistence(format!( - "identity update landed, but the wallet could not store it: {e}" + "identity update landed on Platform and is in the wallet's cache, but the wallet could not \ + store it ({e}); do not resubmit it" )) } @@ -836,6 +820,19 @@ mod update_identity_at_revision_tests { .unwrap(); assert!(store.key_rows().is_empty()); assert_eq!(store.stored_revision(identity_id), Some(5)); + + // An update with keys stores them and the revision in one changeset. + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + let wallet = wallet.identity(); + store.clear(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) + .await + .unwrap(); + assert_eq!(store.changeset_count(), 1); + assert_eq!(store.stored_revision(identity_id), Some(5)); + assert_eq!(store.key_rows().len(), 2); } /// A cache at the transition's revision that lacks a key the update diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index dc3f434e478..d527bc1afb0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -4,6 +4,7 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; use crate::changeset::{ IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, + PersistenceError, }; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -486,18 +487,32 @@ impl ManagedIdentity { /// order-dependent watch-only-then-override). No-op on an empty list. pub fn add_keys( &mut self, - keys: Vec, + keys: Vec, persister: &WalletPersister, - ) -> Result<(), crate::changeset::PersistenceError> { - use dpp::identity::accessors::IdentitySettersV0; - + ) -> Result<(), PersistenceError> { if keys.is_empty() { return Ok(()); } + let keys_cs = self.layer_keys(keys); + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; + // Surface the persist failure — these rows carry the per-key + // derivation breadcrumb + verified scalar that make an imported / + // restored identity signable. Swallowing a failed store here would + // leave the keys in memory but absent from the client store, so the + // identity comes back watch-only after restart with no signal. + persister.store(cs) + } + + /// Layer `keys` onto this identity in memory, returning their key rows. + fn layer_keys(&mut self, keys: Vec) -> IdentityKeysChangeSet { let identity_id = self.id(); let mut current = self.identity.public_keys().clone(); let mut keys_cs = IdentityKeysChangeSet::default(); - for crate::changeset::KeyWithBreadcrumb { + for KeyWithBreadcrumb { key: public_key, breadcrumb, } in keys @@ -528,18 +543,7 @@ impl ManagedIdentity { ); } self.identity.set_public_keys(current); - let cs = crate::changeset::PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }; - // Surface the persist failure — these rows carry the per-key - // derivation breadcrumb + verified scalar that make an imported / - // restored identity signable. Swallowing a failed store here would - // leave the keys in memory but absent from the client store, so the - // identity comes back watch-only after restart with no signal. - persister.store(cs)?; - Ok(()) + keys_cs } /// Replace one public key of the identity with `public_key`, the key as it is stored @@ -621,22 +625,52 @@ impl ManagedIdentity { disabled_at: TimestampMillis, persister: &WalletPersister, ) { - if let Err(e) = self.try_disable_keys(key_ids, disabled_at, persister) { + let keys_cs = self.stamp_disabled(key_ids, disabled_at); + // Nothing matched — leave state untouched rather than churning a + // no-op changeset. + if keys_cs.upserts.is_empty() { + return; + } + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; + if let Err(e) = persister.store(cs) { tracing::error!("Failed to persist changeset: {}", e); } } - /// [`Self::disable_keys`], returning the store's failure instead of - /// logging it. - pub fn try_disable_keys( + /// Apply a landed identity update's keys in memory, `added` layered and + /// `disabled` stamped, and store them with the identity's scalar + /// snapshot (the landed revision included) as one changeset, even when + /// no key row matched. The store's failure is returned; memory holds the + /// whole update either way. + pub fn apply_landed_keys( &mut self, - key_ids: &[KeyID], + added: Vec, + disabled: &[KeyID], disabled_at: TimestampMillis, persister: &WalletPersister, - ) -> Result<(), crate::changeset::PersistenceError> { - use dpp::identity::accessors::IdentitySettersV0; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; + ) -> Result<(), PersistenceError> { + let mut keys_cs = self.layer_keys(added); + keys_cs + .upserts + .extend(self.stamp_disabled(disabled, disabled_at).upserts); + persister.store(crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }) + } + /// Stamp `disabled_at` on the matching keys in memory, returning their + /// key rows (none when nothing matched). + fn stamp_disabled( + &mut self, + key_ids: &[KeyID], + disabled_at: TimestampMillis, + ) -> IdentityKeysChangeSet { let identity_id = self.id(); // Reconstruct the derivation breadcrumb from the cached @@ -687,40 +721,23 @@ impl ManagedIdentity { ); } - // Nothing matched — leave state untouched rather than churning a - // no-op changeset. - if keys_cs.upserts.is_empty() { - return Ok(()); + if !keys_cs.upserts.is_empty() { + self.identity.set_public_keys(keys); } - - self.identity.set_public_keys(keys); - - let cs = crate::changeset::PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }; - persister.store(cs) - } - - /// Store the identity's scalar snapshot (revision, balance, status…) - /// on its own, returning the store's failure. - pub fn store_snapshot( - &self, - persister: &WalletPersister, - ) -> Result<(), crate::changeset::PersistenceError> { - persister.store(self.snapshot_changeset().into()) + keys_cs } } -/// `fetched`'s copy of a key with `cached`'s disabled state: the key's -/// limits change without a revision bump, its disabled state does not. +/// `fetched`'s copy of a key with the disabled state the two copies agree +/// on: the key's limits change without a revision bump, while a disable is +/// permanent on Platform, so either copy showing one keeps it (the cache's +/// timestamp first). fn with_disabled_state_of( fetched: &IdentityPublicKey, cached: &IdentityPublicKey, ) -> IdentityPublicKey { let mut key = fetched.clone(); - match cached.disabled_at() { + match cached.disabled_at().or(fetched.disabled_at()) { Some(disabled_at) => key.set_disabled_at(disabled_at), None => key.remove_disabled_at(), } @@ -838,6 +855,12 @@ mod tests { assert_eq!(managed.identity, at(6, 20, vec![limited(3, 500, Some(9))])); assert_eq!(managed.identity.public_keys()[&3].total_budget(), Some(500)); + // A disable is permanent: one the older fetch shows sticks even + // when the cache's copy is live. + let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); + assert!(!live.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]))); + assert_eq!(live.identity.public_keys()[&3].disabled_at(), Some(4)); + let same = at(6, 30, vec![limited(3, 600, Some(9)), limited(4, 1, None)]); assert!(managed.take_fetched_identity(&same)); assert_eq!(managed.identity, same); @@ -871,11 +894,11 @@ mod tests { }; let decisions = || { vec![ - crate::changeset::KeyWithBreadcrumb { + KeyWithBreadcrumb { key: key(3), breadcrumb: None, }, - crate::changeset::KeyWithBreadcrumb { + KeyWithBreadcrumb { key: key(4), breadcrumb: None, }, @@ -892,6 +915,9 @@ mod tests { ); } + /// A batch of fetched names lands as one snapshot carrying every name, + /// skips labels already held, and persists nothing when all are known. + /// One snapshot per name made the first read as the complete owned set. #[test] fn merge_dpns_names_persists_one_complete_snapshot() { let identity = Identity::V0(IdentityV0 { From bea3aa24b14c3e297d83d9876be0590abe71b653 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:14:43 +0300 Subject: [PATCH 14/72] fix(platform-wallet): store the snapshot when a landed update meets a newer cache On the at-or-past path the touched keys went through add_keys, which stores nothing for an empty list, so a landed update none of whose keys are in the cache stored no revision. That path now stores through apply_landed_keys too, which always writes the identity's snapshot. Docs and log wording follow the shared helpers, and the changeset type is imported at the top. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 20 +++++++++++++++--- .../state/managed_identity/identity_ops.rs | 21 ++++++++++--------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 1c3dc0b86ba..139ecc09b8a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -259,8 +259,7 @@ impl IdentityWallet { /// duplicate. /// - The Swift `PersistentPublicKey` rows never receive /// the new keys (rows arrive via the - /// `IdentityKeysChangeSet` persister callback that - /// `add_key` fires). + /// `IdentityKeysChangeSet` persister callback). /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// @@ -334,8 +333,10 @@ impl IdentityWallet { breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), }); } + // Stored with the identity's snapshot even when no touched key + // is in the cache, so the revision reaches the store too. return managed - .add_keys(keys, &self.persister) + .apply_landed_keys(keys, &[], 0, &self.persister) .map_err(landed_but_not_stored); } @@ -833,6 +834,19 @@ mod update_identity_at_revision_tests { assert_eq!(store.changeset_count(), 1); assert_eq!(store.stored_revision(identity_id), Some(5)); assert_eq!(store.key_rows().len(), 2); + + // A cache already at the transition stores its snapshot too, even + // when no touched key is in it. + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(5, store.clone()).await; + let wallet = wallet.identity(); + store.clear(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![7]) + .await + .unwrap(); + assert_eq!(store.changeset_count(), 1); + assert_eq!(store.stored_revision(identity_id), Some(5)); } /// A cache at the transition's revision that lacks a key the update diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index d527bc1afb0..296888c264c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -4,7 +4,7 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; use crate::changeset::{ IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, - PersistenceError, + PersistenceError, PlatformWalletChangeSet, }; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -494,7 +494,7 @@ impl ManagedIdentity { return Ok(()); } let keys_cs = self.layer_keys(keys); - let cs = crate::changeset::PlatformWalletChangeSet { + let cs = PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), ..Default::default() @@ -618,7 +618,9 @@ impl ManagedIdentity { /// the authoritative on-chain block time. /// /// Does **not** touch the identity revision — the caller bumps it - /// alongside the update transition. + /// alongside the update transition. A store failure is logged, not + /// returned; a landed update goes through [`Self::apply_landed_keys`], + /// which returns it and stores the revision too. pub fn disable_keys( &mut self, key_ids: &[KeyID], @@ -631,7 +633,7 @@ impl ManagedIdentity { if keys_cs.upserts.is_empty() { return; } - let cs = crate::changeset::PlatformWalletChangeSet { + let cs = PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), ..Default::default() @@ -657,7 +659,7 @@ impl ManagedIdentity { keys_cs .upserts .extend(self.stamp_disabled(disabled, disabled_at).upserts); - persister.store(crate::changeset::PlatformWalletChangeSet { + persister.store(PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), ..Default::default() @@ -689,7 +691,7 @@ impl ManagedIdentity { tracing::warn!( identity = %identity_id, key_id, - "disable_keys: key id not present on identity; skipping", + "stamping disabled keys: key id not present on identity; skipping", ); continue; }; @@ -728,10 +730,9 @@ impl ManagedIdentity { } } -/// `fetched`'s copy of a key with the disabled state the two copies agree -/// on: the key's limits change without a revision bump, while a disable is -/// permanent on Platform, so either copy showing one keeps it (the cache's -/// timestamp first). +/// `fetched`'s copy of a key, disabled when either copy is: the key's limits +/// change without a revision bump, while a disable is permanent on Platform +/// (the cache's timestamp first). fn with_disabled_state_of( fetched: &IdentityPublicKey, cached: &IdentityPublicKey, From e5cbebafe6840e1ea7ed0edfb6f7928a7c2b7349 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:23:52 +0300 Subject: [PATCH 15/72] fix(platform-wallet): prefer Platform's disable time and match the paths' disable rule Folding an older fetch keeps a disable from either copy, but now takes the fetch's timestamp first: it is Platform's own, while the cache's may be the local placeholder a landed update stamped. The path that applies a landed payload leaves out a disabled id the update also adds, as the at-or-past path does. The update FFI's panic guidance promises "cannot land twice" only for the export that pinned a revision. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 55 +++++++++++++------ .../src/wallet/identity/network/update.rs | 7 ++- .../state/managed_identity/identity_ops.rs | 23 +++++--- 3 files changed, 59 insertions(+), 26 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 62f4e395193..016b3f7b6c8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -380,8 +380,14 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision /// Post-panic guidance for the identity update exports. A panic can strike /// after the transition was broadcast, so the outcome is ambiguous. -const UPDATE_PANIC_GUIDANCE: &str = "The update may or may not have been broadcast: refresh the \ - identity before deciding again. An update asked at a revision cannot land twice there."; +const UPDATE_PANIC_GUIDANCE: &str = + "The update may or may not have been broadcast: refresh the identity before deciding again."; + +/// The same, for the export that pinned a revision: a retry there cannot +/// land the update twice. +const UPDATE_AT_REVISION_PANIC_GUIDANCE: &str = "The update may or may not have been broadcast: \ + refresh the identity before deciding again. An update asked at a revision cannot land twice \ + there."; /// The shared body of both update exports, under `catch_unwind`: a panic /// (most concretely `block_on_worker`'s `.expect` on a panicking worker) @@ -399,7 +405,7 @@ unsafe fn update_identity_with_signer( revision: Option, signer_handle: *mut SignerHandle, ) -> PlatformWalletFFIResult { - guard_update(|| { + guard_update(revision.is_some(), || { update_identity_with_signer_unguarded( wallet_handle, identity_id, @@ -413,12 +419,21 @@ unsafe fn update_identity_with_signer( }) } -/// `body` under the update exports' panic guard. -fn guard_update(body: impl FnOnce() -> PlatformWalletFFIResult) -> PlatformWalletFFIResult { +/// `body` under the update exports' panic guard; `at_revision` picks the +/// guidance for the export that pinned a revision. +fn guard_update( + at_revision: bool, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + let guidance = if at_revision { + UPDATE_AT_REVISION_PANIC_GUIDANCE + } else { + UPDATE_PANIC_GUIDANCE + }; catch_panic_to_code( "identity update", PlatformWalletFFIResultCode::ErrorUnknown, - UPDATE_PANIC_GUIDANCE, + guidance, body, ) } @@ -522,24 +537,30 @@ mod tests { use dpp::serialization::PlatformSerializable; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use std::ffi::CStr; /// A panic in an update body comes back as `ErrorUnknown` with the /// ambiguous-outcome guidance, not as a definitive failure, and never /// unwinds into the C frame. #[test] fn should_report_a_panicking_update_as_an_ambiguous_outcome() { - let result = guard_update(|| panic!("worker gone")); - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); - let message = unsafe { std::ffi::CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned(); - assert!( - message.contains("identity update panicked: worker gone"), - "{message}" - ); - assert!(message.contains(UPDATE_PANIC_GUIDANCE), "{message}"); + for (at_revision, guidance) in [ + (false, UPDATE_PANIC_GUIDANCE), + (true, UPDATE_AT_REVISION_PANIC_GUIDANCE), + ] { + let result = guard_update(at_revision, || panic!("worker gone")); + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); + let message = unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned(); + assert!( + message.contains("identity update panicked: worker gone"), + "{message}" + ); + assert!(message.ends_with(guidance), "{message}"); + } - let ok = guard_update(PlatformWalletFFIResult::ok); + let ok = guard_update(true, PlatformWalletFFIResult::ok); assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 139ecc09b8a..91078b07e90 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -350,6 +350,11 @@ impl IdentityWallet { // local wall-clock `disabled_at` placeholder, which the next // Platform refresh reconciles to the on-chain block time. Memory // holds the whole update even when the store fails. + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let disabled: Vec = disabled_ids + .into_iter() + .filter(|id| !added_ids.contains(id)) + .collect(); let added = added_keys .into_iter() .map(|key| { @@ -362,7 +367,7 @@ impl IdentityWallet { .map(|d| d.as_millis() as u64) .unwrap_or_default(); managed - .apply_landed_keys(added, &disabled_ids, disabled_at, &self.persister) + .apply_landed_keys(added, &disabled, disabled_at, &self.persister) .map_err(landed_but_not_stored) } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 296888c264c..beb58e002da 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -731,14 +731,15 @@ impl ManagedIdentity { } /// `fetched`'s copy of a key, disabled when either copy is: the key's limits -/// change without a revision bump, while a disable is permanent on Platform -/// (the cache's timestamp first). +/// change without a revision bump, while a disable is permanent on Platform. +/// The fetch's timestamp is Platform's own, so it wins over the cache's, +/// which may be a local placeholder. fn with_disabled_state_of( fetched: &IdentityPublicKey, cached: &IdentityPublicKey, ) -> IdentityPublicKey { let mut key = fetched.clone(); - match cached.disabled_at().or(fetched.disabled_at()) { + match fetched.disabled_at().or(cached.disabled_at()) { Some(disabled_at) => key.set_disabled_at(disabled_at), None => key.remove_disabled_at(), } @@ -759,6 +760,7 @@ mod tests { use dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; use std::collections::BTreeMap; use std::sync::Mutex; @@ -818,10 +820,10 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds: it takes the cache's revision, key set and - /// disabled states back only per field, keeping those and taking the - /// balance and key limits, which change without a revision. A fetch at - /// the same revision or higher replaces the cache. + /// update the cache holds: the cache keeps its revision, key set and + /// disabled states, and takes the fetch's balance and key limits, which + /// change without a revision. A fetch at the same revision or higher + /// replaces the cache. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { @@ -832,7 +834,7 @@ mod tests { contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, read_only: false, - data: dpp::platform_value::BinaryData::new(vec![id as u8; 33]), + data: BinaryData::new(vec![id as u8; 33]), disabled_at, total_budget: Some(budget), expires_at: None, @@ -861,6 +863,11 @@ mod tests { let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); assert!(!live.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]))); assert_eq!(live.identity.public_keys()[&3].disabled_at(), Some(4)); + // Both disabled: the fetch's timestamp is Platform's, the cache's + // may be a local placeholder. + let mut stamped = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, Some(99))]), 0); + assert!(!stamped.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]))); + assert_eq!(stamped.identity.public_keys()[&3].disabled_at(), Some(4)); let same = at(6, 30, vec![limited(3, 600, Some(9)), limited(4, 1, None)]); assert!(managed.take_fetched_identity(&same)); From 37c332652fd724fee00114ece2c7aadca03b1f82 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:33:20 +0300 Subject: [PATCH 16/72] refactor(platform-wallet): one disable rule for both landed paths and accurate fold docs Both landed-update paths now share one added/disabled split computed before the branch, and the at-or-past path stores through ManagedIdentity::store_keys_with_snapshot instead of passing a dummy disabled list and timestamp. The docs say what the fold does with disables (either copy, Platform's timestamp first) and what a refresh stores after a landed-but-not-stored update (its scalar snapshot only; a load or discovery stores the key rows). Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 8 +-- .../src/wallet/identity/network/update.rs | 30 ++++++----- .../state/managed_identity/identity_ops.rs | 50 +++++++++++++------ 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 9763c25f956..eb1be6b706e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -260,7 +260,7 @@ impl IdentityWallet { // appeared, and contact requests referencing them failed // key-index validation). Same fold `refresh_identity` // does: a fetch older than the cache keeps the cache's - // revision, key set and disabled states. The + // revision, key set and every disable. The // `set_status` below persists the identity's scalar // snapshot; the keys reach the store through `add_keys`. let key_decisions = managed.key_decisions_for_fetch(&identity, key_decisions); @@ -316,8 +316,8 @@ impl IdentityWallet { /// on-chain state (keys, balance, revision) is replaced with the latest /// version from Platform and the status is set to `Active`. A fetch at /// a lower revision than the cache's was answered before an update the - /// cache already holds: the cache keeps its revision, key set and - /// disabled states, and takes the fetch's balance and key limits + /// cache already holds: the cache keeps its revision, key set and every + /// disable, and takes the fetch's balance and key limits /// ([`ManagedIdentity::take_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. @@ -592,7 +592,7 @@ mod tests { /// A refresh answered before an update the cache already took (the /// cache at 6, Platform's answer at 5) keeps the cache's revision, key - /// set and disabled states, and takes the answer's balance; an answer + /// set and every disable, and takes the answer's balance; an answer /// at 6 or above replaces the cache. Either way the call returns what /// the cache holds afterwards. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 91078b07e90..3ec35a7c481 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -89,7 +89,7 @@ impl IdentityWallet { /// fetched the identity before the update landed and finishes after /// this step does not take the cache back: a fetched identity at a /// lower revision than the cache's keeps the cache's revision, key set - /// and disabled states (see `ManagedIdentity::take_fetched_identity`). + /// and every disable (see `ManagedIdentity::take_fetched_identity`). /// When the call fails after the broadcast (the proof wait timed out, /// say), the update may have landed without this step: refresh before /// the next update. A `Persistence` error means it landed and is in the @@ -301,8 +301,15 @@ impl IdentityWallet { // copies, with the breadcrumbs the path below would give them: an // added key the caller's, a disabled key the wallet slot's, as // `disable_keys` builds it. + // A key the update both adds and disables is taken as added, on + // both paths. + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let disabled: Vec = disabled_ids + .into_iter() + .filter(|id| !added_ids.contains(id)) + .collect(); + if managed.identity.revision() >= transition_revision { - let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); let slot = managed.wallet_slot(); let mut keys = Vec::new(); // An added key the cache lacks is stored, and taken into the @@ -319,7 +326,7 @@ impl IdentityWallet { breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), }); } - for &id in disabled_ids.iter().filter(|id| !added_ids.contains(id)) { + for &id in &disabled { let Some(key) = managed.identity.public_keys().get(&id) else { tracing::warn!( identity = %identity_id, @@ -336,7 +343,7 @@ impl IdentityWallet { // Stored with the identity's snapshot even when no touched key // is in the cache, so the revision reaches the store too. return managed - .apply_landed_keys(keys, &[], 0, &self.persister) + .store_keys_with_snapshot(keys, &self.persister) .map_err(landed_but_not_stored); } @@ -347,14 +354,10 @@ impl IdentityWallet { // The added and disabled keys and the landed revision go to the // store as one changeset, which holds the revision even when no key // row matched. Disabled keys get the wallet slot's breadcrumb and a - // local wall-clock `disabled_at` placeholder, which the next - // Platform refresh reconciles to the on-chain block time. Memory - // holds the whole update even when the store fails. - let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let disabled: Vec = disabled_ids - .into_iter() - .filter(|id| !added_ids.contains(id)) - .collect(); + // local wall-clock `disabled_at` placeholder: a later fetch brings + // the on-chain block time into memory, and a load or discovery of + // the identity into the store. Memory holds the whole update even + // when the store fails. let added = added_keys .into_iter() .map(|key| { @@ -462,7 +465,8 @@ fn update_revision( /// The error for a landed update the wallet could not store. The update is /// on Platform and in the cache either way, so the caller must not resubmit -/// it; the next refresh or load stores the identity again. +/// it. A load or discovery of the identity stores its key rows again; a +/// refresh stores only its scalar snapshot. fn landed_but_not_stored(e: PersistenceError) -> PlatformWalletError { PlatformWalletError::Persistence(format!( "identity update landed on Platform and is in the wallet's cache, but the wallet could not \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index beb58e002da..90bb3386543 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -3,8 +3,8 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; use crate::changeset::{ - IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, - PersistenceError, PlatformWalletChangeSet, + IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, + IdentityKeysChangeSet, KeyWithBreadcrumb, PersistenceError, PlatformWalletChangeSet, }; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; @@ -394,10 +394,11 @@ impl ManagedIdentity { /// answered before an update the cache already holds (one applied /// locally after it landed, or a newer refresh). The revision orders /// only the keys an identity update adds and disables, so such a fetch - /// is folded per field: the cache keeps its revision, its key set and - /// each key's disabled state, and takes the fetch's balance and each - /// shared key's limits, which change without a revision bump (as a - /// whole fetch set them before). Returns whether it was taken whole. + /// is folded per field: the cache keeps its revision and key set, each + /// shared key is disabled when either copy is (Platform's timestamp + /// first), and the cache takes the fetch's balance and each shared + /// key's limits, which change without a revision bump (as a whole + /// fetch set them before). Returns whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { @@ -419,8 +420,8 @@ impl ManagedIdentity { /// The key decisions to store for `decisions` built from `fetched`. /// Any fetch at the cache's revision or above has its decisions stored /// as they are. For an older fetch each key is folded as - /// [`Self::take_fetched_identity`] folds it (the cache's disabled state, - /// the fetch's limits), so storing its breadcrumb (a key's derivation + /// [`Self::take_fetched_identity`] folds it (disabled when either copy + /// is, the fetch's limits), so storing its breadcrumb (a key's derivation /// never changes) does not take back a disable the cache holds; a key /// the cache lacks is left out, since an older answer cannot show /// whether a later update disabled it, and a fetch at least as new will @@ -523,7 +524,7 @@ impl ManagedIdentity { let (wallet_id, derivation_indices) = match breadcrumb { Some((wallet_id, identity_index, key_index)) => ( Some(wallet_id), - Some(crate::changeset::IdentityKeyDerivationIndices { + Some(IdentityKeyDerivationIndices { identity_index, key_index, }), @@ -659,6 +660,26 @@ impl ManagedIdentity { keys_cs .upserts .extend(self.stamp_disabled(disabled, disabled_at).upserts); + self.store_with_snapshot(keys_cs, persister) + } + + /// Layer `keys` in memory and store them with the identity's scalar + /// snapshot as one changeset, even when `keys` is empty. The store's + /// failure is returned. + pub fn store_keys_with_snapshot( + &mut self, + keys: Vec, + persister: &WalletPersister, + ) -> Result<(), PersistenceError> { + let keys_cs = self.layer_keys(keys); + self.store_with_snapshot(keys_cs, persister) + } + + fn store_with_snapshot( + &self, + keys_cs: IdentityKeysChangeSet, + persister: &WalletPersister, + ) -> Result<(), PersistenceError> { persister.store(PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), identity_keys: Some(keys_cs), @@ -702,7 +723,7 @@ impl ManagedIdentity { let (wallet_id, derivation_indices) = match breadcrumb { Some((wallet_id, identity_index)) => ( Some(wallet_id), - Some(crate::changeset::IdentityKeyDerivationIndices { + Some(IdentityKeyDerivationIndices { identity_index, key_index: key_id, }), @@ -820,10 +841,11 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds: the cache keeps its revision, key set and - /// disabled states, and takes the fetch's balance and key limits, which - /// change without a revision. A fetch at the same revision or higher - /// replaces the cache. + /// update the cache holds: the cache keeps its revision and key set, a + /// key is disabled when either copy is (Platform's timestamp first), and + /// the cache takes the fetch's balance and key limits, which change + /// without a revision. A fetch at the same revision or higher replaces + /// the cache. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { From 3508dceb2b3a3498e39bb3639c6332e15c54311b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:01:11 +0300 Subject: [PATCH 17/72] fix(platform-wallet): an older fetch no longer brings its balance or key limits Identity revision does not order the balance or a key's limits, which change without a revision bump, so a delayed answer at a lower revision could take both back in memory and in the store. Such a fetch now leaves the cache's revision, balance, key set and limits as they are and only brings a disable (permanent on Platform, Platform's timestamp first). refresh_identity stores the rows of keys it disables that way, since it stores no key rows otherwise. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 28 ++++- .../state/managed_identity/identity_ops.rs | 109 +++++++++++------- 2 files changed, 88 insertions(+), 49 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index eb1be6b706e..0ae0c19fac0 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -317,7 +317,7 @@ impl IdentityWallet { /// version from Platform and the status is set to `Active`. A fetch at /// a lower revision than the cache's was answered before an update the /// cache already holds: the cache keeps its revision, key set and every - /// disable, and takes the fetch's balance and key limits + /// disable, and keeps its own balance and key limits /// ([`ManagedIdentity::take_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. @@ -373,9 +373,15 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { + // An older answer only changes keys by disabling them; their + // rows are stored here, since nothing below stores keys. + let disables = managed.disables_from_older_fetch(&identity); if !managed.take_fetched_identity(&identity) { identity = managed.identity.clone(); } + for (key_id, disabled_at) in disables { + managed.disable_keys(&[key_id], disabled_at, &self.persister); + } managed.set_status(IdentityStatus::Active, &self.persister); } } @@ -592,8 +598,8 @@ mod tests { /// A refresh answered before an update the cache already took (the /// cache at 6, Platform's answer at 5) keeps the cache's revision, key - /// set and every disable, and takes the answer's balance; an answer - /// at 6 or above replaces the cache. Either way the call returns what + /// set, every disable and its balance, which the revision does not + /// order; an answer at 6 or above replaces the cache. Either way the call returns what /// the cache holds afterwards. #[tokio::test] async fn should_not_take_the_cache_back_with_an_older_refresh() { @@ -607,13 +613,19 @@ mod tests { let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; let wallet = wallet.identity(); cache_at_6_with_key_1_disabled(wallet).await; + let cached_balance = held(wallet).await.balance(); + assert_ne!(cached_balance, 777); let returned = wallet.refresh_identity(&identity_id).await.unwrap(); let held = held(wallet).await; assert_eq!(returned, held, "answered at {answered}"); assert_eq!(held.revision(), settled, "answered at {answered}"); - assert_eq!(held.balance(), 777, "answered at {answered}"); let older = answered < 6; + assert_eq!( + held.balance(), + if older { cached_balance } else { 777 }, + "answered at {answered}" + ); assert_eq!( held.public_keys()[&1].is_disabled(), older, @@ -650,6 +662,8 @@ mod tests { let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; let wallet = wallet.identity(); cache_at_6_with_key_1_disabled(wallet).await; + let cached_balance = held(wallet).await.balance(); + assert_ne!(cached_balance, 777); store.clear(); let returned = wallet @@ -660,8 +674,12 @@ mod tests { let held = held(wallet).await; assert_eq!(returned, held, "answered at {answered}"); assert_eq!(held.revision(), 6, "answered at {answered}"); - assert_eq!(held.balance(), 777, "answered at {answered}"); let older = answered < 6; + assert_eq!( + held.balance(), + if older { cached_balance } else { 777 }, + "answered at {answered}" + ); assert_eq!( held.public_keys()[&1].is_disabled(), older, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 90bb3386543..b7686da640d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -392,13 +392,13 @@ impl ManagedIdentity { /// A fetch at the cache's revision or above is taken whole, as before. /// Revisions only grow on Platform, so a fetch at a lower revision was /// answered before an update the cache already holds (one applied - /// locally after it landed, or a newer refresh). The revision orders - /// only the keys an identity update adds and disables, so such a fetch - /// is folded per field: the cache keeps its revision and key set, each - /// shared key is disabled when either copy is (Platform's timestamp - /// first), and the cache takes the fetch's balance and each shared - /// key's limits, which change without a revision bump (as a whole - /// fetch set them before). Returns whether it was taken whole. + /// locally after it landed, or a newer refresh). Nothing in such a + /// fetch is known to be newer than the cache: the revision does not + /// order the balance or a key's limits, which change without a revision + /// bump. The cache keeps its revision, balance, key set and each key's + /// limits; it takes only a disable the fetch shows (permanent on + /// Platform, and the fetch's timestamp is Platform's own). Returns + /// whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { @@ -406,26 +406,45 @@ impl ManagedIdentity { self.identity = fetched.clone(); return true; } - self.identity.set_balance(fetched.balance()); let mut keys = self.identity.public_keys().clone(); for (key_id, cached) in keys.iter_mut() { if let Some(fetched_key) = fetched.public_keys().get(key_id) { - *cached = with_disabled_state_of(fetched_key, cached); + *cached = with_disable_from(cached, fetched_key); } } self.identity.set_public_keys(keys); false } + /// The disables an older `fetched` brings to keys the cache holds — what + /// [`Self::take_fetched_identity`] changes in a key — so a caller that + /// stores no key rows of its own can store them (see + /// [`Self::disable_keys`]). Empty for a fetch at the cache's revision or + /// above, which is taken whole. + pub fn disables_from_older_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { + if fetched.revision() >= self.identity.revision() { + return Vec::new(); + } + self.identity + .public_keys() + .iter() + .filter_map(|(key_id, cached)| { + let fetched_key = fetched.public_keys().get(key_id)?; + let folded = with_disable_from(cached, fetched_key).disabled_at()?; + (cached.disabled_at() != Some(folded)).then_some((*key_id, folded)) + }) + .collect() + } + /// The key decisions to store for `decisions` built from `fetched`. /// Any fetch at the cache's revision or above has its decisions stored /// as they are. For an older fetch each key is folded as - /// [`Self::take_fetched_identity`] folds it (disabled when either copy - /// is, the fetch's limits), so storing its breadcrumb (a key's derivation - /// never changes) does not take back a disable the cache holds; a key - /// the cache lacks is left out, since an older answer cannot show - /// whether a later update disabled it, and a fetch at least as new will - /// bring it. + /// [`Self::take_fetched_identity`] folds it (the cache's copy, disabled + /// when the fetch shows a disable), so storing its breadcrumb (a key's + /// derivation never changes) neither takes back a disable nor older + /// limits the cache holds; a key the cache lacks is left out, since an + /// older answer cannot show whether a later update disabled it, and a + /// fetch at least as new will bring it. pub fn key_decisions_for_fetch( &self, fetched: &Identity, @@ -438,7 +457,7 @@ impl ManagedIdentity { .into_iter() .filter_map(|mut decision| { let cached = self.identity.public_keys().get(&decision.key.id())?; - decision.key = with_disabled_state_of(&decision.key, cached); + decision.key = with_disable_from(cached, &decision.key); Some(decision) }) .collect() @@ -751,18 +770,15 @@ impl ManagedIdentity { } } -/// `fetched`'s copy of a key, disabled when either copy is: the key's limits -/// change without a revision bump, while a disable is permanent on Platform. -/// The fetch's timestamp is Platform's own, so it wins over the cache's, -/// which may be a local placeholder. -fn with_disabled_state_of( - fetched: &IdentityPublicKey, - cached: &IdentityPublicKey, -) -> IdentityPublicKey { - let mut key = fetched.clone(); - match fetched.disabled_at().or(cached.disabled_at()) { - Some(disabled_at) => key.set_disabled_at(disabled_at), - None => key.remove_disabled_at(), +/// The cache's copy of a key, disabled when an older fetch's copy is: a +/// disable is permanent on Platform, while nothing else in an older fetch is +/// known to be newer than the cache (its limits change without a revision +/// bump). The fetch's timestamp is Platform's own, so it wins over the +/// cache's, which may be a local placeholder. +fn with_disable_from(cached: &IdentityPublicKey, fetched: &IdentityPublicKey) -> IdentityPublicKey { + let mut key = cached.clone(); + if let Some(disabled_at) = fetched.disabled_at().or(cached.disabled_at()) { + key.set_disabled_at(disabled_at); } key } @@ -776,7 +792,6 @@ mod tests { use crate::wallet::identity::PaymentEntry; use crate::wallet::platform_wallet::WalletId; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; - use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; use dpp::identity::v0::IdentityV0; @@ -841,11 +856,10 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds: the cache keeps its revision and key set, a - /// key is disabled when either copy is (Platform's timestamp first), and - /// the cache takes the fetch's balance and key limits, which change - /// without a revision. A fetch at the same revision or higher replaces - /// the cache. + /// update the cache holds: the cache keeps its revision, balance, key set + /// and key limits (the revision does not order those), and takes only a + /// disable the fetch shows (Platform's timestamp first). A fetch at the + /// same revision or higher replaces the cache. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { @@ -871,20 +885,26 @@ mod tests { }) } // The cache took an update at 6 that disabled key 3; the fetch at 5 - // still shows key 3 live, a raised budget, a newer balance and key - // 4, which the cache never saw. + // still shows key 3 live, another budget, another balance and key 4, + // which the cache never saw. A delayed answer cannot prove its + // balance or budget newer than the cache's. let cache = at(6, 10, vec![limited(3, 100, Some(9))]); let older = at(5, 20, vec![limited(3, 500, None), limited(4, 1, None)]); - let mut managed = ManagedIdentity::new(cache, 0); + let mut managed = ManagedIdentity::new(cache.clone(), 0); assert!(!managed.take_fetched_identity(&older)); - assert_eq!(managed.identity, at(6, 20, vec![limited(3, 500, Some(9))])); - assert_eq!(managed.identity.public_keys()[&3].total_budget(), Some(500)); + assert_eq!(managed.identity, cache); // A disable is permanent: one the older fetch shows sticks even - // when the cache's copy is live. + // when the cache's copy is live, and the cache keeps its limits. let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); - assert!(!live.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]))); - assert_eq!(live.identity.public_keys()[&3].disabled_at(), Some(4)); + let disabling = at(5, 20, vec![limited(3, 500, Some(4))]); + assert_eq!(live.disables_from_older_fetch(&disabling), vec![(3, 4)]); + assert!(!live.take_fetched_identity(&disabling)); + assert_eq!(live.identity, at(6, 10, vec![limited(3, 100, Some(4))])); + assert!( + live.disables_from_older_fetch(&disabling).is_empty(), + "nothing left to bring" + ); // Both disabled: the fetch's timestamp is Platform's, the cache's // may be a local placeholder. let mut stamped = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, Some(99))]), 0); @@ -899,8 +919,9 @@ mod tests { assert_eq!(managed.identity, newer); } - /// An older fetch's key decisions carry the cache's disabled state and - /// the fetch's limits, and a key only the fetch has is left out. + /// An older fetch's key decisions carry the cache's copy of each key + /// (its disabled state and limits), and a key only the fetch has is left + /// out. #[test] fn should_fold_an_older_fetch_s_key_decisions_into_the_cache_s_keys() { let mut disabled = key(3); From 840209c1515c9f575bfbe4830d0172983675cd2b Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:36:51 +0300 Subject: [PATCH 18/72] fix(platform-wallet): fold discovered identities, order fetched balances by proof height - Discovery now folds a known identity the way loading and refresh do, so a newer revision, its keys and its balance reach the cache and the store together and an update is not built from a stale revision. - Identity fetches carry their proof's block, and a fetched balance goes through the balance watermark every verified balance uses: it replaces the cache's only when its block is newer, at any revision. A cached disable is never taken back by a fetch. - Refresh stores an older answer's disables with the scalar snapshot in one changeset and returns a failed store instead of logging it. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 31 +- .../src/wallet/identity/network/loading.rs | 276 +++++++++++++----- .../state/managed_identity/identity_ops.rs | 211 +++++++++---- .../identity/state/managed_identity/sync.rs | 41 ++- 4 files changed, 418 insertions(+), 141 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 9975fc5fe7b..d16ba075fec 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -6,6 +6,7 @@ use key_wallet::bip32::ExtendedPrivKey; use crate::error::PlatformWalletError; use crate::wallet::identity::network::contact_requests::budget_spent; +use crate::BlockTime; use super::*; @@ -415,10 +416,13 @@ impl IdentityWallet { // Query Platform for an identity registered with this key // hash. No locks are held during this network call. - let fetch_result = Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await; + let fetch_result = + Identity::fetch_with_metadata(&self.sdk, PublicKeyHash(key_hash_array), None) + .await; match fetch_result { - Ok(Some(identity)) => { + Ok((Some(identity), metadata)) => { + let block_time = BlockTime::from(metadata); let identity_id = identity.id(); // Derive + verify a candidate for every on-chain key @@ -462,7 +466,7 @@ impl IdentityWallet { .identity_manager .managed_identity_mut(&identity_id) { - managed.set_status(IdentityStatus::Active, &self.persister); + managed.status = IdentityStatus::Active; managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (not just the // MASTER key) so the client (iOS Keychain) can @@ -474,15 +478,16 @@ impl IdentityWallet { // restart, so surface it (matching `add_identity` above). // A fetch older than a known identity's cache stores // only the keys the cache has, with their disables. - let key_decisions = - managed.key_decisions_for_fetch(&identity, key_decisions); - managed - .add_keys(key_decisions, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity keys not persisted during discovery: {e}" - )) - })?; + // A known identity is folded as loading and refresh + // fold it (a newer fetch's revision and keys, the + // balance by proof block), stored with its keys, so + // an update is not built from a stale revision. + managed.store_fetched_identity_keys( + &identity, + key_decisions, + block_time, + &self.persister, + )?; } drop(wm_guard); @@ -491,7 +496,7 @@ impl IdentityWallet { } tally.record_sighting(); } - Ok(None) => { + Ok((None, _)) => { tally.record_miss(); } Err(e) => { diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 0ae0c19fac0..4a8d9d6c67c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -7,6 +7,7 @@ use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use crate::error::PlatformWalletError; +use crate::BlockTime; use super::*; @@ -206,16 +207,19 @@ impl IdentityWallet { }; // Query Platform for an identity registered with this key hash. - let mut identity = match Identity::fetch(&self.sdk, PublicKeyHash(key_hash_array)).await { - Ok(Some(identity)) => identity, - Ok(None) => return Ok(None), - Err(e) => { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to fetch identity at index {}: {}", - identity_index, e - ))); - } - }; + let (mut identity, block_time) = + match Identity::fetch_with_metadata(&self.sdk, PublicKeyHash(key_hash_array), None) + .await + { + Ok((Some(identity), metadata)) => (identity, BlockTime::from(metadata)), + Ok((None, _)) => return Ok(None), + Err(e) => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to fetch identity at index {}: {}", + identity_index, e + ))); + } + }; let identity_id = identity.id(); @@ -260,27 +264,22 @@ impl IdentityWallet { // appeared, and contact requests referencing them failed // key-index validation). Same fold `refresh_identity` // does: a fetch older than the cache keeps the cache's - // revision, key set and every disable. The - // `set_status` below persists the identity's scalar - // snapshot; the keys reach the store through `add_keys`. - let key_decisions = managed.key_decisions_for_fetch(&identity, key_decisions); - let taken = is_new || managed.take_fetched_identity(&identity); - managed.set_status(IdentityStatus::Active, &self.persister); + // revision, key set and every disable, and the balance + // follows the proof's block, not the revision. The keys + // and the folded scalar snapshot reach the store together. + managed.status = IdentityStatus::Active; managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (was MASTER-only). A // failed persist would silently leave the identity watch-only // after restart, so surface it rather than swallow. - managed - .add_keys(key_decisions, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity keys not persisted during load: {e}" - )) - })?; - // A fetch not taken whole returns the cache as folded. - if !taken { - identity = managed.identity.clone(); - } + managed.store_fetched_identity_keys( + &identity, + key_decisions, + block_time, + &self.persister, + )?; + // The cache as folded. + identity = managed.identity.clone(); } } @@ -313,11 +312,13 @@ impl IdentityWallet { /// re-fetching it from Platform. /// /// The identity must already exist in the [`IdentityManager`]. Its - /// on-chain state (keys, balance, revision) is replaced with the latest - /// version from Platform and the status is set to `Active`. A fetch at - /// a lower revision than the cache's was answered before an update the - /// cache already holds: the cache keeps its revision, key set and every - /// disable, and keeps its own balance and key limits + /// on-chain state (keys, revision) is replaced with the latest version + /// from Platform and the status is set to `Active`; a disable the cache + /// holds stays. The balance, at any revision, is taken only when the + /// fetch's proof is at a block above the cache's balance watermark. A + /// fetch at a lower revision than the cache's was answered before an + /// update the cache already holds: the cache keeps its revision, key set, + /// key limits and every disable, and takes only a disable the fetch shows /// ([`ManagedIdentity::take_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. @@ -349,20 +350,21 @@ impl IdentityWallet { } // Fetch the latest state from Platform. - let mut identity = Identity::fetch(&self.sdk, *identity_id) + let (identity, metadata) = Identity::fetch_with_metadata(&self.sdk, *identity_id, None) .await .map_err(|e| { PlatformWalletError::InvalidIdentityData(format!( "Failed to fetch identity {} from Platform: {}", identity_id, e )) - })? - .ok_or_else(|| { - PlatformWalletError::InvalidIdentityData(format!( - "Identity {} not found on Platform", - identity_id - )) })?; + let mut identity = identity.ok_or_else(|| { + PlatformWalletError::InvalidIdentityData(format!( + "Identity {} not found on Platform", + identity_id + )) + })?; + let block_time = BlockTime::from(metadata); // Update the managed identity. { @@ -374,15 +376,15 @@ impl IdentityWallet { })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { // An older answer only changes keys by disabling them; their - // rows are stored here, since nothing below stores keys. + // rows are stored here with the folded scalar snapshot, since + // nothing else here stores keys, and a failed store is + // returned rather than logged. let disables = managed.disables_from_older_fetch(&identity); - if !managed.take_fetched_identity(&identity) { - identity = managed.identity.clone(); - } - for (key_id, disabled_at) in disables { - managed.disable_keys(&[key_id], disabled_at, &self.persister); - } - managed.set_status(IdentityStatus::Active, &self.persister); + managed.take_fetched_identity(&identity, block_time); + managed.status = IdentityStatus::Active; + managed.store_fetched_disables(&disables, &self.persister)?; + // The cache as folded. + identity = managed.identity.clone(); } } @@ -524,10 +526,14 @@ mod tests { use super::super::identity_handle::{ derive_identity_auth_key_hash, derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, }; - use super::super::signing_key::tests::{identity, wallet_with_signing_keys_on, CapturingStore}; - use super::super::IdentityWallet; + use super::super::signing_key::tests::{ + identity, wallet_with_signing_keys_on, CapturingStore, FailingStore, + }; + use super::super::{IdentityDiscoveryOptions, IdentityWallet}; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; + use crate::error::PlatformWalletError; use crate::wallet::persister::NoPlatformPersistence; + use crate::BlockTime; use dash_sdk::platform::types::identity::PublicKeyHash; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::identity_public_key::accessors::v0::{ @@ -598,9 +604,10 @@ mod tests { /// A refresh answered before an update the cache already took (the /// cache at 6, Platform's answer at 5) keeps the cache's revision, key - /// set, every disable and its balance, which the revision does not - /// order; an answer at 6 or above replaces the cache. Either way the call returns what - /// the cache holds afterwards. + /// set and every disable; an answer at 6 or above replaces the cache but + /// keeps the disables. The balance follows the proof, not the revision: + /// the cache holds none, so every answer's balance is taken. Either way + /// the call returns what the cache holds afterwards. #[tokio::test] async fn should_not_take_the_cache_back_with_an_older_refresh() { let identity_id = Identifier::default(); @@ -621,14 +628,9 @@ mod tests { assert_eq!(returned, held, "answered at {answered}"); assert_eq!(held.revision(), settled, "answered at {answered}"); let older = answered < 6; - assert_eq!( - held.balance(), - if older { cached_balance } else { 777 }, - "answered at {answered}" - ); - assert_eq!( + assert_eq!(held.balance(), 777, "answered at {answered}"); + assert!( held.public_keys()[&1].is_disabled(), - older, "answered at {answered}" ); assert_eq!( @@ -640,7 +642,7 @@ mod tests { } /// The same fold through a load by index, in memory, in the returned - /// identity and in the key rows it stores. + /// identity and in the key rows it stores, plus the identity's revision. #[tokio::test] async fn should_keep_a_newer_cache_when_a_load_answers_older() { // The probe the loader sends for index 0 of the fixture's seed. @@ -674,15 +676,11 @@ mod tests { let held = held(wallet).await; assert_eq!(returned, held, "answered at {answered}"); assert_eq!(held.revision(), 6, "answered at {answered}"); + assert_eq!(store.stored_revision(Identifier::default()), Some(6)); let older = answered < 6; - assert_eq!( - held.balance(), - if older { cached_balance } else { 777 }, - "answered at {answered}" - ); - assert_eq!( + assert_eq!(held.balance(), 777, "answered at {answered}"); + assert!( held.public_keys()[&1].is_disabled(), - older, "answered at {answered}" ); assert_eq!( @@ -708,6 +706,150 @@ mod tests { } } + /// A same-revision answer whose proof is not above the cache's balance + /// watermark (mock answers are at height 0) takes the answer's keys but + /// keeps the cache's balance, which the revision does not order. + #[tokio::test] + async fn should_keep_a_balance_a_refresh_s_proof_does_not_order_after() { + let identity_id = Identifier::default(); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(identity_id, Some(answer_at(6))) + .await + .unwrap(); + let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + let watermark = BlockTime::new(1, 1, 1); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .last_updated_balance_block_time = Some(watermark); + } + let cached_balance = held(wallet).await.balance(); + + let returned = wallet.refresh_identity(&identity_id).await.unwrap(); + assert_eq!(returned, held(wallet).await); + assert_eq!(returned.balance(), cached_balance); + assert!(returned.public_keys().contains_key(&4)); + assert!(returned.public_keys()[&1].is_disabled()); + } + + /// A disable an older refresh brings is stored with its key row, and a + /// store that refuses it fails the refresh instead of reporting a + /// success the store does not hold. + #[tokio::test] + async fn should_fail_a_refresh_whose_older_disable_is_not_stored() { + let identity_id = Identifier::default(); + let mut answer = answer_at(5); + let mut key_2 = answer.public_keys()[&2].clone(); + key_2.set_disabled_at(4); + answer.add_public_key(key_2); + for fails in [false, true] { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(identity_id, Some(answer.clone())) + .await + .unwrap(); + let store = Arc::new(FailingStore::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + if fails { + store.arm(); + } + + let result = wallet.refresh_identity(&identity_id).await; + assert_eq!( + matches!(result, Err(PlatformWalletError::PersisterStore(_))), + fails, + "{result:?}" + ); + assert_eq!(held(wallet).await.public_keys()[&2].disabled_at(), Some(4)); + } + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(identity_id, Some(answer)) + .await + .unwrap(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + wallet.refresh_identity(&identity_id).await.unwrap(); + assert_eq!( + store + .stored_key(identity_id, 2) + .map(|row| row.public_key.disabled_at()), + Some(Some(4)) + ); + } + + /// Discovery folds an identity it already knows the way loading does: an + /// answer at a newer revision brings its revision, keys and balance into + /// the cache and the store; an older one keeps the cache's. + #[tokio::test] + async fn should_fold_a_known_identity_discovery_finds() { + let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); + let probe = |index| { + derive_identity_auth_key_hash_from_master( + &master, + Network::Testnet, + index, + MASTER_KEY_INDEX, + ) + .unwrap() + }; + let identity_id = Identifier::default(); + for (answered, settled) in [(5, 6), (7, 7)] { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe(0)), Some(answer_at(answered))) + .await + .unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe(1)), None::) + .await + .unwrap(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + assert_ne!(held(wallet).await.balance(), 777); + store.clear(); + + let found = wallet + .discover_from_master( + IdentityDiscoveryOptions { + start_index: Some(0), + gap_limit: 1, + }, + &master, + ) + .await + .unwrap(); + assert!(found.is_empty(), "the identity was already known"); + let held = held(wallet).await; + let older = answered < 6; + assert_eq!(held.revision(), settled, "answered at {answered}"); + assert_eq!(store.stored_revision(identity_id), Some(settled)); + assert_eq!(held.balance(), 777, "answered at {answered}"); + assert!( + held.public_keys()[&1].is_disabled(), + "answered at {answered}" + ); + assert_eq!( + held.public_keys().contains_key(&4), + !older, + "answered at {answered}" + ); + } + } + /// English BIP-39 test vector (all-zero entropy). Same fixture the /// FFI-side derive tests and the `identity_handle` tests use, so the /// derivations here can be cross-checked against those if a diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index b7686da640d..a8071c48cba 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -6,8 +6,10 @@ use crate::changeset::{ IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, PersistenceError, PlatformWalletChangeSet, }; +use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::wallet::platform_wallet::WalletId; +use crate::BlockTime; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -387,40 +389,54 @@ impl ManagedIdentity { } } - /// Fold an identity fetched from Platform into the cached one. + /// Fold an identity fetched from Platform, at `block_time` (its proof's + /// block), into the cached one. /// - /// A fetch at the cache's revision or above is taken whole, as before. - /// Revisions only grow on Platform, so a fetch at a lower revision was - /// answered before an update the cache already holds (one applied - /// locally after it landed, or a newer refresh). Nothing in such a - /// fetch is known to be newer than the cache: the revision does not - /// order the balance or a key's limits, which change without a revision - /// bump. The cache keeps its revision, balance, key set and each key's - /// limits; it takes only a disable the fetch shows (permanent on - /// Platform, and the fetch's timestamp is Platform's own). Returns + /// The balance is ordered by proof height, not by revision (it changes + /// without a revision bump), as every verified balance is: the fetch's + /// balance replaces the cache's only when its block is above the cache's + /// balance watermark, and is then queued for the next store (see + /// `store_keys_and_balance`). + /// + /// A fetch at the cache's revision or above is otherwise taken whole, + /// except that a key the cache holds disabled stays disabled (a disable + /// is permanent on Platform). Revisions only grow on Platform, so a fetch + /// at a lower revision was answered before an update the cache already + /// holds (one applied locally after it landed, or a newer refresh): + /// the cache keeps its revision, key set and each key's limits, which + /// the revision does not order either; it takes only a disable the + /// fetch shows (the fetch's timestamp is Platform's own). Returns /// whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. - pub fn take_fetched_identity(&mut self, fetched: &Identity) -> bool { - if fetched.revision() >= self.identity.revision() { + pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { + let balance = self.identity.balance(); + let cached = self.identity.public_keys().clone(); + let taken = fetched.revision() >= self.identity.revision(); + let mut keys = if taken { self.identity = fetched.clone(); - return true; - } - let mut keys = self.identity.public_keys().clone(); - for (key_id, cached) in keys.iter_mut() { + self.identity.set_balance(balance); + fetched.public_keys().clone() + } else { + cached.clone() + }; + for (key_id, key) in keys.iter_mut() { if let Some(fetched_key) = fetched.public_keys().get(key_id) { - *cached = with_disable_from(cached, fetched_key); + if let Some(folded) = fold_fetched_key(cached.get(key_id), fetched_key, taken) { + *key = folded; + } } } self.identity.set_public_keys(keys); - false + self.take_fetched_balance(fetched.balance(), block_time); + taken } /// The disables an older `fetched` brings to keys the cache holds — what /// [`Self::take_fetched_identity`] changes in a key — so a caller that /// stores no key rows of its own can store them (see - /// [`Self::disable_keys`]). Empty for a fetch at the cache's revision or - /// above, which is taken whole. + /// [`Self::store_fetched_disables`]). Empty for a fetch at the cache's + /// revision or above, which is taken whole. pub fn disables_from_older_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { if fetched.revision() >= self.identity.revision() { return Vec::new(); @@ -436,28 +452,67 @@ impl ManagedIdentity { .collect() } - /// The key decisions to store for `decisions` built from `fetched`. - /// Any fetch at the cache's revision or above has its decisions stored - /// as they are. For an older fetch each key is folded as - /// [`Self::take_fetched_identity`] folds it (the cache's copy, disabled - /// when the fetch shows a disable), so storing its breadcrumb (a key's - /// derivation never changes) neither takes back a disable nor older - /// limits the cache holds; a key the cache lacks is left out, since an - /// older answer cannot show whether a later update disabled it, and a - /// fetch at least as new will bring it. + /// Stamp the disables an older fetch brought (see + /// [`Self::disables_from_older_fetch`]) and store their key rows, with + /// the wallet slot's breadcrumb as [`Self::disable_keys`] gives them, and + /// the identity's scalar snapshot as one changeset (see + /// `store_keys_and_balance`). The store's failure is returned; memory + /// holds the fold either way, as for a landed update. + pub fn store_fetched_disables( + &mut self, + disables: &[(KeyID, TimestampMillis)], + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + let mut keys_cs = IdentityKeysChangeSet::default(); + for &(key_id, disabled_at) in disables { + keys_cs + .upserts + .extend(self.stamp_disabled(&[key_id], disabled_at).upserts); + } + self.store_keys_and_balance(keys_cs, persister) + } + + /// Fold `fetched` (see [`Self::take_fetched_identity`]) with the key + /// `decisions` derived for it, and store the folded keys with their + /// breadcrumbs and the identity's scalar snapshot as one changeset (see + /// `store_keys_and_balance`). Used where a fetch comes with derived + /// breadcrumbs (loading and discovery); an identity added from this + /// fetch just before takes its balance watermark from it. The store's + /// failure is returned; memory holds the fold either way, as for a + /// landed update. + pub fn store_fetched_identity_keys( + &mut self, + fetched: &Identity, + decisions: Vec, + block_time: BlockTime, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + let decisions = self.key_decisions_for_fetch(fetched, decisions); + self.take_fetched_identity(fetched, block_time); + let keys_cs = self.layer_keys(decisions); + self.store_keys_and_balance(keys_cs, persister) + } + + /// The key decisions to store for `decisions` built from `fetched`, each + /// key folded as [`Self::take_fetched_identity`] folds it, so storing its + /// breadcrumb (a key's derivation never changes) never takes back a + /// disable the cache holds. For a fetch at the cache's revision or above + /// that is the fetched key, disabled when the cache's copy is. For an + /// older fetch it is the cache's copy, disabled when the fetch shows a + /// disable, so older limits are not stored either; a key the cache lacks + /// is left out, since an older answer cannot show whether a later update + /// disabled it, and a fetch at least as new will bring it. pub fn key_decisions_for_fetch( &self, fetched: &Identity, decisions: Vec, ) -> Vec { - if fetched.revision() >= self.identity.revision() { - return decisions; - } + let taken = fetched.revision() >= self.identity.revision(); decisions .into_iter() .filter_map(|mut decision| { - let cached = self.identity.public_keys().get(&decision.key.id())?; - decision.key = with_disable_from(cached, &decision.key); + let cached = self.identity.public_keys().get(&decision.key.id()); + decision.key = fold_fetched_key(cached, &decision.key, taken)?; Some(decision) }) .collect() @@ -694,7 +749,7 @@ impl ManagedIdentity { self.store_with_snapshot(keys_cs, persister) } - fn store_with_snapshot( + pub(super) fn store_with_snapshot( &self, keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, @@ -770,6 +825,29 @@ impl ManagedIdentity { } } +/// How a fetched key lands in the cache, the one rule the identity fold and +/// its key decisions share. From a fetch taken whole (`taken`) it is the +/// fetched key, disabled when the cache's copy is (a disable is permanent +/// on Platform; Platform's timestamp first). From an older fetch it is the +/// cache's copy with the fetch's disable, or nothing for a key the cache +/// lacks. +fn fold_fetched_key( + cached: Option<&IdentityPublicKey>, + fetched: &IdentityPublicKey, + taken: bool, +) -> Option { + if !taken { + return cached.map(|cached| with_disable_from(cached, fetched)); + } + let mut key = fetched.clone(); + if key.disabled_at().is_none() { + if let Some(disabled_at) = cached.and_then(|cached| cached.disabled_at()) { + key.set_disabled_at(disabled_at); + } + } + Some(key) +} + /// The cache's copy of a key, disabled when an older fetch's copy is: a /// disable is permanent on Platform, while nothing else in an older fetch is /// known to be newer than the cache (its limits change without a revision @@ -856,10 +934,11 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds: the cache keeps its revision, balance, key set - /// and key limits (the revision does not order those), and takes only a - /// disable the fetch shows (Platform's timestamp first). A fetch at the - /// same revision or higher replaces the cache. + /// update the cache holds: the cache keeps its revision, key set and key + /// limits (the revision does not order those), and takes only a disable + /// the fetch shows (Platform's timestamp first). A fetch at the same + /// revision or higher replaces the cache but never takes back a disable. + /// Either way the balance follows the proof's block, not the revision. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { @@ -876,6 +955,9 @@ mod tests { expires_at: None, }) } + fn block(height: u64) -> BlockTime { + BlockTime::new(height, 1, height) + } fn at(revision: u64, balance: u64, keys: Vec) -> Identity { Identity::V0(IdentityV0 { id: Identifier::from([1u8; 32]), @@ -884,39 +966,56 @@ mod tests { revision, }) } - // The cache took an update at 6 that disabled key 3; the fetch at 5 - // still shows key 3 live, another budget, another balance and key 4, - // which the cache never saw. A delayed answer cannot prove its - // balance or budget newer than the cache's. + // The cache took an update at 6 that disabled key 3, and holds a + // balance proved at block 9; the fetch at 5 still shows key 3 live, + // another budget and key 4, which the cache never saw. A delayed + // answer cannot prove its budget newer than the cache's, and its + // balance is no newer than the cache's proof. let cache = at(6, 10, vec![limited(3, 100, Some(9))]); let older = at(5, 20, vec![limited(3, 500, None), limited(4, 1, None)]); let mut managed = ManagedIdentity::new(cache.clone(), 0); - assert!(!managed.take_fetched_identity(&older)); - assert_eq!(managed.identity, cache); + managed.last_updated_balance_block_time = Some(block(9)); + for height in [8, 9] { + assert!(!managed.take_fetched_identity(&older, block(height))); + assert_eq!(managed.identity, cache); + } + // A balance proved at a later block is taken whatever the revision. + assert!(!managed.take_fetched_identity(&older, block(10))); + assert_eq!(managed.identity, at(6, 20, vec![limited(3, 100, Some(9))])); + assert_eq!(managed.last_updated_balance_block_time, Some(block(10))); // A disable is permanent: one the older fetch shows sticks even // when the cache's copy is live, and the cache keeps its limits. let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); + live.last_updated_balance_block_time = Some(block(9)); let disabling = at(5, 20, vec![limited(3, 500, Some(4))]); - assert_eq!(live.disables_from_older_fetch(&disabling), vec![(3, 4)]); - assert!(!live.take_fetched_identity(&disabling)); + assert!(!live.take_fetched_identity(&disabling, block(9))); assert_eq!(live.identity, at(6, 10, vec![limited(3, 100, Some(4))])); - assert!( - live.disables_from_older_fetch(&disabling).is_empty(), - "nothing left to bring" - ); // Both disabled: the fetch's timestamp is Platform's, the cache's // may be a local placeholder. let mut stamped = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, Some(99))]), 0); - assert!(!stamped.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]))); + assert!( + !stamped.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]), block(9)) + ); assert_eq!(stamped.identity.public_keys()[&3].disabled_at(), Some(4)); - let same = at(6, 30, vec![limited(3, 600, Some(9)), limited(4, 1, None)]); - assert!(managed.take_fetched_identity(&same)); + // An answer at the cache's revision or above is taken whole... + let same = at(6, 30, vec![limited(3, 600, Some(7)), limited(4, 1, None)]); + assert!(managed.take_fetched_identity(&same, block(11))); assert_eq!(managed.identity, same); - let newer = at(7, 40, vec![limited(3, 600, Some(9))]); - assert!(managed.take_fetched_identity(&newer)); + assert_eq!(managed.last_updated_balance_block_time, Some(block(11))); + let newer = at(7, 40, vec![limited(3, 600, Some(7))]); + assert!(managed.take_fetched_identity(&newer, block(12))); assert_eq!(managed.identity, newer); + + // ...except for a balance its proof does not order after the cache's, + // and a disable the cache holds and the answer lacks. + let delayed = at(7, 35, vec![limited(3, 700, None)]); + for height in [11, 12] { + assert!(managed.take_fetched_identity(&delayed, block(height))); + assert_eq!(managed.identity, at(7, 40, vec![limited(3, 700, Some(7))])); + assert_eq!(managed.last_updated_balance_block_time, Some(block(12))); + } } /// An older fetch's key decisions carry the cache's copy of each key diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 0450f0f690c..49912e9b9d8 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -1,6 +1,7 @@ //! Synchronization and block time management for ManagedIdentity use super::ManagedIdentity; +use crate::changeset::IdentityKeysChangeSet; use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::BlockTime; @@ -87,6 +88,40 @@ impl ManagedIdentity { Ok(()) } + /// Take a verified balance when its proof's block is above the cache's + /// balance watermark (see `balance_snapshot_is_newer`). Memory only: the + /// balance is queued, so the next store carries it (see + /// `store_keys_and_balance`) and a failed store keeps it queued. + pub(crate) fn take_fetched_balance(&mut self, balance: u64, block_time: BlockTime) { + if self.balance_snapshot_is_newer(block_time) { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = Some(block_time); + self.pending_balance_snapshot = Some((balance, block_time)); + } + } + + /// Store `keys_cs` with the identity's scalar snapshot (status, revision + /// and any queued balance) as one changeset. The store's failure is + /// returned. A store that commits inline has committed the queued + /// balance, which is then cleared; a buffered one keeps it queued, so it + /// rides later snapshots until a balance write flushes it (see + /// `retry_pending_balance`), and no flush runs here. + pub(crate) fn store_keys_and_balance( + &mut self, + keys_cs: IdentityKeysChangeSet, + persister: &WalletPersister, + ) -> Result<(), PlatformWalletError> { + let (balance, block_time) = self.balance_snapshot_for_persistence(); + self.store_with_snapshot(keys_cs, persister) + .map_err(|e| persister.classify_store_failure(e))?; + if self.pending_balance_snapshot.is_some() && persister.store_commits_inline() { + self.identity.set_balance(balance); + self.last_updated_balance_block_time = block_time; + self.pending_balance_snapshot = None; + } + Ok(()) + } + pub(crate) fn persist_refreshed_balance( &mut self, balance: u64, @@ -110,11 +145,7 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> u64 { - if self.balance_snapshot_is_newer(block_time) { - self.identity.set_balance(balance); - self.last_updated_balance_block_time = Some(block_time); - self.pending_balance_snapshot = Some((balance, block_time)); - } + self.take_fetched_balance(balance, block_time); // A transaction response can confirm the exact pending query snapshot // whose cache write failed. Publish that verified value now, while // retaining the same write obligation if storage is still unavailable. From 88510b8e359c0bc13caa4f41485a8bf420f4cc61 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 02:26:45 +0300 Subject: [PATCH 19/72] fix(platform-wallet): keep the higher key limit, retry a fetched disable whose store failed - A fetched key's limits are folded by value: consensus only ever raises a key's budget or expiry and never adds or removes a limit, so each key keeps the higher of the cache's and the fetch's at any revision. A delayed answer can no longer take a raise back, and an older answer can bring one the cache missed. A landed key limits update is folded over the cache's copy the same way. - Refresh stores the key rows of the disables a fetch brings at any revision, not only for an older answer. - When the store of a refresh, load or discovery fold fails, the keys the fetch disables get back the disabled state they had in memory, so the next refresh finds the disable again and stores its row. The rest of the fold stays: the revision the next update is built from, and a newer balance, queued for the next store. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/key_limits.rs | 5 +- .../src/wallet/identity/network/loading.rs | 191 ++++++++---- .../wallet/identity/network/signing_key.rs | 22 +- .../state/managed_identity/identity_ops.rs | 271 ++++++++++++++---- 4 files changed, 371 insertions(+), 118 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs index 003b81803ba..293ec287ad6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs @@ -33,7 +33,10 @@ impl IdentityWallet { /// /// Resolves to the key as stored after the update, which is also laid over the cached /// identity and upserted through the persister, as - /// [`Self::update_identity_with_external_signer`] does for an added key. + /// [`Self::update_identity_with_external_signer`] does for an added key. The key returned is + /// Platform's; the cache and the store hold it folded over the cache's copy (see + /// `ManagedIdentity::replace_key`), which can carry a disable or a higher limit a refresh + /// brought while the update was in flight. pub async fn update_identity_key_limits_with_external_signer( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 4a8d9d6c67c..e003a47a5f6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -314,12 +314,17 @@ impl IdentityWallet { /// The identity must already exist in the [`IdentityManager`]. Its /// on-chain state (keys, revision) is replaced with the latest version /// from Platform and the status is set to `Active`; a disable the cache - /// holds stays. The balance, at any revision, is taken only when the - /// fetch's proof is at a block above the cache's balance watermark. A - /// fetch at a lower revision than the cache's was answered before an - /// update the cache already holds: the cache keeps its revision, key set, - /// key limits and every disable, and takes only a disable the fetch shows - /// ([`ManagedIdentity::take_fetched_identity`]). + /// holds stays, and so does a key limit the cache holds higher. The + /// balance, at any revision, is taken only when the fetch's proof is at a + /// block above the cache's balance watermark. A fetch at a lower revision + /// than the cache's was answered before an update the cache already + /// holds: the cache keeps its revision, key set and every disable, and + /// takes only a disable or a raised limit the fetch shows + /// ([`ManagedIdentity::take_fetched_identity`]). The rows of keys the + /// fetch disables are stored with the scalar snapshot; when that store + /// fails, the error is returned and those keys keep the disabled state + /// they had, so the next refresh stores the disable again + /// ([`ManagedIdentity::store_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. /// @@ -375,14 +380,8 @@ impl IdentityWallet { ) })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { - // An older answer only changes keys by disabling them; their - // rows are stored here with the folded scalar snapshot, since - // nothing else here stores keys, and a failed store is - // returned rather than logged. - let disables = managed.disables_from_older_fetch(&identity); - managed.take_fetched_identity(&identity, block_time); managed.status = IdentityStatus::Active; - managed.store_fetched_disables(&disables, &self.persister)?; + managed.store_fetched_identity(&identity, block_time, &self.persister)?; // The cache as folded. identity = managed.identity.clone(); } @@ -706,6 +705,62 @@ mod tests { } } + /// A load whose store fails returns the failure and leaves the keys the + /// answer disables as they were in memory, so a refresh finds the + /// disable again and stores its row; the rest of the fold stays. + #[tokio::test] + async fn should_leave_a_load_s_disable_for_a_refresh_when_its_store_fails() { + let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); + let probe = derive_load_probe_hash( + ResolvedLoadKeyHashSource::Master(&master), + Network::Testnet, + 0, + ) + .unwrap(); + let mut answer = answer_at(7); + let mut key_2 = answer.public_keys()[&2].clone(); + key_2.set_disabled_at(4); + answer.add_public_key(key_2); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(answer.clone())) + .await + .unwrap(); + sdk.mock() + .expect_fetch(Identifier::default(), Some(answer)) + .await + .unwrap(); + let store = Arc::new(FailingStore::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + store.arm(); + + let result = wallet.load_identity_by_index_from_master(0, &master).await; + assert!( + matches!(result, Err(PlatformWalletError::PersisterStore(_))), + "{result:?}" + ); + let after = held(wallet).await; + assert_eq!(after.public_keys()[&2].disabled_at(), None); + assert_eq!(after.revision(), 7); + assert!(after.public_keys().contains_key(&4)); + + store.disarm(); + wallet + .refresh_identity(&Identifier::default()) + .await + .unwrap(); + assert_eq!(held(wallet).await.public_keys()[&2].disabled_at(), Some(4)); + assert_eq!( + store + .taken() + .stored_key(Identifier::default(), 2) + .map(|row| row.public_key.disabled_at()), + Some(Some(4)) + ); + } + /// A same-revision answer whose proof is not above the cache's balance /// watermark (mock answers are at height 0) takes the answer's keys but /// keeps the cache's balance, which the revision does not order. @@ -739,54 +794,86 @@ mod tests { assert!(returned.public_keys()[&1].is_disabled()); } - /// A disable an older refresh brings is stored with its key row, and a - /// store that refuses it fails the refresh instead of reporting a - /// success the store does not hold. + /// A disable a refresh brings, from an answer older or newer than the + /// cache, is stored with its key row. A store that refuses it fails the + /// refresh and leaves those keys as they were in memory, so the next + /// refresh finds the disable again instead of holding one in memory the + /// store lacks. #[tokio::test] - async fn should_fail_a_refresh_whose_older_disable_is_not_stored() { + async fn should_store_a_refresh_s_disable_or_leave_the_cache_as_it_was() { let identity_id = Identifier::default(); - let mut answer = answer_at(5); - let mut key_2 = answer.public_keys()[&2].clone(); - key_2.set_disabled_at(4); - answer.add_public_key(key_2); - for fails in [false, true] { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(identity_id, Some(answer.clone())) - .await - .unwrap(); + for revision in [5, 7] { + let mut answer = answer_at(revision); + let mut key_2 = answer.public_keys()[&2].clone(); + key_2.set_disabled_at(4); + answer.add_public_key(key_2); + // Key 1 is disabled in the cache at 9, a placeholder; Platform's + // timestamp is 3. + let mut key_1 = answer.public_keys()[&1].clone(); + key_1.set_disabled_at(3); + answer.add_public_key(key_1); + let mock = || async { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(identity_id, Some(answer.clone())) + .await + .unwrap(); + sdk + }; + let store = Arc::new(FailingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet_with_signing_keys_on(mock().await, store.clone()).await; let wallet = wallet.identity(); cache_at_6_with_key_1_disabled(wallet).await; - if fails { - store.arm(); - } - + assert_ne!(held(wallet).await.balance(), 777); + store.arm(); let result = wallet.refresh_identity(&identity_id).await; - assert_eq!( + assert!( matches!(result, Err(PlatformWalletError::PersisterStore(_))), - fails, - "{result:?}" + "answered at {revision}: {result:?}" ); - assert_eq!(held(wallet).await.public_keys()[&2].disabled_at(), Some(4)); + // Key 2 is live as it was; the rest of the fold stays: the + // revision, and the answer's balance (the cache holds no + // watermark), queued for the next store. + let after = held(wallet).await; + assert_eq!(after.public_keys()[&2].disabled_at(), None); + assert_eq!(after.public_keys()[&1].disabled_at(), Some(9)); + assert_eq!(after.revision(), revision.max(6), "answered at {revision}"); + assert_eq!(after.balance(), 777); + store.disarm(); + store.taken().clear(); + wallet.refresh_identity(&identity_id).await.unwrap(); + let after = held(wallet).await; + for (key_id, disabled_at) in [(1, 3), (2, 4)] { + assert_eq!( + after.public_keys()[&key_id].disabled_at(), + Some(disabled_at) + ); + assert_eq!( + store + .taken() + .stored_key(identity_id, key_id) + .map(|row| row.public_key.disabled_at()), + Some(Some(disabled_at)), + "key {key_id} retried at {revision}" + ); + } + + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_with_signing_keys_on(mock().await, store.clone()).await; + let wallet = wallet.identity(); + cache_at_6_with_key_1_disabled(wallet).await; + wallet.refresh_identity(&identity_id).await.unwrap(); + for (key_id, disabled_at) in [(1, 3), (2, 4)] { + assert_eq!( + store + .stored_key(identity_id, key_id) + .map(|row| row.public_key.disabled_at()), + Some(Some(disabled_at)), + "key {key_id} answered at {revision}" + ); + } } - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(identity_id, Some(answer)) - .await - .unwrap(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - wallet.refresh_identity(&identity_id).await.unwrap(); - assert_eq!( - store - .stored_key(identity_id, 2) - .map(|row| row.public_key.disabled_at()), - Some(Some(4)) - ); } /// Discovery folds an identity it already knows the way loading does: an diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 50d92202451..d26392a21b5 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -442,22 +442,36 @@ pub(super) mod tests { } } - /// Takes every changeset until armed, then refuses each. + /// Takes every changeset until armed, then refuses each; what it takes is + /// recorded as [`CapturingStore`] records it. #[derive(Default)] - pub(crate) struct FailingStore(AtomicBool); + pub(crate) struct FailingStore(AtomicBool, CapturingStore); impl FailingStore { + /// What the store took, while disarmed. + pub(crate) fn taken(&self) -> &CapturingStore { + &self.1 + } + pub(crate) fn arm(&self) { self.0.store(true, Ordering::SeqCst); } + + pub(crate) fn disarm(&self) { + self.0.store(false, Ordering::SeqCst); + } } impl PlatformWalletPersistence for FailingStore { - fn store(&self, _: WalletId, _: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + fn store( + &self, + wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { if self.0.load(Ordering::SeqCst) { return Err(PersistenceError::backend("store armed to fail")); } - Ok(()) + self.1.store(wallet_id, changeset) } fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { Ok(()) diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index a8071c48cba..01f3a9a6b3c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -14,6 +14,7 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; @@ -398,15 +399,18 @@ impl ManagedIdentity { /// balance watermark, and is then queued for the next store (see /// `store_keys_and_balance`). /// + /// A key's limits are not ordered by revision either (a limits update + /// claims none), but they only ever rise, so each shared key keeps the + /// higher of the two (see `with_raised_limits`) at any revision. + /// /// A fetch at the cache's revision or above is otherwise taken whole, /// except that a key the cache holds disabled stays disabled (a disable /// is permanent on Platform). Revisions only grow on Platform, so a fetch /// at a lower revision was answered before an update the cache already /// holds (one applied locally after it landed, or a newer refresh): - /// the cache keeps its revision, key set and each key's limits, which - /// the revision does not order either; it takes only a disable the - /// fetch shows (the fetch's timestamp is Platform's own). Returns - /// whether it was taken whole. + /// the cache keeps its revision and key set; it takes only a disable the + /// fetch shows (the fetch's timestamp is Platform's own) and a raised + /// limit. Returns whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { @@ -432,44 +436,57 @@ impl ManagedIdentity { taken } - /// The disables an older `fetched` brings to keys the cache holds — what - /// [`Self::take_fetched_identity`] changes in a key — so a caller that - /// stores no key rows of its own can store them (see - /// [`Self::store_fetched_disables`]). Empty for a fetch at the cache's - /// revision or above, which is taken whole. - pub fn disables_from_older_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { - if fetched.revision() >= self.identity.revision() { - return Vec::new(); - } + /// The disables `fetched` brings to keys the cache holds, at any + /// revision — what [`Self::take_fetched_identity`] changes in a key's + /// disabled state — so a caller that stores no key rows of its own can + /// store them (see [`Self::store_fetched_identity`]), and a failed store + /// can put those keys back (see [`Self::disabled_states`]). + pub fn disables_from_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { + let taken = fetched.revision() >= self.identity.revision(); self.identity .public_keys() .iter() .filter_map(|(key_id, cached)| { let fetched_key = fetched.public_keys().get(key_id)?; - let folded = with_disable_from(cached, fetched_key).disabled_at()?; + let folded = fold_fetched_key(Some(cached), fetched_key, taken)?.disabled_at()?; (cached.disabled_at() != Some(folded)).then_some((*key_id, folded)) }) .collect() } - /// Stamp the disables an older fetch brought (see - /// [`Self::disables_from_older_fetch`]) and store their key rows, with - /// the wallet slot's breadcrumb as [`Self::disable_keys`] gives them, and - /// the identity's scalar snapshot as one changeset (see - /// `store_keys_and_balance`). The store's failure is returned; memory - /// holds the fold either way, as for a landed update. - pub fn store_fetched_disables( + /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the key + /// rows of the disables it brings (see [`Self::disables_from_fetch`]), + /// with the wallet slot's breadcrumb as [`Self::disable_keys`] gives + /// them, and the identity's scalar snapshot as one changeset (see + /// `store_keys_and_balance`). Used where a fetch comes without derived + /// breadcrumbs (refresh), which stores no other key rows. + /// + /// A failed store is returned, and those keys get back the disabled state + /// they had in memory: a disable the store lacks would otherwise stay in + /// memory only, and no later refresh would see it as new and store it. + /// The rest of the fold stays (the revision above all, which the next + /// update is built from), as does a balance it took, queued for the next + /// store as every verified balance is after a failed store. + pub fn store_fetched_identity( &mut self, - disables: &[(KeyID, TimestampMillis)], + fetched: &Identity, + block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { + let disables = self.disables_from_fetch(fetched); + let before = self.disabled_states(&disables); + self.take_fetched_identity(fetched, block_time); let mut keys_cs = IdentityKeysChangeSet::default(); - for &(key_id, disabled_at) in disables { + for (key_id, disabled_at) in disables { keys_cs .upserts .extend(self.stamp_disabled(&[key_id], disabled_at).upserts); } - self.store_keys_and_balance(keys_cs, persister) + let result = self.store_keys_and_balance(keys_cs, persister); + if result.is_err() { + self.restore_disabled_states(before); + } + result } /// Fold `fetched` (see [`Self::take_fetched_identity`]) with the key @@ -477,9 +494,10 @@ impl ManagedIdentity { /// breadcrumbs and the identity's scalar snapshot as one changeset (see /// `store_keys_and_balance`). Used where a fetch comes with derived /// breadcrumbs (loading and discovery); an identity added from this - /// fetch just before takes its balance watermark from it. The store's - /// failure is returned; memory holds the fold either way, as for a - /// landed update. + /// fetch just before takes its balance watermark from it. A failed store + /// is returned, and the keys the fetch disables get back the disabled + /// state they had in memory, as in [`Self::store_fetched_identity`], so + /// the next refresh finds the disable again and stores its row. pub fn store_fetched_identity_keys( &mut self, fetched: &Identity, @@ -487,21 +505,57 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { + let before = self.disabled_states(&self.disables_from_fetch(fetched)); let decisions = self.key_decisions_for_fetch(fetched, decisions); self.take_fetched_identity(fetched, block_time); let keys_cs = self.layer_keys(decisions); - self.store_keys_and_balance(keys_cs, persister) + let result = self.store_keys_and_balance(keys_cs, persister); + if result.is_err() { + self.restore_disabled_states(before); + } + result + } + + /// The disabled state the cache holds for each key of `disables`, for + /// [`Self::restore_disabled_states`] to put back. + fn disabled_states( + &self, + disables: &[(KeyID, TimestampMillis)], + ) -> Vec<(KeyID, Option)> { + disables + .iter() + .filter_map(|(key_id, _)| { + let key = self.identity.public_keys().get(key_id)?; + Some((*key_id, key.disabled_at())) + }) + .collect() + } + + /// Put back the disabled states [`Self::disabled_states`] read, after a + /// fold whose store failed. + fn restore_disabled_states(&mut self, states: Vec<(KeyID, Option)>) { + let mut keys = self.identity.public_keys().clone(); + for (key_id, disabled_at) in states { + if let Some(key) = keys.get_mut(&key_id) { + match disabled_at { + Some(disabled_at) => key.set_disabled_at(disabled_at), + None => key.remove_disabled_at(), + } + } + } + self.identity.set_public_keys(keys); } /// The key decisions to store for `decisions` built from `fetched`, each /// key folded as [`Self::take_fetched_identity`] folds it, so storing its /// breadcrumb (a key's derivation never changes) never takes back a - /// disable the cache holds. For a fetch at the cache's revision or above - /// that is the fetched key, disabled when the cache's copy is. For an - /// older fetch it is the cache's copy, disabled when the fetch shows a - /// disable, so older limits are not stored either; a key the cache lacks - /// is left out, since an older answer cannot show whether a later update - /// disabled it, and a fetch at least as new will bring it. + /// disable or a raised limit the cache holds. For a fetch at the cache's + /// revision or above that is the fetched key, disabled when the cache's + /// copy is. For an older fetch it is the cache's copy, disabled when the + /// fetch shows a disable; either way each limit is the higher of the two. + /// A key the cache lacks is left out, since an older answer cannot show + /// whether a later update disabled it, and a fetch at least as new will + /// bring it. pub fn key_decisions_for_fetch( &self, fetched: &Identity, @@ -625,7 +679,9 @@ impl ManagedIdentity { /// after a key limits update, and emit a single-key [`IdentityKeysChangeSet`] upsert for /// it: the limits-side counterpart to [`Self::add_key`] and [`Self::disable_keys`]. /// - /// The key is layered by id, so a key the identity did not hold is added. The entry carries + /// The key is layered by id, so a key the identity did not hold is added. It is folded over + /// the cache's copy as a fetched key is (see `fold_fetched_key`), so a disable or a higher + /// limit a refresh brought while the update was in flight is not taken back. The entry carries /// no derivation breadcrumb: a limits update changes nothing about where the key came from, /// and the managed identity does not know per key whether it was derived from the wallet /// seed or supplied from outside. Every persister keeps the breadcrumb it already holds for @@ -642,6 +698,9 @@ impl ManagedIdentity { let identity_id = self.id(); let key_id = public_key.id(); + let public_key = + fold_fetched_key(self.identity.public_keys().get(&key_id), &public_key, true) + .unwrap_or(public_key); let public_key_hash = pubkey_hash_of(&public_key); let mut keys = self.identity.public_keys().clone(); @@ -830,29 +889,49 @@ impl ManagedIdentity { /// fetched key, disabled when the cache's copy is (a disable is permanent /// on Platform; Platform's timestamp first). From an older fetch it is the /// cache's copy with the fetch's disable, or nothing for a key the cache -/// lacks. +/// lacks. Either way each limit is the higher of the two copies'. fn fold_fetched_key( cached: Option<&IdentityPublicKey>, fetched: &IdentityPublicKey, taken: bool, ) -> Option { if !taken { - return cached.map(|cached| with_disable_from(cached, fetched)); + return cached + .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); } let mut key = fetched.clone(); + let Some(cached) = cached else { + return Some(key); + }; if key.disabled_at().is_none() { - if let Some(disabled_at) = cached.and_then(|cached| cached.disabled_at()) { + if let Some(disabled_at) = cached.disabled_at() { key.set_disabled_at(disabled_at); } } - Some(key) + Some(with_raised_limits(key, cached)) +} + +/// `key` with each of its limits raised to `other`'s where that is higher. +/// Consensus only ever raises a key's limits (a budget top-up, a later +/// expiry; anything else is refused as not raising it) and never adds or +/// removes one, so the higher value is the newer one whichever answer +/// carries it, and a delayed answer cannot take a raise back. A limit +/// only `other` has is not taken. +fn with_raised_limits(key: IdentityPublicKey, other: &IdentityPublicKey) -> IdentityPublicKey { + fn higher(own: Option, other: Option) -> Option { + own.map(|own| other.map_or(own, |other| own.max(other))) + } + let total_budget = higher(key.total_budget(), other.total_budget()); + let expires_at = higher(key.expires_at(), other.expires_at()); + if (total_budget, expires_at) == (key.total_budget(), key.expires_at()) { + return key; + } + key.with_limits(total_budget, expires_at) } /// The cache's copy of a key, disabled when an older fetch's copy is: a -/// disable is permanent on Platform, while nothing else in an older fetch is -/// known to be newer than the cache (its limits change without a revision -/// bump). The fetch's timestamp is Platform's own, so it wins over the -/// cache's, which may be a local placeholder. +/// disable is permanent on Platform. The fetch's timestamp is Platform's +/// own, so it wins over the cache's, which may be a local placeholder. fn with_disable_from(cached: &IdentityPublicKey, fetched: &IdentityPublicKey) -> IdentityPublicKey { let mut key = cached.clone(); if let Some(disabled_at) = fetched.disabled_at().or(cached.disabled_at()) { @@ -934,11 +1013,11 @@ mod tests { } /// A fetch at a lower revision than the cache's was answered before an - /// update the cache holds: the cache keeps its revision, key set and key - /// limits (the revision does not order those), and takes only a disable - /// the fetch shows (Platform's timestamp first). A fetch at the same - /// revision or higher replaces the cache but never takes back a disable. - /// Either way the balance follows the proof's block, not the revision. + /// update the cache holds: the cache keeps its revision and key set, and + /// takes only a disable the fetch shows (Platform's timestamp first) and + /// a raised limit. A fetch at the same revision or higher replaces the + /// cache but never takes back a disable or a raise. Either way the + /// balance follows the proof's block, not the revision. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { @@ -968,11 +1047,11 @@ mod tests { } // The cache took an update at 6 that disabled key 3, and holds a // balance proved at block 9; the fetch at 5 still shows key 3 live, - // another budget and key 4, which the cache never saw. A delayed - // answer cannot prove its budget newer than the cache's, and its - // balance is no newer than the cache's proof. + // a lower budget and key 4, which the cache never saw. Budgets only + // rise, so the lower one is older, and the fetch's balance is no + // newer than the cache's proof. let cache = at(6, 10, vec![limited(3, 100, Some(9))]); - let older = at(5, 20, vec![limited(3, 500, None), limited(4, 1, None)]); + let older = at(5, 20, vec![limited(3, 50, None), limited(4, 1, None)]); let mut managed = ManagedIdentity::new(cache.clone(), 0); managed.last_updated_balance_block_time = Some(block(9)); for height in [8, 9] { @@ -985,12 +1064,13 @@ mod tests { assert_eq!(managed.last_updated_balance_block_time, Some(block(10))); // A disable is permanent: one the older fetch shows sticks even - // when the cache's copy is live, and the cache keeps its limits. + // when the cache's copy is live. A higher budget it shows is a raise + // the cache missed (a limits update claims no revision). let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); live.last_updated_balance_block_time = Some(block(9)); let disabling = at(5, 20, vec![limited(3, 500, Some(4))]); assert!(!live.take_fetched_identity(&disabling, block(9))); - assert_eq!(live.identity, at(6, 10, vec![limited(3, 100, Some(4))])); + assert_eq!(live.identity, at(6, 10, vec![limited(3, 500, Some(4))])); // Both disabled: the fetch's timestamp is Platform's, the cache's // may be a local placeholder. let mut stamped = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, Some(99))]), 0); @@ -1009,18 +1089,39 @@ mod tests { assert_eq!(managed.identity, newer); // ...except for a balance its proof does not order after the cache's, - // and a disable the cache holds and the answer lacks. - let delayed = at(7, 35, vec![limited(3, 700, None)]); + // a disable the cache holds and the answer lacks, and a budget the + // cache holds higher: a delayed answer cannot take a raise back. + let delayed = at(7, 35, vec![limited(3, 550, None)]); for height in [11, 12] { assert!(managed.take_fetched_identity(&delayed, block(height))); - assert_eq!(managed.identity, at(7, 40, vec![limited(3, 700, Some(7))])); + assert_eq!(managed.identity, at(7, 40, vec![limited(3, 600, Some(7))])); assert_eq!(managed.last_updated_balance_block_time, Some(block(12))); } } + /// Each limit is the higher of the two copies', since consensus only + /// raises a key's limits; a limit only the other copy has is not taken, + /// since none is ever added or removed. + #[test] + fn should_keep_the_higher_of_each_key_limit() { + let limited = |total_budget, expires_at| key(3).with_limits(total_budget, expires_at); + assert_eq!( + with_raised_limits(limited(Some(100), Some(10)), &limited(Some(50), Some(20))), + limited(Some(100), Some(20)) + ); + assert_eq!( + with_raised_limits(limited(Some(100), None), &limited(Some(50), Some(20))), + limited(Some(100), None) + ); + assert_eq!( + with_raised_limits(key(3), &limited(Some(50), Some(20))), + key(3) + ); + } + /// An older fetch's key decisions carry the cache's copy of each key - /// (its disabled state and limits), and a key only the fetch has is left - /// out. + /// (its disabled state, each limit the higher of the two), and a key + /// only the fetch has is left out. #[test] fn should_fold_an_older_fetch_s_key_decisions_into_the_cache_s_keys() { let mut disabled = key(3); @@ -1065,6 +1166,56 @@ mod tests { ); } + /// A key decision keeps the higher of each limit at any revision, so a + /// load answered before a limits update the cache holds does not store + /// the lower one. + #[test] + fn should_keep_a_raised_limit_in_a_fetch_s_key_decisions() { + let identity = |revision, budget| { + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(3, key(3).with_limits(Some(budget), None))]), + balance: 0, + revision, + }) + }; + let managed = ManagedIdentity::new(identity(6, 500), 0); + for revision in [5, 6, 7] { + let decisions = vec![KeyWithBreadcrumb { + key: key(3).with_limits(Some(100), None), + breadcrumb: None, + }]; + let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); + assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); + } + } + + /// A landed limits update is folded over the cache's copy: a disable or + /// a higher limit a refresh brought while it was in flight is not taken + /// back. + #[test] + fn replace_key_keeps_a_disable_and_a_higher_limit_the_cache_holds() { + let mut cached = key(1).with_limits(Some(2_000), Some(50)); + cached.set_disabled_at(9); + let identity = Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(1, cached)]), + balance: 0, + revision: 0, + }); + let mut managed = ManagedIdentity::new(identity, 0); + let p = WalletPersister::new( + [0xAB; 32], + std::sync::Arc::new(CapturingPersister::default()) as _, + ); + managed + .replace_key(key(1).with_limits(Some(1_500), Some(60)), &p) + .expect("replace_key persists in test"); + let mut expected = key(1).with_limits(Some(2_000), Some(60)); + expected.set_disabled_at(9); + assert_eq!(managed.identity.public_keys()[&1], expected); + } + /// A batch of fetched names lands as one snapshot carrying every name, /// skips labels already held, and persists nothing when all are known. /// One snapshot per name made the first read as the complete owned set. @@ -1329,8 +1480,6 @@ mod tests { /// breadcrumb when an upsert carries none. #[test] fn replace_key_carries_no_breadcrumb() { - use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; - let identity = Identity::V0(IdentityV0 { id: Identifier::from([1u8; 32]), public_keys: BTreeMap::new(), From e0b1d7ce4bce49fa95c1dd6a55247df1c42dd221 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:29:16 +0300 Subject: [PATCH 20/72] fix(platform-wallet): queue refused key rows for the next store, take a one-sided key limit - A key store that fails (a refresh, load or discovery fold, a landed identity update or key limits update) queues its key rows on the managed identity, and the identity's next key store carries them, each with the key as memory then holds it, so a queued row never takes a later change back. A row the next store carries itself wins but keeps the queued breadcrumb when it has none. Rows refused as invalid data are not queued. This replaces restoring the disabled state of the keys a failed fold disabled: memory keeps the whole fold, and the queue is the retry. - A key limit only one copy has is taken from that copy: consensus never adds or removes a limit, so the other copy lost it. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/loading.rs | 50 ++-- .../src/wallet/identity/network/update.rs | 16 +- .../state/managed_identity/identity_ops.rs | 282 +++++++++++++----- .../identity/state/managed_identity/mod.rs | 11 +- .../identity/state/managed_identity/sync.rs | 16 +- 5 files changed, 267 insertions(+), 108 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index e003a47a5f6..1ad0685a917 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -321,10 +321,11 @@ impl IdentityWallet { /// holds: the cache keeps its revision, key set and every disable, and /// takes only a disable or a raised limit the fetch shows /// ([`ManagedIdentity::take_fetched_identity`]). The rows of keys the - /// fetch disables are stored with the scalar snapshot; when that store - /// fails, the error is returned and those keys keep the disabled state - /// they had, so the next refresh stores the disable again - /// ([`ManagedIdentity::store_fetched_identity`]). + /// fetch disables are stored with the scalar snapshot, and so are key + /// rows an earlier refused store queued; when that store fails, the error + /// is returned, memory keeps the fold and the rows stay queued for the + /// identity's next key store (a refresh, load, discovery or landed + /// update; [`ManagedIdentity::store_fetched_identity`]). /// /// Returns the identity the cache holds afterwards. /// @@ -705,11 +706,11 @@ mod tests { } } - /// A load whose store fails returns the failure and leaves the keys the - /// answer disables as they were in memory, so a refresh finds the - /// disable again and stores its row; the rest of the fold stays. + /// A load whose store fails returns the failure and queues its key rows, + /// and a refresh, which stores no rows of its own + /// for keys memory already holds, stores them. #[tokio::test] - async fn should_leave_a_load_s_disable_for_a_refresh_when_its_store_fails() { + async fn should_store_a_failed_load_s_key_rows_with_the_next_refresh() { let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); let probe = derive_load_probe_hash( ResolvedLoadKeyHashSource::Master(&master), @@ -742,23 +743,27 @@ mod tests { "{result:?}" ); let after = held(wallet).await; - assert_eq!(after.public_keys()[&2].disabled_at(), None); + assert_eq!(after.public_keys()[&2].disabled_at(), Some(4)); assert_eq!(after.revision(), 7); assert!(after.public_keys().contains_key(&4)); store.disarm(); + store.taken().clear(); wallet .refresh_identity(&Identifier::default()) .await .unwrap(); - assert_eq!(held(wallet).await.public_keys()[&2].disabled_at(), Some(4)); + let stored = |key_id| store.taken().stored_key(Identifier::default(), key_id); assert_eq!( - store - .taken() - .stored_key(Identifier::default(), 2) - .map(|row| row.public_key.disabled_at()), + stored(2).map(|row| row.public_key.disabled_at()), Some(Some(4)) ); + let key_4 = stored(4).expect("the new key's row is stored"); + assert_eq!(key_4.public_key, held(wallet).await.public_keys()[&4]); + assert_eq!( + store.taken().stored_revision(Identifier::default()), + Some(7) + ); } /// A same-revision answer whose proof is not above the cache's balance @@ -796,11 +801,10 @@ mod tests { /// A disable a refresh brings, from an answer older or newer than the /// cache, is stored with its key row. A store that refuses it fails the - /// refresh and leaves those keys as they were in memory, so the next - /// refresh finds the disable again instead of holding one in memory the - /// store lacks. + /// refresh and queues the row, which the next refresh stores although + /// memory already holds the disable. #[tokio::test] - async fn should_store_a_refresh_s_disable_or_leave_the_cache_as_it_was() { + async fn should_store_a_refresh_s_disable_or_queue_it_for_the_next_store() { let identity_id = Identifier::default(); for revision in [5, 7] { let mut answer = answer_at(revision); @@ -832,12 +836,12 @@ mod tests { matches!(result, Err(PlatformWalletError::PersisterStore(_))), "answered at {revision}: {result:?}" ); - // Key 2 is live as it was; the rest of the fold stays: the - // revision, and the answer's balance (the cache holds no - // watermark), queued for the next store. + // Memory holds the fold: the disables, the revision, and the + // answer's balance (the cache holds no watermark), queued for + // the next store with the refused rows. let after = held(wallet).await; - assert_eq!(after.public_keys()[&2].disabled_at(), None); - assert_eq!(after.public_keys()[&1].disabled_at(), Some(9)); + assert_eq!(after.public_keys()[&2].disabled_at(), Some(4)); + assert_eq!(after.public_keys()[&1].disabled_at(), Some(3)); assert_eq!(after.revision(), revision.max(6), "answered at {revision}"); assert_eq!(after.balance(), 777); store.disarm(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 3ec35a7c481..147e34c3fcc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -296,9 +296,9 @@ impl IdentityWallet { // A cache refreshed to the transition or past it already holds // this update's keys as Platform has them (on-chain `disabled_at` // included) and whatever later updates did to them: applying the - // payload would take those back. A refresh persists no keys, - // though, so the touched keys are stored here from the cache's own - // copies, with the breadcrumbs the path below would give them: an + // payload would take those back. A refresh stores no rows for + // added keys, though, so the touched keys are stored here from the + // cache's own copies, with the breadcrumbs the path below would give them: an // added key the caller's, a disabled key the wallet slot's, as // `disable_keys` builds it. // A key the update both adds and disables is taken as added, on @@ -355,9 +355,9 @@ impl IdentityWallet { // store as one changeset, which holds the revision even when no key // row matched. Disabled keys get the wallet slot's breadcrumb and a // local wall-clock `disabled_at` placeholder: a later fetch brings - // the on-chain block time into memory, and a load or discovery of - // the identity into the store. Memory holds the whole update even - // when the store fails. + // the on-chain block time into memory and the store. Memory holds + // the whole update even when the store fails, and the rows stay + // queued for the identity's next key store. let added = added_keys .into_iter() .map(|key| { @@ -465,8 +465,8 @@ fn update_revision( /// The error for a landed update the wallet could not store. The update is /// on Platform and in the cache either way, so the caller must not resubmit -/// it. A load or discovery of the identity stores its key rows again; a -/// refresh stores only its scalar snapshot. +/// it. Its key rows stay queued, and the identity's next key store (a +/// refresh, load, discovery or another landed update) carries them. fn landed_but_not_stored(e: PersistenceError) -> PlatformWalletError { PlatformWalletError::Persistence(format!( "identity update landed on Platform and is in the wallet's cache, but the wallet could not \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 01f3a9a6b3c..4435b30ba73 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -4,7 +4,8 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; use crate::changeset::{ IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, KeyWithBreadcrumb, PersistenceError, PlatformWalletChangeSet, + IdentityKeysChangeSet, KeyWithBreadcrumb, PersistenceError, PersistenceErrorKind, + PlatformWalletChangeSet, }; use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; @@ -90,6 +91,7 @@ impl ManagedIdentity { identity_index: Some(identity_index), last_updated_balance_block_time: None, pending_balance_snapshot: None, + pending_key_rows: BTreeMap::new(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -111,6 +113,7 @@ impl ManagedIdentity { identity_index: None, last_updated_balance_block_time: None, pending_balance_snapshot: None, + pending_key_rows: BTreeMap::new(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -439,8 +442,7 @@ impl ManagedIdentity { /// The disables `fetched` brings to keys the cache holds, at any /// revision — what [`Self::take_fetched_identity`] changes in a key's /// disabled state — so a caller that stores no key rows of its own can - /// store them (see [`Self::store_fetched_identity`]), and a failed store - /// can put those keys back (see [`Self::disabled_states`]). + /// store them (see [`Self::store_fetched_identity`]). pub fn disables_from_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { let taken = fetched.revision() >= self.identity.revision(); self.identity @@ -461,12 +463,10 @@ impl ManagedIdentity { /// `store_keys_and_balance`). Used where a fetch comes without derived /// breadcrumbs (refresh), which stores no other key rows. /// - /// A failed store is returned, and those keys get back the disabled state - /// they had in memory: a disable the store lacks would otherwise stay in - /// memory only, and no later refresh would see it as new and store it. - /// The rest of the fold stays (the revision above all, which the next - /// update is built from), as does a balance it took, queued for the next - /// store as every verified balance is after a failed store. + /// A failed store is returned; memory holds the fold (the revision above + /// all, which the next update is built from), and the refused key rows + /// and a balance it took stay queued for the identity's next key store, + /// a fetch's or a landed update's (see `store_keys_and_balance`). pub fn store_fetched_identity( &mut self, fetched: &Identity, @@ -474,7 +474,6 @@ impl ManagedIdentity { persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { let disables = self.disables_from_fetch(fetched); - let before = self.disabled_states(&disables); self.take_fetched_identity(fetched, block_time); let mut keys_cs = IdentityKeysChangeSet::default(); for (key_id, disabled_at) in disables { @@ -482,11 +481,7 @@ impl ManagedIdentity { .upserts .extend(self.stamp_disabled(&[key_id], disabled_at).upserts); } - let result = self.store_keys_and_balance(keys_cs, persister); - if result.is_err() { - self.restore_disabled_states(before); - } - result + self.store_keys_and_balance(keys_cs, persister) } /// Fold `fetched` (see [`Self::take_fetched_identity`]) with the key @@ -495,9 +490,9 @@ impl ManagedIdentity { /// `store_keys_and_balance`). Used where a fetch comes with derived /// breadcrumbs (loading and discovery); an identity added from this /// fetch just before takes its balance watermark from it. A failed store - /// is returned, and the keys the fetch disables get back the disabled - /// state they had in memory, as in [`Self::store_fetched_identity`], so - /// the next refresh finds the disable again and stores its row. + /// is returned; memory holds the fold, and the refused key rows (with + /// their breadcrumbs) stay queued for the identity's next key store, a + /// refresh included (see `store_keys_and_balance`). pub fn store_fetched_identity_keys( &mut self, fetched: &Identity, @@ -505,45 +500,56 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { - let before = self.disabled_states(&self.disables_from_fetch(fetched)); let decisions = self.key_decisions_for_fetch(fetched, decisions); self.take_fetched_identity(fetched, block_time); let keys_cs = self.layer_keys(decisions); - let result = self.store_keys_and_balance(keys_cs, persister); - if result.is_err() { - self.restore_disabled_states(before); - } - result - } - - /// The disabled state the cache holds for each key of `disables`, for - /// [`Self::restore_disabled_states`] to put back. - fn disabled_states( - &self, - disables: &[(KeyID, TimestampMillis)], - ) -> Vec<(KeyID, Option)> { - disables - .iter() - .filter_map(|(key_id, _)| { - let key = self.identity.public_keys().get(key_id)?; - Some((*key_id, key.disabled_at())) - }) - .collect() - } - - /// Put back the disabled states [`Self::disabled_states`] read, after a - /// fold whose store failed. - fn restore_disabled_states(&mut self, states: Vec<(KeyID, Option)>) { - let mut keys = self.identity.public_keys().clone(); - for (key_id, disabled_at) in states { - if let Some(key) = keys.get_mut(&key_id) { - match disabled_at { - Some(disabled_at) => key.set_disabled_at(disabled_at), - None => key.remove_disabled_at(), + self.store_keys_and_balance(keys_cs, persister) + } + + /// Store `keys_cs` with the key rows a refused store queued (see + /// `pending_key_rows`) and the identity's scalar snapshot as one + /// changeset. A queued row `keys_cs` carries itself gives way to it (its + /// own row is newer) but lends it its breadcrumb when it has none, as a + /// persister keeps a stored breadcrumb. Every other one is stored with + /// the key as memory holds it now, so a row queued before a later change + /// to the key (a landed disable, a raised limit) does not take that + /// change back; a queued key memory no longer holds is dropped. When the + /// store fails, its key rows are queued for the next one, unless the + /// store refused the data itself (a constraint failure, which a retry + /// would only repeat), and the failure is returned. + pub(super) fn store_with_queued_key_rows( + &mut self, + mut keys_cs: IdentityKeysChangeSet, + persister: &WalletPersister, + ) -> Result<(), PersistenceError> { + for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { + if let Some(own) = keys_cs.upserts.get_mut(&id) { + if own.derivation_indices.is_none() && entry.derivation_indices.is_some() { + own.wallet_id = entry.wallet_id; + own.derivation_indices = entry.derivation_indices; } + continue; } + let Some(key) = self.identity.public_keys().get(&entry.key_id) else { + continue; + }; + entry.public_key_hash = pubkey_hash_of(key); + entry.public_key = key.clone(); + keys_cs.upserts.insert(id, entry); } - self.identity.set_public_keys(keys); + let result = self.store_with_snapshot(keys_cs.clone(), persister); + match &result { + Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { + tracing::warn!( + identity = %self.id(), + error = %e, + "identity key rows refused as invalid; not queued for a retry", + ); + } + Err(_) => self.pending_key_rows = keys_cs.upserts, + Ok(()) => {} + } + result } /// The key decisions to store for `decisions` built from `fetched`, each @@ -688,6 +694,9 @@ impl ManagedIdentity { /// the key when an upsert arrives without one, so the private-key linkage survives the /// upsert; inventing coordinates here would overwrite it for an external key. /// + /// A failed store is returned, and the row stays queued for the identity's next key store + /// (see [`Self::store_with_queued_key_rows`]). + /// /// Does **not** touch the identity revision: a key limits update claims none. pub fn replace_key( &mut self, @@ -719,13 +728,7 @@ impl ManagedIdentity { derivation_indices: None, }, ); - let cs = crate::changeset::PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }; - persister.store(cs)?; - Ok(()) + self.store_with_queued_key_rows(keys_cs, persister) } /// Stamp `disabled_at` on the public keys named by `key_ids` and @@ -781,7 +784,8 @@ impl ManagedIdentity { /// `disabled` stamped, and store them with the identity's scalar /// snapshot (the landed revision included) as one changeset, even when /// no key row matched. The store's failure is returned; memory holds the - /// whole update either way. + /// whole update either way, and the key rows stay queued for the next + /// identity store (see [`Self::store_with_queued_key_rows`]). pub fn apply_landed_keys( &mut self, added: Vec, @@ -793,19 +797,20 @@ impl ManagedIdentity { keys_cs .upserts .extend(self.stamp_disabled(disabled, disabled_at).upserts); - self.store_with_snapshot(keys_cs, persister) + self.store_with_queued_key_rows(keys_cs, persister) } /// Layer `keys` in memory and store them with the identity's scalar /// snapshot as one changeset, even when `keys` is empty. The store's - /// failure is returned. + /// failure is returned, and the key rows stay queued for the next + /// identity store (see [`Self::store_with_queued_key_rows`]). pub fn store_keys_with_snapshot( &mut self, keys: Vec, persister: &WalletPersister, ) -> Result<(), PersistenceError> { let keys_cs = self.layer_keys(keys); - self.store_with_snapshot(keys_cs, persister) + self.store_with_queued_key_rows(keys_cs, persister) } pub(super) fn store_with_snapshot( @@ -915,11 +920,16 @@ fn fold_fetched_key( /// Consensus only ever raises a key's limits (a budget top-up, a later /// expiry; anything else is refused as not raising it) and never adds or /// removes one, so the higher value is the newer one whichever answer -/// carries it, and a delayed answer cannot take a raise back. A limit -/// only `other` has is not taken. +/// carries it, and a delayed answer cannot take a raise back. A limit only +/// one copy has is therefore one the other copy lost (a row stored without +/// it, say), not one Platform removed or has yet to add, so it is taken +/// from whichever copy has it. fn with_raised_limits(key: IdentityPublicKey, other: &IdentityPublicKey) -> IdentityPublicKey { fn higher(own: Option, other: Option) -> Option { - own.map(|own| other.map_or(own, |other| own.max(other))) + match (own, other) { + (Some(own), Some(other)) => Some(own.max(other)), + (own, other) => own.or(other), + } } let total_budget = higher(key.total_budget(), other.total_budget()); let expires_at = higher(key.expires_at(), other.expires_at()); @@ -1100,8 +1110,8 @@ mod tests { } /// Each limit is the higher of the two copies', since consensus only - /// raises a key's limits; a limit only the other copy has is not taken, - /// since none is ever added or removed. + /// raises a key's limits; a limit only one copy has is taken from it, + /// since none is ever added or removed, so the other copy lost it. #[test] fn should_keep_the_higher_of_each_key_limit() { let limited = |total_budget, expires_at| key(3).with_limits(total_budget, expires_at); @@ -1111,12 +1121,13 @@ mod tests { ); assert_eq!( with_raised_limits(limited(Some(100), None), &limited(Some(50), Some(20))), - limited(Some(100), None) + limited(Some(100), Some(20)) ); assert_eq!( with_raised_limits(key(3), &limited(Some(50), Some(20))), - key(3) + limited(Some(50), Some(20)) ); + assert_eq!(with_raised_limits(key(3), &key(3)), key(3)); } /// An older fetch's key decisions carry the cache's copy of each key @@ -1216,6 +1227,139 @@ mod tests { assert_eq!(managed.identity.public_keys()[&1], expected); } + /// Persister that refuses every store while armed and records the rest. + #[derive(Default)] + struct ArmedPersister { + armed: std::sync::atomic::AtomicBool, + constraint: std::sync::atomic::AtomicBool, + stores: Mutex>, + } + impl PlatformWalletPersistence for ArmedPersister { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if self.constraint.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend_with_kind( + PersistenceErrorKind::Constraint, + "invalid row", + )); + } + if self.armed.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend("armed")); + } + self.stores.lock().unwrap().push(changeset); + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } + } + + /// The rows of a refused store are queued and the next store carries + /// them, each with the key as memory holds it then (a later raise is + /// not taken back) and its breadcrumb; a row the next store carries + /// itself replaces the queued one but keeps its breadcrumb when it has + /// none. + #[test] + fn should_carry_a_refused_store_s_key_rows_into_the_next_one() { + let wallet_id: WalletId = [0xAB; 32]; + let mut managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + ); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { + breadcrumb: Some((wallet_id, 0, key.id())), + key, + }; + let row = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .last() + .and_then(|cs| cs.identity_keys.as_ref()) + .and_then(|keys| keys.upserts.get(&(managed_id(), key_id)).cloned()) + }; + fn managed_id() -> Identifier { + Identifier::from([1u8; 32]) + } + + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + let limited = key(1).with_limits(Some(100), None); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(limited.clone())], &p) + .is_err()); + // A raise lands in memory before the retry. + managed + .identity + .add_public_key(key(1).with_limits(Some(200), None)); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) + .unwrap(); + let retried = row(1).expect("the refused row rides the next store"); + assert_eq!(retried.public_key, key(1).with_limits(Some(200), None)); + assert!(retried.derivation_indices.is_some()); + assert!(row(2).is_some()); + + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + let raised = key(2).with_limits(Some(5), None); + managed + .store_keys_with_snapshot( + vec![KeyWithBreadcrumb { + key: raised.clone(), + breadcrumb: None, + }], + &p, + ) + .unwrap(); + // The new row's key, the queued row's breadcrumb. + let replaced = row(2).expect("the new row is stored"); + assert_eq!(replaced.public_key, raised); + assert!(replaced.derivation_indices.is_some()); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(row(2), None, "the queue is empty after a store succeeds"); + + // Rows refused as invalid data are not queued: a retry would only + // repeat the refusal. + persister + .constraint + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(3))], &p) + .is_err()); + persister + .constraint + .store(false, std::sync::atomic::Ordering::SeqCst); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(row(3), None); + } + /// A batch of fetched names lands as one snapshot carrying every name, /// skips labels already held, and persists nothing when all are known. /// One snapshot per name made the first read as the complete owned set. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 436dbaaabf6..856fbcba97c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -20,7 +20,10 @@ pub use crate::wallet::identity::types::key_storage::{ self, DpnsFetch, DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData, }; -use dpp::identity::Identity; +use crate::changeset::IdentityKeyEntry; +use dpp::identity::{Identity, KeyID}; +use dpp::prelude::Identifier; +use std::collections::BTreeMap; /// A managed identity that combines an Identity with wallet-specific metadata. /// @@ -58,6 +61,12 @@ pub struct ManagedIdentity { /// retry. Query results are published only after that retry commits. pending_balance_snapshot: Option<(u64, BlockTime)>, + /// Key rows a fetch's fold or a landed update stored and the store + /// refused, owed to persistence. Every later such store carries them + /// (see `store_with_queued_key_rows`), each with the key as memory then + /// holds it, until one succeeds. + pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 49912e9b9d8..68f359be5c3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -100,19 +100,21 @@ impl ManagedIdentity { } } - /// Store `keys_cs` with the identity's scalar snapshot (status, revision - /// and any queued balance) as one changeset. The store's failure is - /// returned. A store that commits inline has committed the queued - /// balance, which is then cleared; a buffered one keeps it queued, so it - /// rides later snapshots until a balance write flushes it (see - /// `retry_pending_balance`), and no flush runs here. + /// Store `keys_cs`, the key rows an earlier refused store queued, and the + /// identity's scalar snapshot (status, revision and any queued balance) + /// as one changeset (see `store_with_queued_key_rows`). The store's + /// failure is returned, and its key rows are queued for the next one. A store that + /// commits inline has committed the queued balance, which is then + /// cleared; a buffered one keeps it queued, so it rides later snapshots + /// until a balance write flushes it (see `retry_pending_balance`), and no + /// flush runs here. pub(crate) fn store_keys_and_balance( &mut self, keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { let (balance, block_time) = self.balance_snapshot_for_persistence(); - self.store_with_snapshot(keys_cs, persister) + self.store_with_queued_key_rows(keys_cs, persister) .map_err(|e| persister.classify_store_failure(e))?; if self.pending_balance_snapshot.is_some() && persister.store_commits_inline() { self.identity.set_balance(balance); From 144958e8dba2c582521f90f862e847c362dad3f1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:26:50 +0300 Subject: [PATCH 21/72] fix(platform-wallet): refresh stores every key it changes, SQLite keeps a stored breadcrumb - Refresh stores the row of every key its fold adds or changes (a disable, a raised limit, a key added at a newer revision), without a derivation breadcrumb: it has none to give, and an inferred wallet slot is not one. Any refresh, the first after a restart included, brings the store up to the fetch, which the in-memory queue of refused rows cannot do across a restart. - The SQLite identity_keys writer keeps the breadcrumb a row holds when an upsert carries none, as the Swift and Kotlin persisters already do, so a key-state write cannot turn a wallet-derived key watch-only. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/identity_keys.rs | 28 ++++++++- .../sqlite_identity_keys_reader.rs | 62 +++++++++++++++++++ .../src/wallet/identity/network/loading.rs | 9 ++- .../state/managed_identity/identity_ops.rs | 56 ++++++++--------- 4 files changed, 124 insertions(+), 31 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index e985717c40b..98f3a195233 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -6,7 +6,7 @@ //! the key with bincode's native `Encode`/`Decode` and rides the surrounding //! fields on the serde encoder, keeping one blob per row. -use rusqlite::{params, Connection, Transaction}; +use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -161,6 +161,15 @@ pub fn apply( // 32 zero bytes instead would produce a row that matches no // wallet, no NULL-scoped reader, and no guard. let wallet_id_param = super::wallet_id_to_param(wallet_id); + // An entry without a derivation breadcrumb keeps the one the row + // already holds under this scope, as the Swift and Kotlin persisters + // do: a key's derivation never changes, and an upsert that only + // knows the key's new state (a raised limit, a disable, a refresh) + // must not turn a wallet-derived key watch-only. + let mut stored = tx.prepare_cached( + "SELECT public_key_blob FROM identity_keys \ + WHERE identity_id = ?1 AND key_id = ?2 AND wallet_id IS ?3", + )?; for ((identity_id, key_id), entry) in &cs.upserts { // Typed columns and blob fields must agree so a row can never // diverge on disk. @@ -172,7 +181,22 @@ pub fn apply( return Err(WalletStorageError::IdentityKeyEntryMismatch); } } - let wire = IdentityKeyWire::from_entry(entry)?; + let mut wire = IdentityKeyWire::from_entry(entry)?; + if wire.derivation_indices.is_none() { + let held: Option> = stored + .query_row( + params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], + |row| row.get(0), + ) + .optional()?; + if let Some(held) = held { + let held: IdentityKeyWire = blob::decode(&held)?; + if held.derivation_indices.is_some() { + wire.wallet_id = held.wallet_id; + wire.derivation_indices = held.derivation_indices; + } + } + } let entry_blob = blob::encode(&wire)?; stmt.execute(params![ wallet_id_param, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index ceae21c6219..7921571679c 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -48,6 +48,68 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { } } +/// An upsert without a derivation breadcrumb keeps the one the row holds +/// and takes the rest of the entry: a key's derivation never changes, and a +/// write that only knows the key's new state (a raised limit, a disable) +/// must not turn a wallet-derived key watch-only. A row without one stays +/// without one. +#[test] +fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD2); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0C; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let store = |entries: Vec| { + let mut keys = IdentityKeysChangeSet::default(); + for entry in entries { + keys.upserts.insert((entry.identity_id, entry.key_id), entry); + } + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + }; + + let derived = key_entry(id, 0, 0x11); + let mut watch_only = key_entry(id, 1, 0x22); + watch_only.derivation_indices = None; + store(vec![derived.clone(), watch_only.clone()]); + + let mut disabled = derived.clone(); + disabled.derivation_indices = None; + if let IdentityPublicKey::V0(key) = &mut disabled.public_key { + key.disabled_at = Some(7); + } + let mut watch_only_disabled = watch_only.clone(); + if let IdentityPublicKey::V0(key) = &mut watch_only_disabled.public_key { + key.disabled_at = Some(8); + } + store(vec![disabled.clone(), watch_only_disabled.clone()]); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + + let mut expected = disabled; + expected.derivation_indices = derived.derivation_indices; + assert_eq!(cs.upserts.get(&(id, 0)), Some(&expected)); + assert_eq!(cs.upserts.get(&(id, 1)), Some(&watch_only_disabled)); +} + /// Identity-key rows round-trip bit-exact into the keyless /// `IdentityKeysChangeSet`. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 1ad0685a917..cca659b0f8b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -800,7 +800,8 @@ mod tests { } /// A disable a refresh brings, from an answer older or newer than the - /// cache, is stored with its key row. A store that refuses it fails the + /// cache, is stored with its key row, as is a key a newer answer adds. A + /// store that refuses them fails the /// refresh and queues the row, which the next refresh stores although /// memory already holds the disable. #[tokio::test] @@ -877,6 +878,12 @@ mod tests { "key {key_id} answered at {revision}" ); } + // A key a newer answer adds is stored too, without derivation + // coordinates the refresh cannot know; an older answer adds + // none. + let key_4 = store.stored_key(identity_id, 4); + assert_eq!(key_4.is_some(), revision > 6, "answered at {revision}"); + assert!(key_4.is_none_or(|row| row.derivation_indices.is_none())); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 4435b30ba73..2ee361be933 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -439,29 +439,17 @@ impl ManagedIdentity { taken } - /// The disables `fetched` brings to keys the cache holds, at any - /// revision — what [`Self::take_fetched_identity`] changes in a key's - /// disabled state — so a caller that stores no key rows of its own can - /// store them (see [`Self::store_fetched_identity`]). - pub fn disables_from_fetch(&self, fetched: &Identity) -> Vec<(KeyID, TimestampMillis)> { - let taken = fetched.revision() >= self.identity.revision(); - self.identity - .public_keys() - .iter() - .filter_map(|(key_id, cached)| { - let fetched_key = fetched.public_keys().get(key_id)?; - let folded = fold_fetched_key(Some(cached), fetched_key, taken)?.disabled_at()?; - (cached.disabled_at() != Some(folded)).then_some((*key_id, folded)) - }) - .collect() - } - - /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the key - /// rows of the disables it brings (see [`Self::disables_from_fetch`]), - /// with the wallet slot's breadcrumb as [`Self::disable_keys`] gives - /// them, and the identity's scalar snapshot as one changeset (see - /// `store_keys_and_balance`). Used where a fetch comes without derived - /// breadcrumbs (refresh), which stores no other key rows. + /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row + /// of every key the fold adds or changes (a disable, a raised limit, a + /// key added at a newer revision) with the identity's scalar snapshot as + /// one changeset (see `store_keys_and_balance`). Used where a fetch comes + /// without derived breadcrumbs (refresh), so the rows carry none: every + /// persister keeps the breadcrumb a row already holds, and a key with no + /// row yet is stored watch-only until a load or discovery derives it, + /// rather than with derivation coordinates inferred from the identity's + /// wallet slot. Storing what the fold changes makes any refresh bring the + /// store up to the fetch, a refresh after a restart included, which a + /// queue held in memory cannot. /// /// A failed store is returned; memory holds the fold (the revision above /// all, which the next update is built from), and the refused key rows @@ -473,13 +461,25 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { - let disables = self.disables_from_fetch(fetched); + let before = self.identity.public_keys().clone(); self.take_fetched_identity(fetched, block_time); + let identity_id = self.id(); let mut keys_cs = IdentityKeysChangeSet::default(); - for (key_id, disabled_at) in disables { - keys_cs - .upserts - .extend(self.stamp_disabled(&[key_id], disabled_at).upserts); + for (key_id, key) in self.identity.public_keys() { + if before.get(key_id) == Some(key) { + continue; + } + keys_cs.upserts.insert( + (identity_id, *key_id), + IdentityKeyEntry { + identity_id, + key_id: *key_id, + public_key: key.clone(), + public_key_hash: pubkey_hash_of(key), + wallet_id: None, + derivation_indices: None, + }, + ); } self.store_keys_and_balance(keys_cs, persister) } From 4d1f50a7d364555c71208f58662b45b5c856ba56 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:35:40 +0300 Subject: [PATCH 22/72] feat(platform-wallet)!: let an identity update declare the keys it adds from outside the wallet An identity update stored every added key with the derivation breadcrumb of its wallet slot, (wallet_id, identity_index, key_id), inferred from the identity's wallet membership. A key the caller generated or received (the share-login-key browser key) was then taken by the Swift signer for one it can derive from the seed. The wallet cannot tell on its own: identity authentication paths are hardened and the update carries no mnemonic. - `update_identity_with_external_signer_at_revision` takes `external_key_ids`, the added keys that do not come from the wallet's derivation. They are stored without a breadcrumb on both landed paths; an id that names no added key is refused before signing. `update_identity_with_external_signer` keeps its signature and treats every added key as derived, as before. - FFI: both update exports take `external_key_ids` / `external_key_ids_count` after the added keys (C ABI change). - Swift `updateIdentity(...)` gains `externalKeyIds: [UInt32] = []`, the Kotlin `IdentityUpdates.update(...)` `externalKeyIds: List = emptyList()`, and the JNI `updateIdentity` an `externalKeyIds` int[]. - SwiftExampleApp's share-login-key flow declares the browser key external. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/TransactionsNative.kt | 4 + .../dashsdk/identity/IdentityUpdates.kt | 12 ++ .../src/identity_update.rs | 29 +++++ .../src/wallet/identity/network/update.rs | 112 ++++++++++++++++-- .../rs-unified-sdk-jni/src/transactions.rs | 78 ++++++------ .../ManagedPlatformWallet.swift | 52 +++++--- .../Views/ShareLoginKeyView.swift | 3 + 7 files changed, 227 insertions(+), 63 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index a328b0e9396..4364928d354 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -44,6 +44,9 @@ internal object TransactionsNative { * `u64 totalBudget` (bit 0) and `u64 expiresAt` (bit 1). May be empty. * @param disablePublicKeyIds key ids to disable; may be empty. At least * one of add / disable must be non-empty. + * @param externalKeyIds ids of the added keys that do not come from the + * wallet's derivation; they are stored without derivation coordinates. + * May be empty. */ external fun updateIdentity( walletHandle: Long, @@ -51,6 +54,7 @@ internal object TransactionsNative { addPubkeysBlob: ByteArray, disablePublicKeyIds: IntArray, signerHandle: Long, + externalKeyIds: IntArray, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 154fd06dbd3..606a3c69b56 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -210,6 +210,13 @@ class IdentityUpdates internal constructor( * Add [addPublicKeys] and/or disable [disablePublicKeyIds] on * [identityId] in a single `IdentityUpdateTransition`, signed via * [signerHandle]. At least one of add / disable must be non-empty. + * + * [externalKeyIds] names the added keys that do not come from the + * wallet's derivation (a key generated or received outside it, whose + * private key the wallet cannot derive): they are stored without + * derivation coordinates. Every other added key is stored as derived at + * the identity's index and the key's id. An id that names no added key + * is refused before anything is signed. */ suspend fun update( walletHandle: Long, @@ -217,6 +224,7 @@ class IdentityUpdates internal constructor( addPublicKeys: List = emptyList(), disablePublicKeyIds: List = emptyList(), signerHandle: Long, + externalKeyIds: List = emptyList(), ) = gate.op { require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" @@ -227,6 +235,9 @@ class IdentityUpdates internal constructor( require(disablePublicKeyIds.all { it >= 0 }) { "every disabled key id must be non-negative, got $disablePublicKeyIds" } + require(externalKeyIds.all { it >= 0 }) { + "every external key id must be non-negative, got $externalKeyIds" + } mapNativeErrors { TransactionsNative.updateIdentity( walletHandle, @@ -234,6 +245,7 @@ class IdentityUpdates internal constructor( IdentityPubkeyCodec.encode(addPublicKeys), disablePublicKeyIds.toIntArray(), signerHandle, + externalKeyIds.toIntArray(), ) } } diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 016b3f7b6c8..17e6e774241 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -303,6 +303,12 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// existing key IDs, signing the resulting `IdentityUpdateTransition` /// with the supplied `signer_handle`. /// +/// `external_key_ids` names the added keys that do not come from the +/// wallet's derivation (a key the caller generated or received): they are +/// stored without derivation coordinates. Every other added key is stored +/// as derived at the identity's index and the key's id. An id that names no +/// added key is refused before anything is signed. +/// /// # Safety /// - `wallet_handle` must come from the platform-wallet handle registry. /// - `identity_id` must be null (refused) or point at a 32-byte buffer. @@ -310,6 +316,8 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// point at a valid, aligned `[IdentityPubkeyFFI; add_public_keys_count]` /// array whose rows satisfy the contract documented on /// [`IdentityPubkeyFFI`]. +/// - When `external_key_ids_count` is non-zero, `external_key_ids` must +/// point at a valid, aligned `[u32; external_key_ids_count]` array. /// - When `disable_public_key_ids_count` is non-zero, /// `disable_public_key_ids` must point at a valid, aligned /// `[u32; disable_public_key_ids_count]` array. @@ -329,6 +337,8 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, + external_key_ids: *const u32, + external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, signer_handle: *mut SignerHandle, @@ -338,6 +348,8 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( identity_id, add_public_keys, add_public_keys_count, + external_key_ids, + external_key_ids_count, disable_public_key_ids, disable_public_key_ids_count, None, @@ -361,6 +373,8 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, + external_key_ids: *const u32, + external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, revision: u64, @@ -371,6 +385,8 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision identity_id, add_public_keys, add_public_keys_count, + external_key_ids, + external_key_ids_count, disable_public_key_ids, disable_public_key_ids_count, Some(revision), @@ -400,6 +416,8 @@ unsafe fn update_identity_with_signer( identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, + external_key_ids: *const u32, + external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, revision: Option, @@ -411,6 +429,8 @@ unsafe fn update_identity_with_signer( identity_id, add_public_keys, add_public_keys_count, + external_key_ids, + external_key_ids_count, disable_public_key_ids, disable_public_key_ids_count, revision, @@ -444,6 +464,8 @@ unsafe fn update_identity_with_signer_unguarded( identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, + external_key_ids: *const u32, + external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, revision: Option, @@ -498,6 +520,12 @@ unsafe fn update_identity_with_signer_unguarded( keys }; + let external_ids: Vec = if external_key_ids.is_null() || external_key_ids_count == 0 { + Vec::new() + } else { + slice::from_raw_parts(external_key_ids, external_key_ids_count).to_vec() + }; + let disable_ids: Vec = if disable_public_key_ids.is_null() || disable_public_key_ids_count == 0 { Vec::new() @@ -515,6 +543,7 @@ unsafe fn update_identity_with_signer_unguarded( .update_identity_with_external_signer_at_revision( &id, add_keys, + &external_ids, disable_ids, revision, signer, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 147e34c3fcc..36b1524f0a9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -8,6 +8,7 @@ use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; @@ -79,6 +80,12 @@ impl IdentityWallet { /// in the typical case). The signer here only signs the update /// transition itself; it does not derive the new keys. /// + /// Every added key is stored as derived from the wallet seed at the + /// identity's index and the key's id. A caller adding a key from + /// outside the wallet (a browser session key, say) declares it through + /// [`Self::update_identity_with_external_signer_at_revision`]'s + /// `external_key_ids`, so it is stored without derivation coordinates. + /// /// CACHE: after a successful broadcast the in-process /// `IdentityManager` takes the update locally (see /// `apply_landed_update`): the cached revision moves to the @@ -108,6 +115,7 @@ impl IdentityWallet { self.update_identity_with_external_signer_at_revision( identity_id, add_public_keys, + &[], disable_public_keys, None, signer, @@ -129,10 +137,20 @@ impl IdentityWallet { /// signed or broadcast, so the revision the caller recorded is the only /// one this update can land at. `None` takes whatever the cache gives, /// as [`Self::update_identity_with_external_signer`] does. + /// + /// `external_key_ids` names the added keys that do not come from the + /// wallet's derivation (a key the caller generated or received, whose + /// private key the wallet cannot derive from its seed). They are stored + /// without derivation coordinates, so no client takes them for keys it + /// can sign with by deriving; every other added key is stored as derived + /// at the identity's index and the key's id. An id that names no added + /// key is refused before anything is signed. + #[allow(clippy::too_many_arguments)] pub async fn update_identity_with_external_signer_at_revision( &self, identity_id: &Identifier, add_public_keys: Vec, + external_key_ids: &[KeyID], disable_public_keys: Vec, revision: Option, signer: &S, @@ -173,6 +191,15 @@ impl IdentityWallet { (identity, index) }; + if let Some(id) = external_key_ids + .iter() + .find(|id| !add_public_keys.iter().any(|key| key.id() == **id)) + { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "external key id {id} names no key this update adds" + ))); + } + // Increment revision for the update transition. let transition_revision = update_revision(identity_id, identity.revision(), revision)?; identity.set_revision(transition_revision); @@ -243,6 +270,7 @@ impl IdentityWallet { identity_index, transition_revision, added_keys_for_local_apply, + external_key_ids, disabled_ids_for_local_apply, ) .await @@ -275,14 +303,24 @@ impl IdentityWallet { /// key the cache lacks from the payload, taken into the cache too). /// Either way a store failure is returned as an update that landed /// but was not stored. + /// + /// An added key named in `external_key_ids` is stored without a + /// breadcrumb on both paths; every other one with the breadcrumb of + /// its slot, `(wallet_id, identity_index, key_id)`. async fn apply_landed_update( &self, identity_id: &Identifier, identity_index: Option, transition_revision: u64, added_keys: Vec, + external_key_ids: &[KeyID], disabled_ids: Vec, ) -> Result<(), PlatformWalletError> { + let added_breadcrumb = |id: KeyID| { + identity_index + .filter(|_| !external_key_ids.contains(&id)) + .map(|idx| (self.wallet_id, idx, id)) + }; let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -323,7 +361,7 @@ impl IdentityWallet { .get(&id) .unwrap_or(key) .clone(), - breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, id)), + breadcrumb: added_breadcrumb(id), }); } for &id in &disabled { @@ -361,7 +399,7 @@ impl IdentityWallet { let added = added_keys .into_iter() .map(|key| { - let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); + let breadcrumb = added_breadcrumb(key.id()); KeyWithBreadcrumb { key, breadcrumb } }) .collect(); @@ -618,6 +656,7 @@ mod update_identity_at_revision_tests { .update_identity_with_external_signer_at_revision( &identity_id, vec![], + &[], vec![1], Some(expected), &signer, @@ -647,6 +686,7 @@ mod update_identity_at_revision_tests { .update_identity_with_external_signer_at_revision( &identity_id, vec![], + &[], vec![1], Some(5), &signer, @@ -673,7 +713,7 @@ mod update_identity_at_revision_tests { let wallet = wallet_at_revision(4).await; let wallet = wallet.identity(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], &[], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -683,7 +723,7 @@ mod update_identity_at_revision_tests { let wallet = wallet_at_revision(4).await; let wallet = wallet.identity(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![1]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -707,7 +747,7 @@ mod update_identity_at_revision_tests { } let before = cached_identity(wallet, &identity_id).await; wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], &[], vec![1]) .await .unwrap(); assert_eq!( @@ -747,7 +787,7 @@ mod update_identity_at_revision_tests { managed.identity.add_public_key(key_1); } wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added], &[], vec![1]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -787,7 +827,7 @@ mod update_identity_at_revision_tests { let wallet = wallet.identity(); store.arm(); let error = wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![1]) .await .unwrap_err(); assert!( @@ -806,7 +846,7 @@ mod update_identity_at_revision_tests { let wallet = wallet.identity(); store.arm(); let error = wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![1]) .await .unwrap_err(); assert!( @@ -825,7 +865,7 @@ mod update_identity_at_revision_tests { let wallet = wallet.identity(); store.clear(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![7]) + .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![7]) .await .unwrap(); assert!(store.key_rows().is_empty()); @@ -837,7 +877,7 @@ mod update_identity_at_revision_tests { let wallet = wallet.identity(); store.clear(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![1]) .await .unwrap(); assert_eq!(store.changeset_count(), 1); @@ -851,7 +891,7 @@ mod update_identity_at_revision_tests { let wallet = wallet.identity(); store.clear(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![7]) + .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![7]) .await .unwrap(); assert_eq!(store.changeset_count(), 1); @@ -868,7 +908,7 @@ mod update_identity_at_revision_tests { let wallet = wallet_at_revision_on(5, store.clone()).await; let wallet = wallet.identity(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![]) + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -884,6 +924,54 @@ mod update_identity_at_revision_tests { ); } + /// An added key the caller declares external is stored without a + /// derivation breadcrumb whether the cache is behind the update or + /// already at it, so no client takes it for a key it can derive. + #[tokio::test] + async fn should_store_an_external_added_key_without_a_breadcrumb() { + let identity_id = Identifier::default(); + for cached in [4, 5] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(cached, store.clone()).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[3], vec![]) + .await + .unwrap(); + let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); + assert_eq!(stored.public_key, key_3(), "cached at {cached}"); + assert_eq!(stored.derivation_indices, None, "cached at {cached}"); + assert_eq!(stored.wallet_id, None, "cached at {cached}"); + } + } + + /// An external key id that names no key the update adds is refused + /// before anything is signed. + #[tokio::test] + async fn should_refuse_an_external_id_the_update_does_not_add() { + let identity_id = Identifier::default(); + let wallet = wallet_at_revision(4).await; + let signer = RecordingSigner::default(); + let error = wallet + .identity() + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![key_3()], + &[9], + vec![], + None, + &signer, + None, + ) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::InvalidIdentityData(_)), + "{error:?}" + ); + assert_eq!(signer.0.load(Ordering::SeqCst), 0); + } + /// The key the landed-update tests add. fn key_3() -> IdentityPublicKey { IdentityPublicKeyV0 { diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index c9347bedfc8..5065ad573ce 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -132,6 +132,34 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── +/// Read a JVM `int[]` of key ids into `Vec`. A null or empty array is +/// no ids; a negative entry throws (it would bit-cast to a bogus huge key +/// id) and returns `None`. jni 0.21 has no `convert_int_array`, so the +/// length is read first, then the region into an owned buffer. +fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option> { + if ids.is_null() { + return Some(Vec::new()); + } + match env.get_array_length(ids) { + Ok(len) if len > 0 => { + let mut buf = vec![0i32; len as usize]; + if env.get_int_array_region(ids, 0, &mut buf).is_err() { + let _ = env.exception_clear(); + return Some(Vec::new()); + } + if buf.iter().any(|&i| i < 0) { + throw_sdk_exception(env, 1, "keyIds must be non-negative"); + return None; + } + Some(buf.into_iter().map(|i| i as u32).collect()) + } + _ => { + let _ = env.exception_clear(); + Some(Vec::new()) + } + } +} + /// Add public keys and/or disable existing key ids on an identity, signing /// the resulting `IdentityUpdateTransition` via the external `signerHandle` /// (the identity's MASTER auth key) — the exact call @@ -168,7 +196,9 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { /// /// `disablePublicKeyIds` is a JVM `int[]` of key ids to disable (may be /// empty). At least one of add / disable must be non-empty for the FFI to -/// build a transition. Returns nothing on success (throws on error) — Room +/// build a transition. `externalKeyIds` is a JVM `int[]` (may be empty) of +/// the added keys that do not come from the wallet's derivation; they are +/// stored without derivation coordinates. Returns nothing on success (throws on error) — Room /// learns of the change through the persistence changeset. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentity( @@ -179,6 +209,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up add_pubkeys_blob: JByteArray, disable_public_key_ids: jni::objects::JIntArray, signer_handle: jlong, + external_key_ids: jni::objects::JIntArray, ) { guard(&mut env, (), |env| { let Some(id) = read_id32(env, &identity_id, "identityId") else { @@ -192,37 +223,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up return; }; - // Read the disable ids (a JVM int[] → Vec). A null int[] is a - // legitimate "no disables"; jni 0.21 has no `convert_int_array`, so - // read the length then the region into an owned buffer. - let disable_ids: Vec = if disable_public_key_ids.is_null() { - Vec::new() - } else { - match env.get_array_length(&disable_public_key_ids) { - Ok(len) if len > 0 => { - let mut buf = vec![0i32; len as usize]; - if env - .get_int_array_region(&disable_public_key_ids, 0, &mut buf) - .is_err() - { - let _ = env.exception_clear(); - Vec::new() - } else { - // Reject negative entries before the sign-losing - // cast — a negative int would otherwise bit-cast - // to a bogus huge u32 key id. - if buf.iter().any(|&i| i < 0) { - throw_sdk_exception(env, 1, "keyIds must be non-negative"); - return; - } - buf.into_iter().map(|i| i as u32).collect() - } - } - _ => { - let _ = env.exception_clear(); - Vec::new() - } - } + // Read the disable and external ids (JVM int[] → Vec). + let Some(disable_ids) = read_key_ids(env, &disable_public_key_ids) else { + return; + }; + let Some(external_ids) = read_key_ids(env, &external_key_ids) else { + return; }; if decoded.is_empty() && disable_ids.is_empty() { @@ -247,6 +253,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up ffi_rows.as_ptr() }, ffi_rows.len(), + if external_ids.is_empty() { + ptr::null() + } else { + external_ids.as_ptr() + }, + external_ids.len(), if disable_ids.is_empty() { ptr::null() } else { @@ -256,7 +268,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up signer_handle as *mut SignerHandle, ) }; - // `decoded` / `ffi_rows` / `disable_ids` own the buffers the pointers + // `decoded` / `ffi_rows` / `external_ids` / `disable_ids` own the buffers the pointers // reference; they stay in scope through the FFI call above. take_pwffi_error(env, result); }) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 67ed8d6ef7d..ea8f455d3f3 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3739,9 +3739,19 @@ extension ManagedPlatformWallet { /// revision this update can land at: Drive takes an identity update /// only at one above the identity's revision. `nil` takes whatever the /// cache gives. + /// + /// `externalKeyIds` names the added keys that do not come from the + /// wallet's derivation (a key generated or received outside it, such + /// as a browser session key, whose private key the wallet cannot + /// derive from its seed). They are persisted without a derivation + /// path, so the signer never takes them for keys it can derive. Every + /// other added key is persisted as derived at the identity's index and + /// the key's id. An id that names no added key throws before anything + /// is signed. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], + externalKeyIds: [UInt32] = [], disablePublicKeyIds: [UInt32] = [], revision: UInt64? = nil, signer: KeychainSigner @@ -3752,6 +3762,7 @@ extension ManagedPlatformWallet { Array(UnsafeBufferPointer(start: ptr, count: 32)) } let addPubkeys = addPublicKeys + let externalIds = externalKeyIds let disableIds = disablePublicKeyIds try await Task.detached(priority: .userInitiated) { _ = signer @@ -3761,26 +3772,31 @@ extension ManagedPlatformWallet { let pubkeyBuffers: [Data] = addPubkeys.map { $0.pubkeyBytes } let result = idBytes.withUnsafeBufferPointer { idBp -> PlatformWalletFFIResult in - disableIds.withUnsafeBufferPointer { disableBp in - let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in - let disable = disableIds.isEmpty ? nil : disableBp.baseAddress - guard let revision else { - return platform_wallet_update_identity_with_signer( + externalIds.withUnsafeBufferPointer { externalBp -> PlatformWalletFFIResult in + disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in + let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in + let external = externalIds.isEmpty ? nil : externalBp.baseAddress + let disable = disableIds.isEmpty ? nil : disableBp.baseAddress + guard let revision else { + return platform_wallet_update_identity_with_signer( + handle, idBp.baseAddress!, rows, UInt(count), + external, UInt(externalIds.count), + disable, UInt(disableIds.count), signerHandle) + } + return platform_wallet_update_identity_with_signer_at_revision( handle, idBp.baseAddress!, rows, UInt(count), - disable, UInt(disableIds.count), signerHandle) + external, UInt(externalIds.count), + disable, UInt(disableIds.count), revision, signerHandle) + } + if addPubkeys.isEmpty { + return update(nil, 0) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + addPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + update(ffiRowsPtr, ffiRowsCount) } - return platform_wallet_update_identity_with_signer_at_revision( - handle, idBp.baseAddress!, rows, UInt(count), - disable, UInt(disableIds.count), revision, signerHandle) - } - if addPubkeys.isEmpty { - return update(nil, 0) - } - return ManagedPlatformWallet.withPubkeyFFIArray( - addPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - update(ffiRowsPtr, ffiRowsCount) } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift index 2205dd5da4f..7874e58a5e0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift @@ -311,9 +311,12 @@ struct ShareLoginKeyView: View { ) let signer = KeychainSigner(modelContainer: modelContext.container) + // The browser's key: the phone never holds its private key, + // so it is stored without a derivation path. try await wallet.updateIdentity( identityId: identity.identityId, addPublicKeys: [newKey], + externalKeyIds: [keyId], signer: signer ) _ = signer // keepalive: see KeychainSigner lifetime contract. From 2c31b5f31be6053656ae85c1e652b8ea27e80a69 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 12:49:22 +0300 Subject: [PATCH 23/72] fix(platform-wallet): disables carry no breadcrumb, a buffered merge keeps one - A disabled key's row carries no derivation breadcrumb, on the disable_keys path and both landed-update paths: a disable says nothing about where the key came from, and every persister keeps the one it holds. The wallet slot no longer stands in for one, which gave an external key derivation coordinates. - Merging identity key changesets (the SQLite buffer) keeps an earlier entry's breadcrumb when the later one has none. - A stored identity_keys row that does not decode no longer fails the write of that key; it is replaced, as before rows kept breadcrumbs. - The FFI refuses a null external_key_ids with a non-zero count, and the JNI binding throws on a key-id array it cannot read, instead of taking either as no ids. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 9 +++- .../src/sqlite/schema/identity_keys.rs | 14 +++-- .../sqlite_identity_keys_reader.rs | 3 +- .../src/changeset/changeset.rs | 54 ++++++++++++++++++- .../src/wallet/identity/network/loading.rs | 7 +-- .../src/wallet/identity/network/update.rs | 31 +++++------ .../state/managed_identity/identity_ops.rs | 33 +++--------- .../rs-unified-sdk-jni/src/transactions.rs | 7 +-- 8 files changed, 102 insertions(+), 56 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 17e6e774241..f8312f60762 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -520,8 +520,15 @@ unsafe fn update_identity_with_signer_unguarded( keys }; - let external_ids: Vec = if external_key_ids.is_null() || external_key_ids_count == 0 { + // A null array with a count would silently store the caller's + // external keys as derived, so it is refused. + let external_ids: Vec = if external_key_ids_count == 0 { Vec::new() + } else if external_key_ids.is_null() { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + "external_key_ids is null with a non-zero count", + ); } else { slice::from_raw_parts(external_key_ids, external_key_ids_count).to_vec() }; diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 98f3a195233..766fe5e7dc2 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -189,12 +189,20 @@ pub fn apply( |row| row.get(0), ) .optional()?; - if let Some(held) = held { - let held: IdentityKeyWire = blob::decode(&held)?; - if held.derivation_indices.is_some() { + // A stored row that does not decode holds no breadcrumb + // to keep; the write replaces it, as it did before rows + // kept their breadcrumbs. + match held.map(|held| blob::decode::(&held)) { + Some(Ok(held)) if held.derivation_indices.is_some() => { wire.wallet_id = held.wallet_id; wire.derivation_indices = held.derivation_indices; } + Some(Err(e)) => tracing::warn!( + key_id = *key_id, + error = %e, + "identity_keys: stored row does not decode; replacing it", + ), + _ => {} } } let entry_blob = blob::encode(&wire)?; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index 7921571679c..d971e3670ad 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -68,7 +68,8 @@ fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { let store = |entries: Vec| { let mut keys = IdentityKeysChangeSet::default(); for entry in entries { - keys.upserts.insert((entry.identity_id, entry.key_id), entry); + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); } persister .store( diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 47e5836972b..ab75ba7625f 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1076,8 +1076,22 @@ impl Merge for IdentityKeysChangeSet { // Last-write-wins per composite key. Matches the discipline // used by other scalar-entry changesets in this module; the // "don't emit both upsert + remove for the same key in one - // mutation" invariant is enforced on the emitter side. - self.upserts.extend(other.upserts); + // mutation" invariant is enforced on the emitter side. A later + // entry without a derivation breadcrumb keeps the earlier one's, + // as every persister keeps a stored breadcrumb: a key's derivation + // never changes, and a buffered write that only knows the key's + // new state must not turn a wallet-derived key watch-only. + for (id, mut entry) in other.upserts { + if entry.derivation_indices.is_none() { + if let Some(earlier) = self.upserts.get(&id) { + if earlier.derivation_indices.is_some() { + entry.wallet_id = earlier.wallet_id; + entry.derivation_indices = earlier.derivation_indices; + } + } + } + self.upserts.insert(id, entry); + } self.removed.extend(other.removed); } @@ -2526,6 +2540,42 @@ mod tests { } } + /// A later key entry without a derivation breadcrumb keeps the earlier + /// one's and takes the rest; one with a breadcrumb replaces it whole. + #[test] + fn should_keep_an_earlier_breadcrumb_when_a_later_key_entry_has_none() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + let id = Identifier::from([0x54; 32]); + let entry = |disabled_at: Option, key_index: Option| IdentityKeyEntry { + identity_id: id, + key_id: 3, + public_key: IdentityPublicKeyV0 { + id: 3, + disabled_at, + ..Default::default() + } + .into(), + public_key_hash: [3; 20], + wallet_id: key_index.map(|_| [9; 32]), + derivation_indices: key_index.map(|key_index| IdentityKeyDerivationIndices { + identity_index: 0, + key_index, + }), + }; + let changeset = |entry: IdentityKeyEntry| { + let mut cs = IdentityKeysChangeSet::default(); + cs.upserts.insert((id, 3), entry); + cs + }; + + let mut merged = changeset(entry(None, Some(3))); + merged.merge(changeset(entry(Some(7), None))); + assert_eq!(merged.upserts[&(id, 3)], entry(Some(7), Some(3))); + + merged.merge(changeset(entry(Some(8), Some(4)))); + assert_eq!(merged.upserts[&(id, 3)], entry(Some(8), Some(4))); + } + #[test] fn should_merge_balance_and_watermark_under_the_same_revision_gate() { let id = Identifier::from([0x53; 32]); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index cca659b0f8b..afe54b3b8ef 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -320,9 +320,10 @@ impl IdentityWallet { /// than the cache's was answered before an update the cache already /// holds: the cache keeps its revision, key set and every disable, and /// takes only a disable or a raised limit the fetch shows - /// ([`ManagedIdentity::take_fetched_identity`]). The rows of keys the - /// fetch disables are stored with the scalar snapshot, and so are key - /// rows an earlier refused store queued; when that store fails, the error + /// ([`ManagedIdentity::take_fetched_identity`]). The row of every key the + /// fold adds or changes is stored with the scalar snapshot, without a + /// derivation breadcrumb, and so are key rows an earlier refused store + /// queued; when that store fails, the error /// is returned, memory keeps the fold and the rows stay queued for the /// identity's next key store (a refresh, load, discovery or landed /// update; [`ManagedIdentity::store_fetched_identity`]). diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 36b1524f0a9..9d1237a1d5f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -334,11 +334,12 @@ impl IdentityWallet { // A cache refreshed to the transition or past it already holds // this update's keys as Platform has them (on-chain `disabled_at` // included) and whatever later updates did to them: applying the - // payload would take those back. A refresh stores no rows for - // added keys, though, so the touched keys are stored here from the - // cache's own copies, with the breadcrumbs the path below would give them: an - // added key the caller's, a disabled key the wallet slot's, as - // `disable_keys` builds it. + // payload would take those back. A refresh stores the rows it + // changes without breadcrumbs, though, so the touched keys are + // stored here from the cache's own copies, with the breadcrumbs the + // path below would give them: an added key its slot's unless the + // caller declared it external, a disabled key none (the persisters + // keep the one they hold). // A key the update both adds and disables is taken as added, on // both paths. let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); @@ -348,7 +349,6 @@ impl IdentityWallet { .collect(); if managed.identity.revision() >= transition_revision { - let slot = managed.wallet_slot(); let mut keys = Vec::new(); // An added key the cache lacks is stored, and taken into the // cache, as the update registered it. @@ -375,7 +375,7 @@ impl IdentityWallet { }; keys.push(KeyWithBreadcrumb { key: key.clone(), - breadcrumb: slot.map(|(wallet_id, idx)| (wallet_id, idx, id)), + breadcrumb: None, }); } // Stored with the identity's snapshot even when no touched key @@ -391,8 +391,8 @@ impl IdentityWallet { // The added and disabled keys and the landed revision go to the // store as one changeset, which holds the revision even when no key - // row matched. Disabled keys get the wallet slot's breadcrumb and a - // local wall-clock `disabled_at` placeholder: a later fetch brings + // row matched. Disabled keys get no breadcrumb (the persisters keep + // the one they hold) and a local wall-clock `disabled_at` placeholder: a later fetch brings // the on-chain block time into memory and the store. Memory holds // the whole update even when the store fails, and the rows stay // queued for the identity's next key store. @@ -761,9 +761,9 @@ mod update_identity_at_revision_tests { /// An update at 5 added key 3, a later one at 6 disabled it, and the /// cache was refreshed to 6 before the confirmation of 5 came back: /// key 3 stays disabled, and is stored that way with its breadcrumb (a - /// refresh stores no keys). A key the update disabled is stored as the - /// refresh has it, with the on-chain `disabled_at` and the wallet - /// slot's breadcrumb. + /// refresh stores its rows without one). A key the update disabled is + /// stored as the refresh has it, with the on-chain `disabled_at` and no + /// breadcrumb, which the persisters keep from the row they hold. #[tokio::test] async fn should_store_the_newer_cache_keys_instead_of_an_older_landed_payload() { let identity_id = Identifier::default(); @@ -807,11 +807,8 @@ mod update_identity_at_revision_tests { let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); assert_eq!(stored.public_key.disabled_at(), Some(2)); assert_eq!( - stored - .derivation_indices - .map(|indices| (indices.identity_index, indices.key_index)), - Some((0, 1)), - "the wallet slot's breadcrumb, as disable_keys gives it" + stored.derivation_indices, None, + "a disable says nothing about where the key came from" ); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 2ee361be933..cb8e41f0802 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -740,13 +740,11 @@ impl ManagedIdentity { /// `Identity` (so every signing / introspection path sees the /// disabled flag immediately) and a combined changeset is persisted /// so the client's `PersistentPublicKey.disabledAt` rows flip - /// without a network re-fetch. Each emitted entry reuses the same - /// `(wallet_id, identity_index, key_index)` derivation breadcrumb - /// `add_key` carries, so the client re-derives idempotently and - /// keeps the key's existing private-key linkage rather than - /// dropping it on the upsert. Out-of-wallet identities (no - /// derivation context) emit a breadcrumb-less entry, matching - /// their watch-only state. + /// without a network re-fetch. Each emitted entry carries no + /// derivation breadcrumb: a disable changes nothing about where the + /// key came from, every persister keeps the breadcrumb it already + /// holds for the key, and the identity's wallet slot does not say + /// whether this key was derived there or supplied from outside. /// /// `key_ids` not present on the identity are skipped (logged) and /// no changeset is emitted when nothing matched. `disabled_at` is @@ -834,12 +832,6 @@ impl ManagedIdentity { ) -> IdentityKeysChangeSet { let identity_id = self.id(); - // Reconstruct the derivation breadcrumb from the cached - // wallet/identity slot. Wallet-owned identities carry both - // halves; out-of-wallet ones have neither (and no private key - // to preserve), so they fall through to a watch-only entry. - let breadcrumb = self.wallet_slot(); - // Operate on a clone of the key map, mirroring `add_key`, then // write it back once all matching keys have been stamped. let mut keys = self.identity.public_keys().clone(); @@ -858,17 +850,6 @@ impl ManagedIdentity { public_key.set_disabled_at(disabled_at); let public_key_hash = pubkey_hash_of(public_key); - let (wallet_id, derivation_indices) = match breadcrumb { - Some((wallet_id, identity_index)) => ( - Some(wallet_id), - Some(IdentityKeyDerivationIndices { - identity_index, - key_index: key_id, - }), - ), - None => (None, None), - }; - keys_cs.upserts.insert( (identity_id, key_id), IdentityKeyEntry { @@ -876,8 +857,8 @@ impl ManagedIdentity { key_id, public_key: public_key.clone(), public_key_hash, - wallet_id, - derivation_indices, + wallet_id: None, + derivation_indices: None, }, ); } diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 5065ad573ce..6cd200b9122 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -133,8 +133,8 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── /// Read a JVM `int[]` of key ids into `Vec`. A null or empty array is -/// no ids; a negative entry throws (it would bit-cast to a bogus huge key -/// id) and returns `None`. jni 0.21 has no `convert_int_array`, so the +/// no ids; an array that cannot be read, or a negative entry (it would +/// bit-cast to a bogus huge key id), throws and returns `None`. jni 0.21 has no `convert_int_array`, so the /// length is read first, then the region into an owned buffer. fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option> { if ids.is_null() { @@ -145,7 +145,8 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option Date: Tue, 20 Oct 2026 14:25:55 +0300 Subject: [PATCH 24/72] fix(platform-wallet): only a path that knows a key's origin creates its row - Refresh no longer stores a row for a key its fold adds: it cannot know the key's breadcrumb, and a row without one would make a wallet-derived key watch-only in the store until a load or discovery ran. It still stores the rows of keys the cache held that the fold changes, which the persisters update keeping their breadcrumb. A key's row is created by a load or discovery (derived breadcrumb) or a landed update (declared). - SQLite keeps a stored breadcrumb only from a row that passes the load-time checks against its columns and scope and holds the same public key; any other row is replaced. - Kotlin `IdentityUpdates.update(..., revision: Long? = null)` pins the update's revision through the JNI binding, which calls `platform_wallet_update_identity_with_signer_at_revision` when it is set, as the Swift wrapper does. - The JNI binding throws when it cannot read a key-id array's length, instead of taking it as no ids. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/TransactionsNative.kt | 6 ++ .../dashsdk/identity/IdentityUpdates.kt | 15 ++++ .../src/sqlite/schema/identity_keys.rs | 68 +++++++++++---- .../sqlite_identity_keys_reader.rs | 42 ++++++++++ .../src/wallet/identity/network/loading.rs | 28 ++++--- .../state/managed_identity/identity_ops.rs | 30 ++++--- .../rs-unified-sdk-jni/src/transactions.rs | 83 +++++++++++++------ 7 files changed, 205 insertions(+), 67 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 4364928d354..069cc33b42c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -47,6 +47,10 @@ internal object TransactionsNative { * @param externalKeyIds ids of the added keys that do not come from the * wallet's derivation; they are stored without derivation coordinates. * May be empty. + * @param hasRevision whether the update must carry [revision]: one the + * cached identity would give another revision is refused before + * anything is signed (bridges + * `platform_wallet_update_identity_with_signer_at_revision`). */ external fun updateIdentity( walletHandle: Long, @@ -55,6 +59,8 @@ internal object TransactionsNative { disablePublicKeyIds: IntArray, signerHandle: Long, externalKeyIds: IntArray, + hasRevision: Boolean, + revision: Long, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 606a3c69b56..9600b4d4456 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -217,6 +217,15 @@ class IdentityUpdates internal constructor( * derivation coordinates. Every other added key is stored as derived at * the identity's index and the key's id. An id that names no added key * is refused before anything is signed. + * + * The transition carries the cached identity's revision plus one, read + * when the update starts. With [revision] set, an update that would carry + * another one throws [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionMismatch] + * before anything is signed or broadcast, so a caller that recorded + * [revision] beforehand knows the only revision this update can land at + * (Drive takes an identity update only at one above the identity's + * revision). `null` takes whatever the cache gives. Port of the + * `revision:` argument of Swift `ManagedPlatformWallet.updateIdentity`. */ suspend fun update( walletHandle: Long, @@ -225,6 +234,7 @@ class IdentityUpdates internal constructor( disablePublicKeyIds: List = emptyList(), signerHandle: Long, externalKeyIds: List = emptyList(), + revision: Long? = null, ) = gate.op { require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" @@ -238,6 +248,9 @@ class IdentityUpdates internal constructor( require(externalKeyIds.all { it >= 0 }) { "every external key id must be non-negative, got $externalKeyIds" } + require(revision == null || revision >= 0) { + "revision must be non-negative, got $revision" + } mapNativeErrors { TransactionsNative.updateIdentity( walletHandle, @@ -246,6 +259,8 @@ class IdentityUpdates internal constructor( disablePublicKeyIds.toIntArray(), signerHandle, externalKeyIds.toIntArray(), + revision != null, + revision ?: 0L, ) } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 766fe5e7dc2..a5f349d3613 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -72,6 +72,48 @@ impl IdentityKeyWire { } } +/// The breadcrumb a stored row holds for `entry`'s key, to keep on an upsert +/// that carries none. Only a row that passes the checks `load_state` applies +/// (its blob agrees with its typed columns and its wallet scope) and stores +/// the same public key gives one: a breadcrumb belongs to one key's +/// derivation. A row that fails them, or does not decode, holds nothing to +/// keep; the write replaces it, as it did before rows kept breadcrumbs. +fn held_breadcrumb( + (blob_len, payload, typed_public_key_hash): (i64, Vec, Vec), + entry: &IdentityKeyEntry, + wallet_id: &WalletId, +) -> Option<(Option<[u8; 32]>, IdentityKeyDerivationIndices)> { + let held = blob::check_size(blob_len).and_then(|()| decode_entry(&payload)); + let held = match held { + Ok(held) => held, + Err(e) => { + tracing::warn!( + key_id = entry.key_id, + error = %e, + "identity_keys: stored row does not decode; replacing it", + ); + return None; + } + }; + let consistent = held.identity_id == entry.identity_id + && held.key_id == entry.key_id + && held.public_key.id() == entry.key_id + && held.public_key_hash[..] == typed_public_key_hash[..] + && held.public_key_hash == entry.public_key_hash + && held.public_key.data() == entry.public_key.data() + && held + .wallet_id + .is_none_or(|held_wallet_id| held_wallet_id == *wallet_id); + if !consistent { + tracing::warn!( + key_id = entry.key_id, + "identity_keys: stored row contradicts its columns or the key; replacing it", + ); + return None; + } + Some((held.wallet_id, held.derivation_indices?)) +} + /// SQLite's extended result code for a foreign-key constraint failure /// (`SQLITE_CONSTRAINT_FOREIGNKEY`). Discriminated numerically — never by /// matching the driver's message text. @@ -167,7 +209,8 @@ pub fn apply( // knows the key's new state (a raised limit, a disable, a refresh) // must not turn a wallet-derived key watch-only. let mut stored = tx.prepare_cached( - "SELECT public_key_blob FROM identity_keys \ + "SELECT length(public_key_blob), public_key_blob, public_key_hash \ + FROM identity_keys \ WHERE identity_id = ?1 AND key_id = ?2 AND wallet_id IS ?3", )?; for ((identity_id, key_id), entry) in &cs.upserts { @@ -183,26 +226,17 @@ pub fn apply( } let mut wire = IdentityKeyWire::from_entry(entry)?; if wire.derivation_indices.is_none() { - let held: Option> = stored + let held: Option<(i64, Vec, Vec)> = stored .query_row( params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), ) .optional()?; - // A stored row that does not decode holds no breadcrumb - // to keep; the write replaces it, as it did before rows - // kept their breadcrumbs. - match held.map(|held| blob::decode::(&held)) { - Some(Ok(held)) if held.derivation_indices.is_some() => { - wire.wallet_id = held.wallet_id; - wire.derivation_indices = held.derivation_indices; - } - Some(Err(e)) => tracing::warn!( - key_id = *key_id, - error = %e, - "identity_keys: stored row does not decode; replacing it", - ), - _ => {} + if let Some((wallet_id_kept, derivation_indices)) = + held.and_then(|held| held_breadcrumb(held, entry, wallet_id)) + { + wire.wallet_id = wallet_id_kept; + wire.derivation_indices = Some(derivation_indices); } } let entry_blob = blob::encode(&wire)?; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index d971e3670ad..f9716db6f54 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -111,6 +111,48 @@ fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { assert_eq!(cs.upserts.get(&(id, 1)), Some(&watch_only_disabled)); } +/// A breadcrumb belongs to one key: an upsert of another public key under a +/// stored row's key id does not take the row's breadcrumb. +#[test] +fn should_not_keep_a_breadcrumb_for_another_public_key() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD3); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0D; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let store = |entry: IdentityKeyEntry| { + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + }; + store(key_entry(id, 0, 0x11)); + let mut other = key_entry(id, 0, 0x44); + other.derivation_indices = None; + store(other.clone()); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + assert_eq!(cs.upserts.get(&(id, 0)), Some(&other)); +} + /// Identity-key rows round-trip bit-exact into the keyless /// `IdentityKeysChangeSet`. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index afe54b3b8ef..70b7efdc516 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -321,9 +321,11 @@ impl IdentityWallet { /// holds: the cache keeps its revision, key set and every disable, and /// takes only a disable or a raised limit the fetch shows /// ([`ManagedIdentity::take_fetched_identity`]). The row of every key the - /// fold adds or changes is stored with the scalar snapshot, without a - /// derivation breadcrumb, and so are key rows an earlier refused store - /// queued; when that store fails, the error + /// cache held that the fold changes is stored with the scalar snapshot, + /// without a derivation breadcrumb (the persisters keep the one they + /// hold), and so are key rows an earlier refused store queued; a key the + /// fold adds gets its row from a load or discovery, which derive its + /// breadcrumb; when that store fails, the error /// is returned, memory keeps the fold and the rows stay queued for the /// identity's next key store (a refresh, load, discovery or landed /// update; [`ManagedIdentity::store_fetched_identity`]). @@ -801,8 +803,9 @@ mod tests { } /// A disable a refresh brings, from an answer older or newer than the - /// cache, is stored with its key row, as is a key a newer answer adds. A - /// store that refuses them fails the + /// cache, is stored with its key row; a key a newer answer adds is not, + /// since only a load or discovery knows its breadcrumb. A store that + /// refuses them fails the /// refresh and queues the row, which the next refresh stores although /// memory already holds the disable. #[tokio::test] @@ -879,12 +882,15 @@ mod tests { "key {key_id} answered at {revision}" ); } - // A key a newer answer adds is stored too, without derivation - // coordinates the refresh cannot know; an older answer adds - // none. - let key_4 = store.stored_key(identity_id, 4); - assert_eq!(key_4.is_some(), revision > 6, "answered at {revision}"); - assert!(key_4.is_none_or(|row| row.derivation_indices.is_none())); + // A key a newer answer adds is in memory, but gets no row: a + // refresh cannot know its breadcrumb, and a row without one + // would call it watch-only. A load or discovery stores it. + assert_eq!( + held(wallet).await.public_keys().contains_key(&4), + revision > 6, + "answered at {revision}" + ); + assert!(store.stored_key(identity_id, 4).is_none()); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index cb8e41f0802..a665f52ce76 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -440,16 +440,21 @@ impl ManagedIdentity { } /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row - /// of every key the fold adds or changes (a disable, a raised limit, a - /// key added at a newer revision) with the identity's scalar snapshot as - /// one changeset (see `store_keys_and_balance`). Used where a fetch comes - /// without derived breadcrumbs (refresh), so the rows carry none: every - /// persister keeps the breadcrumb a row already holds, and a key with no - /// row yet is stored watch-only until a load or discovery derives it, - /// rather than with derivation coordinates inferred from the identity's - /// wallet slot. Storing what the fold changes makes any refresh bring the - /// store up to the fetch, a refresh after a restart included, which a - /// queue held in memory cannot. + /// of every key the cache held that the fold changes (a disable, a raised + /// limit) with the identity's scalar snapshot as one changeset (see + /// `store_keys_and_balance`). Used where a fetch comes without derived + /// breadcrumbs (refresh), so the rows carry none and every persister + /// keeps the breadcrumb the row holds; a key's state reaches the store on + /// any refresh, a refresh after a restart included, which a queue held + /// in memory cannot. + /// + /// A key the fold adds gets no row here. Only a path that knows where a + /// key came from creates its row: a load or discovery, which derive its + /// breadcrumb from the seed, or a landed update, whose caller declares + /// it. A row created here would have to call the key watch-only, which + /// the store would then keep for a wallet-derived key until one of those + /// paths ran. The key is in memory meanwhile, and a refused row of one of + /// those paths rides the next store (see `store_with_queued_key_rows`). /// /// A failed store is returned; memory holds the fold (the revision above /// all, which the next update is built from), and the refused key rows @@ -466,8 +471,9 @@ impl ManagedIdentity { let identity_id = self.id(); let mut keys_cs = IdentityKeysChangeSet::default(); for (key_id, key) in self.identity.public_keys() { - if before.get(key_id) == Some(key) { - continue; + match before.get(key_id) { + Some(held) if held != key => {} + _ => continue, } keys_cs.upserts.insert( (identity_id, *key_id), diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 6cd200b9122..dc021474580 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -141,7 +141,8 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option 0 => { + Ok(0) => Some(Vec::new()), + Ok(len) => { let mut buf = vec![0i32; len as usize]; if env.get_int_array_region(ids, 0, &mut buf).is_err() { let _ = env.exception_clear(); @@ -154,9 +155,10 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option { + Err(_) => { let _ = env.exception_clear(); - Some(Vec::new()) + throw_sdk_exception(env, 1, "keyIds could not be read"); + None } } } @@ -199,7 +201,10 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option = decoded.iter().map(|row| row.to_ffi()).collect(); + let rows = if ffi_rows.is_empty() { + ptr::null() + } else { + ffi_rows.as_ptr() + }; + let external = if external_ids.is_empty() { + ptr::null() + } else { + external_ids.as_ptr() + }; + let disable = if disable_ids.is_empty() { + ptr::null() + } else { + disable_ids.as_ptr() + }; + if has_revision != 0 && revision < 0 { + throw_sdk_exception(env, 1, "revision must be non-negative"); + return; + } let result = unsafe { - platform_wallet_ffi::platform_wallet_update_identity_with_signer( - wallet_handle as Handle, - id.as_ptr(), - if ffi_rows.is_empty() { - ptr::null() - } else { - ffi_rows.as_ptr() - }, - ffi_rows.len(), - if external_ids.is_empty() { - ptr::null() - } else { - external_ids.as_ptr() - }, - external_ids.len(), - if disable_ids.is_empty() { - ptr::null() - } else { - disable_ids.as_ptr() - }, - disable_ids.len(), - signer_handle as *mut SignerHandle, - ) + if has_revision != 0 { + platform_wallet_ffi::platform_wallet_update_identity_with_signer_at_revision( + wallet_handle as Handle, + id.as_ptr(), + rows, + ffi_rows.len(), + external, + external_ids.len(), + disable, + disable_ids.len(), + revision as u64, + signer_handle as *mut SignerHandle, + ) + } else { + platform_wallet_ffi::platform_wallet_update_identity_with_signer( + wallet_handle as Handle, + id.as_ptr(), + rows, + ffi_rows.len(), + external, + external_ids.len(), + disable, + disable_ids.len(), + signer_handle as *mut SignerHandle, + ) + } }; // `decoded` / `ffi_rows` / `external_ids` / `disable_ids` own the buffers the pointers // reference; they stay in scope through the FFI call above. From f589e113600e99c72a6f840f62e2d03409ee9c1c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:38:22 +0300 Subject: [PATCH 25/72] fix(platform-wallet): a key a refresh adds stays rowless until its origin is known A key a refresh takes into a wallet-owned identity has no row, and a later refresh that changes it, a landed disable or a limits update would otherwise create one without a breadcrumb, calling a wallet-derived key watch-only. `ManagedIdentity` now tracks such keys (`rowless_key_ids`, memory only): a breadcrumb-less write of one is not stored, and a path that knows the key's origin (a load or discovery, a landed update) takes it off the set when it stores its row. An out-of-wallet identity's added keys are watch-only, and no load or discovery runs for it, so refresh stores them. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/TransactionsNative.kt | 6 +- .../src/wallet/identity/network/update.rs | 8 +- .../state/managed_identity/identity_ops.rs | 120 ++++++++++++++++-- .../identity/state/managed_identity/mod.rs | 9 +- .../rs-unified-sdk-jni/src/transactions.rs | 4 +- 5 files changed, 131 insertions(+), 16 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 069cc33b42c..107e390a9b3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -32,7 +32,9 @@ internal object TransactionsNative { * Add public keys and/or disable existing key ids on an identity, * signing the resulting `IdentityUpdateTransition` via [signerHandle] * (the identity's MASTER auth key). Bridges - * `platform_wallet_update_identity_with_signer` — the exact call Swift + * `platform_wallet_update_identity_with_signer`, or + * `platform_wallet_update_identity_with_signer_at_revision` with + * [hasRevision] — the exact call Swift * `AddIdentityKeyView.submit` makes through `wallet.updateIdentity(...)`. * * @param addPubkeysBlob big-endian rows for the keys to add: `u32 @@ -51,6 +53,8 @@ internal object TransactionsNative { * cached identity would give another revision is refused before * anything is signed (bridges * `platform_wallet_update_identity_with_signer_at_revision`). + * @param revision the revision the update must carry; read only with + * [hasRevision]. */ external fun updateIdentity( walletHandle: Long, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9d1237a1d5f..b9028075485 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -334,9 +334,9 @@ impl IdentityWallet { // A cache refreshed to the transition or past it already holds // this update's keys as Platform has them (on-chain `disabled_at` // included) and whatever later updates did to them: applying the - // payload would take those back. A refresh stores the rows it - // changes without breadcrumbs, though, so the touched keys are - // stored here from the cache's own copies, with the breadcrumbs the + // payload would take those back. A refresh stores no row for a key + // it adds and the keys it changes without breadcrumbs, though, so + // the touched keys are stored here from the cache's own copies, with the breadcrumbs the // path below would give them: an added key its slot's unless the // caller declared it external, a disabled key none (the persisters // keep the one they hold). @@ -761,7 +761,7 @@ mod update_identity_at_revision_tests { /// An update at 5 added key 3, a later one at 6 disabled it, and the /// cache was refreshed to 6 before the confirmation of 5 came back: /// key 3 stays disabled, and is stored that way with its breadcrumb (a - /// refresh stores its rows without one). A key the update disabled is + /// refresh gives a key it adds no row). A key the update disabled is /// stored as the refresh has it, with the on-chain `disabled_at` and no /// breadcrumb, which the persisters keep from the row they hold. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index a665f52ce76..a836a271f01 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -92,6 +92,7 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), + rowless_key_ids: Default::default(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -114,6 +115,7 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), + rowless_key_ids: Default::default(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -448,13 +450,17 @@ impl ManagedIdentity { /// any refresh, a refresh after a restart included, which a queue held /// in memory cannot. /// - /// A key the fold adds gets no row here. Only a path that knows where a - /// key came from creates its row: a load or discovery, which derive its - /// breadcrumb from the seed, or a landed update, whose caller declares - /// it. A row created here would have to call the key watch-only, which - /// the store would then keep for a wallet-derived key until one of those - /// paths ran. The key is in memory meanwhile, and a refused row of one of - /// those paths rides the next store (see `store_with_queued_key_rows`). + /// A key the fold adds to a wallet-owned identity gets no row here, and + /// no later breadcrumb-less write gives it one (see `rowless_key_ids`). + /// Only a path that knows where a key came from creates its row: a load + /// or discovery, which derive its breadcrumb from the seed, or a landed + /// update, whose caller declares it. A row created here would have to + /// call the key watch-only, which the store would then keep for a + /// wallet-derived key until one of those paths ran. The key is in memory + /// meanwhile, and a refused row of one of those paths rides the next + /// store (see `store_with_queued_key_rows`). An out-of-wallet identity's + /// keys are all watch-only and no load or discovery runs for it, so its + /// added keys are stored here. /// /// A failed store is returned; memory holds the fold (the revision above /// all, which the next update is built from), and the refused key rows @@ -470,10 +476,15 @@ impl ManagedIdentity { self.take_fetched_identity(fetched, block_time); let identity_id = self.id(); let mut keys_cs = IdentityKeysChangeSet::default(); + let in_wallet = self.identity_index.is_some(); for (key_id, key) in self.identity.public_keys() { match before.get(key_id) { - Some(held) if held != key => {} - _ => continue, + Some(held) if held == key => continue, + None if in_wallet => { + self.rowless_key_ids.insert(*key_id); + continue; + } + _ => {} } keys_cs.upserts.insert( (identity_id, *key_id), @@ -528,6 +539,12 @@ impl ManagedIdentity { mut keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PersistenceError> { + // A breadcrumb-less write of a key no row holds yet would create a + // watch-only row for it (see `rowless_key_ids`). + let rowless = &self.rowless_key_ids; + keys_cs.upserts.retain(|(_, key_id), entry| { + entry.derivation_indices.is_some() || !rowless.contains(key_id) + }); for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { if let Some(own) = keys_cs.upserts.get_mut(&id) { if own.derivation_indices.is_none() && entry.derivation_indices.is_some() { @@ -649,6 +666,8 @@ impl ManagedIdentity { } /// Layer `keys` onto this identity in memory, returning their key rows. + /// A key layered here gets its row (the caller knows its origin), so it + /// leaves `rowless_key_ids`. fn layer_keys(&mut self, keys: Vec) -> IdentityKeysChangeSet { let identity_id = self.id(); let mut current = self.identity.public_keys().clone(); @@ -659,6 +678,7 @@ impl ManagedIdentity { } in keys { let key_id = public_key.id(); + self.rowless_key_ids.remove(&key_id); let public_key_hash = pubkey_hash_of(&public_key); current.insert(key_id, public_key.clone()); let (wallet_id, derivation_indices) = match breadcrumb { @@ -1347,6 +1367,88 @@ mod tests { assert_eq!(row(3), None); } + /// A key a refresh adds to a wallet-owned identity gets no row, not even + /// when a later refresh changes it: a row without a breadcrumb would + /// call a wallet-derived key watch-only. A load's row, which carries the + /// derived breadcrumb, creates it, and later changes then reach it. An + /// out-of-wallet identity's added keys are watch-only and get rows. + #[test] + fn should_leave_a_key_a_refresh_adds_to_a_path_that_knows_its_origin() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let identity = |revision, keys: Vec| { + Identity::V0(IdentityV0 { + id, + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision, + }) + }; + let block = |height| BlockTime::new(height, 1, height); + let mut disabled = key(4); + disabled.set_disabled_at(5); + let row = |persister: &CapturingPersister, key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .find_map(|cs| { + cs.identity_keys + .as_ref()? + .upserts + .get(&(id, key_id)) + .cloned() + }) + }; + + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let mut managed = ManagedIdentity::new(identity(6, vec![key(1)]), 0); + managed.wallet_id = Some(wallet_id); + managed + .store_fetched_identity(&identity(7, vec![key(1), key(4)]), block(1), &p) + .unwrap(); + assert!(managed.identity.public_keys().contains_key(&4)); + assert_eq!(row(&persister, 4), None); + managed + .store_fetched_identity(&identity(8, vec![key(1), disabled.clone()]), block(2), &p) + .unwrap(); + assert_eq!(managed.identity.public_keys()[&4], disabled); + assert_eq!( + row(&persister, 4), + None, + "a later change creates no row either" + ); + + managed + .store_keys_with_snapshot( + vec![KeyWithBreadcrumb { + key: disabled.clone(), + breadcrumb: Some((wallet_id, 0, 4)), + }], + &p, + ) + .unwrap(); + assert!(row(&persister, 4).unwrap().derivation_indices.is_some()); + let mut later = disabled.clone(); + later.set_disabled_at(6); + managed + .store_fetched_identity(&identity(9, vec![key(1), later.clone()]), block(3), &p) + .unwrap(); + assert_eq!(row(&persister, 4).map(|row| row.public_key), Some(later)); + + let persister = std::sync::Arc::new(CapturingPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let mut watched = ManagedIdentity::new_out_of_wallet(identity(6, vec![key(1)])); + watched + .store_fetched_identity(&identity(7, vec![key(1), key(4)]), block(1), &p) + .unwrap(); + let stored = row(&persister, 4).expect("an out-of-wallet key gets its row"); + assert_eq!(stored.derivation_indices, None); + } + /// A batch of fetched names lands as one snapshot carrying every name, /// skips labels already held, and persists nothing when all are known. /// One snapshot per name made the first read as the complete owned set. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 856fbcba97c..419157d7bc1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -23,7 +23,7 @@ pub use crate::wallet::identity::types::key_storage::{ use crate::changeset::IdentityKeyEntry; use dpp::identity::{Identity, KeyID}; use dpp::prelude::Identifier; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// A managed identity that combines an Identity with wallet-specific metadata. /// @@ -67,6 +67,13 @@ pub struct ManagedIdentity { /// holds it, until one succeeds. pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, + /// Keys a refresh took into memory that have no row in the store yet: + /// only a path that knows a key's origin (a load or discovery, a landed + /// update) creates its row, so a breadcrumb-less update of one of these + /// keys is not stored (see `store_with_queued_key_rows`). Memory only: + /// after a restart the cache's keys come from rows. + rowless_key_ids: BTreeSet, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index dc021474580..d8ffd6ba28e 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -168,7 +168,9 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option Date: Tue, 20 Oct 2026 16:15:30 +0300 Subject: [PATCH 26/72] fix(platform-wallet): a breadcrumb-less key row means unknown origin, load stores a new identity whole - Refresh stores every key its fold adds or changes again, without a breadcrumb. A breadcrumb-less row says the key's origin is not known there, not that it is watch-only: every persister keeps a stored breadcrumb, and a load or discovery gives a new key its own. A key a refresh adds is no longer left out of the store (`rowless_key_ids` is gone), so it survives a restart. - Load and discovery add a new identity in memory only (`IdentityManager::add_identity_unstored`) and store it with the keys they verified against the seed as one changeset, so no breadcrumb-less snapshot of its keys can commit ahead of the verified rows. - Kotlin `IdentityUpdates.update` leaves the key-id and revision range checks to the JNI binding, where they become u32 / u64. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/identity/IdentityUpdates.kt | 11 +- .../src/wallet/identity/network/discovery.rs | 3 +- .../src/wallet/identity/network/loading.rs | 86 ++++++++++---- .../src/wallet/identity/network/update.rs | 8 +- .../state/managed_identity/identity_ops.rs | 106 ++++++------------ .../identity/state/managed_identity/mod.rs | 9 +- .../identity/state/manager/lifecycle.rs | 66 +++++++---- 7 files changed, 152 insertions(+), 137 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 9600b4d4456..b8cc26eca9b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -242,15 +242,8 @@ class IdentityUpdates internal constructor( require(addPublicKeys.isNotEmpty() || disablePublicKeyIds.isNotEmpty()) { "updateIdentity needs at least one key to add or disable" } - require(disablePublicKeyIds.all { it >= 0 }) { - "every disabled key id must be non-negative, got $disablePublicKeyIds" - } - require(externalKeyIds.all { it >= 0 }) { - "every external key id must be non-negative, got $externalKeyIds" - } - require(revision == null || revision >= 0) { - "revision must be non-negative, got $revision" - } + // Key ids and the revision are range-checked where they become + // the native u32 / u64 (the JNI binding), and refused there. mapNativeErrors { TransactionsNative.updateIdentity( walletHandle, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index d16ba075fec..f9a6cbcde11 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -454,11 +454,10 @@ impl IdentityWallet { })?; let is_new = info_guard.identity_manager.identity(&identity_id).is_none(); if is_new { - info_guard.identity_manager.add_identity( + info_guard.identity_manager.add_identity_unstored( identity.clone(), identity_index, wallet_id, - &self.persister, )?; } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 70b7efdc516..245527d9b66 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -248,11 +248,11 @@ impl IdentityWallet { })?; let is_new = info.identity_manager.identity(&identity_id).is_none(); if is_new { - info.identity_manager.add_identity( + // Stored below, with its verified keys, as one changeset. + info.identity_manager.add_identity_unstored( identity.clone(), identity_index, wallet_id, - &self.persister, )?; } @@ -321,11 +321,10 @@ impl IdentityWallet { /// holds: the cache keeps its revision, key set and every disable, and /// takes only a disable or a raised limit the fetch shows /// ([`ManagedIdentity::take_fetched_identity`]). The row of every key the - /// cache held that the fold changes is stored with the scalar snapshot, - /// without a derivation breadcrumb (the persisters keep the one they - /// hold), and so are key rows an earlier refused store queued; a key the - /// fold adds gets its row from a load or discovery, which derive its - /// breadcrumb; when that store fails, the error + /// fold adds or changes is stored with the scalar snapshot, without a + /// derivation breadcrumb (the persisters keep the one they hold, and a + /// load or discovery gives a new key its own), and so are key rows an + /// earlier refused store queued; when that store fails, the error /// is returned, memory keeps the fold and the rows stay queued for the /// identity's next key store (a refresh, load, discovery or landed /// update; [`ManagedIdentity::store_fetched_identity`]). @@ -769,6 +768,60 @@ mod tests { ); } + /// A load that finds a new identity stores it and its keys, with the + /// breadcrumbs verified against the seed, as one changeset: there is no + /// breadcrumb-less snapshot of its keys that could commit on its own, so + /// a store that refuses the load leaves nothing of the identity behind. + #[tokio::test] + async fn should_store_a_newly_loaded_identity_with_its_keys_in_one_changeset() { + let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); + let probe = derive_load_probe_hash( + ResolvedLoadKeyHashSource::Master(&master), + Network::Testnet, + 0, + ) + .unwrap(); + for fails in [false, true] { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(answer_at(7))) + .await + .unwrap(); + let store = Arc::new(FailingStore::default()); + let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .remove_identity(&Identifier::default(), &wallet.persister) + .unwrap(); + } + store.taken().clear(); + if fails { + store.arm(); + } + + let result = wallet.load_identity_by_index_from_master(0, &master).await; + assert_eq!(result.is_err(), fails, "{result:?}"); + let stores = store.taken().changeset_count(); + if fails { + assert_eq!(stores, 0, "nothing of a refused load is stored"); + continue; + } + assert_eq!(stores, 1, "the identity and its keys go out together"); + assert_eq!( + store.taken().stored_revision(Identifier::default()), + Some(7) + ); + // The fixture's keys are not derived from this seed, so they + // carry no breadcrumb; what matters is that they ride the + // identity's changeset. + assert_eq!(store.taken().key_rows().len(), 3); + } + } + /// A same-revision answer whose proof is not above the cache's balance /// watermark (mock answers are at height 0) takes the answer's keys but /// keeps the cache's balance, which the revision does not order. @@ -803,9 +856,8 @@ mod tests { } /// A disable a refresh brings, from an answer older or newer than the - /// cache, is stored with its key row; a key a newer answer adds is not, - /// since only a load or discovery knows its breadcrumb. A store that - /// refuses them fails the + /// cache, is stored with its key row, as is a key a newer answer adds. A + /// store that refuses them fails the /// refresh and queues the row, which the next refresh stores although /// memory already holds the disable. #[tokio::test] @@ -882,15 +934,11 @@ mod tests { "key {key_id} answered at {revision}" ); } - // A key a newer answer adds is in memory, but gets no row: a - // refresh cannot know its breadcrumb, and a row without one - // would call it watch-only. A load or discovery stores it. - assert_eq!( - held(wallet).await.public_keys().contains_key(&4), - revision > 6, - "answered at {revision}" - ); - assert!(store.stored_key(identity_id, 4).is_none()); + // A key a newer answer adds is stored too, without the + // breadcrumb a refresh cannot know; an older answer adds none. + let key_4 = store.stored_key(identity_id, 4); + assert_eq!(key_4.is_some(), revision > 6, "answered at {revision}"); + assert!(key_4.is_none_or(|row| row.derivation_indices.is_none())); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index b9028075485..8b8056bda40 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -334,9 +334,9 @@ impl IdentityWallet { // A cache refreshed to the transition or past it already holds // this update's keys as Platform has them (on-chain `disabled_at` // included) and whatever later updates did to them: applying the - // payload would take those back. A refresh stores no row for a key - // it adds and the keys it changes without breadcrumbs, though, so - // the touched keys are stored here from the cache's own copies, with the breadcrumbs the + // payload would take those back. A refresh stores the keys it adds + // or changes without breadcrumbs, though, so the touched keys are + // stored here from the cache's own copies, with the breadcrumbs the // path below would give them: an added key its slot's unless the // caller declared it external, a disabled key none (the persisters // keep the one they hold). @@ -761,7 +761,7 @@ mod update_identity_at_revision_tests { /// An update at 5 added key 3, a later one at 6 disabled it, and the /// cache was refreshed to 6 before the confirmation of 5 came back: /// key 3 stays disabled, and is stored that way with its breadcrumb (a - /// refresh gives a key it adds no row). A key the update disabled is + /// refresh stores its rows without one). A key the update disabled is /// stored as the refresh has it, with the on-chain `disabled_at` and no /// breadcrumb, which the persisters keep from the row they hold. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index a836a271f01..085d9272b9b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -92,7 +92,6 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), - rowless_key_ids: Default::default(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -115,7 +114,6 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), - rowless_key_ids: Default::default(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -442,25 +440,19 @@ impl ManagedIdentity { } /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row - /// of every key the cache held that the fold changes (a disable, a raised - /// limit) with the identity's scalar snapshot as one changeset (see - /// `store_keys_and_balance`). Used where a fetch comes without derived - /// breadcrumbs (refresh), so the rows carry none and every persister - /// keeps the breadcrumb the row holds; a key's state reaches the store on - /// any refresh, a refresh after a restart included, which a queue held - /// in memory cannot. + /// of every key the fold adds or changes (a disable, a raised limit, a + /// key added at a newer revision) with the identity's scalar snapshot as + /// one changeset (see `store_keys_and_balance`), so any refresh, the + /// first after a restart included, brings the store up to the fetch. /// - /// A key the fold adds to a wallet-owned identity gets no row here, and - /// no later breadcrumb-less write gives it one (see `rowless_key_ids`). - /// Only a path that knows where a key came from creates its row: a load - /// or discovery, which derive its breadcrumb from the seed, or a landed - /// update, whose caller declares it. A row created here would have to - /// call the key watch-only, which the store would then keep for a - /// wallet-derived key until one of those paths ran. The key is in memory - /// meanwhile, and a refused row of one of those paths rides the next - /// store (see `store_with_queued_key_rows`). An out-of-wallet identity's - /// keys are all watch-only and no load or discovery runs for it, so its - /// added keys are stored here. + /// Used where a fetch comes without derived breadcrumbs (refresh), so the + /// rows carry none. A row without a breadcrumb says the key's origin is + /// not known here, not that the key is watch-only: every persister keeps + /// the breadcrumb a row already holds, and a load or discovery, which + /// verify a key against the seed, give a new key its breadcrumb. Until + /// one runs, a key a refresh adds is stored without one, rather than with + /// derivation coordinates inferred from the identity's wallet slot, or not + /// at all. /// /// A failed store is returned; memory holds the fold (the revision above /// all, which the next update is built from), and the refused key rows @@ -476,15 +468,9 @@ impl ManagedIdentity { self.take_fetched_identity(fetched, block_time); let identity_id = self.id(); let mut keys_cs = IdentityKeysChangeSet::default(); - let in_wallet = self.identity_index.is_some(); for (key_id, key) in self.identity.public_keys() { - match before.get(key_id) { - Some(held) if held == key => continue, - None if in_wallet => { - self.rowless_key_ids.insert(*key_id); - continue; - } - _ => {} + if before.get(key_id) == Some(key) { + continue; } keys_cs.upserts.insert( (identity_id, *key_id), @@ -539,12 +525,6 @@ impl ManagedIdentity { mut keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PersistenceError> { - // A breadcrumb-less write of a key no row holds yet would create a - // watch-only row for it (see `rowless_key_ids`). - let rowless = &self.rowless_key_ids; - keys_cs.upserts.retain(|(_, key_id), entry| { - entry.derivation_indices.is_some() || !rowless.contains(key_id) - }); for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { if let Some(own) = keys_cs.upserts.get_mut(&id) { if own.derivation_indices.is_none() && entry.derivation_indices.is_some() { @@ -666,8 +646,6 @@ impl ManagedIdentity { } /// Layer `keys` onto this identity in memory, returning their key rows. - /// A key layered here gets its row (the caller knows its origin), so it - /// leaves `rowless_key_ids`. fn layer_keys(&mut self, keys: Vec) -> IdentityKeysChangeSet { let identity_id = self.id(); let mut current = self.identity.public_keys().clone(); @@ -678,7 +656,6 @@ impl ManagedIdentity { } in keys { let key_id = public_key.id(); - self.rowless_key_ids.remove(&key_id); let public_key_hash = pubkey_hash_of(&public_key); current.insert(key_id, public_key.clone()); let (wallet_id, derivation_indices) = match breadcrumb { @@ -1367,13 +1344,10 @@ mod tests { assert_eq!(row(3), None); } - /// A key a refresh adds to a wallet-owned identity gets no row, not even - /// when a later refresh changes it: a row without a breadcrumb would - /// call a wallet-derived key watch-only. A load's row, which carries the - /// derived breadcrumb, creates it, and later changes then reach it. An - /// out-of-wallet identity's added keys are watch-only and get rows. + /// A key a refresh adds is stored without a breadcrumb, which a load's + /// row then gives it, and a later breadcrumb-less change keeps. #[test] - fn should_leave_a_key_a_refresh_adds_to_a_path_that_knows_its_origin() { + fn should_store_a_key_a_refresh_adds_and_keep_a_breadcrumb_a_load_gives_it() { let id = Identifier::from([1u8; 32]); let wallet_id: WalletId = [0xAB; 32]; let identity = |revision, keys: Vec| { @@ -1385,22 +1359,20 @@ mod tests { }) }; let block = |height| BlockTime::new(height, 1, height); - let mut disabled = key(4); - disabled.set_disabled_at(5); - let row = |persister: &CapturingPersister, key_id: KeyID| { + let rows = |persister: &CapturingPersister, key_id: KeyID| -> Vec { persister .stores .lock() .unwrap() .iter() - .rev() - .find_map(|cs| { + .filter_map(|cs| { cs.identity_keys .as_ref()? .upserts .get(&(id, key_id)) .cloned() }) + .collect() }; let persister = std::sync::Arc::new(CapturingPersister::default()); @@ -1410,43 +1382,33 @@ mod tests { managed .store_fetched_identity(&identity(7, vec![key(1), key(4)]), block(1), &p) .unwrap(); - assert!(managed.identity.public_keys().contains_key(&4)); - assert_eq!(row(&persister, 4), None); - managed - .store_fetched_identity(&identity(8, vec![key(1), disabled.clone()]), block(2), &p) - .unwrap(); - assert_eq!(managed.identity.public_keys()[&4], disabled); + let added = rows(&persister, 4); assert_eq!( - row(&persister, 4), - None, - "a later change creates no row either" + added.len(), + 1, + "the added key is stored, its origin unknown" ); + assert_eq!(added[0].derivation_indices, None); managed .store_keys_with_snapshot( vec![KeyWithBreadcrumb { - key: disabled.clone(), + key: key(4), breadcrumb: Some((wallet_id, 0, 4)), }], &p, ) .unwrap(); - assert!(row(&persister, 4).unwrap().derivation_indices.is_some()); - let mut later = disabled.clone(); - later.set_disabled_at(6); + let mut disabled = key(4); + disabled.set_disabled_at(5); managed - .store_fetched_identity(&identity(9, vec![key(1), later.clone()]), block(3), &p) - .unwrap(); - assert_eq!(row(&persister, 4).map(|row| row.public_key), Some(later)); - - let persister = std::sync::Arc::new(CapturingPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let mut watched = ManagedIdentity::new_out_of_wallet(identity(6, vec![key(1)])); - watched - .store_fetched_identity(&identity(7, vec![key(1), key(4)]), block(1), &p) + .store_fetched_identity(&identity(8, vec![key(1), disabled.clone()]), block(2), &p) .unwrap(); - let stored = row(&persister, 4).expect("an out-of-wallet key gets its row"); - assert_eq!(stored.derivation_indices, None); + // The refresh's row carries no breadcrumb; the persisters keep the + // load's (SQLite: `should_keep_a_stored_breadcrumb_when_an_upsert_carries_none`). + let last = rows(&persister, 4).pop().unwrap(); + assert_eq!(last.public_key, disabled); + assert_eq!(last.derivation_indices, None); } /// A batch of fetched names lands as one snapshot carrying every name, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 419157d7bc1..856fbcba97c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -23,7 +23,7 @@ pub use crate::wallet::identity::types::key_storage::{ use crate::changeset::IdentityKeyEntry; use dpp::identity::{Identity, KeyID}; use dpp::prelude::Identifier; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeMap; /// A managed identity that combines an Identity with wallet-specific metadata. /// @@ -67,13 +67,6 @@ pub struct ManagedIdentity { /// holds it, until one succeeds. pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, - /// Keys a refresh took into memory that have no row in the store yet: - /// only a path that knows a key's origin (a load or discovery, a landed - /// update) creates its row, so a breadcrumb-less update of one of these - /// keys is not stored (see `store_with_queued_key_rows`). Memory only: - /// after a restart the cache's keys come from rows. - rowless_key_ids: BTreeSet, - /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index f5af15abaf1..fb5e914fb2f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -41,6 +41,49 @@ impl IdentityManager { persister: &WalletPersister, ) -> Result<(), PlatformWalletError> { let identity_id = identity.id(); + self.add_identity_unstored(identity, identity_index, wallet_id)?; + let managed_identity = self + .managed_identity_mut(&identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; + + let entry = IdentityEntry::from_managed(managed_identity); + // No `key_storage` to snapshot here anymore — the field is + // dropped from `ManagedIdentity`. Public keys still arrive + // via the separate `IdentityKeysChangeSet` path. + let keys_cs = managed_identity.keys_snapshot_changeset(); + + let mut id_cs = IdentityChangeSet::default(); + id_cs.identities.insert(identity_id, entry); + + let cs = PlatformWalletChangeSet { + identities: Some(id_cs), + identity_keys: if keys_cs.upserts.is_empty() { + None + } else { + Some(keys_cs) + }, + ..Default::default() + }; + + if let Err(e) = persister.store(cs) { + tracing::error!("Failed to persist changeset: {}", e); + } + + Ok(()) + } + + /// [`Self::add_identity`] in memory only: the caller stores the identity + /// and its keys itself, as one changeset. Load and discovery use it, so + /// the keys they verified against the seed reach the store with the + /// identity, never after a breadcrumb-less snapshot of them that could + /// commit on its own. + pub(crate) fn add_identity_unstored( + &mut self, + identity: Identity, + identity_index: u32, + wallet_id: WalletId, + ) -> Result<(), PlatformWalletError> { + let identity_id = identity.id(); if self.identity(&identity_id).is_some() { return Err(PlatformWalletError::IdentityAlreadyExists(identity_id)); @@ -52,12 +95,6 @@ impl IdentityManager { // without re-threading the keys. managed_identity.wallet_id = Some(wallet_id); - let entry = IdentityEntry::from_managed(&managed_identity); - // No `key_storage` to snapshot here anymore — the field is - // dropped from `ManagedIdentity`. Public keys still arrive - // via the separate `IdentityKeysChangeSet` path. - let keys_cs = managed_identity.keys_snapshot_changeset(); - self.wallet_identities .entry(wallet_id) .or_default() @@ -72,23 +109,6 @@ impl IdentityManager { }, ); - let mut id_cs = IdentityChangeSet::default(); - id_cs.identities.insert(identity_id, entry); - - let cs = PlatformWalletChangeSet { - identities: Some(id_cs), - identity_keys: if keys_cs.upserts.is_empty() { - None - } else { - Some(keys_cs) - }, - ..Default::default() - }; - - if let Err(e) = persister.store(cs) { - tracing::error!("Failed to persist changeset: {}", e); - } - Ok(()) } From ec3e2255cee72a62f2071d32dafb50a9a711d782 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:22:28 +0300 Subject: [PATCH 27/72] test(platform-wallet): a load needs one write to store a new identity The new-identity load test now also runs on a store that takes one write and refuses the next, the case where the old two-write load committed a breadcrumb-less key snapshot and then failed. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 2 +- .../src/wallet/identity/network/loading.rs | 19 ++++++++++------- .../wallet/identity/network/signing_key.rs | 21 ++++++++++++++++++- .../identity/state/manager/lifecycle.rs | 3 ++- 4 files changed, 35 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index f9a6cbcde11..c24ffde233c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -474,7 +474,7 @@ impl IdentityWallet { // imported identity cannot sign with its HIGH / // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after - // restart, so surface it (matching `add_identity` above). + // restart, so surface it. // A fetch older than a known identity's cache stores // only the keys the cache has, with their disables. // A known identity is folded as loading and refresh diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 245527d9b66..8f3d2bd7866 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -770,8 +770,10 @@ mod tests { /// A load that finds a new identity stores it and its keys, with the /// breadcrumbs verified against the seed, as one changeset: there is no - /// breadcrumb-less snapshot of its keys that could commit on its own, so - /// a store that refuses the load leaves nothing of the identity behind. + /// breadcrumb-less snapshot of its keys written ahead of them. A store + /// that takes one write and refuses the next is enough for the load (a + /// second write was the one that could fail after the first committed + /// watch-only rows), and a store that refuses it leaves nothing behind. #[tokio::test] async fn should_store_a_newly_loaded_identity_with_its_keys_in_one_changeset() { let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); @@ -781,7 +783,9 @@ mod tests { 0, ) .unwrap(); - for fails in [false, true] { + // How many writes the store takes before it refuses; `None` takes + // every write. + for takes in [None, Some(1), Some(0)] { let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); sdk.mock() .expect_fetch(PublicKeyHash(probe), Some(answer_at(7))) @@ -799,17 +803,18 @@ mod tests { .unwrap(); } store.taken().clear(); - if fails { - store.arm(); + if let Some(takes) = takes { + store.arm_after(takes); } let result = wallet.load_identity_by_index_from_master(0, &master).await; - assert_eq!(result.is_err(), fails, "{result:?}"); let stores = store.taken().changeset_count(); - if fails { + if takes == Some(0) { + assert!(result.is_err()); assert_eq!(stores, 0, "nothing of a refused load is stored"); continue; } + result.unwrap(); assert_eq!(stores, 1, "the identity and its keys go out together"); assert_eq!( store.taken().stored_revision(Identifier::default()), diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index d26392a21b5..dc2b987a137 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -445,7 +445,12 @@ pub(super) mod tests { /// Takes every changeset until armed, then refuses each; what it takes is /// recorded as [`CapturingStore`] records it. #[derive(Default)] - pub(crate) struct FailingStore(AtomicBool, CapturingStore); + pub(crate) struct FailingStore( + AtomicBool, + CapturingStore, + AtomicBool, + std::sync::atomic::AtomicUsize, + ); impl FailingStore { /// What the store took, while disarmed. @@ -459,6 +464,13 @@ pub(super) mod tests { pub(crate) fn disarm(&self) { self.0.store(false, Ordering::SeqCst); + self.2.store(false, Ordering::SeqCst); + } + + /// Take the next `stores` changesets, then refuse each. + pub(crate) fn arm_after(&self, stores: usize) { + self.3.store(stores, Ordering::SeqCst); + self.2.store(true, Ordering::SeqCst); } } @@ -471,6 +483,13 @@ pub(super) mod tests { if self.0.load(Ordering::SeqCst) { return Err(PersistenceError::backend("store armed to fail")); } + if self.2.load(Ordering::SeqCst) { + let left = self.3.load(Ordering::SeqCst); + if left == 0 { + return Err(PersistenceError::backend("store armed to fail")); + } + self.3.store(left - 1, Ordering::SeqCst); + } self.1.store(wallet_id, changeset) } fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index fb5e914fb2f..03bbfbdc4ed 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -3,7 +3,8 @@ //! Adds, removes, and looks up the HD index for managed identities. //! Every mutation here persists a corresponding //! [`IdentityChangeSet`](crate::changeset::IdentityChangeSet) via -//! the supplied [`WalletPersister`]. +//! the supplied [`WalletPersister`], except +//! [`IdentityManager::add_identity_unstored`], whose caller stores. use super::{IdentityLocation, IdentityManager}; use crate::changeset::{IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet}; From 2f3561d7d8e94cfc8efba48f8f52bd02accc203a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:44:51 +0300 Subject: [PATCH 28/72] fix(platform-wallet): add_keys and disable_keys queue refused rows, check a stored blob's size first - `add_keys` (registration, invitation) and `disable_keys` store through `store_with_queued_key_rows`, so a refused row, breadcrumb included, rides the identity's next key store; a refresh that finds the same keys has no row of its own to give them. - The SQLite writer checks a stored row's blob length before reading it to look for a breadcrumb, as `load_state` does; an oversized row is not read and is replaced. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/identity_keys.rs | 13 +++- .../sqlite_identity_keys_reader.rs | 53 ++++++++++++++ .../state/managed_identity/identity_ops.rs | 70 +++++++++++++++---- 3 files changed, 121 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index a5f349d3613..cd98b5e27ca 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -229,7 +229,18 @@ pub fn apply( let held: Option<(i64, Vec, Vec)> = stored .query_row( params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + |row| { + // The size guard runs before the payload is read, + // as in `load_state`; an oversized row is read as + // empty and replaced (`held_breadcrumb`). + let blob_len: i64 = row.get(0)?; + let payload: Vec = if blob::check_size(blob_len).is_ok() { + row.get(1)? + } else { + Vec::new() + }; + Ok((blob_len, payload, row.get(2)?)) + }, ) .optional()?; if let Some((wallet_id_kept, derivation_indices)) = diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index f9716db6f54..1e9d7280c72 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -111,6 +111,59 @@ fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { assert_eq!(cs.upserts.get(&(id, 1)), Some(&watch_only_disabled)); } +/// A stored row over the blob size limit is not read into memory to look for +/// a breadcrumb; the upsert replaces it. +#[test] +fn should_replace_an_oversized_stored_row_without_reading_it() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD4); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0E; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let entry = key_entry(id, 0, 0x11); + persister + .lock_conn_for_test() + .execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, zeroblob(?3), ?4, NULL)", + rusqlite::params![ + w.as_slice(), + id.as_slice(), + (platform_wallet_storage::SIZE_LIMIT_BYTES + 1) as i64, + &entry.public_key_hash[..], + ], + ) + .unwrap(); + + let mut upsert = entry.clone(); + upsert.derivation_indices = None; + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts.insert((id, 0), upsert.clone()); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + assert_eq!(cs.upserts.get(&(id, 0)), Some(&upsert)); +} + /// A breadcrumb belongs to one key: an upsert of another public key under a /// stored row's key id does not take the row's breadcrumb. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 085d9272b9b..8897b5f6a4c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -632,17 +632,15 @@ impl ManagedIdentity { return Ok(()); } let keys_cs = self.layer_keys(keys); - let cs = PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }; // Surface the persist failure — these rows carry the per-key // derivation breadcrumb + verified scalar that make an imported / // restored identity signable. Swallowing a failed store here would // leave the keys in memory but absent from the client store, so the - // identity comes back watch-only after restart with no signal. - persister.store(cs) + // identity comes back watch-only after restart with no signal. The + // refused rows, breadcrumbs included, also ride the identity's next + // key store (see `store_with_queued_key_rows`): a refresh that finds + // the same keys has no row of its own to give them. + self.store_with_queued_key_rows(keys_cs, persister) } /// Layer `keys` onto this identity in memory, returning their key rows. @@ -756,7 +754,8 @@ impl ManagedIdentity { /// the authoritative on-chain block time. /// /// Does **not** touch the identity revision — the caller bumps it - /// alongside the update transition. A store failure is logged, not + /// alongside the update transition. A store failure is logged (its rows + /// queued for the next key store, see `store_with_queued_key_rows`), not /// returned; a landed update goes through [`Self::apply_landed_keys`], /// which returns it and stores the revision too. pub fn disable_keys( @@ -771,12 +770,7 @@ impl ManagedIdentity { if keys_cs.upserts.is_empty() { return; } - let cs = PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }; - if let Err(e) = persister.store(cs) { + if let Err(e) = self.store_with_queued_key_rows(keys_cs, persister) { tracing::error!("Failed to persist changeset: {}", e); } } @@ -1411,6 +1405,54 @@ mod tests { assert_eq!(last.derivation_indices, None); } + /// A key row `add_keys` could not store, breadcrumb included, rides the + /// next key store, here a refresh that finds the same keys and so has no + /// row of its own to give them. + #[test] + fn should_store_a_refused_add_key_row_with_the_next_refresh() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let identity = |keys: Vec| { + Identity::V0(IdentityV0 { + id, + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision: 6, + }) + }; + let mut managed = ManagedIdentity::new(identity(vec![key(1)]), 0); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .add_keys( + vec![KeyWithBreadcrumb { + key: key(4), + breadcrumb: Some((wallet_id, 0, 4)), + }], + &p, + ) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + managed + .store_fetched_identity(&identity(vec![key(1), key(4)]), BlockTime::new(1, 1, 1), &p) + .unwrap(); + let stores = persister.stores.lock().unwrap(); + let row = stores + .last() + .and_then(|cs| cs.identity_keys.as_ref()) + .and_then(|keys| keys.upserts.get(&(id, 4))) + .expect("the refused row rides the refresh's store"); + assert_eq!(row.public_key, key(4)); + assert!(row.derivation_indices.is_some()); + } + /// A batch of fetched names lands as one snapshot carrying every name, /// skips labels already held, and persists nothing when all are known. /// One snapshot per name made the first read as the complete owned set. From d6d43e31c1e5decf28649ca57c1fc2a84686c3e2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:52:55 +0300 Subject: [PATCH 29/72] fix(platform-wallet)!: tell an unknown key origin from a rejected one, lend a breadcrumb to the same key only - `IdentityKeyEntry` / `KeyWithBreadcrumb` gain `derivation_rejected`: with no breadcrumb, `false` leaves the key's origin unknown (refresh, limits update, disable), so a persister keeps the breadcrumb it holds; `true` settles the key as not derived from the wallet (a load or discovery checked its derivation candidate and it did not reproduce the key, or the update's caller declared it external), so a persister clears a stored breadcrumb, which can only be a stale inferred one. - Merging key changesets, the refused-row queue and SQLite lend a breadcrumb only to an entry that leaves the origin of the same public key (data and hash) and scope unknown; a queued row whose key memory now holds differently is stored without one. - FFI: `IdentityKeyEntryFFI` gains a trailing `derivation_rejected` (224 bytes); the JNI upsert callback passes it, and the Swift and Kotlin persisters clear a key row's derivation path on it. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/NativePersistenceBridge.kt | 5 + .../PlatformWalletPersistenceHandler.kt | 18 ++- .../PlatformWalletPersistenceHandlerTest.kt | 28 ++++ .../src/identity_persistence.rs | 33 ++++- .../src/sqlite/schema/identities.rs | 2 + .../src/sqlite/schema/identity_keys.rs | 15 +- .../sqlite_contacts_keys_rehydration.rs | 1 + ...qlite_delete_wallet_constraint_carveout.rs | 1 + .../sqlite_fk_changeset_ordering.rs | 1 + .../sqlite_identity_hard_delete.rs | 1 + .../sqlite_identity_keys_reader.rs | 55 ++++++++ .../integration/sqlite_persist_roundtrip.rs | 1 + .../tests/integration/sqlite_recovery_mode.rs | 1 + .../sqlite_structural_hardening.rs | 1 + .../integration/sqlite_unowned_identities.rs | 1 + .../tests/integration/sqlite_version_bump.rs | 1 + .../src/changeset/changeset.rs | 76 ++++++++-- .../src/wallet/identity/network/discovery.rs | 23 ++- .../src/wallet/identity/network/update.rs | 13 +- .../state/managed_identity/identity_ops.rs | 132 +++++++++++++++++- .../rs-unified-sdk-jni/src/persistence.rs | 3 +- .../PlatformWalletPersistenceHandler.swift | 15 +- 22 files changed, 397 insertions(+), 30 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index f662cfae2fe..d9cc5d5dd6b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -545,6 +545,10 @@ abstract class NativePersistenceBridge { * key's usage limits (protocol version 14): the credits it may spend over * its lifetime when [totalBudgetIsSome], and the block time in milliseconds * from which it can no longer sign when [expiresAtIsSome]. + * + * With no derivation indices, [derivationRejected] says Rust settled the + * key as not derived from the wallet, so a stored derivation breadcrumb + * is cleared; `false` leaves the key's origin unknown, which keeps it. */ @Suppress("LongParameterList") open fun onPersistIdentityKeyUpsert( @@ -571,6 +575,7 @@ abstract class NativePersistenceBridge { totalBudget: Long = 0L, expiresAtIsSome: Boolean = false, expiresAt: Long = 0L, + derivationRejected: Boolean = false, ): Int = 0 /** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index dbaa458df17..be3e0fb7560 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1900,6 +1900,7 @@ class PlatformWalletPersistenceHandler( totalBudget: Long, expiresAtIsSome: Boolean, expiresAt: Long, + derivationRejected: Boolean, ): Int = guarded { // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only @@ -2049,10 +2050,19 @@ class PlatformWalletPersistenceHandler( // failure marker; the null identifier is). They make the // pending-repair state reconstructible after restart // (dashpay/platform#4060 finding 5). - derivationIdentityIndex = - if (derivationIndicesIsSome) identityIndex else existing?.derivationIdentityIndex, - derivationKeyIndex = - if (derivationIndicesIsSome) keyIndex else existing?.derivationKeyIndex, + // Without indices, an upsert that leaves the key's origin + // unknown keeps the stored breadcrumb; one that settled the + // key as not derived ([derivationRejected]) clears it. + derivationIdentityIndex = when { + derivationIndicesIsSome -> identityIndex + derivationRejected -> null + else -> existing?.derivationIdentityIndex + }, + derivationKeyIndex = when { + derivationIndicesIsSome -> keyIndex + derivationRejected -> null + else -> existing?.derivationKeyIndex + }, identityId = identityBase58, identityIdData = identityId, createdAt = existing?.createdAt ?: java.util.Date(), diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index cc6dede2141..2aaeb348559 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1188,6 +1188,34 @@ class PlatformWalletPersistenceHandlerTest { assertNull(row!!.privateKeyKeychainIdentifier) } + @Test + fun identityKeyUpsertKeepsAnUnknownOriginAndClearsARejectedOne() = runTest { + // Without indices, an upsert that leaves the key's origin unknown + // keeps the stored breadcrumb; one Rust settled as not derived clears + // it (a stale breadcrumb inferred for an external key). + val identityId = ByteArray(32) { 15 } + seedIdentity(identityId) + fun upsert(indicesIsSome: Boolean, rejected: Boolean) { + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + ByteArray(33) { 10 }, ByteArray(20), true, walletId, + indicesIsSome, 0, 7, 0, ByteArray(32), null, + derivationRejected = rejected, + ) + handler.onChangesetEnd(walletId, success = true) + } + suspend fun stored() = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + + upsert(indicesIsSome = true, rejected = false) + assertEquals(7, stored().derivationKeyIndex) + upsert(indicesIsSome = false, rejected = false) + assertEquals(7, stored().derivationKeyIndex) + upsert(indicesIsSome = false, rejected = true) + assertNull(stored().derivationKeyIndex) + assertNull(stored().derivationIdentityIndex) + } + @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index e04c82a2ff0..ecdcfb3cfc1 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -313,6 +313,13 @@ pub struct IdentityKeyEntryFFI { pub total_budget: u64, pub expires_at_is_some: bool, pub expires_at: u64, + + /// With `derivation_indices_is_some` false: `true` when Rust settled the + /// key as not derived from the wallet (its derivation candidate did not + /// reproduce it, or the caller declared it external), so the client + /// clears a derivation path it holds for the key; `false` when the + /// key's origin is unknown here, so the client keeps the path it holds. + pub derivation_rejected: bool, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -365,9 +372,11 @@ pub struct IdentityKeyRemovalFFI { // 200 expires_at_is_some bool // 201..=207 (padding to 8) // 208..=215 expires_at u64 +// 216 derivation_rejected bool +// 217..=223 (padding to 8) // -// Total size = 216, alignment = 8 (from u64 / pointer). -const _: [u8; 216] = [0u8; std::mem::size_of::()]; +// Total size = 224, alignment = 8 (from u64 / pointer). +const _: [u8; 224] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -757,6 +766,7 @@ impl IdentityKeyEntryFFI { total_budget, expires_at_is_some, expires_at, + derivation_rejected: entry.derivation_indices.is_none() && entry.derivation_rejected, } } } @@ -1221,6 +1231,7 @@ mod tests { identity_index: 3, key_index: 5, }), + derivation_rejected: false, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [2u8; 32]); @@ -1263,10 +1274,25 @@ mod tests { public_key_hash: [0x00; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); + assert!( + !ffi.derivation_rejected, + "an unknown origin keeps a stored path" + ); + let settled = IdentityKeyEntry { + derivation_rejected: true, + ..entry.clone() + }; + let mut settled_ffi = IdentityKeyEntryFFI::from_entry(&settled); + assert!( + settled_ffi.derivation_rejected, + "a settled origin clears it" + ); + unsafe { free_identity_key_entry_ffi(&mut settled_ffi) }; assert!(ffi.read_only); assert!(ffi.disabled_at_is_some); assert_eq!(ffi.disabled_at, 1_700_000_000); @@ -1298,6 +1324,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 3); @@ -1327,6 +1354,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 1); @@ -1359,6 +1387,7 @@ mod tests { public_key_hash: [0x22; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 2); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index d29c622f5eb..538e5378b6f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -903,6 +903,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts @@ -1111,6 +1112,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index cd98b5e27ca..1af41263621 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -68,6 +68,7 @@ impl IdentityKeyWire { public_key_hash: self.public_key_hash, wallet_id: self.wallet_id, derivation_indices: self.derivation_indices, + derivation_rejected: false, }) } } @@ -203,11 +204,12 @@ pub fn apply( // 32 zero bytes instead would produce a row that matches no // wallet, no NULL-scoped reader, and no guard. let wallet_id_param = super::wallet_id_to_param(wallet_id); - // An entry without a derivation breadcrumb keeps the one the row - // already holds under this scope, as the Swift and Kotlin persisters - // do: a key's derivation never changes, and an upsert that only + // An entry without a derivation breadcrumb that leaves the key's + // origin unknown keeps the one the row already holds under this + // scope, as the Swift and Kotlin persisters do: an upsert that only // knows the key's new state (a raised limit, a disable, a refresh) - // must not turn a wallet-derived key watch-only. + // must not turn a wallet-derived key watch-only. One that settled the + // key as not derived (`derivation_rejected`) clears it. let mut stored = tx.prepare_cached( "SELECT length(public_key_blob), public_key_blob, public_key_hash \ FROM identity_keys \ @@ -225,7 +227,9 @@ pub fn apply( } } let mut wire = IdentityKeyWire::from_entry(entry)?; - if wire.derivation_indices.is_none() { + // An entry that settled the key as not derived clears the row's + // breadcrumb; one that leaves its origin unknown keeps it. + if wire.derivation_indices.is_none() && !entry.derivation_rejected { let held: Option<(i64, Vec, Vec)> = stored .query_row( params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], @@ -702,6 +706,7 @@ mod tests { identity_index: 1, key_index: 2, }), + derivation_rejected: false, }; let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs index 948d4a981c4..3b70af80f80 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs @@ -86,6 +86,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs index c7a444c8676..d1ecd6bba67 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs @@ -58,6 +58,7 @@ fn delete_wallet_aborts_on_a_constraint_failure_outside_the_carve_out() { public_key_hash: [9u8; 20], wallet_id: Some(w), derivation_indices: None, + derivation_rejected: false, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 1), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs index 8d5cac6a6e9..20af95211fb 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs @@ -45,6 +45,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs index 7a861e2fa21..58385c32496 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs @@ -81,6 +81,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index 1e9d7280c72..77603067934 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -45,6 +45,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { identity_index: 0, key_index: u32::from(byte), }), + derivation_rejected: false, } } @@ -111,6 +112,60 @@ fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { assert_eq!(cs.upserts.get(&(id, 1)), Some(&watch_only_disabled)); } +/// An upsert that settles a key as not derived (`derivation_rejected`) +/// clears a stored breadcrumb, which can only be a stale inferred one, while +/// one that leaves the key's origin unknown keeps a valid same-key one. +#[test] +fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD5); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x0F; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let store = |entries: Vec| { + let mut keys = IdentityKeysChangeSet::default(); + for entry in entries { + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); + } + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + }; + // Key 0: an external key carrying the legacy inferred breadcrumb; key 1: + // a wallet-derived key with its verified one. + store(vec![key_entry(id, 0, 0x11), key_entry(id, 1, 0x22)]); + + let mut rejected = key_entry(id, 0, 0x11); + rejected.derivation_indices = None; + rejected.derivation_rejected = true; + let mut unknown = key_entry(id, 1, 0x22); + unknown.derivation_indices = None; + store(vec![rejected.clone(), unknown]); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + let mut cleared = rejected; + cleared.derivation_rejected = false; + assert_eq!(cs.upserts.get(&(id, 0)), Some(&cleared)); + assert_eq!(cs.upserts.get(&(id, 1)), Some(&key_entry(id, 1, 0x22))); +} + /// A stored row over the blob size limit is not read into memory to look for /// a breadcrumb; the upsert replaces it. #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index d13e50be66f..3c8b54661b9 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -225,6 +225,7 @@ fn tc007_identity_key_entry_roundtrip() { identity_index: 1, key_index: 2, }), + derivation_rejected: false, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 7), entry.clone()); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs index a2f518dcf97..482ead775c6 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs @@ -1339,6 +1339,7 @@ fn identity_key_entry( public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs index 414f37074d4..086123bb5df 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs @@ -272,6 +272,7 @@ fn identity_key_entry_mismatch_rejected() { public_key_hash: [3u8; 20], wallet_id: Some(w), derivation_indices: None, + derivation_rejected: false, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((key_identity, 0), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs index 9e5a5bfe869..7ca0e285d2b 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs @@ -70,6 +70,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs index 73d2437db0b..dd9d9e15669 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs @@ -321,6 +321,7 @@ fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { public_key_hash: [3u8; 20], wallet_id: None, derivation_indices: None, + derivation_rejected: false, } } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index ab75ba7625f..ffb79ad00f6 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1023,6 +1023,12 @@ pub struct KeyWithBreadcrumb { pub key: dpp::identity::IdentityPublicKey, /// Derivation coordinates for re-derivable keys; `None` for watch-only. pub breadcrumb: Option, + /// With no `breadcrumb`: whether the key's origin was settled as not + /// derived from the wallet (a derivation candidate was checked against + /// it and did not reproduce it, or the caller declared it external), so + /// a stored breadcrumb is cleared, rather than unknown, which keeps one. + /// See [`IdentityKeyEntry::derivation_rejected`]. + pub derivation_rejected: bool, } /// A single identity-key entry in an [`IdentityKeysChangeSet`]. @@ -1053,6 +1059,36 @@ pub struct IdentityKeyEntry { /// re-derive the private key. `None` means "view-only, no /// private key recoverable". pub derivation_indices: Option, + /// With no `derivation_indices`, what the entry knows about the key's + /// origin. `false`: unknown here (a refresh, a limits update, a disable), + /// so a persister keeps the breadcrumb a row already holds. `true`: the + /// key was settled as not derived from the wallet (a load or discovery + /// checked its derivation candidate and it did not reproduce the key, or + /// the caller of an update declared it external), so a persister clears + /// a stored breadcrumb, which can only be a stale inferred one. Ignored + /// with `derivation_indices`. + #[cfg_attr(feature = "serde", serde(default))] + pub derivation_rejected: bool, +} + +impl IdentityKeyEntry { + /// Whether this breadcrumb-less entry leaves the key's origin unknown, + /// so it takes the breadcrumb `earlier` holds for the same public key + /// (an earlier row or a stored one), as every persister keeps a stored + /// breadcrumb. A breadcrumb belongs to one key's derivation, so `earlier` + /// must carry the same public key and scope. + pub fn takes_breadcrumb_of(&self, earlier: &IdentityKeyEntry) -> bool { + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + self.derivation_indices.is_none() + && !self.derivation_rejected + && earlier.derivation_indices.is_some() + && earlier.public_key_hash == self.public_key_hash + && earlier.public_key.data() == self.public_key.data() + && self + .wallet_id + .zip(earlier.wallet_id) + .is_none_or(|(own, other)| own == other) + } } /// Changes to per-identity key storage. @@ -1077,17 +1113,16 @@ impl Merge for IdentityKeysChangeSet { // used by other scalar-entry changesets in this module; the // "don't emit both upsert + remove for the same key in one // mutation" invariant is enforced on the emitter side. A later - // entry without a derivation breadcrumb keeps the earlier one's, - // as every persister keeps a stored breadcrumb: a key's derivation - // never changes, and a buffered write that only knows the key's - // new state must not turn a wallet-derived key watch-only. + // entry that leaves the key's origin unknown keeps the earlier + // one's breadcrumb for the same public key, as every persister keeps + // a stored breadcrumb: a buffered write that only knows the key's new + // state must not turn a wallet-derived key watch-only. One that + // settles the key as not derived clears it. for (id, mut entry) in other.upserts { - if entry.derivation_indices.is_none() { - if let Some(earlier) = self.upserts.get(&id) { - if earlier.derivation_indices.is_some() { - entry.wallet_id = earlier.wallet_id; - entry.derivation_indices = earlier.derivation_indices; - } + if let Some(earlier) = self.upserts.get(&id) { + if entry.takes_breadcrumb_of(earlier) { + entry.wallet_id = earlier.wallet_id; + entry.derivation_indices = earlier.derivation_indices; } } self.upserts.insert(id, entry); @@ -2561,6 +2596,7 @@ mod tests { identity_index: 0, key_index, }), + derivation_rejected: false, }; let changeset = |entry: IdentityKeyEntry| { let mut cs = IdentityKeysChangeSet::default(); @@ -2574,6 +2610,26 @@ mod tests { merged.merge(changeset(entry(Some(8), Some(4)))); assert_eq!(merged.upserts[&(id, 3)], entry(Some(8), Some(4))); + + // An entry that settles the key as not derived clears it. + let mut rejected = entry(Some(9), None); + rejected.derivation_rejected = true; + merged.merge(changeset(rejected.clone())); + assert_eq!(merged.upserts[&(id, 3)], rejected); + + // A breadcrumb belongs to one public key: another key under the same + // row id does not take it. + let mut merged = changeset(entry(None, Some(3))); + let mut other = entry(None, None); + other.public_key = IdentityPublicKeyV0 { + id: 3, + data: vec![7; 33].into(), + ..Default::default() + } + .into(); + other.public_key_hash = [7; 20]; + merged.merge(changeset(other.clone())); + assert_eq!(merged.upserts[&(id, 3)], other); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index c24ffde233c..a16c1e4b3bd 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -74,14 +74,19 @@ fn breadcrumb_decisions( .public_keys() .iter() .map(|(key_id, on_chain_key)| { - let reproduces = candidate_scalars - .get(key_id) + let candidate = candidate_scalars.get(key_id); + let reproduces = candidate .map(|scalar| { on_chain_key .validate_private_key_bytes(scalar, network) .unwrap_or(false) }) .unwrap_or(false); + // A candidate that does not reproduce the key settles it as not + // derived from this wallet, which clears a stale breadcrumb a + // client may hold for it; with no candidate (derivation failed) + // its origin stays unknown. + let derivation_rejected = candidate.is_some() && !reproduces; let breadcrumb = if reproduces { // The candidate scalar reproduced the on-chain key, so this // wallet owns it: emit the DIP-9 coordinates. The scalar is @@ -106,6 +111,7 @@ fn breadcrumb_decisions( crate::changeset::KeyWithBreadcrumb { key: on_chain_key.clone(), breadcrumb, + derivation_rejected, } }) .collect() @@ -960,6 +966,19 @@ mod tests { "reproducible key breadcrumbed" ); assert_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); + assert!(!by_id[&0].derivation_rejected); + assert!( + by_id[&1].derivation_rejected, + "a checked candidate that does not reproduce the key settles it as not derived" + ); + + // With no candidate for a key (its derivation failed), its origin + // stays unknown. + scalars.remove(&1); + let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); + let unchecked = decisions.iter().find(|d| d.key.id() == 1).unwrap(); + assert_eq!(unchecked.breadcrumb, None); + assert!(!unchecked.derivation_rejected); } /// An on-chain key typed `ECDSA_HASH160` (data = the 20-byte hash of diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 8b8056bda40..bb7d9ab8751 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -362,6 +362,7 @@ impl IdentityWallet { .unwrap_or(key) .clone(), breadcrumb: added_breadcrumb(id), + derivation_rejected: external_key_ids.contains(&id), }); } for &id in &disabled { @@ -376,6 +377,7 @@ impl IdentityWallet { keys.push(KeyWithBreadcrumb { key: key.clone(), breadcrumb: None, + derivation_rejected: false, }); } // Stored with the identity's snapshot even when no touched key @@ -400,7 +402,12 @@ impl IdentityWallet { .into_iter() .map(|key| { let breadcrumb = added_breadcrumb(key.id()); - KeyWithBreadcrumb { key, breadcrumb } + let derivation_rejected = external_key_ids.contains(&key.id()); + KeyWithBreadcrumb { + key, + breadcrumb, + derivation_rejected, + } }) .collect(); let disabled_at = SystemTime::now() @@ -939,6 +946,10 @@ mod update_identity_at_revision_tests { assert_eq!(stored.public_key, key_3(), "cached at {cached}"); assert_eq!(stored.derivation_indices, None, "cached at {cached}"); assert_eq!(stored.wallet_id, None, "cached at {cached}"); + assert!( + stored.derivation_rejected, + "a declared external key clears a stale breadcrumb, cached at {cached}" + ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 8897b5f6a4c..bfd0162384c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -72,6 +72,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(pub_key), wallet_id: None, derivation_indices: None, + derivation_rejected: false, }, ); } @@ -481,6 +482,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(key), wallet_id: None, derivation_indices: None, + derivation_rejected: false, }, ); } @@ -512,8 +514,9 @@ impl ManagedIdentity { /// Store `keys_cs` with the key rows a refused store queued (see /// `pending_key_rows`) and the identity's scalar snapshot as one /// changeset. A queued row `keys_cs` carries itself gives way to it (its - /// own row is newer) but lends it its breadcrumb when it has none, as a - /// persister keeps a stored breadcrumb. Every other one is stored with + /// own row is newer) but lends it its breadcrumb when that row leaves the + /// origin of the same public key unknown, as a persister keeps a stored + /// breadcrumb (see [`IdentityKeyEntry::takes_breadcrumb_of`]). Every other one is stored with /// the key as memory holds it now, so a row queued before a later change /// to the key (a landed disable, a raised limit) does not take that /// change back; a queued key memory no longer holds is dropped. When the @@ -527,7 +530,7 @@ impl ManagedIdentity { ) -> Result<(), PersistenceError> { for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { if let Some(own) = keys_cs.upserts.get_mut(&id) { - if own.derivation_indices.is_none() && entry.derivation_indices.is_some() { + if own.takes_breadcrumb_of(&entry) { own.wallet_id = entry.wallet_id; own.derivation_indices = entry.derivation_indices; } @@ -536,7 +539,16 @@ impl ManagedIdentity { let Some(key) = self.identity.public_keys().get(&entry.key_id) else { continue; }; - entry.public_key_hash = pubkey_hash_of(key); + // A breadcrumb belongs to the key it was derived for: a queued + // row whose key memory now holds differently leaves its origin + // unknown. + let public_key_hash = pubkey_hash_of(key); + if public_key_hash != entry.public_key_hash || key.data() != entry.public_key.data() { + entry.wallet_id = None; + entry.derivation_indices = None; + entry.derivation_rejected = false; + } + entry.public_key_hash = public_key_hash; entry.public_key = key.clone(); keys_cs.upserts.insert(id, entry); } @@ -608,6 +620,7 @@ impl ManagedIdentity { vec![crate::changeset::KeyWithBreadcrumb { key: public_key, breadcrumb: derivation_breadcrumb, + derivation_rejected: false, }], persister, ) @@ -651,6 +664,7 @@ impl ManagedIdentity { for KeyWithBreadcrumb { key: public_key, breadcrumb, + derivation_rejected, } in keys { let key_id = public_key.id(); @@ -675,6 +689,7 @@ impl ManagedIdentity { public_key_hash, wallet_id, derivation_indices, + derivation_rejected: derivation_indices.is_none() && derivation_rejected, }, ); } @@ -727,6 +742,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, + derivation_rejected: false, }, ); self.store_with_queued_key_rows(keys_cs, persister) @@ -856,6 +872,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, + derivation_rejected: false, }, ); } @@ -1137,10 +1154,12 @@ mod tests { KeyWithBreadcrumb { key: key(3), breadcrumb: None, + derivation_rejected: false, }, KeyWithBreadcrumb { key: key(4), breadcrumb: None, + derivation_rejected: false, }, ] }; @@ -1173,6 +1192,7 @@ mod tests { let decisions = vec![KeyWithBreadcrumb { key: key(3).with_limits(Some(100), None), breadcrumb: None, + derivation_rejected: false, }]; let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); @@ -1261,6 +1281,7 @@ mod tests { let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { breadcrumb: Some((wallet_id, 0, key.id())), key, + derivation_rejected: false, }; let row = |key_id: KeyID| { persister @@ -1312,6 +1333,7 @@ mod tests { vec![KeyWithBreadcrumb { key: raised.clone(), breadcrumb: None, + derivation_rejected: false, }], &p, ) @@ -1389,6 +1411,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), + derivation_rejected: false, }], &p, ) @@ -1405,6 +1428,102 @@ mod tests { assert_eq!(last.derivation_indices, None); } + /// A refused row lends its breadcrumb only to the same public key: a later + /// row of another key under the same id, or a queued row whose key memory + /// now holds differently, leaves the origin unknown. A row that settles a + /// key as not derived takes nothing. + #[test] + fn should_lend_a_queued_breadcrumb_only_to_the_same_public_key() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let other_key = |key_id: KeyID| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + data: dpp::platform_value::BinaryData::new(vec![0x03; 33]), + ..match key(key_id) { + IdentityPublicKey::V0(v0) => v0, + _ => unreachable!(), + } + }) + }; + let mut managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::from([(1, key(1))]), + balance: 0, + revision: 0, + }), + 0, + ); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let row = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .last() + .and_then(|cs| cs.identity_keys.as_ref()) + .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) + }; + let refuse = |managed: &mut ManagedIdentity, key: IdentityPublicKey| { + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .add_keys( + vec![KeyWithBreadcrumb { + breadcrumb: Some((wallet_id, 0, key.id())), + key, + derivation_rejected: false, + }], + &p, + ) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + }; + + // Its own row for another public key. + refuse(&mut managed, key(2)); + managed + .store_keys_with_snapshot( + vec![KeyWithBreadcrumb { + key: other_key(2), + breadcrumb: None, + derivation_rejected: false, + }], + &p, + ) + .unwrap(); + assert_eq!(row(2).unwrap().derivation_indices, None); + + // A queued row whose key memory now holds differently. + refuse(&mut managed, key(3)); + managed.identity.add_public_key(other_key(3)); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + let queued = row(3).unwrap(); + assert_eq!(queued.public_key, other_key(3)); + assert_eq!(queued.derivation_indices, None); + + // A row that settles the same key as not derived. + refuse(&mut managed, key(4)); + managed + .store_keys_with_snapshot( + vec![KeyWithBreadcrumb { + key: key(4), + breadcrumb: None, + derivation_rejected: true, + }], + &p, + ) + .unwrap(); + let settled = row(4).unwrap(); + assert_eq!(settled.derivation_indices, None); + assert!(settled.derivation_rejected); + } + /// A key row `add_keys` could not store, breadcrumb included, rides the /// next key store, here a refresh that finds the same keys and so has no /// row of its own to give them. @@ -1433,6 +1552,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), + derivation_rejected: false, }], &p, ) @@ -1671,10 +1791,12 @@ mod tests { crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), + derivation_rejected: false, }, crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, + derivation_rejected: false, }, ], &p, @@ -1735,6 +1857,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, + derivation_rejected: false, }], &p, ) @@ -1806,6 +1929,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), + derivation_rejected: false, }], &p, ); diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index da5d40e8f04..d5913648af9 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1542,7 +1542,7 @@ unsafe extern "C" fn tramp_persist_identity_keys( /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves /// the table up front, while the call site only resolves when a key is /// first persisted. -const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I"; +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZ)I"; unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, @@ -1584,6 +1584,7 @@ unsafe fn persist_identity_key_upsert( JValue::Long(e.total_budget as i64), JValue::Bool(e.expires_at_is_some as u8), JValue::Long(e.expires_at as i64), + JValue::Bool(e.derivation_rejected as u8), ], )? .i() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 17563d2b217..8f9fe7d4f4e 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4209,6 +4209,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let path = identityAuthPath(walletId: resolvedWalletId, indices: indices) { row.identityDerivationPath = path } + } else if entry.derivationRejected { + // Rust settled the key as not derived from the wallet (its + // derivation candidate did not reproduce it, or the caller + // declared it external): a path held for it is a stale + // inferred one, and the signer must not derive at it. An + // entry without indices that leaves the origin unknown + // keeps the path. + row.identityDerivationPath = nil } row.lastAccessed = Date() @@ -5247,6 +5255,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `identity.identityPublicKeys`) and so a group bound is not /// mistaken for a whole-contract one. let contractBounds: ManagedPlatformWallet.ContractBounds? + /// With no `derivationIndices`: `true` when Rust settled the key as + /// not derived from the wallet, so a stored derivation path is + /// cleared; `false` when its origin is unknown, which keeps it. + var derivationRejected: Bool = false } // MARK: - Watch-only Restore: Account Addresses @@ -10318,7 +10330,8 @@ private func persistIdentityKeysCallback( publicKeyHash: dataFromTuple20(e.public_key_hash), walletId: walletId, derivationIndices: indices, - contractBounds: bounds + contractBounds: bounds, + derivationRejected: e.derivation_rejected )) } } From 967775037bf36de1f44de8e285de54a7fca8559f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:05:17 +0300 Subject: [PATCH 30/72] fix(platform-wallet): an unknown-origin key row keeps a rejection it merges over A later key entry that leaves a key's origin unknown (a disable, a limits update) now takes what an earlier entry for the same public key settled, its breadcrumb or its rejection (`IdentityKeyEntry::inherit_origin`), in changeset merges and the refused-row queue, so a buffered or retried rejection still clears a stale inferred breadcrumb. Kotlin drops a pending re-derive entry for a key Rust settles as not derived; docs no longer call a breadcrumb-less row watch-only. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/NativePersistenceBridge.kt | 6 +- .../PlatformWalletPersistenceHandler.kt | 5 ++ .../src/identity_persistence.rs | 5 +- .../src/changeset/changeset.rs | 75 ++++++++++++------- .../src/wallet/identity/network/update.rs | 5 +- .../state/managed_identity/identity_ops.rs | 45 ++++++++--- .../rs-unified-sdk-jni/src/persistence.rs | 7 +- .../PlatformWalletPersistenceHandler.swift | 8 +- 8 files changed, 106 insertions(+), 50 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index d9cc5d5dd6b..d9a7aa3cd59 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -538,11 +538,11 @@ abstract class NativePersistenceBridge { /** * One `IdentityKeyEntryFFI` upsert. Descriptor - * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I`. + * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZ)I`. * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, * 3 ContractGroup (`contractBoundsId` is then the contract group id and - * `contractBoundsDocumentType` is null). The last four arguments are the - * key's usage limits (protocol version 14): the credits it may spend over + * `contractBoundsDocumentType` is null). The four arguments before + * [derivationRejected] are the key's usage limits (protocol version 14): the credits it may spend over * its lifetime when [totalBudgetIsSome], and the block time in milliseconds * from which it can no longer sign when [expiresAtIsSome]. * diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index be3e0fb7560..67332bf8a1b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1927,6 +1927,11 @@ class PlatformWalletPersistenceHandler( // alias whose row can never commit (the identity rows are gone), // stranding identity-key ciphertext behind a "successful" wipe. val roundKey = walletId.toHex() + // A key Rust settled as not derived needs no re-derive: drop a + // pending repair entry for it, staged with the round. + if (derivationRejected && !derivationIndicesIsSome) { + stagePendingKeyDelta(roundKey, clearPendingKeyDelta(publicKeyData.toHex())) + } val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } val deriver = privateKeyDeriver diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index ecdcfb3cfc1..e104f5ef289 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -260,8 +260,9 @@ pub struct IdentityKeyEntryFFI { // re-derive the 32-byte ECDSA scalar from the named wallet's // mnemonic at the DIP-9 identity authentication path // `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` and - // persist it locally. When either flag is false the key is - // watch-only. + // persist it locally. When either flag is false the entry gives no + // breadcrumb: `derivation_rejected` then says whether the client clears + // the one it holds (settled as not derived) or keeps it (unknown). pub wallet_id_is_some: bool, pub wallet_id: [u8; 32], pub derivation_indices_is_some: bool, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index ffb79ad00f6..a34728d8c4e 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -40,6 +40,7 @@ use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyID}; use dpp::prelude::{Identifier, Revision}; @@ -1006,7 +1007,8 @@ pub struct IdentityKeyDerivationIndices { /// A derivation breadcrumb as the raw `(wallet_id, identity_index, /// key_index)` triple passed to `ManagedIdentity::add_key` / `add_keys`. /// `Some` lets the client re-derive the private key from the wallet seed; -/// `None` marks a watch-only key. +/// `None` gives none (see [`KeyWithBreadcrumb::derivation_rejected`] for +/// whether a stored one is kept or cleared). pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// One public key paired with its derivation breadcrumb — the unit @@ -1017,11 +1019,12 @@ pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// key, and emits a breadcrumb (the DIP-9 coordinates) only when it matches. /// The scalar itself is never carried out — the client derives the key on /// demand from the Keychain seed at the breadcrumb path. `breadcrumb` is -/// `None` for a watch-only key. +/// `None` for a key this entry gives no coordinates for. pub struct KeyWithBreadcrumb { /// The DPP public-key record. pub key: dpp::identity::IdentityPublicKey, - /// Derivation coordinates for re-derivable keys; `None` for watch-only. + /// Derivation coordinates for re-derivable keys; `None` gives none, its + /// meaning set by `derivation_rejected`. pub breadcrumb: Option, /// With no `breadcrumb`: whether the key's origin was settled as not /// derived from the wallet (a derivation candidate was checked against @@ -1056,8 +1059,9 @@ pub struct IdentityKeyEntry { /// watched-only identities with no wallet association. pub wallet_id: Option<[u8; 32]>, /// DIP-9 `(identity_index, key_index)` pair the client needs to - /// re-derive the private key. `None` means "view-only, no - /// private key recoverable". + /// re-derive the private key. `None` gives none: whether the key is + /// settled as not derived or its origin is unknown here is + /// `derivation_rejected`. pub derivation_indices: Option, /// With no `derivation_indices`, what the entry knows about the key's /// origin. `false`: unknown here (a refresh, a limits update, a disable), @@ -1072,22 +1076,32 @@ pub struct IdentityKeyEntry { } impl IdentityKeyEntry { - /// Whether this breadcrumb-less entry leaves the key's origin unknown, - /// so it takes the breadcrumb `earlier` holds for the same public key - /// (an earlier row or a stored one), as every persister keeps a stored - /// breadcrumb. A breadcrumb belongs to one key's derivation, so `earlier` - /// must carry the same public key and scope. - pub fn takes_breadcrumb_of(&self, earlier: &IdentityKeyEntry) -> bool { - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; - self.derivation_indices.is_none() - && !self.derivation_rejected - && earlier.derivation_indices.is_some() - && earlier.public_key_hash == self.public_key_hash - && earlier.public_key.data() == self.public_key.data() - && self + /// When this entry leaves the key's origin unknown, take what `earlier` + /// (an earlier row for the same key) settled about it: its breadcrumb, + /// as every persister keeps a stored one, or its rejection, so a later + /// unknown-origin write (a disable, a limits update) does not let a + /// persister keep a stale inferred breadcrumb the rejection cleared. A + /// key's origin belongs to one public key, so `earlier` must carry the + /// same public key, and a breadcrumb the same scope. + pub fn inherit_origin(&mut self, earlier: &IdentityKeyEntry) { + let unknown = self.derivation_indices.is_none() && !self.derivation_rejected; + let same_key = earlier.public_key_hash == self.public_key_hash + && earlier.public_key.data() == self.public_key.data(); + if !unknown || !same_key { + return; + } + if earlier.derivation_indices.is_some() { + let same_scope = self .wallet_id .zip(earlier.wallet_id) - .is_none_or(|(own, other)| own == other) + .is_none_or(|(own, other)| own == other); + if same_scope { + self.wallet_id = earlier.wallet_id; + self.derivation_indices = earlier.derivation_indices; + } + } else if earlier.derivation_rejected { + self.derivation_rejected = true; + } } } @@ -1113,17 +1127,15 @@ impl Merge for IdentityKeysChangeSet { // used by other scalar-entry changesets in this module; the // "don't emit both upsert + remove for the same key in one // mutation" invariant is enforced on the emitter side. A later - // entry that leaves the key's origin unknown keeps the earlier - // one's breadcrumb for the same public key, as every persister keeps - // a stored breadcrumb: a buffered write that only knows the key's new - // state must not turn a wallet-derived key watch-only. One that - // settles the key as not derived clears it. + // entry that leaves the key's origin unknown takes what the earlier + // one settled for the same public key (its breadcrumb, as every + // persister keeps a stored one, or its rejection): a buffered write + // that only knows the key's new state must neither turn a + // wallet-derived key watch-only nor bring back a breadcrumb a + // rejection cleared. One that settles the origin itself wins. for (id, mut entry) in other.upserts { if let Some(earlier) = self.upserts.get(&id) { - if entry.takes_breadcrumb_of(earlier) { - entry.wallet_id = earlier.wallet_id; - entry.derivation_indices = earlier.derivation_indices; - } + entry.inherit_origin(earlier); } self.upserts.insert(id, entry); } @@ -2617,6 +2629,13 @@ mod tests { merged.merge(changeset(rejected.clone())); assert_eq!(merged.upserts[&(id, 3)], rejected); + // A later unknown-origin entry (a disable) keeps the rejection. + let later = entry(Some(10), None); + merged.merge(changeset(later.clone())); + let mut kept = later; + kept.derivation_rejected = true; + assert_eq!(merged.upserts[&(id, 3)], kept); + // A breadcrumb belongs to one public key: another key under the same // row id does not take it. let mut merged = changeset(entry(None, Some(3))); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index bb7d9ab8751..922d3068f02 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -305,8 +305,9 @@ impl IdentityWallet { /// but was not stored. /// /// An added key named in `external_key_ids` is stored without a - /// breadcrumb on both paths; every other one with the breadcrumb of - /// its slot, `(wallet_id, identity_index, key_id)`. + /// breadcrumb and settled as not derived on both paths, which clears a + /// breadcrumb a persister holds for it; every other one with the + /// breadcrumb of its slot, `(wallet_id, identity_index, key_id)`. async fn apply_landed_update( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index bfd0162384c..189a14e6922 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -514,9 +514,9 @@ impl ManagedIdentity { /// Store `keys_cs` with the key rows a refused store queued (see /// `pending_key_rows`) and the identity's scalar snapshot as one /// changeset. A queued row `keys_cs` carries itself gives way to it (its - /// own row is newer) but lends it its breadcrumb when that row leaves the - /// origin of the same public key unknown, as a persister keeps a stored - /// breadcrumb (see [`IdentityKeyEntry::takes_breadcrumb_of`]). Every other one is stored with + /// own row is newer) but lends it what it settled about the same public + /// key's origin (a breadcrumb or a rejection) when that row leaves it + /// unknown (see [`IdentityKeyEntry::inherit_origin`]). Every other one is stored with /// the key as memory holds it now, so a row queued before a later change /// to the key (a landed disable, a raised limit) does not take that /// change back; a queued key memory no longer holds is dropped. When the @@ -530,10 +530,7 @@ impl ManagedIdentity { ) -> Result<(), PersistenceError> { for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { if let Some(own) = keys_cs.upserts.get_mut(&id) { - if own.takes_breadcrumb_of(&entry) { - own.wallet_id = entry.wallet_id; - own.derivation_indices = entry.derivation_indices; - } + own.inherit_origin(&entry); continue; } let Some(key) = self.identity.public_keys().get(&entry.key_id) else { @@ -607,7 +604,9 @@ impl ManagedIdentity { /// `wallet_id` and `(identity_index, key_index)` together let the /// client (iOS Keychain) re-derive the matching private key from /// the wallet seed at the DIP-9 identity authentication path. - /// Pass `None` for a watch-only key the wallet didn't derive. + /// Pass `None` for a key the wallet didn't derive (the row then keeps + /// a breadcrumb the persister holds; see + /// [`KeyWithBreadcrumb::derivation_rejected`]). pub fn add_key( &mut self, public_key: dpp::identity::IdentityPublicKey, @@ -629,7 +628,9 @@ impl ManagedIdentity { /// Layer several `IdentityPublicKey`s onto this identity and emit ONE /// batched [`IdentityKeysChangeSet`] carrying each key's derivation /// breadcrumb (`Some((wallet_id, identity_index, key_index))`) or - /// `None` for a watch-only key the wallet can't re-derive. + /// `None` for a key the wallet can't re-derive (set + /// `derivation_rejected` when that is settled, so a stored breadcrumb is + /// cleared). /// /// The single-write batch form of [`Self::add_key`], used by discovery /// to materialize every re-derivable key of a freshly found identity in @@ -1522,6 +1523,32 @@ mod tests { let settled = row(4).unwrap(); assert_eq!(settled.derivation_indices, None); assert!(settled.derivation_rejected); + + // A refused rejection is kept by the next store's own row for the + // same key that leaves its origin unknown (a disable). + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .add_keys( + vec![KeyWithBreadcrumb { + key: key(5), + breadcrumb: None, + derivation_rejected: true, + }], + &p, + ) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + managed.disable_keys(&[5], 9, &p); + let disabled = row(5).unwrap(); + assert_eq!(disabled.public_key.disabled_at(), Some(9)); + assert!( + disabled.derivation_rejected, + "the queued rejection survives" + ); } /// A key row `add_keys` could not store, breadcrumb included, rides the diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index d5913648af9..067877b5f21 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1536,9 +1536,10 @@ unsafe extern "C" fn tramp_persist_identity_keys( }) } -/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` is the -/// key usage limits pair (`hasTotalBudget`, `totalBudget`, `hasExpiresAt`, -/// `expiresAt`). Bound at the `call_method` site and in +/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` before +/// the final `Z` is the key usage limits pair (`hasTotalBudget`, +/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `Z` is +/// `derivationRejected`. Bound at the `call_method` site and in /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves /// the table up front, while the call site only resolves when a key is /// first persisted. diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 8f9fe7d4f4e..4e932781d0b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -5241,9 +5241,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let publicKeyHash: Data /// Owning wallet if this key is derivable from one we control. let walletId: Data? - /// DIP-9 `(identity_index, key_index)` pair. Present iff the key is - /// wallet-derivable; the client derives it on demand from the Keychain - /// seed at this path when it needs to sign (no secret crosses the FFI). + /// DIP-9 `(identity_index, key_index)` pair, present when Rust + /// verified the key as wallet-derivable; the client derives it on + /// demand from the Keychain seed at this path when it needs to sign + /// (no secret crosses the FFI). `nil` gives none, its meaning set by + /// `derivationRejected`. let derivationIndices: (identityIndex: UInt32, keyIndex: UInt32)? /// Full ContractBounds projection mirrored from Rust: /// `nil` when the key has no bounds; `.singleContract` for From b016e9ed64072d94b57bc61c21e95c0ed0887e45 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 00:40:31 +0300 Subject: [PATCH 31/72] fix(platform-wallet): a rejection refutes only the coordinates it checked, mobile persisters keep origin per public key - `derivation_rejected` now carries the derivation coordinates the key was checked at (`Option`): load and discovery set the candidate's `(identity_index, key_id)` when it does not reproduce the key, and a landed update the slot it would have inferred for a declared external key. Persisters clear a stored breadcrumb only when it names exactly those coordinates, so a breadcrumb supplied at other coordinates survives; a check that fails leaves the origin unknown instead of rejecting it. - FFI: `IdentityKeyEntryFFI` carries `derivation_rejected_is_some` and the rejected `(identity_index, key_index)` (232 bytes); the JNI upsert callback passes them (`...ZII)I`). - The Swift and Kotlin persisters keep a row's derivation path and private-key linkage only for the same public key: another key under the same key id takes neither. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/ffi/NativePersistenceBridge.kt | 12 ++- .../PlatformWalletPersistenceHandler.kt | 28 ++++--- .../PlatformWalletPersistenceHandlerTest.kt | 24 ++++-- .../src/identity_persistence.rs | 71 +++++++++++----- .../src/sqlite/schema/identities.rs | 4 +- .../src/sqlite/schema/identity_keys.rs | 19 ++--- .../sqlite_contacts_keys_rehydration.rs | 2 +- ...qlite_delete_wallet_constraint_carveout.rs | 2 +- .../sqlite_fk_changeset_ordering.rs | 2 +- .../sqlite_identity_hard_delete.rs | 2 +- .../sqlite_identity_keys_reader.rs | 65 ++++++++++++++- .../integration/sqlite_persist_roundtrip.rs | 2 +- .../tests/integration/sqlite_recovery_mode.rs | 2 +- .../sqlite_structural_hardening.rs | 2 +- .../integration/sqlite_unowned_identities.rs | 2 +- .../tests/integration/sqlite_version_bump.rs | 2 +- .../src/changeset/changeset.rs | 81 +++++++++++-------- .../src/wallet/identity/network/discovery.rs | 43 ++++++---- .../src/wallet/identity/network/update.rs | 27 +++++-- .../state/managed_identity/identity_ops.rs | 55 +++++++------ .../rs-unified-sdk-jni/src/persistence.rs | 10 ++- .../PlatformWalletPersistenceHandler.swift | 40 ++++++--- 22 files changed, 339 insertions(+), 158 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index d9a7aa3cd59..8c7298418d3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -538,7 +538,7 @@ abstract class NativePersistenceBridge { /** * One `IdentityKeyEntryFFI` upsert. Descriptor - * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZ)I`. + * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZII)I`. * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, * 3 ContractGroup (`contractBoundsId` is then the contract group id and * `contractBoundsDocumentType` is null). The four arguments before @@ -546,9 +546,11 @@ abstract class NativePersistenceBridge { * its lifetime when [totalBudgetIsSome], and the block time in milliseconds * from which it can no longer sign when [expiresAtIsSome]. * - * With no derivation indices, [derivationRejected] says Rust settled the - * key as not derived from the wallet, so a stored derivation breadcrumb - * is cleared; `false` leaves the key's origin unknown, which keeps it. + * With no derivation indices, [derivationRejected] says Rust checked the + * key at `(rejectedIdentityIndex, rejectedKeyIndex)` and found it does + * not come from there, so a stored derivation breadcrumb naming exactly + * those coordinates is cleared; `false` leaves the key's origin unknown, + * which keeps it. */ @Suppress("LongParameterList") open fun onPersistIdentityKeyUpsert( @@ -576,6 +578,8 @@ abstract class NativePersistenceBridge { expiresAtIsSome: Boolean = false, expiresAt: Long = 0L, derivationRejected: Boolean = false, + rejectedIdentityIndex: Int = 0, + rejectedKeyIndex: Int = 0, ): Int = 0 /** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 67332bf8a1b..b9adb4027d1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1901,6 +1901,8 @@ class PlatformWalletPersistenceHandler( expiresAtIsSome: Boolean, expiresAt: Long, derivationRejected: Boolean, + rejectedIdentityIndex: Int, + rejectedKeyIndex: Int, ): Int = guarded { // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only @@ -2018,7 +2020,15 @@ class PlatformWalletPersistenceHandler( stage(walletId) { db -> val identityBase58 = identityId.toBase58String() - val existing = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) + val stored = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) + // A row for another public key under this key id carries that + // key's derivation and private-key linkage, which must not pass to + // this one: only a row for the same public key lends them. + val existing = stored?.takeIf { it.publicKeyData.contentEquals(publicKeyData) } + // A rejection refutes only the coordinates it was checked at. + val refuted = derivationRejected && !derivationIndicesIsSome && + existing?.derivationIdentityIndex == rejectedIdentityIndex && + existing.derivationKeyIndex == rejectedKeyIndex // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2029,7 +2039,7 @@ class PlatformWalletPersistenceHandler( val docTypeName = if (boundsKind == 2) contractBoundsDocumentType else null val row = PublicKeyEntity( - id = existing?.id ?: 0, + id = stored?.id ?: 0, keyId = keyId, purpose = (purpose.toInt() and 0xFF).toString(), securityLevel = (securityLevel.toInt() and 0xFF).toString(), @@ -2055,25 +2065,25 @@ class PlatformWalletPersistenceHandler( // failure marker; the null identifier is). They make the // pending-repair state reconstructible after restart // (dashpay/platform#4060 finding 5). - // Without indices, an upsert that leaves the key's origin - // unknown keeps the stored breadcrumb; one that settled the - // key as not derived ([derivationRejected]) clears it. + // Without indices, an upsert keeps the stored breadcrumb of + // the same public key unless it refutes exactly those + // coordinates ([derivationRejected]). derivationIdentityIndex = when { derivationIndicesIsSome -> identityIndex - derivationRejected -> null + refuted -> null else -> existing?.derivationIdentityIndex }, derivationKeyIndex = when { derivationIndicesIsSome -> keyIndex - derivationRejected -> null + refuted -> null else -> existing?.derivationKeyIndex }, identityId = identityBase58, identityIdData = identityId, - createdAt = existing?.createdAt ?: java.util.Date(), + createdAt = stored?.createdAt ?: java.util.Date(), lastAccessed = now(), ) - if (existing == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) + if (stored == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) } 0 } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 2aaeb348559..58d59c4d8b1 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1191,17 +1191,25 @@ class PlatformWalletPersistenceHandlerTest { @Test fun identityKeyUpsertKeepsAnUnknownOriginAndClearsARejectedOne() = runTest { // Without indices, an upsert that leaves the key's origin unknown - // keeps the stored breadcrumb; one Rust settled as not derived clears - // it (a stale breadcrumb inferred for an external key). + // keeps the stored breadcrumb; one that refutes exactly its + // coordinates clears it, and one that refutes other coordinates + // keeps it. Another public key under the key id takes nothing. val identityId = ByteArray(32) { 15 } seedIdentity(identityId) - fun upsert(indicesIsSome: Boolean, rejected: Boolean) { + fun upsert( + indicesIsSome: Boolean, + rejected: Boolean, + rejectedKeyIndex: Int = 0, + key: ByteArray = ByteArray(33) { 10 }, + ) { handler.onChangesetBegin(walletId) handler.onPersistIdentityKeyUpsert( walletId, identityId, 0, 0, 0, 0, false, false, 0, - ByteArray(33) { 10 }, ByteArray(20), true, walletId, + key, ByteArray(20), true, walletId, indicesIsSome, 0, 7, 0, ByteArray(32), null, derivationRejected = rejected, + rejectedIdentityIndex = 0, + rejectedKeyIndex = rejectedKeyIndex, ) handler.onChangesetEnd(walletId, success = true) } @@ -1211,9 +1219,15 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(7, stored().derivationKeyIndex) upsert(indicesIsSome = false, rejected = false) assertEquals(7, stored().derivationKeyIndex) - upsert(indicesIsSome = false, rejected = true) + upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 0) + assertEquals("another slot's rejection keeps it", 7, stored().derivationKeyIndex) + upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 7) assertNull(stored().derivationKeyIndex) assertNull(stored().derivationIdentityIndex) + + upsert(indicesIsSome = true, rejected = false) + upsert(indicesIsSome = false, rejected = false, key = ByteArray(33) { 11 }) + assertNull("another public key takes no breadcrumb", stored().derivationKeyIndex) } @Test diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index e104f5ef289..0af4f790ac8 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -261,8 +261,9 @@ pub struct IdentityKeyEntryFFI { // mnemonic at the DIP-9 identity authentication path // `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` and // persist it locally. When either flag is false the entry gives no - // breadcrumb: `derivation_rejected` then says whether the client clears - // the one it holds (settled as not derived) or keeps it (unknown). + // breadcrumb: `derivation_rejected_is_some` then says whether the client + // clears the one it holds when it names the rejected coordinates, or + // keeps it (unknown origin). pub wallet_id_is_some: bool, pub wallet_id: [u8; 32], pub derivation_indices_is_some: bool, @@ -315,12 +316,16 @@ pub struct IdentityKeyEntryFFI { pub expires_at_is_some: bool, pub expires_at: u64, - /// With `derivation_indices_is_some` false: `true` when Rust settled the - /// key as not derived from the wallet (its derivation candidate did not - /// reproduce it, or the caller declared it external), so the client - /// clears a derivation path it holds for the key; `false` when the - /// key's origin is unknown here, so the client keeps the path it holds. - pub derivation_rejected: bool, + /// With `derivation_indices_is_some` false and this flag true, the + /// `(rejected_identity_index, rejected_key_index)` coordinates the key + /// was checked at and found not to come from (its candidate there did not + /// reproduce it, or the caller declared the key external): the client + /// clears a derivation path it holds for the key only when it names + /// exactly these coordinates. With the flag false the key's origin is + /// unknown here, and the client keeps the path it holds. + pub derivation_rejected_is_some: bool, + pub rejected_identity_index: u32, + pub rejected_key_index: u32, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -373,11 +378,14 @@ pub struct IdentityKeyRemovalFFI { // 200 expires_at_is_some bool // 201..=207 (padding to 8) // 208..=215 expires_at u64 -// 216 derivation_rejected bool -// 217..=223 (padding to 8) +// 216 derivation_rejected_is_some bool +// 217..=219 (padding to 4) +// 220..=223 rejected_identity_index u32 +// 224..=227 rejected_key_index u32 +// 228..=231 (padding to 8) // -// Total size = 224, alignment = 8 (from u64 / pointer). -const _: [u8; 224] = [0u8; std::mem::size_of::()]; +// Total size = 232, alignment = 8 (from u64 / pointer). +const _: [u8; 232] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -717,6 +725,13 @@ impl IdentityKeyEntryFFI { Some(idx) => (true, idx.identity_index, idx.key_index), None => (false, 0, 0), }; + let (derivation_rejected_is_some, rejected_identity_index, rejected_key_index) = match entry + .derivation_rejected + .filter(|_| entry.derivation_indices.is_none()) + { + Some(idx) => (true, idx.identity_index, idx.key_index), + None => (false, 0, 0), + }; // Project the DPP `ContractBounds` enum into the kind / // id / doc-type-cstring trio so the Swift side can switch @@ -767,7 +782,9 @@ impl IdentityKeyEntryFFI { total_budget, expires_at_is_some, expires_at, - derivation_rejected: entry.derivation_indices.is_none() && entry.derivation_rejected, + derivation_rejected_is_some, + rejected_identity_index, + rejected_key_index, } } } @@ -1232,7 +1249,7 @@ mod tests { identity_index: 3, key_index: 5, }), - derivation_rejected: false, + derivation_rejected: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [2u8; 32]); @@ -1275,23 +1292,33 @@ mod tests { public_key_hash: [0x00; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); assert!( - !ffi.derivation_rejected, + !ffi.derivation_rejected_is_some, "an unknown origin keeps a stored path" ); let settled = IdentityKeyEntry { - derivation_rejected: true, + derivation_rejected: Some(IdentityKeyDerivationIndices { + identity_index: 2, + key_index: 5, + }), ..entry.clone() }; let mut settled_ffi = IdentityKeyEntryFFI::from_entry(&settled); assert!( - settled_ffi.derivation_rejected, - "a settled origin clears it" + settled_ffi.derivation_rejected_is_some, + "a refuted path is cleared" + ); + assert_eq!( + ( + settled_ffi.rejected_identity_index, + settled_ffi.rejected_key_index + ), + (2, 5) ); unsafe { free_identity_key_entry_ffi(&mut settled_ffi) }; assert!(ffi.read_only); @@ -1325,7 +1352,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 3); @@ -1355,7 +1382,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 1); @@ -1388,7 +1415,7 @@ mod tests { public_key_hash: [0x22; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 2); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 538e5378b6f..291e121e6df 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -903,7 +903,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts @@ -1112,7 +1112,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 1af41263621..f6ac2b625e6 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -68,7 +68,7 @@ impl IdentityKeyWire { public_key_hash: self.public_key_hash, wallet_id: self.wallet_id, derivation_indices: self.derivation_indices, - derivation_rejected: false, + derivation_rejected: None, }) } } @@ -208,8 +208,8 @@ pub fn apply( // origin unknown keeps the one the row already holds under this // scope, as the Swift and Kotlin persisters do: an upsert that only // knows the key's new state (a raised limit, a disable, a refresh) - // must not turn a wallet-derived key watch-only. One that settled the - // key as not derived (`derivation_rejected`) clears it. + // must not turn a wallet-derived key watch-only. One that refutes the + // coordinates the row holds (`derivation_rejected`) clears them. let mut stored = tx.prepare_cached( "SELECT length(public_key_blob), public_key_blob, public_key_hash \ FROM identity_keys \ @@ -227,9 +227,9 @@ pub fn apply( } } let mut wire = IdentityKeyWire::from_entry(entry)?; - // An entry that settled the key as not derived clears the row's - // breadcrumb; one that leaves its origin unknown keeps it. - if wire.derivation_indices.is_none() && !entry.derivation_rejected { + // An entry without a breadcrumb keeps the row's, unless it refutes + // exactly the coordinates the row holds (`derivation_rejected`). + if wire.derivation_indices.is_none() { let held: Option<(i64, Vec, Vec)> = stored .query_row( params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], @@ -247,8 +247,9 @@ pub fn apply( }, ) .optional()?; - if let Some((wallet_id_kept, derivation_indices)) = - held.and_then(|held| held_breadcrumb(held, entry, wallet_id)) + if let Some((wallet_id_kept, derivation_indices)) = held + .and_then(|held| held_breadcrumb(held, entry, wallet_id)) + .filter(|(_, held)| entry.derivation_rejected != Some(*held)) { wire.wallet_id = wallet_id_kept; wire.derivation_indices = Some(derivation_indices); @@ -706,7 +707,7 @@ mod tests { identity_index: 1, key_index: 2, }), - derivation_rejected: false, + derivation_rejected: None, }; let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs index 3b70af80f80..c9229429f19 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs @@ -86,7 +86,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs index d1ecd6bba67..72b2382cf91 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs @@ -58,7 +58,7 @@ fn delete_wallet_aborts_on_a_constraint_failure_outside_the_carve_out() { public_key_hash: [9u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 1), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs index 20af95211fb..fd067f6507c 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs @@ -45,7 +45,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs index 58385c32496..9051d3f728c 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs @@ -81,7 +81,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index 77603067934..d95366447ef 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -45,7 +45,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { identity_index: 0, key_index: u32::from(byte), }), - derivation_rejected: false, + derivation_rejected: None, } } @@ -149,7 +149,10 @@ fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { let mut rejected = key_entry(id, 0, 0x11); rejected.derivation_indices = None; - rejected.derivation_rejected = true; + rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 0x11, + }); let mut unknown = key_entry(id, 1, 0x22); unknown.derivation_indices = None; store(vec![rejected.clone(), unknown]); @@ -161,11 +164,67 @@ fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { .expect("load_state"); drop(conn); let mut cleared = rejected; - cleared.derivation_rejected = false; + cleared.derivation_rejected = None; assert_eq!(cs.upserts.get(&(id, 0)), Some(&cleared)); assert_eq!(cs.upserts.get(&(id, 1)), Some(&key_entry(id, 1, 0x22))); } +/// A rejection refutes only the coordinates it was checked at: a breadcrumb +/// stored at other coordinates (supplied explicitly through `add_keys`) is +/// kept. +#[test] +fn should_keep_a_stored_breadcrumb_a_rejection_does_not_name() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xD6); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x10; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let store = |entry: IdentityKeyEntry| { + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + }; + // Key id 3, stored with the breadcrumb `(0, 0x33)` its caller supplied. + let mut stored = key_entry(id, 3, 0x33); + stored.public_key = key_entry(id, 3, 0x33).public_key; + store(stored.clone()); + // A load checks `(0, 3)` and rejects it. + let mut rejected = stored.clone(); + rejected.derivation_indices = None; + rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 3, + }); + store(rejected); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + assert_eq!( + cs.upserts + .get(&(id, 3)) + .and_then(|row| row.derivation_indices), + stored.derivation_indices + ); +} + /// A stored row over the blob size limit is not read into memory to look for /// a breadcrumb; the upsert replaces it. #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index 3c8b54661b9..04c9121dbca 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -225,7 +225,7 @@ fn tc007_identity_key_entry_roundtrip() { identity_index: 1, key_index: 2, }), - derivation_rejected: false, + derivation_rejected: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 7), entry.clone()); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs index 482ead775c6..6dacc4683b2 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs @@ -1339,7 +1339,7 @@ fn identity_key_entry( public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs index 086123bb5df..fca58eb887f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs @@ -272,7 +272,7 @@ fn identity_key_entry_mismatch_rejected() { public_key_hash: [3u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((key_identity, 0), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs index 7ca0e285d2b..179f56572e0 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs @@ -70,7 +70,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs index dd9d9e15669..58605b409c5 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs @@ -321,7 +321,7 @@ fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { public_key_hash: [3u8; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, } } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index a34728d8c4e..0764fbf1644 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1026,12 +1026,13 @@ pub struct KeyWithBreadcrumb { /// Derivation coordinates for re-derivable keys; `None` gives none, its /// meaning set by `derivation_rejected`. pub breadcrumb: Option, - /// With no `breadcrumb`: whether the key's origin was settled as not - /// derived from the wallet (a derivation candidate was checked against - /// it and did not reproduce it, or the caller declared it external), so - /// a stored breadcrumb is cleared, rather than unknown, which keeps one. - /// See [`IdentityKeyEntry::derivation_rejected`]. - pub derivation_rejected: bool, + /// With no `breadcrumb`: the derivation coordinates this key was + /// checked at and found not to come from (a candidate derived there did + /// not reproduce it, or the caller declared the key external), so a + /// stored breadcrumb naming exactly these coordinates is cleared; any + /// other stored breadcrumb, and every one when `None` (the origin is + /// unknown), is kept. See [`IdentityKeyEntry::derivation_rejected`]. + pub derivation_rejected: Option, } /// A single identity-key entry in an [`IdentityKeysChangeSet`]. @@ -1064,43 +1065,47 @@ pub struct IdentityKeyEntry { /// `derivation_rejected`. pub derivation_indices: Option, /// With no `derivation_indices`, what the entry knows about the key's - /// origin. `false`: unknown here (a refresh, a limits update, a disable), - /// so a persister keeps the breadcrumb a row already holds. `true`: the - /// key was settled as not derived from the wallet (a load or discovery - /// checked its derivation candidate and it did not reproduce the key, or - /// the caller of an update declared it external), so a persister clears - /// a stored breadcrumb, which can only be a stale inferred one. Ignored - /// with `derivation_indices`. + /// origin. `None`: unknown here (a refresh, a limits update, a disable), + /// so a persister keeps the breadcrumb a row already holds. `Some`: the + /// coordinates the key was checked at and found not to come from (a load + /// or discovery derived a candidate there and it did not reproduce the + /// key, or the caller of an update declared the key external), so a + /// persister clears a stored breadcrumb naming exactly these coordinates, + /// a stale inferred one, and keeps any other: only the derivation that + /// was checked is refuted. Ignored with `derivation_indices`. #[cfg_attr(feature = "serde", serde(default))] - pub derivation_rejected: bool, + pub derivation_rejected: Option, } impl IdentityKeyEntry { - /// When this entry leaves the key's origin unknown, take what `earlier` - /// (an earlier row for the same key) settled about it: its breadcrumb, - /// as every persister keeps a stored one, or its rejection, so a later - /// unknown-origin write (a disable, a limits update) does not let a - /// persister keep a stale inferred breadcrumb the rejection cleared. A - /// key's origin belongs to one public key, so `earlier` must carry the - /// same public key, and a breadcrumb the same scope. + /// When this entry gives no breadcrumb, take what `earlier` (an earlier + /// row for the same key) settled about the key's origin: its breadcrumb, + /// as every persister keeps a stored one, unless this entry's own + /// rejection names those coordinates; or, when this entry's origin is + /// unknown, its rejection, so a later unknown-origin write (a disable, a + /// limits update) does not let a persister keep a stale inferred + /// breadcrumb the rejection cleared. A key's origin belongs to one public + /// key, so `earlier` must carry the same public key, and a breadcrumb the + /// same scope. pub fn inherit_origin(&mut self, earlier: &IdentityKeyEntry) { - let unknown = self.derivation_indices.is_none() && !self.derivation_rejected; let same_key = earlier.public_key_hash == self.public_key_hash && earlier.public_key.data() == self.public_key.data(); - if !unknown || !same_key { + if self.derivation_indices.is_some() || !same_key { return; } - if earlier.derivation_indices.is_some() { + if let Some(indices) = earlier.derivation_indices { let same_scope = self .wallet_id .zip(earlier.wallet_id) .is_none_or(|(own, other)| own == other); - if same_scope { + // This entry's own rejection refutes exactly those coordinates. + if same_scope && self.derivation_rejected != Some(indices) { self.wallet_id = earlier.wallet_id; - self.derivation_indices = earlier.derivation_indices; + self.derivation_indices = Some(indices); + self.derivation_rejected = None; } - } else if earlier.derivation_rejected { - self.derivation_rejected = true; + } else if self.derivation_rejected.is_none() { + self.derivation_rejected = earlier.derivation_rejected; } } } @@ -2608,7 +2613,7 @@ mod tests { identity_index: 0, key_index, }), - derivation_rejected: false, + derivation_rejected: None, }; let changeset = |entry: IdentityKeyEntry| { let mut cs = IdentityKeysChangeSet::default(); @@ -2623,9 +2628,21 @@ mod tests { merged.merge(changeset(entry(Some(8), Some(4)))); assert_eq!(merged.upserts[&(id, 3)], entry(Some(8), Some(4))); - // An entry that settles the key as not derived clears it. + // A rejection of other coordinates does not refute the breadcrumb. + let mut elsewhere = entry(Some(9), None); + elsewhere.derivation_rejected = Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 3, + }); + merged.merge(changeset(elsewhere)); + assert_eq!(merged.upserts[&(id, 3)], entry(Some(9), Some(4))); + + // A rejection of exactly its coordinates clears it. let mut rejected = entry(Some(9), None); - rejected.derivation_rejected = true; + rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 4, + }); merged.merge(changeset(rejected.clone())); assert_eq!(merged.upserts[&(id, 3)], rejected); @@ -2633,7 +2650,7 @@ mod tests { let later = entry(Some(10), None); merged.merge(changeset(later.clone())); let mut kept = later; - kept.derivation_rejected = true; + kept.derivation_rejected = rejected.derivation_rejected; assert_eq!(merged.upserts[&(id, 3)], kept); // A breadcrumb belongs to one public key: another key under the same diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index a16c1e4b3bd..e429039ff46 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -74,19 +74,24 @@ fn breadcrumb_decisions( .public_keys() .iter() .map(|(key_id, on_chain_key)| { - let candidate = candidate_scalars.get(key_id); - let reproduces = candidate - .map(|scalar| { - on_chain_key - .validate_private_key_bytes(scalar, network) - .unwrap_or(false) - }) - .unwrap_or(false); - // A candidate that does not reproduce the key settles it as not - // derived from this wallet, which clears a stale breadcrumb a - // client may hold for it; with no candidate (derivation failed) - // its origin stays unknown. - let derivation_rejected = candidate.is_some() && !reproduces; + // `None` when there is no candidate (its derivation failed) or + // the check itself failed: the origin stays unknown. + let checked = candidate_scalars.get(key_id).and_then(|scalar| { + on_chain_key + .validate_private_key_bytes(scalar, network) + .ok() + }); + let reproduces = checked == Some(true); + // A candidate that does not reproduce the key refutes exactly the + // coordinates it was derived at, `(identity_index, key_id)`, which + // clears a stale breadcrumb a client holds there; a breadcrumb + // stored at other coordinates is kept. + let derivation_rejected = (checked == Some(false)).then_some( + crate::changeset::IdentityKeyDerivationIndices { + identity_index, + key_index: *key_id, + }, + ); let breadcrumb = if reproduces { // The candidate scalar reproduced the on-chain key, so this // wallet owns it: emit the DIP-9 coordinates. The scalar is @@ -966,10 +971,14 @@ mod tests { "reproducible key breadcrumbed" ); assert_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); - assert!(!by_id[&0].derivation_rejected); - assert!( + assert_eq!(by_id[&0].derivation_rejected, None); + assert_eq!( by_id[&1].derivation_rejected, - "a checked candidate that does not reproduce the key settles it as not derived" + Some(crate::changeset::IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 1, + }), + "a checked candidate that does not reproduce the key refutes its coordinates" ); // With no candidate for a key (its derivation failed), its origin @@ -978,7 +987,7 @@ mod tests { let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); let unchecked = decisions.iter().find(|d| d.key.id() == 1).unwrap(); assert_eq!(unchecked.breadcrumb, None); - assert!(!unchecked.derivation_rejected); + assert_eq!(unchecked.derivation_rejected, None); } /// An on-chain key typed `ECDSA_HASH160` (data = the 20-byte hash of diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 922d3068f02..27ef7185191 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -21,7 +21,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::changeset::{KeyWithBreadcrumb, PersistenceError}; +use crate::changeset::{IdentityKeyDerivationIndices, KeyWithBreadcrumb, PersistenceError}; use crate::error::PlatformWalletError; use super::signing_key::AvailableSigningKey; @@ -322,6 +322,17 @@ impl IdentityWallet { .filter(|_| !external_key_ids.contains(&id)) .map(|idx| (self.wallet_id, idx, id)) }; + // A declared external key refutes the coordinates an update would + // have inferred for it (older code stored them), so a persister clears + // exactly that breadcrumb. + let external_slot = |id: KeyID| { + identity_index + .filter(|_| external_key_ids.contains(&id)) + .map(|identity_index| IdentityKeyDerivationIndices { + identity_index, + key_index: id, + }) + }; let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -363,7 +374,7 @@ impl IdentityWallet { .unwrap_or(key) .clone(), breadcrumb: added_breadcrumb(id), - derivation_rejected: external_key_ids.contains(&id), + derivation_rejected: external_slot(id), }); } for &id in &disabled { @@ -378,7 +389,7 @@ impl IdentityWallet { keys.push(KeyWithBreadcrumb { key: key.clone(), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }); } // Stored with the identity's snapshot even when no touched key @@ -403,7 +414,7 @@ impl IdentityWallet { .into_iter() .map(|key| { let breadcrumb = added_breadcrumb(key.id()); - let derivation_rejected = external_key_ids.contains(&key.id()); + let derivation_rejected = external_slot(key.id()); KeyWithBreadcrumb { key, breadcrumb, @@ -947,9 +958,13 @@ mod update_identity_at_revision_tests { assert_eq!(stored.public_key, key_3(), "cached at {cached}"); assert_eq!(stored.derivation_indices, None, "cached at {cached}"); assert_eq!(stored.wallet_id, None, "cached at {cached}"); - assert!( + assert_eq!( stored.derivation_rejected, - "a declared external key clears a stale breadcrumb, cached at {cached}" + Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 3, + }), + "a declared external key refutes its slot's breadcrumb, cached at {cached}" ); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 189a14e6922..a81616bdc4b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -72,7 +72,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(pub_key), wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }, ); } @@ -482,7 +482,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(key), wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }, ); } @@ -543,7 +543,7 @@ impl ManagedIdentity { if public_key_hash != entry.public_key_hash || key.data() != entry.public_key.data() { entry.wallet_id = None; entry.derivation_indices = None; - entry.derivation_rejected = false; + entry.derivation_rejected = None; } entry.public_key_hash = public_key_hash; entry.public_key = key.clone(); @@ -619,7 +619,7 @@ impl ManagedIdentity { vec![crate::changeset::KeyWithBreadcrumb { key: public_key, breadcrumb: derivation_breadcrumb, - derivation_rejected: false, + derivation_rejected: None, }], persister, ) @@ -690,7 +690,8 @@ impl ManagedIdentity { public_key_hash, wallet_id, derivation_indices, - derivation_rejected: derivation_indices.is_none() && derivation_rejected, + derivation_rejected: derivation_rejected + .filter(|_| derivation_indices.is_none()), }, ); } @@ -743,7 +744,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }, ); self.store_with_queued_key_rows(keys_cs, persister) @@ -873,7 +874,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, - derivation_rejected: false, + derivation_rejected: None, }, ); } @@ -1155,12 +1156,12 @@ mod tests { KeyWithBreadcrumb { key: key(3), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }, KeyWithBreadcrumb { key: key(4), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }, ] }; @@ -1193,7 +1194,7 @@ mod tests { let decisions = vec![KeyWithBreadcrumb { key: key(3).with_limits(Some(100), None), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }]; let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); @@ -1282,7 +1283,7 @@ mod tests { let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { breadcrumb: Some((wallet_id, 0, key.id())), key, - derivation_rejected: false, + derivation_rejected: None, }; let row = |key_id: KeyID| { persister @@ -1334,7 +1335,7 @@ mod tests { vec![KeyWithBreadcrumb { key: raised.clone(), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1412,7 +1413,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1476,7 +1477,7 @@ mod tests { vec![KeyWithBreadcrumb { breadcrumb: Some((wallet_id, 0, key.id())), key, - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1493,7 +1494,7 @@ mod tests { vec![KeyWithBreadcrumb { key: other_key(2), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1515,14 +1516,17 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: None, - derivation_rejected: true, + derivation_rejected: Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 4, + }), }], &p, ) .unwrap(); let settled = row(4).unwrap(); assert_eq!(settled.derivation_indices, None); - assert!(settled.derivation_rejected); + assert!(settled.derivation_rejected.is_some()); // A refused rejection is kept by the next store's own row for the // same key that leaves its origin unknown (a disable). @@ -1534,7 +1538,10 @@ mod tests { vec![KeyWithBreadcrumb { key: key(5), breadcrumb: None, - derivation_rejected: true, + derivation_rejected: Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 5, + }), }], &p, ) @@ -1546,7 +1553,7 @@ mod tests { let disabled = row(5).unwrap(); assert_eq!(disabled.public_key.disabled_at(), Some(9)); assert!( - disabled.derivation_rejected, + disabled.derivation_rejected.is_some(), "the queued rejection survives" ); } @@ -1579,7 +1586,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1818,12 +1825,12 @@ mod tests { crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: false, + derivation_rejected: None, }, crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }, ], &p, @@ -1884,7 +1891,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: false, + derivation_rejected: None, }], &p, ) @@ -1956,7 +1963,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: false, + derivation_rejected: None, }], &p, ); diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 067877b5f21..d58411b5caf 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1538,12 +1538,12 @@ unsafe extern "C" fn tramp_persist_identity_keys( /// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` before /// the final `Z` is the key usage limits pair (`hasTotalBudget`, -/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `Z` is -/// `derivationRejected`. Bound at the `call_method` site and in +/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `ZII` is +/// `derivationRejected`, `rejectedIdentityIndex`, `rejectedKeyIndex`. Bound at the `call_method` site and in /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves /// the table up front, while the call site only resolves when a key is /// first persisted. -const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZ)I"; +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZII)I"; unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, @@ -1585,7 +1585,9 @@ unsafe fn persist_identity_key_upsert( JValue::Long(e.total_budget as i64), JValue::Bool(e.expires_at_is_some as u8), JValue::Long(e.expires_at as i64), - JValue::Bool(e.derivation_rejected as u8), + JValue::Bool(e.derivation_rejected_is_some as u8), + JValue::Int(e.rejected_identity_index as i32), + JValue::Int(e.rejected_key_index as i32), ], )? .i() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 4e932781d0b..7b536a54615 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4157,6 +4157,13 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { parent.addPublicKey(row) } } + // Another public key under this key id: the row's derivation + // path and private-key linkage belonged to the old key and must + // not pass to this one. + if row.publicKeyData != entry.publicKeyData { + row.identityDerivationPath = nil + row.privateKeyKeychainIdentifier = nil + } // Refresh mutable fields every upsert. row.publicKeyData = entry.publicKeyData row.readOnly = entry.readOnly @@ -4209,13 +4216,19 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let path = identityAuthPath(walletId: resolvedWalletId, indices: indices) { row.identityDerivationPath = path } - } else if entry.derivationRejected { - // Rust settled the key as not derived from the wallet (its - // derivation candidate did not reproduce it, or the caller - // declared it external): a path held for it is a stale - // inferred one, and the signer must not derive at it. An - // entry without indices that leaves the origin unknown - // keeps the path. + } else if let rejected = entry.derivationRejected, + let rejectedPath = identityAuthPath( + walletId: row.walletId ?? entry.walletId ?? walletId, + indices: rejected + ), + row.identityDerivationPath == rejectedPath + { + // Rust checked the key at these coordinates and found it does + // not come from there (the candidate did not reproduce it, or + // the caller declared it external): the path is a stale + // inferred one, and the signer must not derive at it. A path + // at other coordinates, and every path when the origin is + // unknown, is kept. row.identityDerivationPath = nil } @@ -5257,10 +5270,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `identity.identityPublicKeys`) and so a group bound is not /// mistaken for a whole-contract one. let contractBounds: ManagedPlatformWallet.ContractBounds? - /// With no `derivationIndices`: `true` when Rust settled the key as - /// not derived from the wallet, so a stored derivation path is - /// cleared; `false` when its origin is unknown, which keeps it. - var derivationRejected: Bool = false + /// With no `derivationIndices`: the coordinates Rust checked the key + /// at and found it does not come from, so a stored derivation path + /// naming exactly them is cleared; `nil` when its origin is unknown, + /// which keeps the stored path. + var derivationRejected: (identityIndex: UInt32, keyIndex: UInt32)? = nil } // MARK: - Watch-only Restore: Account Addresses @@ -10333,7 +10347,9 @@ private func persistIdentityKeysCallback( walletId: walletId, derivationIndices: indices, contractBounds: bounds, - derivationRejected: e.derivation_rejected + derivationRejected: e.derivation_rejected_is_some + ? (e.rejected_identity_index, e.rejected_key_index) + : nil )) } } From bb184f50290c9c012bbfbb49814ac8a707a5caf0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 12:30:38 +0300 Subject: [PATCH 32/72] fix(platform-wallet): a rejection drops only its slot's pending repair, a non-ECDSA key settles as not derived - Kotlin drops a pending re-derive entry for a key only when the entry's slot is the one a rejection refutes; a repair pending at other coordinates stays. - Discovery settles a key whose derivation check cannot run on its type (BLS, EdDSA, a script hash) as not derived from the wallet, which only derives ECDSA identity keys; a failed check on an ECDSA key still leaves the origin unknown. - Swift builds the refuted path from the persisting wallet, as the stored path was built. - Docs describe a breadcrumb-less key row by its three states instead of calling it watch-only. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 17 ++++++++++++++--- .../src/changeset/changeset.rs | 16 +++++++++------- .../src/wallet/identity/network/discovery.rs | 17 +++++++++++++---- .../src/wallet/identity/network/update.rs | 7 ++++--- .../state/managed_identity/identity_ops.rs | 4 ++-- packages/rs-unified-sdk-jni/src/persistence.rs | 4 ++-- .../PlatformWalletPersistenceHandler.swift | 2 +- 7 files changed, 45 insertions(+), 22 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index b9adb4027d1..c1a1d4fd9c9 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1929,10 +1929,21 @@ class PlatformWalletPersistenceHandler( // alias whose row can never commit (the identity rows are gone), // stranding identity-key ciphertext behind a "successful" wipe. val roundKey = walletId.toHex() - // A key Rust settled as not derived needs no re-derive: drop a - // pending repair entry for it, staged with the round. + // A rejection refutes the slot it checked: a pending repair entry + // for that slot needs no re-derive and is dropped, staged with the + // round; one for another slot stays. if (derivationRejected && !derivationIndicesIsSome) { - stagePendingKeyDelta(roundKey, clearPendingKeyDelta(publicKeyData.toHex())) + val hex = publicKeyData.toHex() + stagePendingKeyDelta(roundKey) { map -> + val pending = map[hex] + if (pending != null && pending.identityIndex == rejectedIdentityIndex && + pending.keyIndex == rejectedKeyIndex + ) { + map - hex + } else { + map + } + } } val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 0764fbf1644..83c28a1db13 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1037,12 +1037,13 @@ pub struct KeyWithBreadcrumb { /// A single identity-key entry in an [`IdentityKeysChangeSet`]. /// -/// Carries the DPP public-key record and a breadcrumb pointing at the wallet -/// derivation that produced it. No private material crosses here: the client -/// derives the key on demand from the Keychain seed at the breadcrumb path -/// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). When -/// `derivation_indices` is `None` the key is watch-only from this wallet's -/// point of view. +/// Carries the DPP public-key record and, when its origin was verified, a +/// breadcrumb pointing at the wallet derivation that produced it. No private +/// material crosses here: the client derives the key on demand from the +/// Keychain seed at the breadcrumb path +/// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). An entry with no +/// `derivation_indices` gives no coordinates; whether a persister then keeps +/// the breadcrumb it holds for the key or clears it is `derivation_rejected`. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { @@ -1137,7 +1138,8 @@ impl Merge for IdentityKeysChangeSet { // persister keeps a stored one, or its rejection): a buffered write // that only knows the key's new state must neither turn a // wallet-derived key watch-only nor bring back a breadcrumb a - // rejection cleared. One that settles the origin itself wins. + // rejection cleared. Its own breadcrumb wins, and its own rejection + // wins over an earlier breadcrumb at exactly those coordinates. for (id, mut entry) in other.upserts { if let Some(earlier) = self.upserts.get(&id) { entry.inherit_origin(earlier); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index e429039ff46..1e7cad15a27 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -75,11 +75,20 @@ fn breadcrumb_decisions( .iter() .map(|(key_id, on_chain_key)| { // `None` when there is no candidate (its derivation failed) or - // the check itself failed: the origin stays unknown. + // the check failed on an ECDSA key: the origin stays unknown. A + // check that cannot run on another key type (BLS, EdDSA, a script + // hash) settles it as not this wallet's: the wallet only derives + // ECDSA identity keys. + let ecdsa = matches!( + on_chain_key.key_type(), + KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 + ); let checked = candidate_scalars.get(key_id).and_then(|scalar| { - on_chain_key - .validate_private_key_bytes(scalar, network) - .ok() + match on_chain_key.validate_private_key_bytes(scalar, network) { + Ok(reproduces) => Some(reproduces), + Err(_) if !ecdsa => Some(false), + Err(_) => None, + } }); let reproduces = checked == Some(true); // A candidate that does not reproduce the key refutes exactly the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 27ef7185191..a5986c31c61 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -305,9 +305,10 @@ impl IdentityWallet { /// but was not stored. /// /// An added key named in `external_key_ids` is stored without a - /// breadcrumb and settled as not derived on both paths, which clears a - /// breadcrumb a persister holds for it; every other one with the - /// breadcrumb of its slot, `(wallet_id, identity_index, key_id)`. + /// breadcrumb and refutes its slot's coordinates on both paths, which + /// clears a breadcrumb a persister holds there (one an older update + /// inferred); every other one with the breadcrumb of its slot, + /// `(wallet_id, identity_index, key_id)`. async fn apply_landed_update( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index a81616bdc4b..6b641ac0163 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -629,8 +629,8 @@ impl ManagedIdentity { /// batched [`IdentityKeysChangeSet`] carrying each key's derivation /// breadcrumb (`Some((wallet_id, identity_index, key_index))`) or /// `None` for a key the wallet can't re-derive (set - /// `derivation_rejected` when that is settled, so a stored breadcrumb is - /// cleared). + /// `derivation_rejected` to the coordinates checked when that is settled, + /// so a stored breadcrumb naming them is cleared). /// /// The single-write batch form of [`Self::add_key`], used by discovery /// to materialize every re-derivable key of a freshly found identity in diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index d58411b5caf..637e6c8a8fe 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1536,8 +1536,8 @@ unsafe extern "C" fn tramp_persist_identity_keys( }) } -/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` before -/// the final `Z` is the key usage limits pair (`hasTotalBudget`, +/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The `ZJZJ` before the +/// final `ZII` is the key usage limits pair (`hasTotalBudget`, /// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `ZII` is /// `derivationRejected`, `rejectedIdentityIndex`, `rejectedKeyIndex`. Bound at the `call_method` site and in /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 7b536a54615..d88d86da77f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4218,7 +4218,7 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } else if let rejected = entry.derivationRejected, let rejectedPath = identityAuthPath( - walletId: row.walletId ?? entry.walletId ?? walletId, + walletId: walletId, indices: rejected ), row.identityDerivationPath == rejectedPath From 67fbf95baa096e12449f24ddf0954a28b19f4523 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:39:01 +0300 Subject: [PATCH 33/72] fix(platform-wallet): a rejection clears only the persisting wallet's breadcrumb A rejection is checked under the seed of the wallet persisting it, and the same coordinates under another wallet's seed are another derivation. Swift clears a refuted path only when the key row's wallet is the persisting one, and Kotlin clears refuted indices only when the identity belongs to the persisting wallet, and a pending repair only when it is that wallet's. SQLite already reads only the persisting wallet's rows. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 18 ++++++++++++------ .../PlatformWalletPersistenceHandlerTest.kt | 9 ++++++--- .../src/changeset/changeset.rs | 8 +++++--- .../PlatformWalletPersistenceHandler.swift | 3 +++ 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index c1a1d4fd9c9..6cf26d0a900 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1929,14 +1929,16 @@ class PlatformWalletPersistenceHandler( // alias whose row can never commit (the identity rows are gone), // stranding identity-key ciphertext behind a "successful" wipe. val roundKey = walletId.toHex() - // A rejection refutes the slot it checked: a pending repair entry - // for that slot needs no re-derive and is dropped, staged with the - // round; one for another slot stays. + // A rejection refutes the slot it checked under this wallet's seed: a + // pending repair entry for that slot of this wallet needs no + // re-derive and is dropped, staged with the round; one for another + // slot, or another wallet, stays. if (derivationRejected && !derivationIndicesIsSome) { val hex = publicKeyData.toHex() stagePendingKeyDelta(roundKey) { map -> val pending = map[hex] - if (pending != null && pending.identityIndex == rejectedIdentityIndex && + if (pending != null && pending.walletIdHex == roundKey && + pending.identityIndex == rejectedIdentityIndex && pending.keyIndex == rejectedKeyIndex ) { map - hex @@ -2036,10 +2038,14 @@ class PlatformWalletPersistenceHandler( // key's derivation and private-key linkage, which must not pass to // this one: only a row for the same public key lends them. val existing = stored?.takeIf { it.publicKeyData.contentEquals(publicKeyData) } - // A rejection refutes only the coordinates it was checked at. + // A rejection refutes only the coordinates it was checked at, under + // the seed of the wallet persisting it: a key row's breadcrumb is + // its identity's wallet's derivation, so another wallet's + // rejection leaves it. val refuted = derivationRejected && !derivationIndicesIsSome && existing?.derivationIdentityIndex == rejectedIdentityIndex && - existing.derivationKeyIndex == rejectedKeyIndex + existing.derivationKeyIndex == rejectedKeyIndex && + db.identityDao().getByIdentityId(identityId)?.walletId?.contentEquals(walletId) == true // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 58d59c4d8b1..21b350412d5 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1201,17 +1201,18 @@ class PlatformWalletPersistenceHandlerTest { rejected: Boolean, rejectedKeyIndex: Int = 0, key: ByteArray = ByteArray(33) { 10 }, + from: ByteArray = walletId, ) { - handler.onChangesetBegin(walletId) + handler.onChangesetBegin(from) handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, + from, identityId, 0, 0, 0, 0, false, false, 0, key, ByteArray(20), true, walletId, indicesIsSome, 0, 7, 0, ByteArray(32), null, derivationRejected = rejected, rejectedIdentityIndex = 0, rejectedKeyIndex = rejectedKeyIndex, ) - handler.onChangesetEnd(walletId, success = true) + handler.onChangesetEnd(from, success = true) } suspend fun stored() = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! @@ -1221,6 +1222,8 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(7, stored().derivationKeyIndex) upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 0) assertEquals("another slot's rejection keeps it", 7, stored().derivationKeyIndex) + upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 7, from = ByteArray(32) { 2 }) + assertEquals("another wallet's seed refutes nothing here", 7, stored().derivationKeyIndex) upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 7) assertNull(stored().derivationKeyIndex) assertNull(stored().derivationIdentityIndex) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 83c28a1db13..c66aac7a243 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1071,9 +1071,11 @@ pub struct IdentityKeyEntry { /// coordinates the key was checked at and found not to come from (a load /// or discovery derived a candidate there and it did not reproduce the /// key, or the caller of an update declared the key external), so a - /// persister clears a stored breadcrumb naming exactly these coordinates, - /// a stale inferred one, and keeps any other: only the derivation that - /// was checked is refuted. Ignored with `derivation_indices`. + /// persister clears a stored breadcrumb naming exactly these coordinates + /// under the seed of the wallet persisting the entry, a stale inferred + /// one, and keeps any other: only the derivation that was checked is + /// refuted, and the same coordinates under another wallet's seed are + /// another derivation. Ignored with `derivation_indices`. #[cfg_attr(feature = "serde", serde(default))] pub derivation_rejected: Option, } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index d88d86da77f..21ab2203ecf 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4217,6 +4217,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { row.identityDerivationPath = path } } else if let rejected = entry.derivationRejected, + // The rejection was checked under this wallet's seed: a path + // another wallet derived is not refuted by it. + (row.walletId ?? walletId) == walletId, let rejectedPath = identityAuthPath( walletId: walletId, indices: rejected From 18bc03ffaf53cb4bbef71b92a5e9d466cc779622 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:39:23 +0300 Subject: [PATCH 34/72] fix(platform-wallet)!: combine a key's rejections as a set, scope Kotlin breadcrumbs to their wallet - `derivation_rejected` is a set of the coordinates a key was checked at under the persisting wallet's seed. Changeset merges and the refused-row queue join the sets, so combined writes clear what applying them one by one clears: a buffered rejection of `(0, 3)` followed by one of `(1, 3)` still clears a stale breadcrumb at `(0, 3)`. SQLite, Swift and Kotlin clear a stored breadcrumb that one of them names. - FFI: `IdentityKeyEntryFFI` carries the set as owned flat `[identity_index, key_index]` pairs (`rejected_derivations_ptr` / `_len`, freed with the entry); the JNI upsert callback passes it as an `int[]` (`...ZJZJ[I)I`). - Kotlin records the wallet a breadcrumb belongs to on the key row (`public_keys.derivationWalletId`, Room schema 16; the 15 -> 16 migration backfills it from the identity's wallet for rows that carried one) and clears a breadcrumb only for a rejection by that wallet, instead of reading the identity's wallet, which the same round may re-associate. - Regressions: a full Kotlin round (A's breadcrumb, then B's identity upsert and rejection), the 15 -> 16 migration, SQLite buffered and immediate writes of two rejections, and a refused rejection retried with a second one. Closes #5525. Co-Authored-By: Claude Opus 5.5 --- .../16.json | 4207 +++++++++++++++++ .../persistence/DashDatabaseMigrationTest.kt | 53 +- .../dashsdk/ffi/NativePersistenceBridge.kt | 18 +- .../dashsdk/persistence/DashDatabase.kt | 22 +- .../PlatformWalletPersistenceHandler.kt | 47 +- .../persistence/entities/PublicKeyEntity.kt | 10 + .../PlatformWalletPersistenceHandlerTest.kt | 57 +- .../src/identity_persistence.rs | 105 +- .../src/sqlite/schema/identities.rs | 5 +- .../src/sqlite/schema/identity_keys.rs | 7 +- .../sqlite_contacts_keys_rehydration.rs | 3 +- ...qlite_delete_wallet_constraint_carveout.rs | 3 +- .../sqlite_fk_changeset_ordering.rs | 3 +- .../sqlite_identity_hard_delete.rs | 2 +- .../sqlite_identity_keys_reader.rs | 82 +- .../integration/sqlite_persist_roundtrip.rs | 3 +- .../tests/integration/sqlite_recovery_mode.rs | 3 +- .../sqlite_structural_hardening.rs | 3 +- .../integration/sqlite_unowned_identities.rs | 3 +- .../tests/integration/sqlite_version_bump.rs | 3 +- .../src/changeset/changeset.rs | 92 +- .../src/wallet/identity/network/discovery.rs | 19 +- .../src/wallet/identity/network/update.rs | 11 +- .../state/managed_identity/identity_ops.rs | 91 +- .../rs-unified-sdk-jni/src/persistence.rs | 22 +- .../PlatformWalletPersistenceHandler.swift | 35 +- 26 files changed, 4718 insertions(+), 191 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json new file mode 100644 index 00000000000..cf8bd68cf5e --- /dev/null +++ b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json @@ -0,0 +1,4207 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "bf2c2e1447c26d26f643457c97fe5452", + "entities": [ + { + "tableName": "wallets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletGroupId", + "columnName": "walletGroupId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT" + }, + { + "fieldPath": "walletDescription", + "columnName": "walletDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "birthHeight", + "columnName": "birthHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedHeight", + "columnName": "syncedHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSynced", + "columnName": "lastSynced", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAppliedChainLockBytes", + "columnName": "lastAppliedChainLockBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "lastAppliedChainLockHeight", + "columnName": "lastAppliedChainLockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillFloor", + "columnName": "dashPayBackfillFloor", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillRewoundFrom", + "columnName": "dashPayBackfillRewoundFrom", + "affinity": "INTEGER" + }, + { + "fieldPath": "dashPayBackfillCovered", + "columnName": "dashPayBackfillCovered", + "affinity": "BLOB" + }, + { + "fieldPath": "isImported", + "columnName": "isImported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_wallets_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_wallets_walletGroupId", + "unique": false, + "columnNames": [ + "walletGroupId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" + } + ] + }, + { + "tableName": "accounts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountType", + "columnName": "accountType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountTypeName", + "columnName": "accountTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceConfirmed", + "columnName": "balanceConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balanceUnconfirmed", + "columnName": "balanceUnconfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "externalHighestUsed", + "columnName": "externalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "internalHighestUsed", + "columnName": "internalHighestUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "standardTag", + "columnName": "standardTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "registrationIndex", + "columnName": "registrationIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyClass", + "columnName": "keyClass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userIdentityId", + "columnName": "userIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "friendIdentityId", + "columnName": "friendIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountExtendedPubKeyBytes", + "columnName": "accountExtendedPubKeyBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_accounts_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", + "unique": true, + "columnNames": [ + "walletId", + "accountType", + "accountIndex", + "standardTag", + "registrationIndex", + "keyClass", + "userIdentityId", + "friendIdentityId" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" + }, + { + "name": "index_accounts_accountExtendedPubKeyBytes", + "unique": true, + "columnNames": [ + "accountExtendedPubKeyBytes" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "transactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", + "fields": [ + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionData", + "columnName": "transactionData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "context", + "columnName": "context", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHash", + "columnName": "blockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "blockTimestamp", + "columnName": "blockTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockPosition", + "columnName": "blockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockPosition", + "columnName": "hasBlockPosition", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transactionType", + "columnName": "transactionType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionTypeKind", + "columnName": "transactionTypeKind", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "netAmount", + "columnName": "netAmount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER" + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "firstSeen", + "columnName": "firstSeen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "txid" + ] + }, + "indices": [ + { + "name": "index_transactions_firstSeen", + "unique": false, + "columnNames": [ + "firstSeen" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" + } + ] + }, + { + "tableName": "transaction_account_involvements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "transactionTxid", + "columnName": "transactionTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "transactionTxid", + "accountId" + ] + }, + "indices": [ + { + "name": "index_transaction_account_involvements_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "transactionTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "txos", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "vout", + "columnName": "vout", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "scriptPubKey", + "columnName": "scriptPubKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "height", + "columnName": "height", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isCoinbase", + "columnName": "isCoinbase", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isConfirmed", + "columnName": "isConfirmed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInstantLocked", + "columnName": "isInstantLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocked", + "columnName": "isLocked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "spendingInputIndex", + "columnName": "spendingInputIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreAddressId", + "columnName": "coreAddressId", + "affinity": "TEXT" + }, + { + "fieldPath": "supersededByTxid", + "columnName": "supersededByTxid", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outpoint" + ] + }, + "indices": [ + { + "name": "index_txos_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_txos_txid", + "unique": false, + "columnNames": [ + "txid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" + }, + { + "name": "index_txos_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_txos_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" + }, + { + "name": "index_txos_coreAddressId", + "unique": false, + "columnNames": [ + "coreAddressId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "txid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "transactions", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTxid" + ], + "referencedColumns": [ + "txid" + ] + }, + { + "table": "accounts", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "core_addresses", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "coreAddressId" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "core_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "poolTypeTag", + "columnName": "poolTypeTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + }, + "indices": [ + { + "name": "index_core_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "asset_locks", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "transactionBytes", + "columnName": "transactionBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingTypeRaw", + "columnName": "fundingTypeRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityIndexRaw", + "columnName": "identityIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountIndexRaw", + "columnName": "accountIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proofBytes", + "columnName": "proofBytes", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressHash", + "columnName": "recipientPlatformAddressHash", + "affinity": "BLOB" + }, + { + "fieldPath": "recipientPlatformAddressType", + "columnName": "recipientPlatformAddressType", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_asset_locks_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "invitations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", + "fields": [ + { + "fieldPath": "outPointHex", + "columnName": "outPointHex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rawOutPoint", + "columnName": "rawOutPoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "fundingIndexRaw", + "columnName": "fundingIndexRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "expiryUnix", + "columnName": "expiryUnix", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtSecs", + "columnName": "createdAtSecs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasInviter", + "columnName": "hasInviter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "reclaimInFlight", + "columnName": "reclaimInFlight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "outPointHex" + ] + }, + "indices": [ + { + "name": "index_invitations_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "identities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocal", + "columnName": "isLocal", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "alias", + "columnName": "alias", + "affinity": "TEXT" + }, + { + "fieldPath": "dpnsName", + "columnName": "dpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "mainDpnsName", + "columnName": "mainDpnsName", + "affinity": "TEXT" + }, + { + "fieldPath": "identityType", + "columnName": "identityType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "votingPrivateKeyIdentifier", + "columnName": "votingPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "ownerPrivateKeyIdentifier", + "columnName": "ownerPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "payoutPrivateKeyIdentifier", + "columnName": "payoutPrivateKeyIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB" + }, + { + "fieldPath": "identityIndex", + "columnName": "identityIndex", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "identityId" + ] + }, + "indices": [ + { + "name": "index_identities_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_identities_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ], + "foreignKeys": [ + { + "table": "wallets", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "walletId" + ], + "referencedColumns": [ + "walletId" + ] + } + ] + }, + { + "tableName": "public_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, `derivationWalletId` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keyId", + "columnName": "keyId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "purpose", + "columnName": "purpose", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyType", + "columnName": "keyType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "readOnly", + "columnName": "readOnly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "disabledAt", + "columnName": "disabledAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "totalBudget", + "columnName": "totalBudget", + "affinity": "INTEGER" + }, + { + "fieldPath": "expiresAt", + "columnName": "expiresAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "publicKeyData", + "columnName": "publicKeyData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractBoundsData", + "columnName": "contractBoundsData", + "affinity": "BLOB" + }, + { + "fieldPath": "contractBoundsDocumentTypeName", + "columnName": "contractBoundsDocumentTypeName", + "affinity": "TEXT" + }, + { + "fieldPath": "contractBoundsKind", + "columnName": "contractBoundsKind", + "affinity": "INTEGER" + }, + { + "fieldPath": "privateKeyKeychainIdentifier", + "columnName": "privateKeyKeychainIdentifier", + "affinity": "TEXT" + }, + { + "fieldPath": "derivationIdentityIndex", + "columnName": "derivationIdentityIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "derivationKeyIndex", + "columnName": "derivationKeyIndex", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessed", + "columnName": "lastAccessed", + "affinity": "INTEGER" + }, + { + "fieldPath": "identityIdData", + "columnName": "identityIdData", + "affinity": "BLOB" + }, + { + "fieldPath": "derivationWalletId", + "columnName": "derivationWalletId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_public_keys_identityId_keyId", + "unique": false, + "columnNames": [ + "identityId", + "keyId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" + }, + { + "name": "index_public_keys_identityIdData", + "unique": false, + "columnNames": [ + "identityIdData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" + }, + { + "name": "index_public_keys_publicKeyData", + "unique": false, + "columnNames": [ + "publicKeyData" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityIdData" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dpns_names", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedLabel", + "columnName": "normalizedLabel", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "parentDomainName", + "columnName": "parentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "normalizedParentDomainName", + "columnName": "normalizedParentDomainName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "acquiredAt", + "columnName": "acquiredAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "BLOB" + }, + { + "fieldPath": "isOwned", + "columnName": "isOwned", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "priceCredits", + "columnName": "priceCredits", + "affinity": "INTEGER" + }, + { + "fieldPath": "saleStatusRaw", + "columnName": "saleStatusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB" + }, + { + "fieldPath": "documentCreatedAtMs", + "columnName": "documentCreatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentUpdatedAtMs", + "columnName": "documentUpdatedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTransferredAtMs", + "columnName": "documentTransferredAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "marketplaceUpdatedAt", + "columnName": "marketplaceUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "normalizedParentDomainName", + "normalizedLabel" + ] + }, + "indices": [ + { + "name": "index_dpns_names_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_dpns_names_documentId", + "unique": false, + "columnNames": [ + "documentId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "identityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_profiles_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "identityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "isOutgoing", + "columnName": "isOutgoing", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderKeyIndex", + "columnName": "senderKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "recipientKeyIndex", + "columnName": "recipientKeyIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountReference", + "columnName": "accountReference", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "encryptedPublicKey", + "columnName": "encryptedPublicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "encryptedAccountLabel", + "columnName": "encryptedAccountLabel", + "affinity": "BLOB" + }, + { + "fieldPath": "autoAcceptProof", + "columnName": "autoAcceptProof", + "affinity": "BLOB" + }, + { + "fieldPath": "coreHeightCreatedAt", + "columnName": "coreHeightCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMillis", + "columnName": "createdAtMillis", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "paymentChannelBroken", + "columnName": "paymentChannelBroken", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAlias", + "columnName": "contactAlias", + "affinity": "TEXT" + }, + { + "fieldPath": "contactNote", + "columnName": "contactNote", + "affinity": "TEXT" + }, + { + "fieldPath": "contactHidden", + "columnName": "contactHidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "contactAccountLabel", + "columnName": "contactAccountLabel", + "affinity": "TEXT" + }, + { + "fieldPath": "contactAcceptedAccounts", + "columnName": "contactAcceptedAccounts", + "affinity": "BLOB" + }, + { + "fieldPath": "externalAccountReference", + "columnName": "externalAccountReference", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId", + "isOutgoing" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_requests_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_ignored_senders", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredSenderId", + "columnName": "ignoredSenderId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ignoredAt", + "columnName": "ignoredAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "ignoredSenderId" + ] + }, + "indices": [ + { + "name": "index_dashpay_ignored_senders_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_contact_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contactIdentityId", + "columnName": "contactIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT" + }, + { + "fieldPath": "publicMessage", + "columnName": "publicMessage", + "affinity": "TEXT" + }, + { + "fieldPath": "bio", + "columnName": "bio", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarUrl", + "columnName": "avatarUrl", + "affinity": "TEXT" + }, + { + "fieldPath": "avatarHash", + "columnName": "avatarHash", + "affinity": "BLOB" + }, + { + "fieldPath": "avatarFingerprint", + "columnName": "avatarFingerprint", + "affinity": "BLOB" + }, + { + "fieldPath": "checkedAtMs", + "columnName": "checkedAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "contactIdentityId" + ] + }, + "indices": [ + { + "name": "index_dashpay_contact_profiles_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "dashpay_payments", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterpartyIdentityId", + "columnName": "counterpartyIdentityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amountDuffs", + "columnName": "amountDuffs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "directionRaw", + "columnName": "directionRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "statusRaw", + "columnName": "statusRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "txid", + "columnName": "txid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw", + "ownerIdentityId", + "txid" + ] + }, + "indices": [ + { + "name": "index_dashpay_payments_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "data_contracts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "serializedContract", + "columnName": "serializedContract", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "binarySerialization", + "columnName": "binarySerialization", + "affinity": "BLOB" + }, + { + "fieldPath": "version", + "columnName": "version", + "affinity": "INTEGER" + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "BLOB" + }, + { + "fieldPath": "contractDescription", + "columnName": "contractDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "schemaData", + "columnName": "schemaData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypesData", + "columnName": "documentTypesData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "groupsData", + "columnName": "groupsData", + "affinity": "BLOB" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "canBeDeleted", + "columnName": "canBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "readonly", + "columnName": "readonly", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsHistory", + "columnName": "keepsHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "schemaDefs", + "columnName": "schemaDefs", + "affinity": "INTEGER" + }, + { + "fieldPath": "documentsKeepHistoryContractDefault", + "columnName": "documentsKeepHistoryContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutableContractDefault", + "columnName": "documentsMutableContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeletedContractDefault", + "columnName": "documentsCanBeDeletedContractDefault", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasTokens", + "columnName": "hasTokens", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokensData", + "columnName": "tokensData", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_data_contracts_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_data_contracts_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "document_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "schemaJSON", + "columnName": "schemaJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentsKeepHistory", + "columnName": "documentsKeepHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsMutable", + "columnName": "documentsMutable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsCanBeDeleted", + "columnName": "documentsCanBeDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentsTransferable", + "columnName": "documentsTransferable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiredFieldsJSON", + "columnName": "requiredFieldsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "securityLevel", + "columnName": "securityLevel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "creationRestrictionMode", + "columnName": "creationRestrictionMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityEncryptionBoundedKey", + "columnName": "requiresIdentityEncryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "requiresIdentityDecryptionBoundedKey", + "columnName": "requiresIdentityDecryptionBoundedKey", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastAccessedAt", + "columnName": "lastAccessedAt", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_document_types_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "documents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "documentId", + "columnName": "documentId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "documentType", + "columnName": "documentType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "revision", + "columnName": "revision", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "ownerId", + "columnName": "ownerId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractIdData", + "columnName": "contractIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "ownerIdData", + "columnName": "ownerIdData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "transferredAt", + "columnName": "transferredAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtBlockHeight", + "columnName": "createdAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtBlockHeight", + "columnName": "updatedAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtBlockHeight", + "columnName": "transferredAtBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAtCoreBlockHeight", + "columnName": "createdAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedAtCoreBlockHeight", + "columnName": "updatedAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "transferredAtCoreBlockHeight", + "columnName": "transferredAtCoreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "isDeleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localCreatedAt", + "columnName": "localCreatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localUpdatedAt", + "columnName": "localUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeRelationId", + "columnName": "documentTypeRelationId", + "affinity": "BLOB" + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + }, + { + "fieldPath": "ownerIdentityId", + "columnName": "ownerIdentityId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "documentId" + ] + }, + "indices": [ + { + "name": "index_documents_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_documents_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_documents_ownerId", + "unique": false, + "columnNames": [ + "ownerId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" + }, + { + "name": "index_documents_documentTypeRelationId", + "unique": false, + "columnNames": [ + "documentTypeRelationId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" + }, + { + "name": "index_documents_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + }, + { + "name": "index_documents_ownerIdentityId", + "unique": false, + "columnNames": [ + "ownerIdentityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeRelationId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "identities", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "ownerIdentityId" + ], + "referencedColumns": [ + "identityId" + ] + } + ] + }, + { + "tableName": "indices", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "unique", + "columnName": "unique", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nullSearchable", + "columnName": "nullSearchable", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contested", + "columnName": "contested", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "propertiesJSON", + "columnName": "propertiesJSON", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contestedDetailsJSON", + "columnName": "contestedDetailsJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_indices_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "keywords", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "keyword", + "columnName": "keyword", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataContractId", + "columnName": "dataContractId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_keywords_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" + }, + { + "name": "index_keywords_dataContractId", + "unique": false, + "columnNames": [ + "dataContractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "dataContractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "properties", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "documentTypeName", + "columnName": "documentTypeName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "format", + "columnName": "format", + "affinity": "TEXT" + }, + { + "fieldPath": "contentMediaType", + "columnName": "contentMediaType", + "affinity": "TEXT" + }, + { + "fieldPath": "byteArray", + "columnName": "byteArray", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minItems", + "columnName": "minItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxItems", + "columnName": "maxItems", + "affinity": "INTEGER" + }, + { + "fieldPath": "pattern", + "columnName": "pattern", + "affinity": "TEXT" + }, + { + "fieldPath": "minLength", + "columnName": "minLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxLength", + "columnName": "maxLength", + "affinity": "INTEGER" + }, + { + "fieldPath": "minValue", + "columnName": "minValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "maxValue", + "columnName": "maxValue", + "affinity": "INTEGER" + }, + { + "fieldPath": "fieldDescription", + "columnName": "fieldDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "transient", + "columnName": "transient", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isRequired", + "columnName": "isRequired", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentTypeId", + "columnName": "documentTypeId", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_properties_documentTypeId", + "unique": false, + "columnNames": [ + "documentTypeId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" + } + ], + "foreignKeys": [ + { + "table": "document_types", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "documentTypeId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "pending_inputs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "outpoint", + "columnName": "outpoint", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "inputIndex", + "columnName": "inputIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "spendingTxid", + "columnName": "spendingTxid", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spendingTransactionTxid", + "columnName": "spendingTransactionTxid", + "affinity": "BLOB" + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSweptTombstone", + "columnName": "isSweptTombstone", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "winnerMinedHeight", + "columnName": "winnerMinedHeight", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_pending_inputs_outpoint", + "unique": false, + "columnNames": [ + "outpoint" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" + }, + { + "name": "index_pending_inputs_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" + }, + { + "name": "index_pending_inputs_spendingTransactionTxid", + "unique": false, + "columnNames": [ + "spendingTransactionTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" + }, + { + "name": "index_pending_inputs_spendingTxid", + "unique": false, + "columnNames": [ + "spendingTxid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" + }, + { + "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", + "unique": false, + "columnNames": [ + "walletId", + "isSweptTombstone", + "winnerMinedHeight" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" + } + ], + "foreignKeys": [ + { + "table": "transactions", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "spendingTransactionTxid" + ], + "referencedColumns": [ + "txid" + ] + } + ] + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "contractId", + "columnName": "contractId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "baseSupply", + "columnName": "baseSupply", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "maxSupply", + "columnName": "maxSupply", + "affinity": "TEXT" + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localizations", + "columnName": "localizations", + "affinity": "TEXT" + }, + { + "fieldPath": "isPaused", + "columnName": "isPaused", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "allowTransferToFrozenBalance", + "columnName": "allowTransferToFrozenBalance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsTransferHistory", + "columnName": "keepsTransferHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsFreezingHistory", + "columnName": "keepsFreezingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsMintingHistory", + "columnName": "keepsMintingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsBurningHistory", + "columnName": "keepsBurningHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPricingHistory", + "columnName": "keepsDirectPricingHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "keepsDirectPurchaseHistory", + "columnName": "keepsDirectPurchaseHistory", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "conventionsChangeRules", + "columnName": "conventionsChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "maxSupplyChangeRules", + "columnName": "maxSupplyChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualMintingRules", + "columnName": "manualMintingRules", + "affinity": "TEXT" + }, + { + "fieldPath": "manualBurningRules", + "columnName": "manualBurningRules", + "affinity": "TEXT" + }, + { + "fieldPath": "freezeRules", + "columnName": "freezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "unfreezeRules", + "columnName": "unfreezeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "destroyFrozenFundsRules", + "columnName": "destroyFrozenFundsRules", + "affinity": "TEXT" + }, + { + "fieldPath": "emergencyActionRules", + "columnName": "emergencyActionRules", + "affinity": "TEXT" + }, + { + "fieldPath": "perpetualDistribution", + "columnName": "perpetualDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "preProgrammedDistribution", + "columnName": "preProgrammedDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "oncePerIdentityDistribution", + "columnName": "oncePerIdentityDistribution", + "affinity": "TEXT" + }, + { + "fieldPath": "newTokensDestinationIdentity", + "columnName": "newTokensDestinationIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "mintingAllowChoosingDestination", + "columnName": "mintingAllowChoosingDestination", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "distributionChangeRules", + "columnName": "distributionChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "tradeMode", + "columnName": "tradeMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tradeModeChangeRules", + "columnName": "tradeModeChangeRules", + "affinity": "TEXT" + }, + { + "fieldPath": "mainControlGroupPosition", + "columnName": "mainControlGroupPosition", + "affinity": "INTEGER" + }, + { + "fieldPath": "mainControlGroupCanBeModified", + "columnName": "mainControlGroupCanBeModified", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDescription", + "columnName": "tokenDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdatedAt", + "columnName": "lastUpdatedAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyMint", + "columnName": "canManuallyMint", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canManuallyBurn", + "columnName": "canManuallyBurn", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canFreeze", + "columnName": "canFreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canUnfreeze", + "columnName": "canUnfreeze", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canDestroyFrozenFunds", + "columnName": "canDestroyFrozenFunds", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasEmergencyActions", + "columnName": "hasEmergencyActions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeMaxSupply", + "columnName": "canChangeMaxSupply", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeConventions", + "columnName": "canChangeConventions", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "canChangeTradeMode", + "columnName": "canChangeTradeMode", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDistribution", + "columnName": "hasDistribution", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_tokens_contractId", + "unique": false, + "columnNames": [ + "contractId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" + } + ], + "foreignKeys": [ + { + "table": "data_contracts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contractId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_balances", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenId", + "columnName": "tokenId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "frozen", + "columnName": "frozen", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedAt", + "columnName": "lastSyncedAt", + "affinity": "INTEGER" + }, + { + "fieldPath": "tokenName", + "columnName": "tokenName", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenSymbol", + "columnName": "tokenSymbol", + "affinity": "TEXT" + }, + { + "fieldPath": "tokenDecimals", + "columnName": "tokenDecimals", + "affinity": "INTEGER" + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityRef", + "columnName": "identityRef", + "affinity": "BLOB" + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_balances_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + }, + { + "name": "index_token_balances_tokenId_identityId", + "unique": false, + "columnNames": [ + "tokenId", + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" + }, + { + "name": "index_token_balances_identityId", + "unique": false, + "columnNames": [ + "identityId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" + }, + { + "name": "index_token_balances_identityRef", + "unique": false, + "columnNames": [ + "identityRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" + }, + { + "name": "index_token_balances_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "identities", + "onDelete": "SET NULL", + "onUpdate": "NO ACTION", + "columns": [ + "identityRef" + ], + "referencedColumns": [ + "identityId" + ] + }, + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "token_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventType", + "columnName": "eventType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "transactionId", + "columnName": "transactionId", + "affinity": "BLOB" + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "coreBlockHeight", + "columnName": "coreBlockHeight", + "affinity": "INTEGER" + }, + { + "fieldPath": "fromIdentity", + "columnName": "fromIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "toIdentity", + "columnName": "toIdentity", + "affinity": "BLOB" + }, + { + "fieldPath": "performedByIdentity", + "columnName": "performedByIdentity", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceBefore", + "columnName": "balanceBefore", + "affinity": "TEXT" + }, + { + "fieldPath": "balanceAfter", + "columnName": "balanceAfter", + "affinity": "TEXT" + }, + { + "fieldPath": "additionalDataJSON", + "columnName": "additionalDataJSON", + "affinity": "BLOB" + }, + { + "fieldPath": "eventDescription", + "columnName": "eventDescription", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventTimestamp", + "columnName": "eventTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenRef", + "columnName": "tokenRef", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_history_events_tokenRef", + "unique": false, + "columnNames": [ + "tokenRef" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "tokenRef" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "addressType", + "columnName": "addressType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressHash", + "columnName": "addressHash", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "publicKey", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "addressIndex", + "columnName": "addressIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "derivationPath", + "columnName": "derivationPath", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isUsed", + "columnName": "isUsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "balance", + "columnName": "balance", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nonce", + "columnName": "nonce", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstSeenHeight", + "columnName": "firstSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSeenHeight", + "columnName": "lastSeenHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "accountId", + "columnName": "accountId", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "address" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_walletId_addressHash", + "unique": true, + "columnNames": [ + "walletId", + "addressHash" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" + }, + { + "name": "index_platform_addresses_accountId", + "unique": false, + "columnNames": [ + "accountId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" + } + ], + "foreignKeys": [ + { + "table": "accounts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "accountId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "platform_addresses_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncHeight", + "columnName": "syncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncTimestamp", + "columnName": "syncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastKnownRecentBlock", + "columnName": "lastKnownRecentBlock", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId" + ] + }, + "indices": [ + { + "name": "index_platform_addresses_sync_states_networkRaw", + "unique": false, + "columnNames": [ + "networkRaw" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" + } + ] + }, + { + "tableName": "shielded_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", + "fields": [ + { + "fieldPath": "nullifier", + "columnName": "nullifier", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSpent", + "columnName": "isSpent", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "noteData", + "columnName": "noteData", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "nullifier" + ] + }, + "indices": [ + { + "name": "index_shielded_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_outgoing_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "cmx", + "columnName": "cmx", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "recipient", + "columnName": "recipient", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "cmx" + ] + }, + "indices": [ + { + "name": "index_shielded_outgoing_notes_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_activities", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "entryId", + "columnName": "entryId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "kindTag", + "columnName": "kindTag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "direction", + "columnName": "direction", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "amount", + "columnName": "amount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fee", + "columnName": "fee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasFee", + "columnName": "hasFee", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "blockHeight", + "columnName": "blockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasBlockHeight", + "columnName": "hasBlockHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAtMs", + "columnName": "createdAtMs", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "identityId", + "columnName": "identityId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "counterparty", + "columnName": "counterparty", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "memo", + "columnName": "memo", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "noteCmxs", + "columnName": "noteCmxs", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "spentNullifiers", + "columnName": "spentNullifiers", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex", + "entryId" + ] + }, + "indices": [ + { + "name": "index_shielded_activities_walletId_accountIndex", + "unique": false, + "columnNames": [ + "walletId", + "accountIndex" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" + } + ] + }, + { + "tableName": "shielded_sync_states", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastSyncedIndex", + "columnName": "lastSyncedIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_sync_states_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "shielded_viewing_keys", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", + "fields": [ + { + "fieldPath": "walletId", + "columnName": "walletId", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "accountIndex", + "columnName": "accountIndex", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fvkBytes", + "columnName": "fvkBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "walletId", + "accountIndex" + ] + }, + "indices": [ + { + "name": "index_shielded_viewing_keys_walletId", + "unique": false, + "columnNames": [ + "walletId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" + } + ] + }, + { + "tableName": "wallet_manager_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", + "fields": [ + { + "fieldPath": "networkRaw", + "columnName": "networkRaw", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncHeight", + "columnName": "combinedSyncHeight", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "combinedSyncBlockHash", + "columnName": "combinedSyncBlockHash", + "affinity": "BLOB" + }, + { + "fieldPath": "walletCount", + "columnName": "walletCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "createdAt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "lastUpdated", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "networkRaw" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf2c2e1447c26d26f643457c97fe5452')" + ] + } +} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 9a98201510b..b18c3b4099c 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -690,13 +690,58 @@ class DashDatabaseMigrationTest { db.close() } + /** + * v15 → v16 adds `public_keys.derivationWalletId` and backfills it with + * the identity's wallet for rows that already carried a breadcrumb (whose + * derivation every pre-v16 breadcrumb was); a row without one stays NULL. + */ + @Test + fun migrate15To16BackfillsTheBreadcrumbWallet() { + helper.createDatabase(dbName, 15).apply { + execSQL( + "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + + "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + + "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", + ) + execSQL( + "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + + "createdAt, lastUpdated, networkRaw, identityIndex, walletId) " + + "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0, x'01')", + ) + execSQL( + "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + + "publicKeyData, derivationIdentityIndex, derivationKeyIndex, identityId, " + + "createdAt, identityIdData) " + + "VALUES (0, '0', '2', '0', 0, x'AA', 0, 0, 'a', 0, x'0A')", + ) + execSQL( + "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + + "publicKeyData, identityId, createdAt, identityIdData) " + + "VALUES (1, '0', '2', '0', 0, x'BB', 'a', 0, x'0A')", + ) + close() + } + + val db = helper.runMigrationsAndValidate(dbName, 16, true, DashDatabase.MIGRATION_15_16) + + db.query("SELECT keyId, derivationWalletId FROM public_keys ORDER BY keyId").use { c -> + assertTrue(c.moveToFirst()) + assertEquals(0, c.getInt(0)) + assertTrue("a breadcrumb row takes its identity's wallet", byteArrayOf(1).contentEquals(c.getBlob(1))) + assertTrue(c.moveToNext()) + assertEquals(1, c.getInt(0)) + assertTrue("a row without a breadcrumb stays NULL", c.isNull(1)) + } + db.close() + } + /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -709,16 +754,17 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } - /** The full chain from v1 must also land on a valid v15 schema. */ + /** The full chain from v1 must also land on a valid v16 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 15, + 16, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -734,6 +780,7 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, + DashDatabase.MIGRATION_15_16, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 8c7298418d3..34f1dfe0f1f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -538,19 +538,19 @@ abstract class NativePersistenceBridge { /** * One `IdentityKeyEntryFFI` upsert. Descriptor - * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZII)I`. + * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ[I)I`. * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, * 3 ContractGroup (`contractBoundsId` is then the contract group id and * `contractBoundsDocumentType` is null). The four arguments before - * [derivationRejected] are the key's usage limits (protocol version 14): the credits it may spend over + * [rejectedDerivations] are the key's usage limits (protocol version 14): the credits it may spend over * its lifetime when [totalBudgetIsSome], and the block time in milliseconds * from which it can no longer sign when [expiresAtIsSome]. * - * With no derivation indices, [derivationRejected] says Rust checked the - * key at `(rejectedIdentityIndex, rejectedKeyIndex)` and found it does - * not come from there, so a stored derivation breadcrumb naming exactly - * those coordinates is cleared; `false` leaves the key's origin unknown, - * which keeps it. + * With no derivation indices, [rejectedDerivations] holds the + * coordinates Rust checked the key at under this wallet's seed and found + * it does not come from, as flat `[identityIndex, keyIndex]` pairs: a + * stored derivation breadcrumb of this wallet naming one of them is + * cleared. Empty leaves the key's origin unknown, which keeps it. */ @Suppress("LongParameterList") open fun onPersistIdentityKeyUpsert( @@ -577,9 +577,7 @@ abstract class NativePersistenceBridge { totalBudget: Long = 0L, expiresAtIsSome: Boolean = false, expiresAt: Long = 0L, - derivationRejected: Boolean = false, - rejectedIdentityIndex: Int = 0, - rejectedKeyIndex: Int = 0, + rejectedDerivations: IntArray = IntArray(0), ): Int = 0 /** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index 0283ac2904f..f29aef544e6 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -179,9 +179,16 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * treated the account as rotated, tore it down and rebuilt it — and that * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: * native rebuilds once, stamps it, and never again. + * + * Version 16 (key-origin provenance, dashpay/platform#5487): adds the + * nullable `public_keys.derivationWalletId` column, the wallet whose seed a + * row's derivation breadcrumb belongs to, so a rejection by another wallet's + * seed at the same coordinates does not clear it. Rows that already carried + * a breadcrumb are backfilled with their identity's wallet, which is whose + * derivation every breadcrumb written before this version was. */ @Database( - version = 15, + version = 16, exportSchema = true, entities = [ WalletEntity::class, @@ -716,6 +723,18 @@ abstract class DashDatabase : RoomDatabase() { } } + val MIGRATION_15_16: Migration = object : Migration(15, 16) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE `public_keys` ADD COLUMN `derivationWalletId` BLOB") + db.execSQL( + "UPDATE `public_keys` SET `derivationWalletId` = (" + + "SELECT `walletId` FROM `identities` " + + "WHERE `identities`.`identityId` = `public_keys`.`identityIdData`) " + + "WHERE `derivationKeyIndex` IS NOT NULL", + ) + } + } + /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -746,6 +765,7 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, + MIGRATION_15_16, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 6cf26d0a900..2b2ae1b8605 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1900,9 +1900,7 @@ class PlatformWalletPersistenceHandler( totalBudget: Long, expiresAtIsSome: Boolean, expiresAt: Long, - derivationRejected: Boolean, - rejectedIdentityIndex: Int, - rejectedKeyIndex: Int, + rejectedDerivations: IntArray, ): Int = guarded { // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only @@ -1929,17 +1927,25 @@ class PlatformWalletPersistenceHandler( // alias whose row can never commit (the identity rows are gone), // stranding identity-key ciphertext behind a "successful" wipe. val roundKey = walletId.toHex() - // A rejection refutes the slot it checked under this wallet's seed: a - // pending repair entry for that slot of this wallet needs no + // The slots a rejection refutes under this wallet's seed, as + // `(identityIndex, keyIndex)`; empty when the key's origin is unknown. + val refutedSlots: Set> = + if (derivationIndicesIsSome) { + emptySet() + } else { + (0 until rejectedDerivations.size / 2) + .map { rejectedDerivations[2 * it] to rejectedDerivations[2 * it + 1] } + .toSet() + } + // A pending repair entry for a refuted slot of this wallet needs no // re-derive and is dropped, staged with the round; one for another // slot, or another wallet, stays. - if (derivationRejected && !derivationIndicesIsSome) { + if (refutedSlots.isNotEmpty()) { val hex = publicKeyData.toHex() stagePendingKeyDelta(roundKey) { map -> val pending = map[hex] if (pending != null && pending.walletIdHex == roundKey && - pending.identityIndex == rejectedIdentityIndex && - pending.keyIndex == rejectedKeyIndex + (pending.identityIndex to pending.keyIndex) in refutedSlots ) { map - hex } else { @@ -2039,13 +2045,16 @@ class PlatformWalletPersistenceHandler( // this one: only a row for the same public key lends them. val existing = stored?.takeIf { it.publicKeyData.contentEquals(publicKeyData) } // A rejection refutes only the coordinates it was checked at, under - // the seed of the wallet persisting it: a key row's breadcrumb is - // its identity's wallet's derivation, so another wallet's - // rejection leaves it. - val refuted = derivationRejected && !derivationIndicesIsSome && - existing?.derivationIdentityIndex == rejectedIdentityIndex && - existing.derivationKeyIndex == rejectedKeyIndex && - db.identityDao().getByIdentityId(identityId)?.walletId?.contentEquals(walletId) == true + // the seed of the wallet persisting it: the row's breadcrumb is + // cleared only when it is that wallet's derivation + // ([PublicKeyEntity.derivationWalletId]) at a refuted slot. The + // identity's wallet does not say whose the breadcrumb is: the same + // round may just have re-associated it. + val heldSlot = existing?.derivationIdentityIndex?.let { identity -> + existing.derivationKeyIndex?.let { key -> identity to key } + } + val refuted = heldSlot != null && heldSlot in refutedSlots && + existing.derivationWalletId?.contentEquals(walletId) == true // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2083,8 +2092,7 @@ class PlatformWalletPersistenceHandler( // pending-repair state reconstructible after restart // (dashpay/platform#4060 finding 5). // Without indices, an upsert keeps the stored breadcrumb of - // the same public key unless it refutes exactly those - // coordinates ([derivationRejected]). + // the same public key unless it refutes it ([refuted]). derivationIdentityIndex = when { derivationIndicesIsSome -> identityIndex refuted -> null @@ -2095,6 +2103,11 @@ class PlatformWalletPersistenceHandler( refuted -> null else -> existing?.derivationKeyIndex }, + derivationWalletId = when { + derivationIndicesIsSome -> if (walletIdIsSome) keyWalletId else walletId + refuted -> null + else -> existing?.derivationWalletId + }, identityId = identityBase58, identityIdData = identityId, createdAt = stored?.createdAt ?: java.util.Date(), diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index 9f8a20c35e0..c1c98b00b14 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -94,4 +94,14 @@ data class PublicKeyEntity( val lastAccessed: Date? = null, /** FK materialization of the Swift `identity` relationship (32 bytes). */ val identityIdData: ByteArray? = null, + /** + * The wallet whose seed the derivation breadcrumb + * ([derivationIdentityIndex] / [derivationKeyIndex]) belongs to, as Rust + * emitted it (the key's wallet, else the persisting one). A rejection + * clears the breadcrumb only when the rejecting wallet is this one: the + * same coordinates under another wallet's seed are another derivation. + * Null when the row carries no breadcrumb. Added in schema 16, backfilled + * from the identity's wallet for rows that already carried one. + */ + val derivationWalletId: ByteArray? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 21b350412d5..388bd4f8b5d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1208,9 +1208,7 @@ class PlatformWalletPersistenceHandlerTest { from, identityId, 0, 0, 0, 0, false, false, 0, key, ByteArray(20), true, walletId, indicesIsSome, 0, 7, 0, ByteArray(32), null, - derivationRejected = rejected, - rejectedIdentityIndex = 0, - rejectedKeyIndex = rejectedKeyIndex, + rejectedDerivations = if (rejected) intArrayOf(0, rejectedKeyIndex) else IntArray(0), ) handler.onChangesetEnd(from, success = true) } @@ -1233,6 +1231,59 @@ class PlatformWalletPersistenceHandlerTest { assertNull("another public key takes no breadcrumb", stored().derivationKeyIndex) } + @Test + fun identityKeyRejectionByAnotherWalletKeepsTheBreadcrumbAcrossAFullRound() = runTest { + // Wallet A holds a verified breadcrumb for the key. A full load + // through wallet B re-associates the identity with B (its scalar + // upsert comes first in the round), then rejects the same numeric + // slot under B's seed: A's breadcrumb is another derivation and + // stays. + val walletA = walletId + val walletB = ByteArray(32) { 3 } + val identityId = ByteArray(32) { 16 } + seedIdentity(identityId) + val key = ByteArray(33) { 12 } + handler.onChangesetBegin(walletA) + handler.onPersistIdentityKeyUpsert( + walletA, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), true, walletA, + true, 0, 7, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletA, success = true) + + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + handler.onChangesetBegin(walletB) + handler.onPersistIdentityUpsert( + walletB, identityId, 1, 0, true, 0, 2, true, walletB, + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onPersistIdentityKeyUpsert( + walletB, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(0, 7), + ) + handler.onChangesetEnd(walletB, success = true) + + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertEquals("wallet B's rejection refutes nothing of wallet A", 7, row.derivationKeyIndex) + assertTrue(walletA.contentEquals(row.derivationWalletId)) + + // Wallet A's own rejection of that slot clears it. + handler.onChangesetBegin(walletA) + handler.onPersistIdentityKeyUpsert( + walletA, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(1, 7, 0, 7), + ) + handler.onChangesetEnd(walletA, success = true) + val cleared = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertNull(cleared.derivationKeyIndex) + assertNull(cleared.derivationWalletId) + } + @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index 0af4f790ac8..fd551e3a278 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -316,16 +316,17 @@ pub struct IdentityKeyEntryFFI { pub expires_at_is_some: bool, pub expires_at: u64, - /// With `derivation_indices_is_some` false and this flag true, the - /// `(rejected_identity_index, rejected_key_index)` coordinates the key - /// was checked at and found not to come from (its candidate there did not - /// reproduce it, or the caller declared the key external): the client - /// clears a derivation path it holds for the key only when it names - /// exactly these coordinates. With the flag false the key's origin is - /// unknown here, and the client keeps the path it holds. - pub derivation_rejected_is_some: bool, - pub rejected_identity_index: u32, - pub rejected_key_index: u32, + /// With `derivation_indices_is_some` false: the coordinates the key was + /// checked at under the persisting wallet's seed and found not to come + /// from (a candidate there did not reproduce it, or the caller declared + /// the key external), as `rejected_derivations_len` flat + /// `[identity_index, key_index]` pairs. The client clears a derivation + /// path that wallet holds for the key only when it names one of them. + /// Empty (null, 0) when the key's origin is unknown here: the client + /// keeps the path it holds. Owned by Rust, released by + /// [`free_identity_key_entry_ffi`]. + pub rejected_derivations_ptr: *mut u32, + pub rejected_derivations_len: usize, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -378,11 +379,8 @@ pub struct IdentityKeyRemovalFFI { // 200 expires_at_is_some bool // 201..=207 (padding to 8) // 208..=215 expires_at u64 -// 216 derivation_rejected_is_some bool -// 217..=219 (padding to 4) -// 220..=223 rejected_identity_index u32 -// 224..=227 rejected_key_index u32 -// 228..=231 (padding to 8) +// 216..=223 rejected_derivations_ptr *mut u32 +// 224..=231 rejected_derivations_len usize // // Total size = 232, alignment = 8 (from u64 / pointer). const _: [u8; 232] = [0u8; std::mem::size_of::()]; @@ -725,13 +723,18 @@ impl IdentityKeyEntryFFI { Some(idx) => (true, idx.identity_index, idx.key_index), None => (false, 0, 0), }; - let (derivation_rejected_is_some, rejected_identity_index, rejected_key_index) = match entry - .derivation_rejected - .filter(|_| entry.derivation_indices.is_none()) - { - Some(idx) => (true, idx.identity_index, idx.key_index), - None => (false, 0, 0), - }; + let (rejected_derivations_ptr, rejected_derivations_len) = + if entry.derivation_indices.is_some() || entry.derivation_rejected.is_empty() { + (ptr::null_mut(), 0) + } else { + let pairs: Box<[u32]> = entry + .derivation_rejected + .iter() + .flat_map(|idx| [idx.identity_index, idx.key_index]) + .collect(); + let len = entry.derivation_rejected.len(); + (Box::into_raw(pairs) as *mut u32, len) + }; // Project the DPP `ContractBounds` enum into the kind / // id / doc-type-cstring trio so the Swift side can switch @@ -782,9 +785,8 @@ impl IdentityKeyEntryFFI { total_budget, expires_at_is_some, expires_at, - derivation_rejected_is_some, - rejected_identity_index, - rejected_key_index, + rejected_derivations_ptr, + rejected_derivations_len, } } } @@ -950,6 +952,18 @@ pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { let _ = unsafe { CString::from_raw(entry.contract_bounds_document_type as *mut c_char) }; entry.contract_bounds_document_type = ptr::null(); } + // The rejected-derivation pairs, a boxed `[u32]` of twice the length. + if !entry.rejected_derivations_ptr.is_null() && entry.rejected_derivations_len > 0 { + let slice = unsafe { + std::slice::from_raw_parts_mut( + entry.rejected_derivations_ptr, + entry.rejected_derivations_len * 2, + ) + }; + let _ = unsafe { Box::from_raw(slice as *mut [u32]) }; + entry.rejected_derivations_ptr = ptr::null_mut(); + entry.rejected_derivations_len = 0; + } } #[cfg(test)] @@ -962,6 +976,7 @@ mod tests { use platform_wallet::changeset::{ IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, }; + use std::collections::BTreeSet; #[test] fn test_identity_entry_ffi_round_trip() { @@ -1249,7 +1264,7 @@ mod tests { identity_index: 3, key_index: 5, }), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [2u8; 32]); @@ -1292,34 +1307,32 @@ mod tests { public_key_hash: [0x00; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); assert!( - !ffi.derivation_rejected_is_some, + ffi.rejected_derivations_ptr.is_null() && ffi.rejected_derivations_len == 0, "an unknown origin keeps a stored path" ); let settled = IdentityKeyEntry { - derivation_rejected: Some(IdentityKeyDerivationIndices { - identity_index: 2, - key_index: 5, - }), + derivation_rejected: BTreeSet::from([ + IdentityKeyDerivationIndices { + identity_index: 2, + key_index: 5, + }, + IdentityKeyDerivationIndices { + identity_index: 3, + key_index: 5, + }, + ]), ..entry.clone() }; let mut settled_ffi = IdentityKeyEntryFFI::from_entry(&settled); - assert!( - settled_ffi.derivation_rejected_is_some, - "a refuted path is cleared" - ); - assert_eq!( - ( - settled_ffi.rejected_identity_index, - settled_ffi.rejected_key_index - ), - (2, 5) - ); + assert_eq!(settled_ffi.rejected_derivations_len, 2); + let pairs = unsafe { std::slice::from_raw_parts(settled_ffi.rejected_derivations_ptr, 4) }; + assert_eq!(pairs, &[2, 5, 3, 5], "every refuted path is carried"); unsafe { free_identity_key_entry_ffi(&mut settled_ffi) }; assert!(ffi.read_only); assert!(ffi.disabled_at_is_some); @@ -1352,7 +1365,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 3); @@ -1382,7 +1395,7 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 1); @@ -1415,7 +1428,7 @@ mod tests { public_key_hash: [0x22; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 2); diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 291e121e6df..8e5edf3388c 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -722,6 +722,7 @@ mod tests { use dpp::prelude::Identifier; use platform_wallet::changeset::IdentityChangeSet; use platform_wallet::wallet::identity::IdentityStatus; + use std::collections::BTreeSet; fn migrated_conn() -> Connection { let mut conn = Connection::open_in_memory().unwrap(); @@ -903,7 +904,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts @@ -1112,7 +1113,7 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index f6ac2b625e6..8422537e53b 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -8,6 +8,7 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::IdentityPublicKey; @@ -68,7 +69,7 @@ impl IdentityKeyWire { public_key_hash: self.public_key_hash, wallet_id: self.wallet_id, derivation_indices: self.derivation_indices, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }) } } @@ -249,7 +250,7 @@ pub fn apply( .optional()?; if let Some((wallet_id_kept, derivation_indices)) = held .and_then(|held| held_breadcrumb(held, entry, wallet_id)) - .filter(|(_, held)| entry.derivation_rejected != Some(*held)) + .filter(|(_, held)| !entry.derivation_rejected.contains(held)) { wire.wallet_id = wallet_id_kept; wire.derivation_indices = Some(derivation_indices); @@ -707,7 +708,7 @@ mod tests { identity_index: 1, key_index: 2, }), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs index c9229429f19..ae7a6207c0d 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs @@ -6,6 +6,7 @@ //! `ManagedIdentity` fields directly. use crate::common; +use std::collections::BTreeSet; use std::collections::{BTreeMap, HashSet}; @@ -86,7 +87,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs index 72b2382cf91..1ede76a46e7 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs @@ -12,6 +12,7 @@ //! one moment an operator is removing state. use crate::common; +use std::collections::BTreeSet; use common::{ensure_identity, ensure_wallet_meta, fresh_persister_with_mode}; @@ -58,7 +59,7 @@ fn delete_wallet_aborts_on_a_constraint_failure_outside_the_carve_out() { public_key_hash: [9u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 1), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs index fd067f6507c..3886754e8c1 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs @@ -12,6 +12,7 @@ //! dispatch order writes the parent first for every FK edge. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; @@ -45,7 +46,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs index 9051d3f728c..c5bc4d47113 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs @@ -81,7 +81,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index d95366447ef..dd1150ead35 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -5,6 +5,7 @@ //! corrupt blob. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -45,7 +46,7 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { identity_index: 0, key_index: u32::from(byte), }), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } @@ -149,10 +150,10 @@ fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { let mut rejected = key_entry(id, 0, 0x11); rejected.derivation_indices = None; - rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 0x11, - }); + }]); let mut unknown = key_entry(id, 1, 0x22); unknown.derivation_indices = None; store(vec![rejected.clone(), unknown]); @@ -164,7 +165,7 @@ fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { .expect("load_state"); drop(conn); let mut cleared = rejected; - cleared.derivation_rejected = None; + cleared.derivation_rejected = BTreeSet::new(); assert_eq!(cs.upserts.get(&(id, 0)), Some(&cleared)); assert_eq!(cs.upserts.get(&(id, 1)), Some(&key_entry(id, 1, 0x22))); } @@ -205,10 +206,10 @@ fn should_keep_a_stored_breadcrumb_a_rejection_does_not_name() { // A load checks `(0, 3)` and rejects it. let mut rejected = stored.clone(); rejected.derivation_indices = None; - rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 3, - }); + }]); store(rejected); drop(persister); @@ -225,6 +226,75 @@ fn should_keep_a_stored_breadcrumb_a_rejection_does_not_name() { ); } +/// Two rejections of one key at different coordinates give the same stored +/// row whether they are written one by one or combined in a buffered +/// segment: the legacy inferred breadcrumb either names is cleared. +#[test] +fn should_clear_a_breadcrumb_two_combined_rejections_name_as_one_by_one() { + for mode in [ + platform_wallet_storage::FlushMode::Immediate, + platform_wallet_storage::FlushMode::Manual, + ] { + let (persister, _tmp, path) = common::fresh_persister_with_mode(mode); + let w = wid(0xD7); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x11; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let store = |entry: IdentityKeyEntry| { + let mut keys = IdentityKeysChangeSet::default(); + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .unwrap(); + }; + // The legacy inferred breadcrumb at `(0, 3)`. + let mut inferred = key_entry(id, 3, 0x44); + inferred.derivation_indices = Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 3, + }); + store(inferred.clone()); + persister.flush(w).unwrap(); + + let rejecting = |identity_index| { + let mut entry = inferred.clone(); + entry.derivation_indices = None; + entry.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { + identity_index, + key_index: 3, + }]); + entry + }; + store(rejecting(0)); + store(rejecting(1)); + persister.flush(w).unwrap(); + drop(persister); + + let p2 = reopen(&path); + let conn = p2.lock_conn_for_test(); + let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state"); + drop(conn); + assert_eq!( + cs.upserts.get(&(id, 3)).map(|row| row.derivation_indices), + Some(None), + "{mode:?}" + ); + } +} + /// A stored row over the blob size limit is not read into memory to look for /// a breadcrumb; the upsert replaces it. #[test] diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index 04c9121dbca..f6ea9b8faff 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -7,6 +7,7 @@ //! `sqlite_buffer_semantics.rs`. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use key_wallet::Network; @@ -225,7 +226,7 @@ fn tc007_identity_key_entry_roundtrip() { identity_index: 1, key_index: 2, }), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 7), entry.clone()); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs index 6dacc4683b2..83ab911992f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs @@ -9,6 +9,7 @@ //! projection is served instead. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, fresh_recovery_persister, wid}; use dashcore::hashes::Hash; @@ -1339,7 +1340,7 @@ fn identity_key_entry( public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs index fca58eb887f..a8477bbdd44 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs @@ -7,6 +7,7 @@ //! the truncation guards, and the compaction-marker-only load gate. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; @@ -272,7 +273,7 @@ fn identity_key_entry_mismatch_rejected() { public_key_hash: [3u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((key_identity, 0), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs index 179f56572e0..54831d9261f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs @@ -7,6 +7,7 @@ //! round trip does NOT quietly hand them to a wallet. use crate::common; +use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use dpp::identity::accessors::IdentityGettersV0; @@ -70,7 +71,7 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs index 58605b409c5..b9fe75bc1de 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs @@ -5,6 +5,7 @@ //! bump with none silently excluded, and the seq saturates rather than wrapping. use crate::common; +use std::collections::BTreeSet; use std::collections::BTreeMap; @@ -321,7 +322,7 @@ fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { public_key_hash: [3u8; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c66aac7a243..c9e896d9ae4 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -995,7 +995,7 @@ impl IdentityEntry { /// from the wallet's mnemonic at the DIP-9 identity authentication /// path — platform-wallet itself never carries or persists the key /// bytes. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyDerivationIndices { /// DIP-9 identity index (hardened). @@ -1027,12 +1027,13 @@ pub struct KeyWithBreadcrumb { /// meaning set by `derivation_rejected`. pub breadcrumb: Option, /// With no `breadcrumb`: the derivation coordinates this key was - /// checked at and found not to come from (a candidate derived there did - /// not reproduce it, or the caller declared the key external), so a - /// stored breadcrumb naming exactly these coordinates is cleared; any - /// other stored breadcrumb, and every one when `None` (the origin is - /// unknown), is kept. See [`IdentityKeyEntry::derivation_rejected`]. - pub derivation_rejected: Option, + /// checked at, under the persisting wallet's seed, and found not to come + /// from (a candidate derived there did not reproduce it, or the caller + /// declared the key external), so a stored breadcrumb naming one of them + /// is cleared; any other stored breadcrumb, and every one when the set is + /// empty (the origin is unknown), is kept. See + /// [`IdentityKeyEntry::derivation_rejected`]. + pub derivation_rejected: BTreeSet, } /// A single identity-key entry in an [`IdentityKeysChangeSet`]. @@ -1066,28 +1067,31 @@ pub struct IdentityKeyEntry { /// `derivation_rejected`. pub derivation_indices: Option, /// With no `derivation_indices`, what the entry knows about the key's - /// origin. `None`: unknown here (a refresh, a limits update, a disable), - /// so a persister keeps the breadcrumb a row already holds. `Some`: the - /// coordinates the key was checked at and found not to come from (a load - /// or discovery derived a candidate there and it did not reproduce the - /// key, or the caller of an update declared the key external), so a - /// persister clears a stored breadcrumb naming exactly these coordinates - /// under the seed of the wallet persisting the entry, a stale inferred - /// one, and keeps any other: only the derivation that was checked is + /// origin. Empty: unknown here (a refresh, a limits update, a disable), + /// so a persister keeps the breadcrumb a row already holds. Otherwise: + /// the coordinates the key was checked at and found not to come from, + /// under the seed of the wallet persisting the entry (a load or discovery + /// derived a candidate there and it did not reproduce the key, or the + /// caller of an update declared the key external), so a persister clears + /// a stored breadcrumb that wallet holds at one of them, a stale inferred + /// one, and keeps any other: only the derivations that were checked are /// refuted, and the same coordinates under another wallet's seed are - /// another derivation. Ignored with `derivation_indices`. + /// another derivation. A set, so entries for one key combine by union + /// and give what applying them one by one gives. Ignored with + /// `derivation_indices`. #[cfg_attr(feature = "serde", serde(default))] - pub derivation_rejected: Option, + pub derivation_rejected: BTreeSet, } impl IdentityKeyEntry { /// When this entry gives no breadcrumb, take what `earlier` (an earlier /// row for the same key) settled about the key's origin: its breadcrumb, - /// as every persister keeps a stored one, unless this entry's own - /// rejection names those coordinates; or, when this entry's origin is - /// unknown, its rejection, so a later unknown-origin write (a disable, a - /// limits update) does not let a persister keep a stale inferred - /// breadcrumb the rejection cleared. A key's origin belongs to one public + /// as every persister keeps a stored one, unless one of this entry's own + /// rejections names those coordinates; or its rejections, joined with + /// this entry's, so a later write (a disable, a limits update, another + /// rejection) does not let a persister keep a stale inferred breadcrumb + /// an earlier rejection cleared: combining two entries gives what + /// applying them one after the other gives. A key's origin belongs to one public /// key, so `earlier` must carry the same public key, and a breadcrumb the /// same scope. pub fn inherit_origin(&mut self, earlier: &IdentityKeyEntry) { @@ -1101,14 +1105,16 @@ impl IdentityKeyEntry { .wallet_id .zip(earlier.wallet_id) .is_none_or(|(own, other)| own == other); - // This entry's own rejection refutes exactly those coordinates. - if same_scope && self.derivation_rejected != Some(indices) { + // This entry's own rejections refute exactly those coordinates. + if same_scope && !self.derivation_rejected.contains(&indices) { self.wallet_id = earlier.wallet_id; self.derivation_indices = Some(indices); - self.derivation_rejected = None; + self.derivation_rejected = BTreeSet::new(); } - } else if self.derivation_rejected.is_none() { - self.derivation_rejected = earlier.derivation_rejected; + } else { + // Rejections compose: each clears what it names, in any order. + self.derivation_rejected + .extend(earlier.derivation_rejected.iter().copied()); } } } @@ -2617,7 +2623,7 @@ mod tests { identity_index: 0, key_index, }), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let changeset = |entry: IdentityKeyEntry| { let mut cs = IdentityKeysChangeSet::default(); @@ -2634,22 +2640,44 @@ mod tests { // A rejection of other coordinates does not refute the breadcrumb. let mut elsewhere = entry(Some(9), None); - elsewhere.derivation_rejected = Some(IdentityKeyDerivationIndices { + elsewhere.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 3, - }); + }]); merged.merge(changeset(elsewhere)); assert_eq!(merged.upserts[&(id, 3)], entry(Some(9), Some(4))); // A rejection of exactly its coordinates clears it. let mut rejected = entry(Some(9), None); - rejected.derivation_rejected = Some(IdentityKeyDerivationIndices { + rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 4, - }); + }]); merged.merge(changeset(rejected.clone())); assert_eq!(merged.upserts[&(id, 3)], rejected); + // Two rejections combine: each still refutes what it names. + let mut first = entry(Some(11), None); + first.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 3, + }]); + let mut combined = changeset(first); + combined.merge(changeset(rejected.clone())); + assert_eq!( + combined.upserts[&(id, 3)].derivation_rejected, + BTreeSet::from([ + IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 4, + }, + IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 3, + }, + ]) + ); + // A later unknown-origin entry (a disable) keeps the rejection. let later = entry(Some(10), None); merged.merge(changeset(later.clone())); diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 1e7cad15a27..42e5909de7f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -3,6 +3,7 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use key_wallet::bip32::ExtendedPrivKey; +use std::collections::BTreeSet; use crate::error::PlatformWalletError; use crate::wallet::identity::network::contact_requests::budget_spent; @@ -95,12 +96,13 @@ fn breadcrumb_decisions( // coordinates it was derived at, `(identity_index, key_id)`, which // clears a stale breadcrumb a client holds there; a breadcrumb // stored at other coordinates is kept. - let derivation_rejected = (checked == Some(false)).then_some( - crate::changeset::IdentityKeyDerivationIndices { + let derivation_rejected: BTreeSet<_> = (checked == Some(false)) + .then_some(crate::changeset::IdentityKeyDerivationIndices { identity_index, key_index: *key_id, - }, - ); + }) + .into_iter() + .collect(); let breadcrumb = if reproduces { // The candidate scalar reproduced the on-chain key, so this // wallet owns it: emit the DIP-9 coordinates. The scalar is @@ -865,6 +867,7 @@ mod tests { use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; + use std::collections::BTreeSet; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; @@ -980,13 +983,13 @@ mod tests { "reproducible key breadcrumbed" ); assert_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); - assert_eq!(by_id[&0].derivation_rejected, None); + assert!(by_id[&0].derivation_rejected.is_empty()); assert_eq!( by_id[&1].derivation_rejected, - Some(crate::changeset::IdentityKeyDerivationIndices { + BTreeSet::from([crate::changeset::IdentityKeyDerivationIndices { identity_index: 0, key_index: 1, - }), + }]), "a checked candidate that does not reproduce the key refutes its coordinates" ); @@ -996,7 +999,7 @@ mod tests { let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); let unchecked = decisions.iter().find(|d| d.key.id() == 1).unwrap(); assert_eq!(unchecked.breadcrumb, None); - assert_eq!(unchecked.derivation_rejected, None); + assert!(unchecked.derivation_rejected.is_empty()); } /// An on-chain key typed `ECDSA_HASH160` (data = the 20-byte hash of diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index a5986c31c61..3bb0dbc8688 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentityGettersV0; +use std::collections::BTreeSet; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; @@ -326,13 +327,15 @@ impl IdentityWallet { // A declared external key refutes the coordinates an update would // have inferred for it (older code stored them), so a persister clears // exactly that breadcrumb. - let external_slot = |id: KeyID| { + let external_slot = |id: KeyID| -> BTreeSet { identity_index .filter(|_| external_key_ids.contains(&id)) .map(|identity_index| IdentityKeyDerivationIndices { identity_index, key_index: id, }) + .into_iter() + .collect() }; let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { @@ -390,7 +393,7 @@ impl IdentityWallet { keys.push(KeyWithBreadcrumb { key: key.clone(), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }); } // Stored with the identity's snapshot even when no touched key @@ -961,10 +964,10 @@ mod update_identity_at_revision_tests { assert_eq!(stored.wallet_id, None, "cached at {cached}"); assert_eq!( stored.derivation_rejected, - Some(IdentityKeyDerivationIndices { + BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 3, - }), + }]), "a declared external key refutes its slot's breadcrumb, cached at {cached}" ); } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 6b641ac0163..fa83b9de6ae 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -21,6 +21,7 @@ use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; use dpp::util::hash::ripemd160_sha256; use std::collections::BTreeMap; +use std::collections::BTreeSet; /// Compute the 20-byte RIPEMD160(SHA256) hash of the DPP public-key /// bytes. Shared between `keys_snapshot_changeset` so the FFI surface @@ -72,7 +73,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(pub_key), wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ); } @@ -482,7 +483,7 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(key), wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ); } @@ -543,7 +544,7 @@ impl ManagedIdentity { if public_key_hash != entry.public_key_hash || key.data() != entry.public_key.data() { entry.wallet_id = None; entry.derivation_indices = None; - entry.derivation_rejected = None; + entry.derivation_rejected = BTreeSet::new(); } entry.public_key_hash = public_key_hash; entry.public_key = key.clone(); @@ -619,7 +620,7 @@ impl ManagedIdentity { vec![crate::changeset::KeyWithBreadcrumb { key: public_key, breadcrumb: derivation_breadcrumb, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], persister, ) @@ -690,8 +691,11 @@ impl ManagedIdentity { public_key_hash, wallet_id, derivation_indices, - derivation_rejected: derivation_rejected - .filter(|_| derivation_indices.is_none()), + derivation_rejected: if derivation_indices.is_none() { + derivation_rejected + } else { + BTreeSet::new() + }, }, ); } @@ -744,7 +748,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ); self.store_with_queued_key_rows(keys_cs, persister) @@ -874,7 +878,7 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ); } @@ -1156,12 +1160,12 @@ mod tests { KeyWithBreadcrumb { key: key(3), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, KeyWithBreadcrumb { key: key(4), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ] }; @@ -1194,7 +1198,7 @@ mod tests { let decisions = vec![KeyWithBreadcrumb { key: key(3).with_limits(Some(100), None), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }]; let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); @@ -1283,7 +1287,7 @@ mod tests { let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { breadcrumb: Some((wallet_id, 0, key.id())), key, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }; let row = |key_id: KeyID| { persister @@ -1335,7 +1339,7 @@ mod tests { vec![KeyWithBreadcrumb { key: raised.clone(), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1413,7 +1417,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1477,7 +1481,7 @@ mod tests { vec![KeyWithBreadcrumb { breadcrumb: Some((wallet_id, 0, key.id())), key, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1494,7 +1498,7 @@ mod tests { vec![KeyWithBreadcrumb { key: other_key(2), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1516,17 +1520,17 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: None, - derivation_rejected: Some(IdentityKeyDerivationIndices { + derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 4, - }), + }]), }], &p, ) .unwrap(); let settled = row(4).unwrap(); assert_eq!(settled.derivation_indices, None); - assert!(settled.derivation_rejected.is_some()); + assert!(!settled.derivation_rejected.is_empty()); // A refused rejection is kept by the next store's own row for the // same key that leaves its origin unknown (a disable). @@ -1538,10 +1542,10 @@ mod tests { vec![KeyWithBreadcrumb { key: key(5), breadcrumb: None, - derivation_rejected: Some(IdentityKeyDerivationIndices { + derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { identity_index: 0, key_index: 5, - }), + }]), }], &p, ) @@ -1552,8 +1556,41 @@ mod tests { managed.disable_keys(&[5], 9, &p); let disabled = row(5).unwrap(); assert_eq!(disabled.public_key.disabled_at(), Some(9)); + assert_eq!(disabled.derivation_rejected.len(), 1); + + // A refused rejection retried with a second, distinct one for the + // same key keeps both. + let rejecting = |identity_index| KeyWithBreadcrumb { + key: key(6), + breadcrumb: None, + derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { + identity_index, + key_index: 6, + }]), + }; + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed.add_keys(vec![rejecting(0)], &p).is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + managed.add_keys(vec![rejecting(1)], &p).unwrap(); + assert_eq!( + row(6).unwrap().derivation_rejected, + BTreeSet::from([ + IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 6, + }, + IdentityKeyDerivationIndices { + identity_index: 1, + key_index: 6, + }, + ]) + ); assert!( - disabled.derivation_rejected.is_some(), + !disabled.derivation_rejected.is_empty(), "the queued rejection survives" ); } @@ -1586,7 +1623,7 @@ mod tests { vec![KeyWithBreadcrumb { key: key(4), breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1825,12 +1862,12 @@ mod tests { crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }, ], &p, @@ -1891,7 +1928,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ) @@ -1963,7 +2000,7 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: None, + derivation_rejected: BTreeSet::new(), }], &p, ); diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 637e6c8a8fe..6570f92b8e6 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1537,13 +1537,13 @@ unsafe extern "C" fn tramp_persist_identity_keys( } /// `onPersistIdentityKeyUpsert`'s JNI descriptor. The `ZJZJ` before the -/// final `ZII` is the key usage limits pair (`hasTotalBudget`, -/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `ZII` is -/// `derivationRejected`, `rejectedIdentityIndex`, `rejectedKeyIndex`. Bound at the `call_method` site and in +/// final `[I` is the key usage limits pair (`hasTotalBudget`, +/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `[I` is +/// `rejectedDerivations`, flat `[identityIndex, keyIndex]` pairs. Bound at the `call_method` site and in /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves /// the table up front, while the call site only resolves when a key is /// first persisted. -const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJZII)I"; +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ[I)I"; unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, @@ -1557,6 +1557,16 @@ unsafe fn persist_identity_key_upsert( let key_wallet_id = env.byte_array_from_slice(&e.wallet_id)?; let cb_id = env.byte_array_from_slice(&e.contract_bounds_id)?; let cb_doctype = cstr_opt(env, e.contract_bounds_document_type)?; + // The rejected `[identity_index, key_index]` pairs, flat. + let rejected_pairs: &[u32] = + if e.rejected_derivations_ptr.is_null() || e.rejected_derivations_len == 0 { + &[] + } else { + std::slice::from_raw_parts(e.rejected_derivations_ptr, e.rejected_derivations_len * 2) + }; + let rejected = env.new_int_array(rejected_pairs.len() as i32)?; + let rejected_ints: Vec = rejected_pairs.iter().map(|v| *v as i32).collect(); + env.set_int_array_region(&rejected, 0, &rejected_ints)?; env.call_method( bridge, "onPersistIdentityKeyUpsert", @@ -1585,9 +1595,7 @@ unsafe fn persist_identity_key_upsert( JValue::Long(e.total_budget as i64), JValue::Bool(e.expires_at_is_some as u8), JValue::Long(e.expires_at as i64), - JValue::Bool(e.derivation_rejected_is_some as u8), - JValue::Int(e.rejected_identity_index as i32), - JValue::Int(e.rejected_key_index as i32), + (&rejected).into(), ], )? .i() diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 21ab2203ecf..14d1673b895 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4216,15 +4216,14 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let path = identityAuthPath(walletId: resolvedWalletId, indices: indices) { row.identityDerivationPath = path } - } else if let rejected = entry.derivationRejected, + } else if !entry.derivationRejected.isEmpty, // The rejection was checked under this wallet's seed: a path // another wallet derived is not refuted by it. (row.walletId ?? walletId) == walletId, - let rejectedPath = identityAuthPath( - walletId: walletId, - indices: rejected - ), - row.identityDerivationPath == rejectedPath + let heldPath = row.identityDerivationPath, + entry.derivationRejected.contains(where: { rejected in + identityAuthPath(walletId: walletId, indices: rejected) == heldPath + }) { // Rust checked the key at these coordinates and found it does // not come from there (the candidate did not reproduce it, or @@ -5274,10 +5273,11 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// mistaken for a whole-contract one. let contractBounds: ManagedPlatformWallet.ContractBounds? /// With no `derivationIndices`: the coordinates Rust checked the key - /// at and found it does not come from, so a stored derivation path - /// naming exactly them is cleared; `nil` when its origin is unknown, - /// which keeps the stored path. - var derivationRejected: (identityIndex: UInt32, keyIndex: UInt32)? = nil + /// at under the persisting wallet's seed and found it does not come + /// from, so a stored derivation path of that wallet naming one of them + /// is cleared; empty when its origin is unknown, which keeps the + /// stored path. + var derivationRejected: [(identityIndex: UInt32, keyIndex: UInt32)] = [] } // MARK: - Watch-only Restore: Account Addresses @@ -10350,9 +10350,7 @@ private func persistIdentityKeysCallback( walletId: walletId, derivationIndices: indices, contractBounds: bounds, - derivationRejected: e.derivation_rejected_is_some - ? (e.rejected_identity_index, e.rejected_key_index) - : nil + derivationRejected: rejectedPairs(e) )) } } @@ -10806,6 +10804,17 @@ private func persistContactsCallback( /// identity-persistence callbacks where Rust hands over `[u8; 32]` /// fields as `(UInt8, UInt8, ...)` tuples. @inline(__always) +/// The rejected `[identity_index, key_index]` pairs a key entry carries. +private func rejectedPairs( + _ e: IdentityKeyEntryFFI +) -> [(identityIndex: UInt32, keyIndex: UInt32)] { + guard let ptr = e.rejected_derivations_ptr, e.rejected_derivations_len > 0 else { + return [] + } + let flat = UnsafeBufferPointer(start: ptr, count: Int(e.rejected_derivations_len) * 2) + return stride(from: 0, to: flat.count, by: 2).map { (flat[$0], flat[$0 + 1]) } +} + private func dataFromTuple32(_ tuple: FFIByteTuple32) -> Data { var value = tuple return Swift.withUnsafeBytes(of: &value) { Data($0) } From a683adea9983fe586ad2c8c7be7895220c8e034a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 21:55:43 +0300 Subject: [PATCH 35/72] fix(platform-wallet): rebuild a pending Kotlin repair under its breadcrumb's wallet - Kotlin rebuilds a pending key repair after a restart under the wallet its breadcrumb belongs to (`derivationWalletId`), falling back to the identity's for legacy rows, so a later re-association cannot attribute wallet A's repair to wallet B. - The Swift `rejectedPairs` helper no longer splits `dataFromTuple32` from its doc and `@inline(__always)`; the schema-version test reads 16; docs call the v16 backfill the best available attribution. Co-Authored-By: Claude Opus 5.5 --- .../dashfoundation/dashsdk/persistence/DashDatabase.kt | 4 ++-- .../persistence/PlatformWalletPersistenceHandler.kt | 5 ++++- .../dashsdk/persistence/entities/PublicKeyEntity.kt | 3 ++- .../dashsdk/persistence/DashDatabaseTest.kt | 9 +++++---- .../rs-platform-wallet-ffi/src/identity_persistence.rs | 6 +++--- .../PlatformWalletPersistenceHandler.swift | 8 ++++---- 6 files changed, 20 insertions(+), 15 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index f29aef544e6..e5fc09b072c 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -184,8 +184,8 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * nullable `public_keys.derivationWalletId` column, the wallet whose seed a * row's derivation breadcrumb belongs to, so a rejection by another wallet's * seed at the same coordinates does not clear it. Rows that already carried - * a breadcrumb are backfilled with their identity's wallet, which is whose - * derivation every breadcrumb written before this version was. + * a breadcrumb are backfilled with their identity's wallet, the best + * attribution available for a breadcrumb written before this version. */ @Database( version = 16, diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 2b2ae1b8605..d4dba5761e2 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -4054,7 +4054,10 @@ class PlatformWalletPersistenceHandler( val identity = database.identityDao().getByIdentityId(identityIdData) ?: continue val networkRaw = network?.ffiValue if (networkRaw != null && identity.networkRaw != networkRaw) continue - val walletId = identity.walletId ?: continue + // The breadcrumb's own wallet: the identity may since have been + // re-associated with another wallet, whose seed is another + // derivation (legacy rows without one fall back to the identity's). + val walletId = row.derivationWalletId ?: identity.walletId ?: continue val pubkeyHex = row.publicKeyData.toHex() val usable = row.privateKeyKeychainIdentifier != null && try { diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index c1c98b00b14..c7a6edd94dd 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -101,7 +101,8 @@ data class PublicKeyEntity( * clears the breadcrumb only when the rejecting wallet is this one: the * same coordinates under another wallet's seed are another derivation. * Null when the row carries no breadcrumb. Added in schema 16, backfilled - * from the identity's wallet for rows that already carried one. + * from the identity's wallet (the best attribution available) for rows + * that already carried one. */ val derivationWalletId: ByteArray? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index 41326e84f92..b149c573522 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,16 +233,17 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion16WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, 13 → 14 the token - // once-per-identity distribution block, and 14 → 15 the DashPay - // backfill record columns on `wallets`. - assertEquals(15, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, 14 → 15 the DashPay + // backfill record columns on `wallets`, and 15 → 16 the + // breadcrumb wallet on `public_keys`. + assertEquals(16, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index fd551e3a278..ef907126101 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -261,9 +261,9 @@ pub struct IdentityKeyEntryFFI { // mnemonic at the DIP-9 identity authentication path // `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` and // persist it locally. When either flag is false the entry gives no - // breadcrumb: `derivation_rejected_is_some` then says whether the client - // clears the one it holds when it names the rejected coordinates, or - // keeps it (unknown origin). + // breadcrumb: `rejected_derivations_ptr` / `_len` then say whether the + // client clears the one it holds (it names one of the rejected + // coordinates) or keeps it (empty: unknown origin). pub wallet_id_is_some: bool, pub wallet_id: [u8; 32], pub derivation_indices_is_some: bool, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 14d1673b895..23299ae5c0b 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -10800,10 +10800,6 @@ private func persistContactsCallback( return 0 } -/// Copy a fixed 32-byte C tuple into an owned `Data`. Used by the -/// identity-persistence callbacks where Rust hands over `[u8; 32]` -/// fields as `(UInt8, UInt8, ...)` tuples. -@inline(__always) /// The rejected `[identity_index, key_index]` pairs a key entry carries. private func rejectedPairs( _ e: IdentityKeyEntryFFI @@ -10815,6 +10811,10 @@ private func rejectedPairs( return stride(from: 0, to: flat.count, by: 2).map { (flat[$0], flat[$0 + 1]) } } +/// Copy a fixed 32-byte C tuple into an owned `Data`. Used by the +/// identity-persistence callbacks where Rust hands over `[u8; 32]` +/// fields as `(UInt8, UInt8, ...)` tuples. +@inline(__always) private func dataFromTuple32(_ tuple: FFIByteTuple32) -> Data { var value = tuple return Swift.withUnsafeBytes(of: &value) { Data($0) } From 67ad5badb64cb78779422d09702bc58aee587c67 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 22:33:37 +0300 Subject: [PATCH 36/72] fix(platform-wallet): leave legacy breadcrumb owners unknown, keep a queue through one constraint failure - The Room 15 -> 16 migration no longer backfills `public_keys.derivationWalletId` from the identity's wallet, which may have been re-associated since: a legacy breadcrumb's owner stays NULL, which never lets a rejection clear it, until a load or discovery verifies the key again and records it. - Kotlin drops a pending repair with the row it belongs to: through the round's staged deltas when a round is open, or after the standalone write commits, instead of before the Room write. It drops the entry when this wallet refutes the row's breadcrumb or the entry's own slot (an entry rebuilt for a row with no owner is filed under the identity's wallet). - A key store the store refuses as invalid data keeps the rows an earlier refused store queued, for one more store: a second refusal in a row drops them, so a queued row that is itself invalid cannot refuse every later key store. Co-Authored-By: Claude Opus 5.5 --- .../persistence/DashDatabaseMigrationTest.kt | 11 +- .../dashsdk/persistence/DashDatabase.kt | 18 +-- .../PlatformWalletPersistenceHandler.kt | 40 +++--- .../persistence/entities/PublicKeyEntity.kt | 7 +- .../PlatformWalletPersistenceHandlerTest.kt | 122 ++++++++++++++++++ .../state/managed_identity/identity_ops.rs | 80 +++++++++++- .../identity/state/managed_identity/mod.rs | 7 + 7 files changed, 247 insertions(+), 38 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index b18c3b4099c..166fe745805 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -691,12 +691,13 @@ class DashDatabaseMigrationTest { } /** - * v15 → v16 adds `public_keys.derivationWalletId` and backfills it with - * the identity's wallet for rows that already carried a breadcrumb (whose - * derivation every pre-v16 breadcrumb was); a row without one stays NULL. + * v15 → v16 adds `public_keys.derivationWalletId` without a backfill: a + * legacy breadcrumb's parent may already have been re-associated with + * another wallet, so its owner stays NULL, which never lets a rejection + * clear it. */ @Test - fun migrate15To16BackfillsTheBreadcrumbWallet() { + fun migrate15To16LeavesLegacyBreadcrumbOwnersUnknown() { helper.createDatabase(dbName, 15).apply { execSQL( "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + @@ -727,7 +728,7 @@ class DashDatabaseMigrationTest { db.query("SELECT keyId, derivationWalletId FROM public_keys ORDER BY keyId").use { c -> assertTrue(c.moveToFirst()) assertEquals(0, c.getInt(0)) - assertTrue("a breadcrumb row takes its identity's wallet", byteArrayOf(1).contentEquals(c.getBlob(1))) + assertTrue("a legacy breadcrumb's owner is unknown", c.isNull(1)) assertTrue(c.moveToNext()) assertEquals(1, c.getInt(0)) assertTrue("a row without a breadcrumb stays NULL", c.isNull(1)) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index e5fc09b072c..a2371ba3ca3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -183,9 +183,11 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * Version 16 (key-origin provenance, dashpay/platform#5487): adds the * nullable `public_keys.derivationWalletId` column, the wallet whose seed a * row's derivation breadcrumb belongs to, so a rejection by another wallet's - * seed at the same coordinates does not clear it. Rows that already carried - * a breadcrumb are backfilled with their identity's wallet, the best - * attribution available for a breadcrumb written before this version. + * seed at the same coordinates does not clear it. Every existing row reads + * NULL: its parent identity may already have been re-associated with another + * wallet, so the owner of a legacy breadcrumb is unknown, and a NULL owner + * never lets a rejection clear it. A load or discovery that verifies the key + * again records the owner. */ @Database( version = 16, @@ -725,13 +727,11 @@ abstract class DashDatabase : RoomDatabase() { val MIGRATION_15_16: Migration = object : Migration(15, 16) { override fun migrate(db: SupportSQLiteDatabase) { + // No backfill: a legacy row's parent may already have been + // re-associated with another wallet, so its breadcrumb's + // owner is unknown and stays NULL until a load or discovery + // verifies the key again and records it. db.execSQL("ALTER TABLE `public_keys` ADD COLUMN `derivationWalletId` BLOB") - db.execSQL( - "UPDATE `public_keys` SET `derivationWalletId` = (" + - "SELECT `walletId` FROM `identities` " + - "WHERE `identities`.`identityId` = `public_keys`.`identityIdData`) " + - "WHERE `derivationKeyIndex` IS NOT NULL", - ) } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index d4dba5761e2..bc15533b6f0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -1937,22 +1937,24 @@ class PlatformWalletPersistenceHandler( .map { rejectedDerivations[2 * it] to rejectedDerivations[2 * it + 1] } .toSet() } - // A pending repair entry for a refuted slot of this wallet needs no - // re-derive and is dropped, staged with the round; one for another - // slot, or another wallet, stays. - if (refutedSlots.isNotEmpty()) { - val hex = publicKeyData.toHex() - stagePendingKeyDelta(roundKey) { map -> - val pending = map[hex] - if (pending != null && pending.walletIdHex == roundKey && + // A pending repair entry for the key needs no re-derive once this + // wallet refutes it: either the stored row's breadcrumb (decided + // against the row below), or the entry's own slot under this wallet + // (an entry rebuilt from a row with no recorded owner is filed under + // the identity's wallet, which may be wrong; that wallet's rejection + // still clears it). The deletion is published with the row: through + // the round's staged deltas when a round is open, or after the + // standalone write commits. + val pendingHex = publicKeyData.toHex() + fun dropPending(rowRefuted: Boolean): (Map) -> Map = + { map -> + val pending = map[pendingHex] + val entryRefuted = pending != null && pending.walletIdHex == roundKey && (pending.identityIndex to pending.keyIndex) in refutedSlots - ) { - map - hex - } else { - map - } + if (pending != null && (rowRefuted || entryRefuted)) map - pendingHex else map } - } + val round = buffers[roundKey] + var dropAfterCommit: ((Map) -> Map)? = null val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } val deriver = privateKeyDeriver @@ -2055,6 +2057,10 @@ class PlatformWalletPersistenceHandler( } val refuted = heldSlot != null && heldSlot in refutedSlots && existing.derivationWalletId?.contentEquals(walletId) == true + if (refutedSlots.isNotEmpty()) { + val drop = dropPending(refuted) + if (round != null) round.pendingKeyDeltas.add(drop) else dropAfterCommit = drop + } // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2115,6 +2121,8 @@ class PlatformWalletPersistenceHandler( ) if (stored == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) } + // Standalone path: the write above committed (a failed one threw). + dropAfterCommit?.let { _pendingIdentityKeys.update(it) } 0 } @@ -4056,7 +4064,9 @@ class PlatformWalletPersistenceHandler( if (networkRaw != null && identity.networkRaw != networkRaw) continue // The breadcrumb's own wallet: the identity may since have been // re-associated with another wallet, whose seed is another - // derivation (legacy rows without one fall back to the identity's). + // derivation. A row with no recorded owner (written before schema + // 16) falls back to the identity's wallet, which may be the wrong + // one; that wallet's rejection of the slot drops the entry. val walletId = row.derivationWalletId ?: identity.walletId ?: continue val pubkeyHex = row.publicKeyData.toHex() val usable = row.privateKeyKeychainIdentifier != null && diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index c7a6edd94dd..194194b0059 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -100,9 +100,10 @@ data class PublicKeyEntity( * emitted it (the key's wallet, else the persisting one). A rejection * clears the breadcrumb only when the rejecting wallet is this one: the * same coordinates under another wallet's seed are another derivation. - * Null when the row carries no breadcrumb. Added in schema 16, backfilled - * from the identity's wallet (the best attribution available) for rows - * that already carried one. + * Null when the row carries no breadcrumb, or when its owner is unknown + * (a breadcrumb written before schema 16: its identity may have been + * re-associated since); a null owner never lets a rejection clear the + * breadcrumb. A load or discovery that verifies the key records it. */ val derivationWalletId: ByteArray? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 388bd4f8b5d..4eba61051dd 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1284,6 +1284,47 @@ class PlatformWalletPersistenceHandlerTest { assertNull(cleared.derivationWalletId) } + @Test + fun identityKeyRejectionKeepsALegacyBreadcrumbWhoseOwnerIsUnknown() = runTest { + // A breadcrumb written before schema 16 has no recorded owner (the + // migration leaves it NULL), and its identity may already belong to + // wallet B. B's rejection of the same numeric slot must not clear it. + val walletA = walletId + val walletB = ByteArray(32) { 4 } + val identityId = ByteArray(32) { 17 } + seedIdentity(identityId) + val key = ByteArray(33) { 13 } + handler.onChangesetBegin(walletA) + handler.onPersistIdentityKeyUpsert( + walletA, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), true, walletA, + true, 0, 7, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletA, success = true) + val dao = db.publicKeyDao() + val legacy = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + dao.update(legacy.copy(derivationWalletId = null)) + + handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) + handler.onChangesetBegin(walletB) + handler.onPersistIdentityUpsert( + walletB, identityId, 1, 0, true, 0, 2, true, walletB, + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onPersistIdentityKeyUpsert( + walletB, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(0, 7), + ) + handler.onChangesetEnd(walletB, success = true) + + val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertEquals("an unknown owner refutes nothing", 7, row.derivationKeyIndex) + assertNull(row.derivationWalletId) + } + @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() @@ -1476,6 +1517,87 @@ class PlatformWalletPersistenceHandlerTest { assertEquals("keystore unavailable", entry.reason) } + /** A rejection of `(3, 5)` for [pubkey], sent through a round of [from] or standalone. */ + private fun rejectIdentityKey( + pubkey: ByteArray, + identityId: ByteArray, + from: ByteArray = walletId, + slot: IntArray = intArrayOf(3, 5), + round: Boolean? = true, + ) { + if (round != null) handler.onChangesetBegin(from) + handler.onPersistIdentityKeyUpsert( + from, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = slot, + ) + if (round != null) handler.onChangesetEnd(from, success = round) + } + + @Test + fun aRejectionDropsThePendingRepairOnlyOnceItsRoundCommits() = runTest { + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 20 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 20 } + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // Another wallet's rejection of the slot keeps it. + handler.onPersistWalletMetadata(ByteArray(32) { 5 }, testnet, groupId, 0) + rejectIdentityKey(pubkey, identityId, from = ByteArray(32) { 5 }) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + // A rolled-back round keeps it; a committed one drops it, and only + // after the commit. + rejectIdentityKey(pubkey, identityId, round = false) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(3, 5), + ) + assertNotNull("not before the round commits", handler.pendingIdentityKeys.value[pubkey.toHex()]) + handler.onChangesetEnd(walletId, success = true) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun aStandaloneRejectionDropsThePendingRepairAfterItsWrite() = runTest { + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 21 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 21 } + upsertIdentityKey(pubkey, identityId) + assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + rejectIdentityKey(pubkey, identityId, round = null) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) + } + + @Test + fun aRejectionDropsAPendingRepairFiledUnderItsWalletWhenTheRowHasNoOwner() = runTest { + // A row written before schema 16 has no recorded owner, so the row + // keeps its breadcrumb, but the pending entry filed under this wallet + // at the refuted slot is this wallet's to drop. + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 22 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 22 } + upsertIdentityKey(pubkey, identityId) + val dao = db.publicKeyDao() + val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + dao.update(row.copy(derivationWalletId = null)) + + rejectIdentityKey(pubkey, identityId) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + assertEquals(5, dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) + } + @Test fun deriverReturningNullIsAlsoRecordedAsPending() = runTest { handler = PlatformWalletPersistenceHandler( diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index fa83b9de6ae..e06be47fa76 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -94,6 +94,7 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), + pending_key_rows_refused: false, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -116,6 +117,7 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), + pending_key_rows_refused: false, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -523,13 +525,16 @@ impl ManagedIdentity { /// change back; a queued key memory no longer holds is dropped. When the /// store fails, its key rows are queued for the next one, unless the /// store refused the data itself (a constraint failure, which a retry - /// would only repeat), and the failure is returned. + /// would only repeat): then only the rows queued before it stay queued, + /// for one more store, since a queued row can itself be the invalid one. + /// The failure is returned. pub(super) fn store_with_queued_key_rows( &mut self, mut keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PersistenceError> { - for (id, mut entry) in std::mem::take(&mut self.pending_key_rows) { + let queued = std::mem::take(&mut self.pending_key_rows); + for (id, mut entry) in queued.clone() { if let Some(own) = keys_cs.upserts.get_mut(&id) { own.inherit_origin(&entry); continue; @@ -553,14 +558,29 @@ impl ManagedIdentity { let result = self.store_with_snapshot(keys_cs.clone(), persister); match &result { Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { + // The batch's own rows may be what the store refuses, but a + // row an earlier refused store queued is not shown invalid by + // one refusal: the queue is kept as it was, without this + // batch's rows, once. A second refusal in a row drops it, so + // a queued row that is itself invalid cannot refuse every + // later key store. + let keep = !queued.is_empty() && !self.pending_key_rows_refused; tracing::warn!( identity = %self.id(), error = %e, - "identity key rows refused as invalid; not queued for a retry", + kept_queued = keep, + "identity key rows refused as invalid; this batch's rows not queued", ); + if keep { + self.pending_key_rows = queued; + } + self.pending_key_rows_refused = keep; + } + Err(_) => { + self.pending_key_rows = keys_cs.upserts; + self.pending_key_rows_refused = false; } - Err(_) => self.pending_key_rows = keys_cs.upserts, - Ok(()) => {} + Ok(()) => self.pending_key_rows_refused = false, } result } @@ -1352,7 +1372,7 @@ mod tests { assert_eq!(row(2), None, "the queue is empty after a store succeeds"); // Rows refused as invalid data are not queued: a retry would only - // repeat the refusal. + // repeat the refusal. Rows an earlier refused store queued stay. persister .constraint .store(true, std::sync::atomic::Ordering::SeqCst); @@ -1364,6 +1384,54 @@ mod tests { .store(false, std::sync::atomic::Ordering::SeqCst); managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); assert_eq!(row(3), None); + + // A row queued by an earlier refused store survives a later batch the + // store refuses as invalid, and rides the next store that succeeds. + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + persister + .constraint + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(6))], &p) + .is_err()); + persister + .constraint + .store(false, std::sync::atomic::Ordering::SeqCst); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + let kept = row(5).expect("the earlier queued row survives the constraint failure"); + assert!(kept.derivation_indices.is_some()); + assert_eq!(row(6), None, "the refused batch's own row is not queued"); + + // A queue refused twice in a row is dropped: a queued row that is + // itself invalid cannot refuse every later key store. + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(7))], &p) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + persister + .constraint + .store(true, std::sync::atomic::Ordering::SeqCst); + for _ in 0..2 { + assert!(managed.store_keys_with_snapshot(Vec::new(), &p).is_err()); + } + persister + .constraint + .store(false, std::sync::atomic::Ordering::SeqCst); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(row(7), None, "the twice-refused queue is dropped"); } /// A key a refresh adds is stored without a breadcrumb, which a load's diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 856fbcba97c..05415d18bb9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -67,6 +67,13 @@ pub struct ManagedIdentity { /// holds it, until one succeeds. pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, + /// Whether `pending_key_rows` was already carried through one store the + /// store refused as invalid data. A constraint failure keeps the queue + /// once (the batch's own rows may be the cause), but a second one in a + /// row drops it, so a queued row that is itself invalid cannot refuse + /// every later key store of the identity. + pending_key_rows_refused: bool, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, From 073b8cd76a5b7f3869cafbbe2ca61e0d0b2bf0a2 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:41:37 +0300 Subject: [PATCH 37/72] fix(platform-wallet): trace a constraint failure to the row it refuses A constraint failure names no row, so a batch carrying queued key rows no longer drops the queue after two refusals in a row. The batch is stored again on its own: when the store refuses it too, the queue stays as it was; when it lands, each queued row is stored on its own (merged with the batch's row for the same key, so the batch's newer origin decision is kept) and only a row refused by itself is dropped. A row failing otherwise stays queued, merged, with the rest. Co-Authored-By: Claude Opus 5.5 --- .../state/managed_identity/identity_ops.rs | 334 +++++++++++++++--- .../identity/state/managed_identity/mod.rs | 10 +- .../identity/state/managed_identity/sync.rs | 3 +- 3 files changed, 295 insertions(+), 52 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index e06be47fa76..03f82660de6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -94,7 +94,6 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), - pending_key_rows_refused: false, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -117,7 +116,6 @@ impl ManagedIdentity { last_updated_balance_block_time: None, pending_balance_snapshot: None, pending_key_rows: BTreeMap::new(), - pending_key_rows_refused: false, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -523,25 +521,138 @@ impl ManagedIdentity { /// the key as memory holds it now, so a row queued before a later change /// to the key (a landed disable, a raised limit) does not take that /// change back; a queued key memory no longer holds is dropped. When the - /// store fails, its key rows are queued for the next one, unless the - /// store refused the data itself (a constraint failure, which a retry - /// would only repeat): then only the rows queued before it stay queued, - /// for one more store, since a queued row can itself be the invalid one. - /// The failure is returned. + /// store fails, its key rows are queued for the next one and the failure + /// is returned, unless the store refused the data itself (a constraint + /// failure, which a retry would only repeat). A constraint failure names + /// no row, so with rows queued the batch is stored again on its own: when + /// that is refused too, its failure is returned and the queue stays as it + /// was; when it lands, each queued row is stored on its own and only one + /// refused as invalid is dropped (one failing otherwise stays queued, with + /// the rest, for the next store to report), and the batch's store + /// succeeds. Two queued rows refused only together are told apart by + /// order: the later one is dropped. pub(super) fn store_with_queued_key_rows( &mut self, - mut keys_cs: IdentityKeysChangeSet, + keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PersistenceError> { let queued = std::mem::take(&mut self.pending_key_rows); - for (id, mut entry) in queued.clone() { + let combined = self.with_queued_key_rows(keys_cs.clone(), &queued); + let error = match self.store_with_snapshot(combined.clone(), persister) { + Ok(()) => return Ok(()), + Err(e) => e, + }; + if error.kind() != Some(PersistenceErrorKind::Constraint) { + self.pending_key_rows = combined.upserts; + return Err(error); + } + if queued.is_empty() { + tracing::warn!( + identity = %self.id(), + error = %error, + "identity key rows refused as invalid; not queued for a retry", + ); + return Err(error); + } + // The refusal names no row, so the batch and each queued row are + // stored on their own to tell which one the store refuses. + tracing::warn!( + identity = %self.id(), + error = %error, + queued = queued.len(), + "identity key rows refused as invalid; storing the batch and each queued row on its own", + ); + match self.store_with_snapshot(keys_cs.clone(), persister) { + Ok(()) => {} + Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { + // Refused without any queued row: the queue is not shown + // invalid by it and stays as it was. + tracing::warn!( + identity = %self.id(), + error = %e, + "identity key rows refused as invalid; not queued for a retry", + ); + self.pending_key_rows = queued; + return Err(e); + } + Err(e) => { + self.pending_key_rows = combined.upserts; + return Err(e); + } + } + // The batch landed with the snapshot, so a queued row the store now + // refuses on its own is what it refused. A queued row the batch + // carries itself is retried as the batch's row lending its origin, + // so the batch's newer decision is not taken back; when it lends + // nothing, that row already landed. Each retried row is the merged + // one, and so is what stays queued. + let rows: Vec<_> = queued + .iter() + .filter_map(|(&id, entry)| { + let own = keys_cs.upserts.get(&id); + let mut base = IdentityKeysChangeSet::default(); + if let Some(row) = own { + base.upserts.insert(id, row.clone()); + } + let merged = self + .with_queued_key_rows(base, &BTreeMap::from([(id, entry.clone())])) + .upserts + .remove(&id)?; + (own != Some(&merged)).then_some((id, merged)) + }) + .collect(); + let mut rows = rows.into_iter(); + while let Some((id, row)) = rows.next() { + let mut row_cs = IdentityKeysChangeSet::default(); + row_cs.upserts.insert(id, row.clone()); + match self.store_with_snapshot(row_cs, persister) { + Ok(()) => {} + Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { + tracing::warn!( + identity = %self.id(), + key_id = id.1, + error = %e, + "queued identity key row refused as invalid; dropped", + ); + } + Err(e) => { + // The batch landed, so its store succeeds; the rows not + // yet stored wait for the next one, which reports a + // failure that persists. + tracing::warn!( + identity = %self.id(), + error = %e, + "queued identity key rows not stored; kept for the next store", + ); + self.pending_key_rows.insert(id, row); + self.pending_key_rows.extend(rows); + break; + } + } + } + Ok(()) + } + + /// `keys_cs` with the `queued` key rows: a queued row `keys_cs` carries + /// itself gives way to it but lends it what it settled about the same + /// public key's origin (see [`IdentityKeyEntry::inherit_origin`]); every + /// other one takes the key as memory holds it now, its origin dropped + /// when that is a different public key; one memory no longer holds is + /// left out. + fn with_queued_key_rows( + &self, + mut keys_cs: IdentityKeysChangeSet, + queued: &BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, + ) -> IdentityKeysChangeSet { + for (&id, entry) in queued { if let Some(own) = keys_cs.upserts.get_mut(&id) { - own.inherit_origin(&entry); + own.inherit_origin(entry); continue; } let Some(key) = self.identity.public_keys().get(&entry.key_id) else { continue; }; + let mut entry = entry.clone(); // A breadcrumb belongs to the key it was derived for: a queued // row whose key memory now holds differently leaves its origin // unknown. @@ -555,34 +666,7 @@ impl ManagedIdentity { entry.public_key = key.clone(); keys_cs.upserts.insert(id, entry); } - let result = self.store_with_snapshot(keys_cs.clone(), persister); - match &result { - Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { - // The batch's own rows may be what the store refuses, but a - // row an earlier refused store queued is not shown invalid by - // one refusal: the queue is kept as it was, without this - // batch's rows, once. A second refusal in a row drops it, so - // a queued row that is itself invalid cannot refuse every - // later key store. - let keep = !queued.is_empty() && !self.pending_key_rows_refused; - tracing::warn!( - identity = %self.id(), - error = %e, - kept_queued = keep, - "identity key rows refused as invalid; this batch's rows not queued", - ); - if keep { - self.pending_key_rows = queued; - } - self.pending_key_rows_refused = keep; - } - Err(_) => { - self.pending_key_rows = keys_cs.upserts; - self.pending_key_rows_refused = false; - } - Ok(()) => self.pending_key_rows_refused = false, - } - result + keys_cs } /// The key decisions to store for `decisions` built from `fetched`, each @@ -1256,6 +1340,12 @@ mod tests { struct ArmedPersister { armed: std::sync::atomic::AtomicBool, constraint: std::sync::atomic::AtomicBool, + /// Key ids whose rows the store refuses as invalid. + poison: Mutex>, + /// Store calls so far, and the one (1-based, 0 for none) that fails + /// as a transient error. + attempts: std::sync::atomic::AtomicUsize, + transient_at: std::sync::atomic::AtomicUsize, stores: Mutex>, } impl PlatformWalletPersistence for ArmedPersister { @@ -1264,7 +1354,20 @@ mod tests { _wallet_id: WalletId, changeset: PlatformWalletChangeSet, ) -> Result<(), PersistenceError> { - if self.constraint.load(std::sync::atomic::Ordering::SeqCst) { + let attempt = self + .attempts + .fetch_add(1, std::sync::atomic::Ordering::SeqCst) + + 1; + if attempt == self.transient_at.load(std::sync::atomic::Ordering::SeqCst) { + return Err(PersistenceError::backend("transient")); + } + let poisoned = changeset.identity_keys.as_ref().is_some_and(|keys| { + let poison = self.poison.lock().unwrap(); + keys.upserts + .keys() + .any(|(_, key_id)| poison.contains(key_id)) + }); + if poisoned || self.constraint.load(std::sync::atomic::Ordering::SeqCst) { return Err(PersistenceError::backend_with_kind( PersistenceErrorKind::Constraint, "invalid row", @@ -1410,8 +1513,8 @@ mod tests { assert!(kept.derivation_indices.is_some()); assert_eq!(row(6), None, "the refused batch's own row is not queued"); - // A queue refused twice in a row is dropped: a queued row that is - // itself invalid cannot refuse every later key store. + // However often the batches are refused, the queue stays: a refusal + // the batch earns on its own is no evidence against a queued row. persister .armed .store(true, std::sync::atomic::Ordering::SeqCst); @@ -1424,14 +1527,159 @@ mod tests { persister .constraint .store(true, std::sync::atomic::Ordering::SeqCst); - for _ in 0..2 { + for _ in 0..3 { assert!(managed.store_keys_with_snapshot(Vec::new(), &p).is_err()); } persister .constraint .store(false, std::sync::atomic::Ordering::SeqCst); managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(row(7), None, "the twice-refused queue is dropped"); + assert!( + row(7).is_some(), + "the queue outlives refusals the batch earns" + ); + } + + /// A constraint failure names no row, so the batch and each queued row + /// are stored on their own: only the queued row the store refuses by + /// itself is dropped, the valid ones land, and a queued row the batch + /// carries itself keeps the batch's newer origin decision. + #[test] + fn should_drop_only_the_queued_row_a_constraint_failure_is_traced_to() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let mut managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + ); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { + breadcrumb: Some((wallet_id, 0, key.id())), + key, + derivation_rejected: BTreeSet::new(), + }; + // The latest stored row of `key_id`, in any store. + let stored = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .find_map(|cs| { + cs.identity_keys + .as_ref() + .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) + }) + }; + let arm = |on: bool| { + persister + .armed + .store(on, std::sync::atomic::Ordering::SeqCst) + }; + + arm(true); + assert!(managed + .store_keys_with_snapshot( + vec![ + with_breadcrumb(key(1)), + with_breadcrumb(key(2)), + with_breadcrumb(key(3)), + ], + &p, + ) + .is_err()); + arm(false); + persister.poison.lock().unwrap().insert(2); + // Key 1's newer row rejects the slot its queued breadcrumb names. + managed + .store_keys_with_snapshot( + vec![ + KeyWithBreadcrumb { + key: key(1), + breadcrumb: None, + derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 1, + }]), + }, + with_breadcrumb(key(4)), + ], + &p, + ) + .expect("the batch lands once the refused queued row is traced"); + assert!(stored(4).is_some()); + let valid = stored(3).expect("a valid queued row lands"); + assert!(valid.derivation_indices.is_some()); + assert_eq!(stored(2), None, "the refused queued row never lands"); + let newer = stored(1).expect("the batch's row lands"); + assert_eq!( + newer.derivation_indices, None, + "the queued breadcrumb is not put back" + ); + assert!(!newer.derivation_rejected.is_empty()); + + // The refused row was dropped, so it no longer refuses later stores. + let stores = persister.stores.lock().unwrap().len(); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(persister.stores.lock().unwrap().len(), stores + 1); + assert_eq!(stored(2), None); + + // A transient failure while the queued rows are retried keeps the + // rest queued as merged, so the batch's decision for a key it + // carries is not taken back by the next store. + arm(true); + assert!(managed + .store_keys_with_snapshot( + vec![ + with_breadcrumb(key(5)), + with_breadcrumb(key(6)), + with_breadcrumb(key(7)), + ], + &p, + ) + .is_err()); + arm(false); + *persister.poison.lock().unwrap() = BTreeSet::from([7]); + // Combined (refused), batch alone (lands), key 5 alone (transient). + let attempts = persister.attempts.load(std::sync::atomic::Ordering::SeqCst); + persister + .transient_at + .store(attempts + 3, std::sync::atomic::Ordering::SeqCst); + managed + .store_keys_with_snapshot( + vec![KeyWithBreadcrumb { + key: key(6), + breadcrumb: None, + derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { + identity_index: 0, + key_index: 6, + }]), + }], + &p, + ) + .expect("the batch landed"); + assert_eq!(stored(5), None, "the transient failure kept key 5 queued"); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert!(stored(5) + .expect("key 5 lands next") + .derivation_indices + .is_some()); + assert_eq!( + stored(6) + .expect("the batch's row landed") + .derivation_indices, + None, + "the queued breadcrumb is not put back", + ); + assert_eq!(stored(7), None); } /// A key a refresh adds is stored without a breadcrumb, which a load's diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 05415d18bb9..fabfa7fd609 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -64,16 +64,10 @@ pub struct ManagedIdentity { /// Key rows a fetch's fold or a landed update stored and the store /// refused, owed to persistence. Every later such store carries them /// (see `store_with_queued_key_rows`), each with the key as memory then - /// holds it, until one succeeds. + /// holds it, until they land; one the store refuses as invalid on its + /// own is dropped. pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, - /// Whether `pending_key_rows` was already carried through one store the - /// store refused as invalid data. A constraint failure keeps the queue - /// once (the batch's own rows may be the cause), but a second one in a - /// row drops it, so a queued row that is itself invalid cannot refuse - /// every later key store of the identity. - pending_key_rows_refused: bool, - /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 68f359be5c3..65d0fe5d375 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -103,7 +103,8 @@ impl ManagedIdentity { /// Store `keys_cs`, the key rows an earlier refused store queued, and the /// identity's scalar snapshot (status, revision and any queued balance) /// as one changeset (see `store_with_queued_key_rows`). The store's - /// failure is returned, and its key rows are queued for the next one. A store that + /// failure is returned, and its key rows are queued for the next one + /// (rows refused as invalid excepted, see there). A store that /// commits inline has committed the queued balance, which is then /// cleared; a buffered one keeps it queued, so it rides later snapshots /// until a balance write flushes it (see `retry_pending_balance`), and no From 1f52ec1f3b17aeefe1961e75c64b192cca0be30f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 00:26:51 +0300 Subject: [PATCH 38/72] fix(swift-sdk): keep the signer alive through the identity update FFI call The update worker borrows the signer's vtable for the whole synchronous call, and a bare `_ = signer` keepalive does not guarantee the signer outlives it under optimization. The FFI call, both revision branches included, now runs inside `withExtendedLifetime(signer)`, as the sibling wrappers do. Co-Authored-By: Claude Opus 5.5 --- .../ManagedPlatformWallet.swift | 51 ++++++++++--------- 1 file changed, 27 insertions(+), 24 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index ea8f455d3f3..474da1d718c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3765,37 +3765,40 @@ extension ManagedPlatformWallet { let externalIds = externalKeyIds let disableIds = disablePublicKeyIds try await Task.detached(priority: .userInitiated) { - _ = signer // Mirror the registration FFI's pubkey-pinning pattern via // `withPubkeyFFIArray` so each `pubkey_bytes` pointer the // FFI sees stays valid for the call duration. let pubkeyBuffers: [Data] = addPubkeys.map { $0.pubkeyBytes } - let result = idBytes.withUnsafeBufferPointer { - idBp -> PlatformWalletFFIResult in - externalIds.withUnsafeBufferPointer { externalBp -> PlatformWalletFFIResult in - disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in - let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in - let external = externalIds.isEmpty ? nil : externalBp.baseAddress - let disable = disableIds.isEmpty ? nil : disableBp.baseAddress - guard let revision else { - return platform_wallet_update_identity_with_signer( + // The Rust worker borrows the signer's vtable for the whole + // synchronous call, so the signer must outlive it. + let result = withExtendedLifetime(signer) { + idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + externalIds.withUnsafeBufferPointer { externalBp -> PlatformWalletFFIResult in + disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in + let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in + let external = externalIds.isEmpty ? nil : externalBp.baseAddress + let disable = disableIds.isEmpty ? nil : disableBp.baseAddress + guard let revision else { + return platform_wallet_update_identity_with_signer( + handle, idBp.baseAddress!, rows, UInt(count), + external, UInt(externalIds.count), + disable, UInt(disableIds.count), signerHandle) + } + return platform_wallet_update_identity_with_signer_at_revision( handle, idBp.baseAddress!, rows, UInt(count), external, UInt(externalIds.count), - disable, UInt(disableIds.count), signerHandle) + disable, UInt(disableIds.count), revision, signerHandle) + } + if addPubkeys.isEmpty { + return update(nil, 0) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + addPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + update(ffiRowsPtr, ffiRowsCount) } - return platform_wallet_update_identity_with_signer_at_revision( - handle, idBp.baseAddress!, rows, UInt(count), - external, UInt(externalIds.count), - disable, UInt(disableIds.count), revision, signerHandle) - } - if addPubkeys.isEmpty { - return update(nil, 0) - } - return ManagedPlatformWallet.withPubkeyFFIArray( - addPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - update(ffiRowsPtr, ffiRowsCount) } } } From 512bbcb2a5019e42dc05e006ac135457edadeee8 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:32:52 +0300 Subject: [PATCH 39/72] fix(platform-wallet): trace a constraint failure to the snapshot before any key row A constraint failure names nothing, so the scalar snapshot is stored on its own first: when the store refuses it too (an identity-index conflict), no key row is shown invalid and every row, the batch's included, stays queued until the cause is corrected. When it lands, each row is stored on its own, the batch's first, and only a row refused by itself is dropped; a batch row refused or not stored fails the call. Every row stays queued until it is known to have landed or to be invalid, so a store that unwinds leaves it owed. add_identity now stores through the same queue, so a refused store's key rows ride the identity's next key store. Tests: snapshot-only refusal then an unchanged store, batch-first tie-break, transient failure on a queued row after the batch landed, error precedence, unwind mid-trace, a refused add_identity, a queued key memory lost, a key both added and disabled, an out-of-wallet external key, a key type the wallet never derives. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/discovery.rs | 34 ++ .../src/wallet/identity/network/update.rs | 32 ++ .../state/managed_identity/identity_ops.rs | 431 ++++++++++++++---- .../identity/state/managed_identity/sync.rs | 2 +- .../identity/state/manager/lifecycle.rs | 25 +- 5 files changed, 413 insertions(+), 111 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index 42e5909de7f..aa84ad50b7d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -1042,6 +1042,40 @@ mod tests { ); } + /// A key of a type the wallet never derives (here a script hash), whose + /// check cannot run, is settled as not this wallet's: no breadcrumb, and + /// its candidate's coordinates refuted. + #[test] + fn breadcrumb_decisions_refutes_a_key_type_the_wallet_never_derives() { + let master = test_master(); + let wallet_id = [0xAB; 32]; + let kp = derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 0, 2) + .expect("derive"); + let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 2, + purpose: Purpose::TRANSFER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::BIP13_SCRIPT_HASH, + read_only: false, + data: dpp::platform_value::BinaryData::new(vec![0x11; 20]), + disabled_at: None, + }); + let mut scalars = BTreeMap::new(); + scalars.insert(2u32, kp.private_key); + let identity = identity_with_keys(vec![key]); + + let decisions = breadcrumb_decisions(&identity, 4, wallet_id, Network::Testnet, &scalars); + assert_eq!(decisions[0].breadcrumb, None); + assert_eq!( + decisions[0].derivation_rejected, + BTreeSet::from([crate::changeset::IdentityKeyDerivationIndices { + identity_index: 4, + key_index: 2, + }]), + ); + } + /// Decide key ownership from the candidate's compressed PUBLIC key alone, /// reproducing `IdentityPublicKey::validate_private_key_bytes` without the /// secret scalar: an `ECDSA_SECP256K1` key matches when the on-chain data diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 3bb0dbc8688..d2e617b8cfa 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -973,6 +973,38 @@ mod update_identity_at_revision_tests { } } + /// A key the update both adds and disables is taken as added on both + /// paths, and an out-of-wallet identity's declared external key leaves + /// its origin unknown (there is no slot of this wallet to refute). + #[tokio::test] + async fn should_take_an_added_and_disabled_key_as_added_and_leave_an_out_of_wallet_external_key_unknown( + ) { + let identity_id = Identifier::default(); + for cached in [4, 5] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(cached, store.clone()).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![3]) + .await + .unwrap(); + let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); + assert_eq!(stored.public_key.disabled_at(), None, "cached at {cached}"); + assert!(stored.derivation_indices.is_some(), "cached at {cached}"); + + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(cached, store.clone()).await; + let wallet = wallet.identity(); + wallet + .apply_landed_update(&identity_id, None, 5, vec![key_3()], &[3], vec![]) + .await + .unwrap(); + let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); + assert_eq!(stored.derivation_indices, None, "cached at {cached}"); + assert!(stored.derivation_rejected.is_empty(), "cached at {cached}"); + } + } + /// An external key id that names no key the update adds is refused /// before anything is signed. #[tokio::test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 03f82660de6..f1470490177 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -521,116 +521,101 @@ impl ManagedIdentity { /// the key as memory holds it now, so a row queued before a later change /// to the key (a landed disable, a raised limit) does not take that /// change back; a queued key memory no longer holds is dropped. When the - /// store fails, its key rows are queued for the next one and the failure - /// is returned, unless the store refused the data itself (a constraint - /// failure, which a retry would only repeat). A constraint failure names - /// no row, so with rows queued the batch is stored again on its own: when - /// that is refused too, its failure is returned and the queue stays as it - /// was; when it lands, each queued row is stored on its own and only one - /// refused as invalid is dropped (one failing otherwise stays queued, with - /// the rest, for the next store to report), and the batch's store - /// succeeds. Two queued rows refused only together are told apart by - /// order: the later one is dropped. - pub(super) fn store_with_queued_key_rows( + /// store fails, every row stays queued for the next one and the failure + /// is returned. + /// + /// A constraint failure (invalid data, which a retry would only repeat) + /// names nothing, so it is traced before anything is dropped. The scalar + /// snapshot is stored on its own first: when the store refuses it too, + /// no row is shown invalid and every row stays queued. When it lands, + /// each row (the batch's and the queued ones, merged) is stored on its + /// own, and only a row the store refuses by itself is dropped. A failure + /// of another kind on the way keeps that row and the rest queued. The + /// call fails, with the store's error, when a row of `keys_cs` was + /// refused or not stored; a queued row refused alone fails nothing. Two + /// rows refused only together are told apart by order, the batch's rows + /// first: the later one is dropped. + pub(crate) fn store_with_queued_key_rows( &mut self, keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, ) -> Result<(), PersistenceError> { - let queued = std::mem::take(&mut self.pending_key_rows); + // Every row stays queued until it is known to have landed or to be + // invalid, so a store that unwinds leaves it owed. + debug_assert!( + keys_cs.removed.is_empty(), + "identity key removals do not go through the key-row queue" + ); + let queued = self.pending_key_rows.clone(); let combined = self.with_queued_key_rows(keys_cs.clone(), &queued); + self.pending_key_rows = combined.upserts.clone(); let error = match self.store_with_snapshot(combined.clone(), persister) { - Ok(()) => return Ok(()), + Ok(()) => { + self.pending_key_rows.clear(); + return Ok(()); + } Err(e) => e, }; if error.kind() != Some(PersistenceErrorKind::Constraint) { - self.pending_key_rows = combined.upserts; - return Err(error); - } - if queued.is_empty() { - tracing::warn!( - identity = %self.id(), - error = %error, - "identity key rows refused as invalid; not queued for a retry", - ); return Err(error); } - // The refusal names no row, so the batch and each queued row are - // stored on their own to tell which one the store refuses. tracing::warn!( identity = %self.id(), error = %error, - queued = queued.len(), - "identity key rows refused as invalid; storing the batch and each queued row on its own", + rows = combined.upserts.len(), + "identity store refused as invalid; storing the snapshot and each key row on its own", ); - match self.store_with_snapshot(keys_cs.clone(), persister) { - Ok(()) => {} - Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { - // Refused without any queued row: the queue is not shown - // invalid by it and stays as it was. - tracing::warn!( - identity = %self.id(), - error = %e, - "identity key rows refused as invalid; not queued for a retry", - ); - self.pending_key_rows = queued; - return Err(e); - } - Err(e) => { - self.pending_key_rows = combined.upserts; - return Err(e); - } - } - // The batch landed with the snapshot, so a queued row the store now - // refuses on its own is what it refused. A queued row the batch - // carries itself is retried as the batch's row lending its origin, - // so the batch's newer decision is not taken back; when it lends - // nothing, that row already landed. Each retried row is the merged - // one, and so is what stays queued. - let rows: Vec<_> = queued - .iter() - .filter_map(|(&id, entry)| { - let own = keys_cs.upserts.get(&id); - let mut base = IdentityKeysChangeSet::default(); - if let Some(row) = own { - base.upserts.insert(id, row.clone()); - } - let merged = self - .with_queued_key_rows(base, &BTreeMap::from([(id, entry.clone())])) - .upserts - .remove(&id)?; - (own != Some(&merged)).then_some((id, merged)) - }) - .collect(); - let mut rows = rows.into_iter(); - while let Some((id, row)) = rows.next() { + // The snapshot refused without any row shows no row invalid: every + // one stays queued. + self.store_with_snapshot(IdentityKeysChangeSet::default(), persister)?; + // The snapshot landed, so a row the store now refuses on its own is + // what it refused. The batch's rows go first: of two rows refused + // only together, the later one is dropped, and a queued row is the + // older of the two. + let mut failure = None; + let (own_rows, queued_rows): (Vec<_>, Vec<_>) = combined + .upserts + .into_iter() + .partition(|(id, _)| keys_cs.upserts.contains_key(id)); + for (id, row) in own_rows.into_iter().chain(queued_rows) { + let own = keys_cs.upserts.contains_key(&id); let mut row_cs = IdentityKeysChangeSet::default(); - row_cs.upserts.insert(id, row.clone()); + row_cs.upserts.insert(id, row); match self.store_with_snapshot(row_cs, persister) { - Ok(()) => {} + Ok(()) => { + self.pending_key_rows.remove(&id); + } Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { tracing::warn!( identity = %self.id(), key_id = id.1, error = %e, - "queued identity key row refused as invalid; dropped", + "identity key row refused as invalid; dropped", ); + self.pending_key_rows.remove(&id); + if own { + failure.get_or_insert(e); + } } Err(e) => { - // The batch landed, so its store succeeds; the rows not - // yet stored wait for the next one, which reports a - // failure that persists. + // This row and the rest stay queued. tracing::warn!( identity = %self.id(), error = %e, - "queued identity key rows not stored; kept for the next store", + "identity key rows not stored; kept for the next store", ); - self.pending_key_rows.insert(id, row); - self.pending_key_rows.extend(rows); + let unstored_own = self + .pending_key_rows + .keys() + .any(|id| keys_cs.upserts.contains_key(id)); + if unstored_own { + return Err(failure.unwrap_or(e)); + } break; } } } - Ok(()) + failure.map_or(Ok(()), Err) } /// `keys_cs` with the `queued` key rows: a queued row `keys_cs` carries @@ -1346,6 +1331,12 @@ mod tests { /// as a transient error. attempts: std::sync::atomic::AtomicUsize, transient_at: std::sync::atomic::AtomicUsize, + /// The store call (1-based, 0 for none) that panics. + panic_at: std::sync::atomic::AtomicUsize, + /// Two key ids the store refuses as invalid together, as a unique + /// index would: a store carrying one of them while the other is in + /// it or already stored. + exclusive: Mutex>, stores: Mutex>, } impl PlatformWalletPersistence for ArmedPersister { @@ -1361,13 +1352,27 @@ mod tests { if attempt == self.transient_at.load(std::sync::atomic::Ordering::SeqCst) { return Err(PersistenceError::backend("transient")); } + if attempt == self.panic_at.load(std::sync::atomic::Ordering::SeqCst) { + panic!("store panicked"); + } let poisoned = changeset.identity_keys.as_ref().is_some_and(|keys| { let poison = self.poison.lock().unwrap(); keys.upserts .keys() .any(|(_, key_id)| poison.contains(key_id)) }); - if poisoned || self.constraint.load(std::sync::atomic::Ordering::SeqCst) { + let has = |cs: &PlatformWalletChangeSet, id: KeyID| { + cs.identity_keys + .as_ref() + .is_some_and(|keys| keys.upserts.keys().any(|(_, key_id)| *key_id == id)) + }; + let conflicting = self.exclusive.lock().unwrap().is_some_and(|(a, b)| { + let stores = self.stores.lock().unwrap(); + let present = |id| has(&changeset, id) || stores.iter().any(|cs| has(cs, id)); + (has(&changeset, a) && present(b)) || (has(&changeset, b) && present(a)) + }); + if poisoned || conflicting || self.constraint.load(std::sync::atomic::Ordering::SeqCst) + { return Err(PersistenceError::backend_with_kind( PersistenceErrorKind::Constraint, "invalid row", @@ -1474,19 +1479,31 @@ mod tests { managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); assert_eq!(row(2), None, "the queue is empty after a store succeeds"); - // Rows refused as invalid data are not queued: a retry would only - // repeat the refusal. Rows an earlier refused store queued stay. + // A row the store refuses on its own is dropped, and the call fails: + // a retry would only repeat the refusal. + persister.poison.lock().unwrap().insert(3); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(3))], &p) + .is_err()); + persister.poison.lock().unwrap().clear(); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(row(3), None); + + // A refusal of the scalar snapshot alone shows no row invalid: the + // batch's rows stay queued, and once the cause is corrected a store + // with no key change of its own (an unchanged refresh) writes them. persister .constraint .store(true, std::sync::atomic::Ordering::SeqCst); assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(3))], &p) + .store_keys_with_snapshot(vec![with_breadcrumb(key(4))], &p) .is_err()); persister .constraint .store(false, std::sync::atomic::Ordering::SeqCst); managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(row(3), None); + let kept = row(4).expect("the row outlives a refusal of the snapshot"); + assert!(kept.derivation_indices.is_some()); // A row queued by an earlier refused store survives a later batch the // store refuses as invalid, and rides the next store that succeeds. @@ -1511,7 +1528,10 @@ mod tests { managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); let kept = row(5).expect("the earlier queued row survives the constraint failure"); assert!(kept.derivation_indices.is_some()); - assert_eq!(row(6), None, "the refused batch's own row is not queued"); + assert!( + row(6).is_some(), + "a refusal the snapshot earns keeps the batch's own row too", + ); // However often the batches are refused, the queue stays: a refusal // the batch earns on its own is no evidence against a queued row. @@ -1540,10 +1560,10 @@ mod tests { ); } - /// A constraint failure names no row, so the batch and each queued row - /// are stored on their own: only the queued row the store refuses by - /// itself is dropped, the valid ones land, and a queued row the batch - /// carries itself keeps the batch's newer origin decision. + /// A constraint failure names no row, so the snapshot and then each row + /// are stored on their own: only the row the store refuses by itself is + /// dropped, the valid ones land, and a queued row the batch carries + /// itself keeps the batch's newer origin decision. #[test] fn should_drop_only_the_queued_row_a_constraint_failure_is_traced_to() { let id = Identifier::from([1u8; 32]); @@ -1648,7 +1668,9 @@ mod tests { .is_err()); arm(false); *persister.poison.lock().unwrap() = BTreeSet::from([7]); - // Combined (refused), batch alone (lands), key 5 alone (transient). + // Combined (refused), snapshot alone (lands), key 6 alone (the + // batch's row, stored first: transient), so key 6 and the queued + // rows stay queued. let attempts = persister.attempts.load(std::sync::atomic::Ordering::SeqCst); persister .transient_at @@ -1665,8 +1687,8 @@ mod tests { }], &p, ) - .expect("the batch landed"); - assert_eq!(stored(5), None, "the transient failure kept key 5 queued"); + .expect_err("the batch's own row was not stored"); + assert_eq!(stored(5), None, "the queued rows wait behind the batch's"); managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); assert!(stored(5) .expect("key 5 lands next") @@ -1682,6 +1704,235 @@ mod tests { assert_eq!(stored(7), None); } + /// Tracing a constraint failure stores the batch's rows before the + /// queued ones, so of two rows refused only together the older, queued + /// one is dropped; a failure of another kind on a queued row, once the + /// batch's rows landed, fails nothing and keeps that row queued; and a + /// batch row refused alone is the error returned, ahead of a later + /// failure of another kind. + #[test] + fn should_trace_a_constraint_failure_batch_first_and_report_the_batch_s_own() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let mut managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + ); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { + breadcrumb: Some((wallet_id, 0, key.id())), + key, + derivation_rejected: BTreeSet::new(), + }; + let stored = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .find_map(|cs| { + cs.identity_keys + .as_ref() + .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) + }) + }; + let arm = |on: bool| { + persister + .armed + .store(on, std::sync::atomic::Ordering::SeqCst) + }; + let attempts = || persister.attempts.load(std::sync::atomic::Ordering::SeqCst); + + // Key 2 queued, then a batch with key 5 that it conflicts with. + arm(true); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) + .is_err()); + arm(false); + *persister.exclusive.lock().unwrap() = Some((2, 5)); + managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) + .expect("the batch's row lands; the conflicting queued one is dropped"); + assert!(stored(5).is_some()); + assert_eq!(stored(2), None); + *persister.exclusive.lock().unwrap() = None; + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert_eq!(stored(2), None, "the dropped row is not queued"); + + // Key 9 queued; the next batch (key 1) is refused together with it, + // and key 9's own store then fails transiently: the batch landed. + arm(true); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(9))], &p) + .is_err()); + arm(false); + *persister.exclusive.lock().unwrap() = Some((1, 9)); + // Combined (refused), snapshot (lands), key 1 (lands), key 9 (transient). + persister + .transient_at + .store(attempts() + 4, std::sync::atomic::Ordering::SeqCst); + managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(1))], &p) + .expect("every row of the batch landed"); + assert!(stored(1).is_some()); + assert_eq!(stored(9), None); + *persister.exclusive.lock().unwrap() = None; + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert!(stored(9) + .expect("key 9 stayed queued") + .derivation_indices + .is_some(),); + + // A batch row refused alone (3), then a transient failure on a later + // batch row (4): the constraint failure is the one returned. + persister.poison.lock().unwrap().insert(3); + // Combined (refused), snapshot (lands), key 3 (refused), key 4 (transient). + persister + .transient_at + .store(attempts() + 4, std::sync::atomic::Ordering::SeqCst); + let error = managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(3)), with_breadcrumb(key(4))], &p) + .expect_err("a batch row was refused"); + assert_eq!(error.kind(), Some(PersistenceErrorKind::Constraint)); + persister.poison.lock().unwrap().clear(); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert!(stored(4).is_some(), "the unstored batch row stayed queued"); + assert_eq!(stored(3), None, "the refused batch row is dropped"); + } + + /// A store that unwinds while a constraint failure is traced leaves every + /// row not yet known to have landed queued: the queued row and the + /// batch's ride the next store. + #[test] + fn should_keep_the_rows_queued_when_a_store_unwinds_mid_trace() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let mut managed = ManagedIdentity::new( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::new(), + balance: 0, + revision: 0, + }), + 0, + ); + managed.wallet_id = Some(wallet_id); + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { + breadcrumb: Some((wallet_id, 0, key.id())), + key, + derivation_rejected: BTreeSet::new(), + }; + let stored = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .find_map(|cs| { + cs.identity_keys + .as_ref() + .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) + }) + }; + + persister + .armed + .store(true, std::sync::atomic::Ordering::SeqCst); + assert!(managed + .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) + .is_err()); + persister + .armed + .store(false, std::sync::atomic::Ordering::SeqCst); + *persister.exclusive.lock().unwrap() = Some((2, 5)); + // Combined (refused), snapshot (lands), key 5 alone (panics). + let attempts = persister.attempts.load(std::sync::atomic::Ordering::SeqCst); + persister + .panic_at + .store(attempts + 3, std::sync::atomic::Ordering::SeqCst); + let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + managed.store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) + })); + assert!(unwound.is_err()); + *persister.exclusive.lock().unwrap() = None; + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert!(stored(2) + .expect("the queued row stayed owed") + .derivation_indices + .is_some()); + assert!(stored(5) + .expect("the batch's row stayed owed") + .derivation_indices + .is_some()); + } + + /// A queued row whose key memory no longer holds is dropped, and the + /// rows a refused `add_identity` store carried ride the next key store. + #[test] + fn should_drop_a_queued_row_memory_lost_and_queue_a_refused_add_identity() { + let id = Identifier::from([1u8; 32]); + let wallet_id: WalletId = [0xAB; 32]; + let persister = std::sync::Arc::new(ArmedPersister::default()); + let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + let stored = |key_id: KeyID| { + persister + .stores + .lock() + .unwrap() + .iter() + .rev() + .find_map(|cs| { + cs.identity_keys + .as_ref() + .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) + }) + }; + let arm = |on: bool| { + persister + .armed + .store(on, std::sync::atomic::Ordering::SeqCst) + }; + + let mut manager = crate::wallet::identity::state::manager::IdentityManager::new(); + arm(true); + manager + .add_identity( + Identity::V0(IdentityV0 { + id, + public_keys: BTreeMap::from([(1, key(1)), (2, key(2))]), + balance: 0, + revision: 0, + }), + 0, + wallet_id, + &p, + ) + .expect("a refused store is logged, not returned"); + arm(false); + let managed = manager.managed_identity_mut(&id).unwrap(); + // Key 2 leaves memory before the queue is retried. + managed + .identity + .set_public_keys(BTreeMap::from([(1, key(1))])); + managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); + assert!( + stored(1).is_some(), + "the refused add_identity row rides the next store" + ); + assert_eq!(stored(2), None, "a queued key memory lost is dropped"); + } + /// A key a refresh adds is stored without a breadcrumb, which a load's /// row then gives it, and a later breadcrumb-less change keeps. #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 65d0fe5d375..169fb997241 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -104,7 +104,7 @@ impl ManagedIdentity { /// identity's scalar snapshot (status, revision and any queued balance) /// as one changeset (see `store_with_queued_key_rows`). The store's /// failure is returned, and its key rows are queued for the next one - /// (rows refused as invalid excepted, see there). A store that + /// (rows refused as invalid on their own excepted, see there). A store that /// commits inline has committed the queued balance, which is then /// cleared; a buffered one keeps it queued, so it rides later snapshots /// until a balance write flushes it (see `retry_pending_balance`), and no diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index 03bbfbdc4ed..f5012742976 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -7,7 +7,7 @@ //! [`IdentityManager::add_identity_unstored`], whose caller stores. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet}; +use crate::changeset::{IdentityChangeSet, IdentityEntry}; use crate::error::PlatformWalletError; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use crate::wallet::persister::WalletPersister; @@ -47,26 +47,11 @@ impl IdentityManager { .managed_identity_mut(&identity_id) .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; - let entry = IdentityEntry::from_managed(managed_identity); - // No `key_storage` to snapshot here anymore — the field is - // dropped from `ManagedIdentity`. Public keys still arrive - // via the separate `IdentityKeysChangeSet` path. + // The identity and its key rows go out as one changeset. A refused + // store is logged, not returned (the identity is already held), and + // its key rows stay queued for the identity's next key store. let keys_cs = managed_identity.keys_snapshot_changeset(); - - let mut id_cs = IdentityChangeSet::default(); - id_cs.identities.insert(identity_id, entry); - - let cs = PlatformWalletChangeSet { - identities: Some(id_cs), - identity_keys: if keys_cs.upserts.is_empty() { - None - } else { - Some(keys_cs) - }, - ..Default::default() - }; - - if let Err(e) = persister.store(cs) { + if let Err(e) = managed_identity.store_with_queued_key_rows(keys_cs, persister) { tracing::error!("Failed to persist changeset: {}", e); } From fcf69a6a6bdc213241a1ee8e7ed0320a6033c658 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:32:52 +0300 Subject: [PATCH 40/72] fix(platform-wallet-ffi): refuse a null key array with a non-zero count The identity update exports refused a null external_key_ids with a count but read a null add_public_keys or disable_public_key_ids with a count as empty, so an add-and-revoke call could broadcast an add-only transition. All three are refused with ErrorNullPointer before the wallet lookup or the signer. A test pins that the _at_revision export forwards the revision to the guard before signing. JNI: the rejected-derivation int[] conversion and the key-id / revision validation are pure helpers with tests; the pair count is checked against jint. Co-Authored-By: Claude Opus 5.5 --- .../src/identity_update.rs | 445 +++++++++++++++--- .../rs-unified-sdk-jni/src/persistence.rs | 87 +++- .../rs-unified-sdk-jni/src/transactions.rs | 88 +++- 3 files changed, 541 insertions(+), 79 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index f8312f60762..a58ece91cb1 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -321,6 +321,9 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// - When `disable_public_key_ids_count` is non-zero, /// `disable_public_key_ids` must point at a valid, aligned /// `[u32; disable_public_key_ids_count]` array. +/// - Any of the three arrays that is null with a non-zero count is refused +/// with `ErrorNullPointer` before anything is looked up or signed, never +/// read as empty. /// - `signer_handle` must be a valid, non-destroyed `SignerHandle` from /// rs-sdk-ffi's signer constructors (typically `KeychainSigner.handle`, /// made by `dash_sdk_signer_create_with_ctx`). It is borrowed until the @@ -458,6 +461,45 @@ fn guard_update( ) } +/// A null array with a non-zero count is refused rather than read as empty. +/// Read as empty, it would drop part of what the caller asked for and still +/// broadcast the rest: a null `disable_public_key_ids` turns "add a key and +/// revoke a compromised one" into an add-only update, a null `add_public_keys` +/// turns it into a revoke-only one, and a null `external_key_ids` stores the +/// caller's external keys as derived. +fn refuse_null_with_count( + is_null: bool, + count: usize, + name: &str, +) -> Result<(), PlatformWalletFFIResult> { + if is_null && count != 0 { + return Err(PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + format!("{name} is null with a non-zero count"), + )); + } + Ok(()) +} + +/// Copies a caller's `[u32; count]` key-id array, under +/// [`refuse_null_with_count`]. A zero count reads as empty whatever the +/// pointer. +/// +/// # Safety +/// When `count` is non-zero and `ids` is non-null, `ids` must point at a +/// valid, aligned `[u32; count]` array. +unsafe fn read_key_ids( + ids: *const u32, + count: usize, + name: &str, +) -> Result, PlatformWalletFFIResult> { + refuse_null_with_count(ids.is_null(), count, name)?; + if count == 0 { + return Ok(Vec::new()); + } + Ok(slice::from_raw_parts(ids, count).to_vec()) +} + #[allow(clippy::too_many_arguments)] unsafe fn update_identity_with_signer_unguarded( wallet_handle: Handle, @@ -475,71 +517,68 @@ unsafe fn update_identity_with_signer_unguarded( let id = unwrap_result_or_return!(read_identifier(identity_id)); - let add_keys: Vec = - if add_public_keys.is_null() || add_public_keys_count == 0 { - Vec::new() - } else { - let rows: &[IdentityPubkeyFFI] = - slice::from_raw_parts(add_public_keys, add_public_keys_count); - let mut keys: Vec = Vec::with_capacity(rows.len()); - for (i, row) in rows.iter().enumerate() { - let key_type = unwrap_result_or_return!(KeyType::try_from(row.key_type)); - let purpose = unwrap_result_or_return!(Purpose::try_from(row.purpose)); - let security_level = - unwrap_result_or_return!(SecurityLevel::try_from(row.security_level)); - if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorNullPointer, - format!("add_public_keys[{i}].pubkey_bytes is null or empty"), - ); - } - let pubkey_bytes: Vec = - slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); - - let contract_bounds = unwrap_result_or_return!(decode_contract_bounds( - row, - purpose, - i, - "add_public_keys" - )); - - keys.push(key_with_row_limits( - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: row.key_id, - purpose, - security_level, - contract_bounds, - key_type, - read_only: row.read_only, - data: BinaryData::new(pubkey_bytes), - disabled_at: None, - }), - row, - )); - } - keys - }; + // Every array is checked before any row is read, the wallet is looked up + // or the signer is consulted. + unwrap_result_or_return!(refuse_null_with_count( + add_public_keys.is_null(), + add_public_keys_count, + "add_public_keys" + )); + let external_ids = unwrap_result_or_return!(read_key_ids( + external_key_ids, + external_key_ids_count, + "external_key_ids" + )); + let disable_ids = unwrap_result_or_return!(read_key_ids( + disable_public_key_ids, + disable_public_key_ids_count, + "disable_public_key_ids" + )); - // A null array with a count would silently store the caller's - // external keys as derived, so it is refused. - let external_ids: Vec = if external_key_ids_count == 0 { + let add_keys: Vec = if add_public_keys_count == 0 { Vec::new() - } else if external_key_ids.is_null() { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorNullPointer, - "external_key_ids is null with a non-zero count", - ); } else { - slice::from_raw_parts(external_key_ids, external_key_ids_count).to_vec() + let rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(add_public_keys, add_public_keys_count); + let mut keys: Vec = Vec::with_capacity(rows.len()); + for (i, row) in rows.iter().enumerate() { + let key_type = unwrap_result_or_return!(KeyType::try_from(row.key_type)); + let purpose = unwrap_result_or_return!(Purpose::try_from(row.purpose)); + let security_level = + unwrap_result_or_return!(SecurityLevel::try_from(row.security_level)); + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + format!("add_public_keys[{i}].pubkey_bytes is null or empty"), + ); + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); + + let contract_bounds = unwrap_result_or_return!(decode_contract_bounds( + row, + purpose, + i, + "add_public_keys" + )); + + keys.push(key_with_row_limits( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + row, + )); + } + keys }; - let disable_ids: Vec = - if disable_public_key_ids.is_null() || disable_public_key_ids_count == 0 { - Vec::new() - } else { - slice::from_raw_parts(disable_public_key_ids, disable_public_key_ids_count).to_vec() - }; - let signer_addr = signer_handle as usize; let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { @@ -567,13 +606,24 @@ unsafe fn update_identity_with_signer_unguarded( #[cfg(test)] mod tests { use super::*; + use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; + use dpp::identity::Identity; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::serialization::PlatformSerializable; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; + use dpp::version::PlatformVersion; + use platform_wallet::test_support::test_platform_wallet_manager; + use rs_sdk_ffi::{ + dash_sdk_signer_create_with_ctx, dash_sdk_signer_destroy, SignCompletionCallback, + }; + use std::ffi::c_void; use std::ffi::CStr; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::runtime::runtime; /// A panic in an update body comes back as `ErrorUnknown` with the /// ambiguous-outcome guidance, not as a definitive failure, and never @@ -600,6 +650,281 @@ mod tests { assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); } + /// Counts every call the update makes into a signer created through + /// `dash_sdk_signer_create_with_ctx`; `ctx` points at the counter. + unsafe extern "C" fn counting_sign_async( + ctx: *const c_void, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, + _data: *const u8, + _data_len: usize, + completion_ctx: *mut c_void, + completion: SignCompletionCallback, + ) { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + let message = CString::new("counting signer").unwrap(); + completion(completion_ctx, ptr::null(), 0, 0, message.as_ptr()); + } + + unsafe extern "C" fn counting_can_sign( + ctx: *const c_void, + _pubkey_bytes: *const u8, + _pubkey_len: usize, + _key_type: u8, + ) -> bool { + (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); + true + } + + /// A signer handle whose every callback bumps `calls`. Destroy it with + /// `dash_sdk_signer_destroy` before `calls` goes out of scope. + fn counting_signer(calls: &AtomicUsize) -> *mut SignerHandle { + unsafe { + dash_sdk_signer_create_with_ctx( + calls as *const AtomicUsize as *mut c_void, + counting_sign_async, + counting_can_sign, + None, + ) + } + } + + fn message_of(result: &PlatformWalletFFIResult) -> String { + assert!(!result.message.is_null()); + unsafe { CStr::from_ptr(result.message) } + .to_string_lossy() + .into_owned() + } + + /// Which array is null with a non-zero count, for + /// [`call_update_with_null_array`]. + #[derive(Clone, Copy, Debug)] + enum NullArray { + AddPublicKeys, + ExternalKeyIds, + DisablePublicKeyIds, + } + + /// Calls one update export with the other two arrays well-formed and + /// non-empty and `null` as the named one, over a handle that names no + /// wallet: a refusal that came after the wallet lookup would surface as + /// `NotFound` instead. + fn call_update_with_null_array( + null: NullArray, + revision: Option, + signer: *mut SignerHandle, + ) -> PlatformWalletFFIResult { + let identity_id = [0x11u8; 32]; + let pubkey = [0x02u8; 33]; + let row = IdentityPubkeyFFI { + key_id: 9, + key_type: KeyType::ECDSA_SECP256K1 as u8, + purpose: Purpose::AUTHENTICATION as u8, + security_level: SecurityLevel::HIGH as u8, + pubkey_bytes: pubkey.as_ptr(), + pubkey_len: pubkey.len(), + read_only: false, + contract_bounds_kind: 0, + contract_bounds_id: ptr::null(), + contract_bounds_document_type: ptr::null(), + has_total_budget: false, + total_budget: 0, + has_expires_at: false, + expires_at: 0, + }; + let external_ids = [9u32]; + let disable_ids = [4u32]; + let add_ptr = match null { + NullArray::AddPublicKeys => ptr::null(), + _ => &row as *const IdentityPubkeyFFI, + }; + let external_ptr = match null { + NullArray::ExternalKeyIds => ptr::null(), + _ => external_ids.as_ptr(), + }; + let disable_ptr = match null { + NullArray::DisablePublicKeyIds => ptr::null(), + _ => disable_ids.as_ptr(), + }; + let unknown_handle = Handle::MAX; + unsafe { + match revision { + None => platform_wallet_update_identity_with_signer( + unknown_handle, + identity_id.as_ptr(), + add_ptr, + 1, + external_ptr, + 1, + disable_ptr, + 1, + signer, + ), + Some(revision) => platform_wallet_update_identity_with_signer_at_revision( + unknown_handle, + identity_id.as_ptr(), + add_ptr, + 1, + external_ptr, + 1, + disable_ptr, + 1, + revision, + signer, + ), + } + } + } + + /// Read as empty, a null array with a count would broadcast part of the + /// update (an add without the revoke the caller asked for, say), so each + /// of the three is refused, in both exports, before the wallet is looked + /// up or the signer consulted. + #[test] + fn should_refuse_a_null_array_with_a_count_before_the_wallet_or_the_signer() { + let calls = AtomicUsize::new(0); + let signer = counting_signer(&calls); + for (null, name) in [ + (NullArray::AddPublicKeys, "add_public_keys"), + (NullArray::ExternalKeyIds, "external_key_ids"), + (NullArray::DisablePublicKeyIds, "disable_public_key_ids"), + ] { + for revision in [None, Some(5)] { + let result = call_update_with_null_array(null, revision, signer); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorNullPointer, + "{null:?} at {revision:?}: {}", + message_of(&result) + ); + assert_eq!( + message_of(&result), + format!("{name} is null with a non-zero count"), + "{null:?} at {revision:?}" + ); + } + } + assert_eq!(calls.load(Ordering::SeqCst), 0); + unsafe { dash_sdk_signer_destroy(signer) }; + } + + /// A zero count still reads as empty whatever the pointer, so the + /// refusal is only for a null array that claims rows. + #[test] + fn should_read_a_zero_count_as_empty_whatever_the_pointer() { + assert!(refuse_null_with_count(true, 0, "add_public_keys").is_ok()); + assert!(refuse_null_with_count(false, 3, "add_public_keys").is_ok()); + let ids = [7u32, 8]; + assert_eq!( + unsafe { read_key_ids(ptr::null(), 0, "disable_public_key_ids") }.ok(), + Some(vec![]) + ); + assert_eq!( + unsafe { read_key_ids(ids.as_ptr(), 0, "disable_public_key_ids") }.ok(), + Some(vec![]) + ); + assert_eq!( + unsafe { read_key_ids(ids.as_ptr(), 2, "disable_public_key_ids") }.ok(), + Some(vec![7, 8]) + ); + } + + /// A wallet handle whose cached identity `identity_id` is at `revision` + /// and holds a signable MASTER key (id 0), so an update that got past + /// the revision check would consult the signer. The manager owns the + /// wallet and must outlive the handle. + fn wallet_handle_at_revision(identity_id: Identifier, revision: u64) -> (impl Sized, Handle) { + runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager.get_wallet(&wallet_id).await.expect("test wallet"); + let mut identity = + Identity::default_versioned(PlatformVersion::latest()).expect("identity"); + identity.set_id(identity_id); + identity.add_public_key( + IdentityPublicKeyV0 { + id: 0, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::MASTER, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![0x02; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into(), + ); + identity.set_revision(revision); + { + let mut wm = wallet.wallet_manager().write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .add_identity(identity, 0, wallet_id, wallet.persister()) + .expect("add identity"); + } + let handle = PLATFORM_WALLET_STORAGE.insert(wallet); + (manager, handle) + }) + } + + /// The `_at_revision` export hands its revision to the wallet's guard: a + /// revision the cached identity would not give the transition comes back + /// as `ErrorIdentityRevisionMismatch` (62) before the signer is + /// consulted. The matching revision gets past the guard to the signer + /// (and then fails at the mock SDK), so the zero count comes from the + /// refusal, not from the fixture. + #[test] + fn should_forward_the_revision_to_the_guard_before_signing() { + let identity_id = Identifier::from([0x21; 32]); + let id_bytes = identity_id.to_buffer(); + let disable_ids = [1u32]; + let call = |handle: Handle, revision: u64, signer: *mut SignerHandle| unsafe { + platform_wallet_update_identity_with_signer_at_revision( + handle, + id_bytes.as_ptr(), + ptr::null(), + 0, + ptr::null(), + 0, + disable_ids.as_ptr(), + disable_ids.len(), + revision, + signer, + ) + }; + + // Cached at 5, so the transition would carry 6. + let (_manager, handle) = wallet_handle_at_revision(identity_id, 5); + for asked in [5, 7] { + let calls = AtomicUsize::new(0); + let signer = counting_signer(&calls); + let result = call(handle, asked, signer); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch, + "asked {asked}: {}", + message_of(&result) + ); + assert_eq!(result.code as i32, 62); + assert_eq!(calls.load(Ordering::SeqCst), 0, "asked {asked}"); + unsafe { dash_sdk_signer_destroy(signer) }; + } + + let calls = AtomicUsize::new(0); + let signer = counting_signer(&calls); + let result = call(handle, 6, signer); + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_ne!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch, + "{}", + message_of(&result) + ); + assert!(calls.load(Ordering::SeqCst) > 0); + unsafe { dash_sdk_signer_destroy(signer) }; + + PLATFORM_WALLET_STORAGE.remove(handle); + } + const YAPPR_TAGLESS_IDENTITY_UPDATE_FIXTURE_HEX: &str = concat!( "0089fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae72", "01010200060200020000145e24e38a86e720f61757647996957e322686abb70000", diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 6570f92b8e6..439681b40fc 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1545,6 +1545,36 @@ unsafe extern "C" fn tramp_persist_identity_keys( /// first persisted. const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ[I)I"; +/// The `rejectedDerivations` payload of `onPersistIdentityKeyUpsert`: the +/// `pairs` rejected `[identity_index, key_index]` pairs at `ptr`, flat, as +/// `2 * pairs` ints. Null or zero pairs is empty. Each index crosses as its +/// bit pattern, the cast the same call applies to the entry's own +/// `identity_index` / `key_index`, so a rejected pair compares equal to the +/// coordinates Kotlin stored for the key. A payload whose length does not fit +/// a jint is refused rather than truncated. +/// +/// # Safety +/// When `ptr` is non-null and `pairs` non-zero, `ptr` must point at +/// `2 * pairs` `u32`s, valid for the callback window. +unsafe fn rejected_derivation_ints( + ptr: *const u32, + pairs: usize, +) -> Result, jni::errors::Error> { + if ptr.is_null() || pairs == 0 { + return Ok(Vec::new()); + } + let len = pairs + .checked_mul(2) + .filter(|len| i32::try_from(*len).is_ok()) + .ok_or(jni::errors::Error::JniCall( + jni::errors::JniError::InvalidArguments, + ))?; + Ok(std::slice::from_raw_parts(ptr, len) + .iter() + .map(|&v| v as i32) + .collect()) +} + unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, bridge: &JObject, @@ -1557,15 +1587,10 @@ unsafe fn persist_identity_key_upsert( let key_wallet_id = env.byte_array_from_slice(&e.wallet_id)?; let cb_id = env.byte_array_from_slice(&e.contract_bounds_id)?; let cb_doctype = cstr_opt(env, e.contract_bounds_document_type)?; - // The rejected `[identity_index, key_index]` pairs, flat. - let rejected_pairs: &[u32] = - if e.rejected_derivations_ptr.is_null() || e.rejected_derivations_len == 0 { - &[] - } else { - std::slice::from_raw_parts(e.rejected_derivations_ptr, e.rejected_derivations_len * 2) - }; - let rejected = env.new_int_array(rejected_pairs.len() as i32)?; - let rejected_ints: Vec = rejected_pairs.iter().map(|v| *v as i32).collect(); + let rejected_ints = + rejected_derivation_ints(e.rejected_derivations_ptr, e.rejected_derivations_len)?; + // Fits a jint: `rejected_derivation_ints` refuses a longer payload. + let rejected = env.new_int_array(rejected_ints.len() as i32)?; env.set_int_array_region(&rejected, 0, &rejected_ints)?; env.call_method( bridge, @@ -5195,6 +5220,50 @@ mod tests { ); } + /// The rejected pairs cross flat, in order; null or zero pairs is + /// empty; an index past `i32::MAX` crosses as its bit pattern, as the + /// entry's own `identity_index` / `key_index` do. + #[test] + fn should_flatten_rejected_derivation_pairs_into_the_int_array_payload() { + let pairs = [3u32, 7, 0x8000_0000, u32::MAX]; + unsafe { + assert_eq!( + rejected_derivation_ints(ptr::null(), 0).unwrap(), + Vec::::new() + ); + assert_eq!( + rejected_derivation_ints(ptr::null(), 2).unwrap(), + Vec::::new() + ); + assert_eq!( + rejected_derivation_ints(pairs.as_ptr(), 0).unwrap(), + Vec::::new() + ); + assert_eq!( + rejected_derivation_ints(pairs.as_ptr(), 1).unwrap(), + vec![3, 7] + ); + assert_eq!( + rejected_derivation_ints(pairs.as_ptr(), 2).unwrap(), + vec![3, 7, i32::MIN, -1] + ); + } + } + + /// A pair count whose flat length overflows `usize` or a jint is refused + /// before anything is read, rather than wrapped into a short slice. + #[test] + fn should_refuse_a_rejected_derivation_payload_longer_than_a_jint() { + // Never dereferenced: the length check comes first. + let dangling = ptr::NonNull::::dangling().as_ptr() as *const u32; + for pairs in [usize::MAX, usize::MAX / 2 + 1, i32::MAX as usize / 2 + 1] { + assert!( + unsafe { rejected_derivation_ints(dangling, pairs) }.is_err(), + "{pairs}" + ); + } + } + #[test] fn a_height_past_i32_max_is_refused_rather_than_wrapped() { assert_eq!(jint_height(0).unwrap(), 0); diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index d8ffd6ba28e..a5bf24b00a4 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -149,11 +149,13 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option Some(ids), + Err(message) => { + throw_sdk_exception(env, 1, message); + None + } } - Some(buf.into_iter().map(|i| i as u32).collect()) } Err(_) => { let _ = env.exception_clear(); @@ -163,6 +165,29 @@ fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option) -> Result, &'static str> { + if ints.iter().any(|&i| i < 0) { + return Err("keyIds must be non-negative"); + } + Ok(ints.into_iter().map(|i| i as u32).collect()) +} + +/// The revision `updateIdentity` pins, or the message to throw: `None` +/// without `hasRevision` (whatever `revision` holds), and a negative +/// `revision` with it is refused rather than bit-cast to a huge one. +fn update_revision_arg(has_revision: bool, revision: i64) -> Result, &'static str> { + if !has_revision { + return Ok(None); + } + if revision < 0 { + return Err("revision must be non-negative"); + } + Ok(Some(revision as u64)) +} + /// Add public keys and/or disable existing key ids on an identity, signing /// the resulting `IdentityUpdateTransition` via the external `signerHandle` /// (the identity's MASTER auth key) — the exact call @@ -268,12 +293,15 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up } else { disable_ids.as_ptr() }; - if has_revision != 0 && revision < 0 { - throw_sdk_exception(env, 1, "revision must be non-negative"); - return; - } + let revision = match update_revision_arg(has_revision != 0, revision) { + Ok(revision) => revision, + Err(message) => { + throw_sdk_exception(env, 1, message); + return; + } + }; let result = unsafe { - if has_revision != 0 { + if let Some(revision) = revision { platform_wallet_ffi::platform_wallet_update_identity_with_signer_at_revision( wallet_handle as Handle, id.as_ptr(), @@ -283,7 +311,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up external_ids.len(), disable, disable_ids.len(), - revision as u64, + revision, signer_handle as *mut SignerHandle, ) } else { @@ -996,3 +1024,43 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn should_take_non_negative_key_ids_and_refuse_a_negative_one() { + assert_eq!(key_ids_from_ints(vec![]), Ok(vec![])); + assert_eq!( + key_ids_from_ints(vec![0, 7, i32::MAX]), + Ok(vec![0, 7, i32::MAX as u32]) + ); + for ints in [vec![-1], vec![3, i32::MIN], vec![0, -7, 2]] { + assert_eq!( + key_ids_from_ints(ints.clone()), + Err("keyIds must be non-negative"), + "{ints:?}" + ); + } + } + + #[test] + fn should_pin_a_revision_only_with_has_revision_and_refuse_a_negative_one() { + assert_eq!(update_revision_arg(false, 5), Ok(None)); + // Without `hasRevision` the value is not read, negative or not. + assert_eq!(update_revision_arg(false, -1), Ok(None)); + assert_eq!(update_revision_arg(true, 0), Ok(Some(0))); + assert_eq!( + update_revision_arg(true, i64::MAX), + Ok(Some(i64::MAX as u64)) + ); + for revision in [-1, i64::MIN] { + assert_eq!( + update_revision_arg(true, revision), + Err("revision must be non-negative"), + "{revision}" + ); + } + } +} From fd85f8dd8cdc47d8ac8387ba48e4deb7e489a234 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:32:53 +0300 Subject: [PATCH 41/72] test(platform-wallet-storage): pin each check on a stored identity key row Each check before a stored breadcrumb is reused (identity, key id, public key id, hash column, public key bytes and hash, wallet scope), the replacement of a row that does not decode, the kept wallet id and the size check before a payload is read now has a test that fails without it. The row read is a named function so the size check is testable. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/identity_keys.rs | 73 ++++- .../sqlite_identity_keys_reader.rs | 291 ++++++++++++++++++ 2 files changed, 352 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 8422537e53b..7dc831c300f 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -116,6 +116,20 @@ fn held_breadcrumb( Some((held.wallet_id, held.derivation_indices?)) } +/// Read a stored row selected as `(length(public_key_blob), public_key_blob, +/// public_key_hash)`. The size guard runs before the payload is read, as in +/// `load_state`; an oversized row is read as empty and replaced +/// (`held_breadcrumb`). +fn read_stored_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(i64, Vec, Vec)> { + let blob_len: i64 = row.get(0)?; + let payload: Vec = if blob::check_size(blob_len).is_ok() { + row.get(1)? + } else { + Vec::new() + }; + Ok((blob_len, payload, row.get(2)?)) +} + /// SQLite's extended result code for a foreign-key constraint failure /// (`SQLITE_CONSTRAINT_FOREIGNKEY`). Discriminated numerically — never by /// matching the driver's message text. @@ -234,18 +248,7 @@ pub fn apply( let held: Option<(i64, Vec, Vec)> = stored .query_row( params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], - |row| { - // The size guard runs before the payload is read, - // as in `load_state`; an oversized row is read as - // empty and replaced (`held_breadcrumb`). - let blob_len: i64 = row.get(0)?; - let payload: Vec = if blob::check_size(blob_len).is_ok() { - row.get(1)? - } else { - Vec::new() - }; - Ok((blob_len, payload, row.get(2)?)) - }, + read_stored_row, ) .optional()?; if let Some((wallet_id_kept, derivation_indices)) = held @@ -387,6 +390,15 @@ pub fn load_state( Ok(cs) } +/// Encode an [`IdentityKeyEntry`] to its on-disk `public_key_blob` form via +/// the wire type. Test-only seam so integration tests can forge a row whose +/// blob disagrees with its typed columns without naming the crate-internal +/// [`IdentityKeyWire`]. +#[cfg(any(test, feature = "__test-helpers"))] +pub fn encode_entry_for_test(entry: &IdentityKeyEntry) -> Result, WalletStorageError> { + blob::encode(&IdentityKeyWire::from_entry(entry)?) +} + /// Build an outer `public_key_blob` payload whose inner `public_key_bincode` /// field contains a crafted byte sequence that causes the inner /// `blob::bounded_config()` decode to fail. Used by the blob-gate integration @@ -679,6 +691,43 @@ mod tests { ); } + /// Run `read_stored_row` over a synthetic row whose stated blob length is + /// `blob_len` while the payload cell actually holds `payload`. + fn read_synthetic_row(blob_len: i64, payload: &[u8]) -> (i64, Vec, Vec) { + let conn = Connection::open_in_memory().unwrap(); + conn.query_row( + "SELECT ?1, ?2, ?3", + params![blob_len, payload, &[0x5Au8; 20][..]], + read_stored_row, + ) + .unwrap() + } + + /// A stored row whose length exceeds the blob size limit is not read: the + /// payload comes back empty, whatever the cell holds, so the writer never + /// allocates an oversized buffer to look for a breadcrumb. + #[test] + fn should_read_an_oversized_stored_row_as_an_empty_payload() { + let blob_len = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap() + 1; + let (len, payload, hash) = read_synthetic_row(blob_len, &[0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!(len, blob_len); + assert!( + payload.is_empty(), + "oversized payload was read: {payload:?}" + ); + assert_eq!(hash, vec![0x5Au8; 20]); + } + + /// A stored row within the limit is read in full. + #[test] + fn should_read_a_stored_row_within_the_size_limit() { + let blob_len = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap(); + let (len, payload, hash) = read_synthetic_row(blob_len, &[0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!(len, blob_len); + assert_eq!(payload, vec![0xDE, 0xAD, 0xBE, 0xEF]); + assert_eq!(hash, vec![0x5Au8; 20]); + } + /// `IdentityKeyEntry` carries no key material by construction /// (derive-sign-destroy removed the carried scalar; the client derives it /// on demand from the keychain), so the "no key material at rest outside diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index dd1150ead35..f4c810cf285 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -492,3 +492,294 @@ fn gk3_corrupt_blob_is_hard_error() { "corrupt public_key_blob must be a typed BincodeDecode; got {result:?}" ); } + +/// Store `entries` under `w` as one identity-keys changeset. +fn store_entries( + persister: &platform_wallet_storage::SqlitePersister, + w: [u8; 32], + entries: Vec, +) { + let mut keys = IdentityKeysChangeSet::default(); + for entry in entries { + keys.upserts + .insert((entry.identity_id, entry.key_id), entry); + } + persister + .store( + w, + PlatformWalletChangeSet { + identity_keys: Some(keys), + ..Default::default() + }, + ) + .expect("store identity keys"); +} + +/// Reopen the database at `path` and read `w`'s identity keys back. +fn reload(path: &std::path::Path, w: &[u8; 32]) -> IdentityKeysChangeSet { + let persister = reopen(path); + let conn = persister.lock_conn_for_test(); + identity_keys::load_state(&conn, w, &platform_wallet_storage::LoadCtx::strict()) + .expect("load_state") +} + +/// `entry` as an upsert that carries no derivation breadcrumb. +fn without_breadcrumb(mut entry: IdentityKeyEntry) -> IdentityKeyEntry { + entry.derivation_indices = None; + entry +} + +/// Insert the row `(id, upsert.key_id)` under `w` directly, bypassing the +/// writer, with `stored` as its blob and `hash_column` as its typed +/// `public_key_hash`, so the blob can disagree with the row's columns. Then +/// store `upsert` (which carries no breadcrumb) over it and return the row a +/// reload reads. +fn reload_after_upsert_over_crafted_row( + w: [u8; 32], + id: Identifier, + stored: &IdentityKeyEntry, + hash_column: [u8; 20], + upsert: &IdentityKeyEntry, +) -> Option { + assert!(upsert.derivation_indices.is_none()); + let (persister, _tmp, path) = fresh_persister(); + ensure_wallet_meta(&persister, &w); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + persister + .lock_conn_for_test() + .execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, ?3, ?4, ?5, NULL)", + rusqlite::params![ + w.as_slice(), + id.as_slice(), + i64::from(upsert.key_id), + identity_keys::encode_entry_for_test(stored).unwrap(), + &hash_column[..], + ], + ) + .unwrap(); + store_entries(&persister, w, vec![upsert.clone()]); + drop(persister); + reload(&path, &w).upserts.remove(&(id, upsert.key_id)) +} + +/// A kept breadcrumb keeps the wallet scope the row's blob was stored with: +/// an upsert that carries neither a breadcrumb nor a `wallet_id` takes both +/// from the row, so the reloaded entry still names its wallet. +#[test] +fn should_keep_a_stored_wallet_id_with_the_breadcrumb_when_an_upsert_carries_neither() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xE0); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x20; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let mut stored = key_entry(id, 0, 0x11); + stored.wallet_id = Some(w); + store_entries(&persister, w, vec![stored.clone()]); + + let mut upsert = without_breadcrumb(stored.clone()); + upsert.wallet_id = None; + if let IdentityPublicKey::V0(key) = &mut upsert.public_key { + key.disabled_at = Some(9); + } + store_entries(&persister, w, vec![upsert.clone()]); + drop(persister); + + let row = reload(&path, &w).upserts.remove(&(id, 0)).expect("row"); + assert_eq!(row.wallet_id, Some(w)); + assert_eq!(row.derivation_indices, stored.derivation_indices); + assert_eq!(row.public_key, upsert.public_key); +} + +/// A stored row within the size limit that does not decode holds no +/// breadcrumb to keep: the upsert replaces it and the store succeeds. +#[test] +fn should_replace_a_stored_row_that_does_not_decode() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xE1); + ensure_wallet_meta(&persister, &w); + // The identity and key id the crafted blob itself names. + let id = Identifier::from([0xAA; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let crafted = identity_keys::crafted_entry_blob_with_bad_pk_bincode_for_test(); + assert!(identity_keys::decode_entry(&crafted).is_err()); + persister + .lock_conn_for_test() + .execute( + "INSERT INTO identity_keys \ + (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ + VALUES (?1, ?2, 0, ?3, ?4, NULL)", + rusqlite::params![ + w.as_slice(), + id.as_slice(), + crafted, + &upsert.public_key_hash[..] + ], + ) + .unwrap(); + + store_entries(&persister, w, vec![upsert.clone()]); + drop(persister); + + assert_eq!(reload(&path, &w).upserts.get(&(id, 0)), Some(&upsert)); +} + +/// Control for the crafted-row tests below: a row inserted directly whose +/// blob agrees with its columns keeps its breadcrumb, so each of those tests +/// loses the breadcrumb only to the one disagreement it introduces. +#[test] +fn should_keep_the_breadcrumb_of_a_crafted_row_that_agrees_with_its_columns() { + let id = Identifier::from([0x21; 32]); + let stored = key_entry(id, 0, 0x11); + let upsert = without_breadcrumb(stored.clone()); + let row = reload_after_upsert_over_crafted_row( + wid(0xE2), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(stored)); +} + +/// A stored blob naming another identity than its `identity_id` column +/// holds no breadcrumb to keep. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_identity() { + let id = Identifier::from([0x22; 32]); + let stored = key_entry(Identifier::from([0x23; 32]), 0, 0x11); + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE3), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} + +/// A stored blob naming another key id than its `key_id` column holds no +/// breadcrumb to keep, even when its public key's own id matches the column. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_key_id() { + let id = Identifier::from([0x24; 32]); + let mut stored = key_entry(id, 0, 0x11); + stored.key_id = 1; + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE4), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} + +/// A stored blob whose public key carries another id than the row's +/// `key_id` holds no breadcrumb to keep, even when the blob's own `key_id` +/// matches the column. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_whose_public_key_has_another_id() { + let id = Identifier::from([0x25; 32]); + let mut stored = key_entry(id, 0, 0x11); + if let IdentityPublicKey::V0(key) = &mut stored.public_key { + key.id = 5; + } + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE5), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} + +/// A row whose typed `public_key_hash` column disagrees with the hash in its +/// blob holds no breadcrumb to keep, even when the blob's hash matches the +/// upsert's. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_whose_hash_column_disagrees_with_its_blob() { + let id = Identifier::from([0x26; 32]); + let stored = key_entry(id, 0, 0x11); + let upsert = without_breadcrumb(stored.clone()); + let row = reload_after_upsert_over_crafted_row(wid(0xE6), id, &stored, [0x99; 20], &upsert); + assert_eq!(row, Some(upsert)); +} + +/// A row stored under another public-key hash than the upsert's holds no +/// breadcrumb to keep, even when the key bytes and the row's columns agree. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_stored_under_another_public_key_hash() { + let id = Identifier::from([0x27; 32]); + let mut stored = key_entry(id, 0, 0x11); + stored.public_key_hash = [0x99; 20]; + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE7), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} + +/// A stored blob whose `wallet_id` names another wallet than the scope it is +/// read under holds no breadcrumb to keep. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_wallet() { + let id = Identifier::from([0x28; 32]); + let mut stored = key_entry(id, 0, 0x11); + stored.wallet_id = Some(wid(0xEF)); + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE8), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} + +/// A row holding other public-key bytes than the upsert's holds no +/// breadcrumb to keep, even when it is stored under the upsert's hash. +#[test] +fn should_not_keep_the_breadcrumb_of_a_row_holding_other_public_key_bytes() { + let id = Identifier::from([0x29; 32]); + let mut stored = key_entry(id, 0, 0x11); + if let IdentityPublicKey::V0(key) = &mut stored.public_key { + key.data = BinaryData::new(vec![0x44; 33]); + } + let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); + let row = reload_after_upsert_over_crafted_row( + wid(0xE9), + id, + &stored, + stored.public_key_hash, + &upsert, + ); + assert_eq!(row, Some(upsert)); +} From 6131d4e2889e3f9fffc12708ce7fbeca343d279d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:32:53 +0300 Subject: [PATCH 42/72] fix(swift-sdk): leave a breadcrumb with no recorded owner to its unknown owner A rejection cleared a stored path whose row had no wallet id as if the persisting wallet owned it; the Kotlin persister keeps such a breadcrumb, and an ownership guess from the persisting wallet is what an earlier round removed there. Now only a path recorded under the rejecting wallet is cleared. Every current writer records the wallet with the path, so this changes legacy rows only. Tests for the persister's key-origin rules: unknown origin, another wallet, other coordinates, the matching rejection, a row with no owner, and a replacement public key. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.swift | 6 +- ...ityKeyDerivationPathPersistenceTests.swift | 202 ++++++++++++++++++ 2 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 23299ae5c0b..33f45ff1d85 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4218,8 +4218,10 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { } } else if !entry.derivationRejected.isEmpty, // The rejection was checked under this wallet's seed: a path - // another wallet derived is not refuted by it. - (row.walletId ?? walletId) == walletId, + // another wallet derived is not refuted by it, and neither is + // one whose owner was never recorded (a legacy row), as in + // the Kotlin persister. + row.walletId == walletId, let heldPath = row.identityDerivationPath, entry.derivationRejected.contains(where: { rejected in identityAuthPath(walletId: walletId, indices: rejected) == heldPath diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift new file mode 100644 index 00000000000..2e7685e419f --- /dev/null +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift @@ -0,0 +1,202 @@ +import SwiftData +import XCTest + +@testable import SwiftDashSDK + +/// What a persister round does to a stored key's derivation breadcrumb +/// (`identityDerivationPath`) and private-key linkage +/// (`privateKeyKeychainIdentifier`) when the upsert carries no derivation +/// indices of its own. +/// +/// The rules `persistIdentityKeys` follows: +/// - no breadcrumb and no rejection: the key's origin is unknown, and the +/// stored path of the same public key is kept; +/// - `derivationRejected` clears the stored path only when the row belongs to +/// the persisting wallet (a legacy row without a wallet has an unknown owner +/// and keeps its path) and +/// the held path is the one at a rejected `(identityIndex, keyIndex)` pair; +/// - another public key under the same key id clears both the path and the +/// keychain linkage, which belonged to the old key. +@MainActor +final class IdentityKeyDerivationPathPersistenceTests: XCTestCase { + + private let walletId = Data(repeating: 0xD1, count: 32) + private let otherWalletId = Data(repeating: 0xD2, count: 32) + private let identityId = Data(repeating: 0xD3, count: 32) + private let publicKeyData = Data(repeating: 0x02, count: 33) + private let keychainId = "identity_privkey.stored" + private let keyId: UInt32 = 0 + + /// The DIP-9 identity-auth path the handler formats for the persisting + /// wallet, which lives on testnet. + private func authPath(identityIndex: UInt32, keyIndex: UInt32) throws -> String { + try KeyDerivation.getIdentityAuthenticationPath( + network: .testnet, identityIndex: identityIndex, keyIndex: keyIndex) + } + + /// An in-memory store holding the persisting wallet's row (the handler + /// needs it to format a rejected pair into a path) and one key row that + /// already carries a breadcrumb and a keychain linkage. + private func makeStore( + rowWalletId: Data?, + storedPath: String + ) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { + let container = try DashModelContainer.createInMemory() + let context = ModelContext(container) + context.insert(PersistentWallet(walletId: walletId, network: .testnet)) + let row = PersistentPublicKey( + keyId: Int32(bitPattern: keyId), + purpose: .authentication, + securityLevel: .high, + keyType: .ecdsaSecp256k1, + publicKeyData: publicKeyData, + identityId: identityId.toBase58String()) + row.walletId = rowWalletId + row.identityDerivationPath = storedPath + row.privateKeyKeychainIdentifier = keychainId + context.insert(row) + try context.save() + let handler = PlatformWalletPersistenceHandler( + modelContainer: container, network: .testnet) + return (handler, container) + } + + /// An upsert of the stored key with no derivation indices, as Rust emits + /// it for a key whose origin it could not confirm. + private func snapshot( + publicKey: Data? = nil, + rejected: [(identityIndex: UInt32, keyIndex: UInt32)] = [] + ) -> PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot { + .init( + identityId: identityId, + keyId: keyId, + purpose: KeyPurpose.authentication.rawValue, + securityLevel: SecurityLevel.high.rawValue, + keyType: KeyType.ecdsaSecp256k1.rawValue, + readOnly: false, + disabledAt: nil, + totalBudget: nil, + expiresAt: nil, + publicKeyData: publicKey ?? publicKeyData, + publicKeyHash: Data(repeating: 0x03, count: 20), + walletId: nil, + derivationIndices: nil, + contractBounds: nil, + derivationRejected: rejected) + } + + /// One persist round, bracketed the way the Rust store() round is: + /// `persistIdentityKeys` stages rows and `endChangeset` saves them. + private func persist( + _ handler: PlatformWalletPersistenceHandler, + _ entry: PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot + ) { + handler.beginChangeset(walletId: walletId) + handler.persistIdentityKeys(walletId: walletId, upserts: [entry], removed: []) + XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) + } + + private func storedKey(_ container: ModelContainer) throws -> PersistentPublicKey { + let rows = try ModelContext(container).fetch(FetchDescriptor()) + XCTAssertEqual(rows.count, 1, "one key row per (identity, key id)") + return try XCTUnwrap(rows.first) + } + + /// No breadcrumb and no rejection: Rust does not know where the key comes + /// from, which refutes nothing, so the stored path and linkage stay. + func testUnknownOriginKeepsTheStoredPath() throws { + let path = try authPath(identityIndex: 0, keyIndex: 0) + let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) + + persist(handler, snapshot()) + + let row = try storedKey(container) + XCTAssertEqual(row.identityDerivationPath, path) + XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) + XCTAssertEqual(row.walletId, walletId) + } + + /// The rejection was checked under the persisting wallet's seed. A path + /// another wallet derived is not refuted by it, even when it names the + /// same coordinates (the path string carries no wallet). + func testRejectionByAnotherWalletKeepsTheStoredPath() throws { + let path = try authPath(identityIndex: 0, keyIndex: 0) + let (handler, container) = try makeStore(rowWalletId: otherWalletId, storedPath: path) + + persist(handler, snapshot(rejected: [(identityIndex: 0, keyIndex: 0)])) + + let row = try storedKey(container) + XCTAssertEqual(row.identityDerivationPath, path, + "another wallet's path is not refuted by this wallet's check") + XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) + XCTAssertEqual(row.walletId, otherWalletId) + } + + /// The same wallet rejecting other coordinates says nothing about the + /// path the row holds: it stays. + func testRejectionAtOtherCoordinatesKeepsTheStoredPath() throws { + let path = try authPath(identityIndex: 0, keyIndex: 0) + let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) + + persist(handler, snapshot(rejected: [ + (identityIndex: 0, keyIndex: 1), + (identityIndex: 1, keyIndex: 0), + ])) + + let row = try storedKey(container) + XCTAssertEqual(row.identityDerivationPath, path, + "a rejection at other coordinates must not clear this path") + XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) + } + + /// The persisting wallet rejecting the coordinates the stored path names: + /// the path is a stale inferred one and is cleared, so the signer does not + /// derive at it. The public key is unchanged, so its keychain linkage + /// stays. + func testMatchingRejectionBySameWalletClearsTheStoredPath() throws { + let path = try authPath(identityIndex: 0, keyIndex: 0) + let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) + + persist(handler, snapshot(rejected: [ + (identityIndex: 0, keyIndex: 1), + (identityIndex: 0, keyIndex: 0), + ])) + + let row = try storedKey(container) + XCTAssertNil(row.identityDerivationPath, + "a path at rejected coordinates of this wallet must be cleared") + XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) + } + + /// A legacy row with no wallet recorded has an unknown owner, which the + /// persisting wallet's rejection does not refute: its path is kept, as + /// the Kotlin persister keeps it. + func testMatchingRejectionKeepsTheStoredPathOfARowWithoutAWallet() throws { + let path = try authPath(identityIndex: 2, keyIndex: 3) + let (handler, container) = try makeStore(rowWalletId: nil, storedPath: path) + + persist(handler, snapshot(rejected: [(identityIndex: 2, keyIndex: 3)])) + + let row = try storedKey(container) + XCTAssertEqual(row.identityDerivationPath, path, + "a path whose owner is unknown must not be cleared by a rejection") + } + + /// Another public key under the same key id: the stored path and keychain + /// linkage belonged to the old key and must not pass to the new one, even + /// with no rejection on the upsert. + func testReplacementPublicKeyClearsPathAndKeychainLinkage() throws { + let path = try authPath(identityIndex: 0, keyIndex: 0) + let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) + let replacement = Data(repeating: 0x03, count: 33) + + persist(handler, snapshot(publicKey: replacement)) + + let row = try storedKey(container) + XCTAssertEqual(row.publicKeyData, replacement) + XCTAssertNil(row.identityDerivationPath, + "the old key's derivation path must not sign for the new key") + XCTAssertNil(row.privateKeyKeychainIdentifier, + "the old key's private-key linkage must not pass to the new key") + } +} From f52440d80cce393dea39ce2ff054936e155638b0 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 01:44:05 +0300 Subject: [PATCH 43/72] fix(kotlin-sdk): cancel a key repair only on its own owner's rejection of its own row A rejection cancelled a pending private-key repair by public key and slot alone: one with no recorded owner (rebuilt from a pre-schema-16 row under the identity's current wallet) and one for another row or identity at the same slot could be cancelled. A repair is now cancelled only when its owner is recorded, is the rejecting wallet, and the entry names the upserted row and a rejected slot; an entry rebuilt from a row with no owner is kept. A cancelled repair passes to another row of the same public key only through the predicate the restart rebuild uses, so the live map equals what a restart rebuilds. Repair derives at the row its entry names when the entry is the calling wallet's. A scalar an older SDK derived at a guessed slot is discarded when that slot is rejected: inside the write, compare-and-delete against the blob that was judged, only when the stored scalar equals the slot's and the slot does not reproduce the key. A rejection whose scalar question cannot be settled (the auth window closed, no seed, the blob changed) is not applied, so the next one retries. Standalone changes to the repair map are published after the write. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 384 +++++++-- .../security/IdentityKeyPrivateKeyDeriver.kt | 51 ++ .../dashsdk/security/WalletStorage.kt | 63 +- .../dashsdk/wallet/PlatformWalletManager.kt | 8 +- .../PlatformWalletPersistenceHandlerTest.kt | 775 +++++++++++++++++- .../WalletStorageUpgradeMatrixTest.kt | 23 + 6 files changed, 1199 insertions(+), 105 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index bc15533b6f0..224ad4d8201 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -140,6 +140,16 @@ class PlatformWalletPersistenceHandler( */ private val storedTransactionInputs: StoredTransactionInputs = NativeStoredTransactionInputs(network), + /** + * The CHEAP signing-capability check for the scalar stored under a + * public-key hex (production: `WalletStorage.isPrivateKeyDecryptable`). + * The one usability predicate behind [pendingIdentityKeys]: the restart + * rebuild ([reconstructPendingIdentityKeysFromPersistence]) and a live + * hand-off of a cancelled repair both skip a row it reports usable, so + * the live map is the map a restart rebuilds. The default reports + * nothing usable (no storage wired). + */ + private val isPrivateKeyUsable: suspend (pubkeyHex: String) -> Boolean = { false }, ) : NativePersistenceBridge(), AutoCloseable { override fun persistenceCapabilitiesVersion(): Int = PERSISTENCE_CAPABILITIES_VERSION @@ -364,7 +374,11 @@ class PlatformWalletPersistenceHandler( * (e.g. via `PlatformWalletManager.repairIdentityKey`). */ data class PendingIdentityKey( - /** Hex of the wallet the key belongs to. */ + /** + * Hex of the wallet whose seed the repair derives under. When + * [ownerKnown] is false this is only the identity's current wallet, + * a fallback for a breadcrumb with no recorded owner. + */ val walletIdHex: String, /** Base58 of the owning identity id. */ val identityIdBase58: String, @@ -380,6 +394,14 @@ class PlatformWalletPersistenceHandler( val reason: String, /** Epoch millis of the (latest) failure. */ val failedAtMs: Long, + /** + * Whether [walletIdHex] is the breadcrumb's recorded owner. False for + * an entry rebuilt from a row written before schema 16 + * ([PublicKeyEntity.derivationWalletId] is NULL): the entry is still + * offered for repair under [walletIdHex], but no wallet's rejection + * cancels it, because nothing has decided whose derivation it is. + */ + val ownerKnown: Boolean = true, ) private val _pendingIdentityKeys = @@ -398,8 +420,9 @@ class PlatformWalletPersistenceHandler( * [ChangesetBuffer] and published only after the Room transaction * commits — a rolled-back or aborted round leaves this map exactly as * it was before the round (see [ChangesetBuffer.pendingKeyDeltas]). - * Standalone (non-bracketed) upserts and [markIdentityKeyRepaired] - * publish immediately. + * Standalone (non-bracketed) upserts publish once their own write + * commits (a failed write publishes nothing); [markIdentityKeyRepaired] + * publishes immediately. */ val pendingIdentityKeys: StateFlow> = _pendingIdentityKeys.asStateFlow() @@ -1938,23 +1961,63 @@ class PlatformWalletPersistenceHandler( .toSet() } // A pending repair entry for the key needs no re-derive once this - // wallet refutes it: either the stored row's breadcrumb (decided - // against the row below), or the entry's own slot under this wallet - // (an entry rebuilt from a row with no recorded owner is filed under - // the identity's wallet, which may be wrong; that wallet's rejection - // still clears it). The deletion is published with the row: through - // the round's staged deltas when a round is open, or after the - // standalone write commits. + // rejection refutes the entry's OWN coordinates: it describes the row + // being upserted (same identity and key id), its recorded owner is + // this wallet, and its slot is among the refuted ones. The map is + // keyed by public key, so the entry may describe another row of the + // same key (another identity, key id, wallet, or slot); a rejection + // persisted for this row says nothing about it. An entry whose owner + // is unknown (rebuilt from a row with no recorded owner and filed + // under the identity's wallet) is never cancelled: that wallet's + // rejection does not show the breadcrumb was its own. val pendingHex = publicKeyData.toHex() - fun dropPending(rowRefuted: Boolean): (Map) -> Map = + val identityBase58 = identityId.toBase58String() + fun refutes(entry: PendingIdentityKey): Boolean = + entry.ownerKnown && entry.walletIdHex == roundKey && + entry.identityIdBase58 == identityBase58 && entry.keyId == keyId && + (entry.identityIndex to entry.keyIndex) in refutedSlots + // The cancelled entry hands the key's repair to the row of the same + // public key a restart would rebuild it from ([successor], read once + // the row is written, with the rebuild's own predicate), so one row's + // rejection never hides another row's obligation and the live map + // stays the map a restart rebuilds. When the key has no stored scalar + // once the rejection is settled (none was stored, or a wrong one was + // deleted: [seedIfAbsent]), no row of the key can sign, so an absent + // entry is seeded from the successor as the rebuild would seed it. The change is published with the row: + // through the round's staged deltas when a round is open, or after + // the standalone write commits. + fun handOffPending( + successor: PendingIdentityKey?, + seedIfAbsent: Boolean, + ): (Map) -> Map = { map -> val pending = map[pendingHex] - val entryRefuted = pending != null && pending.walletIdHex == roundKey && - (pending.identityIndex to pending.keyIndex) in refutedSlots - if (pending != null && (rowRefuted || entryRefuted)) map - pendingHex else map + when { + pending != null && refutes(pending) -> + if (successor == null) { + map - pendingHex + } else { + map + ( + pendingHex to successor.copy( + reason = pending.reason, + failedAtMs = pending.failedAtMs, + ) + ) + } + pending == null && seedIfAbsent && successor != null -> + map + (pendingHex to successor) + else -> map + } } val round = buffers[roundKey] - var dropAfterCommit: ((Map) -> Map)? = null + // Every change this upsert makes to [pendingIdentityKeys] takes + // effect only once its row commits: staged with the round when one + // is open, otherwise held here and applied after the standalone + // write (a failed write throws first, leaving the map as it was). + val afterCommit = mutableListOf<(Map) -> Map>() + fun stageWithRow(delta: (Map) -> Map) { + if (round != null) round.pendingKeyDeltas.add(delta) else afterCommit += delta + } val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } val deriver = privateKeyDeriver @@ -1988,7 +2051,7 @@ class PlatformWalletPersistenceHandler( // round rolls back, alias cleanup deletes the newly // stored scalar, so the old watch-only row must keep // its repair signal (finding de3cf44a71fc). - stagePendingKeyDelta(roundKey, clearPendingKeyDelta(publicKeyData.toHex())) + stageWithRow(clearPendingKeyDelta(publicKeyData.toHex())) } else { // NOT silent (dashpay/platform#4053): the key is being // persisted watch-only, so every signature with it will @@ -2010,8 +2073,7 @@ class PlatformWalletPersistenceHandler( // being persisted watch-only INSIDE the round's buffer, // so if the round aborts that row never commits and the // pending entry would be a phantom (finding de3cf44a71fc). - stagePendingKeyDelta( - roundKey, + stageWithRow( recordPendingKeyDelta( PendingIdentityKey( walletIdHex = keyOwnerWalletId.toHex(), @@ -2040,7 +2102,6 @@ class PlatformWalletPersistenceHandler( } stage(walletId) { db -> - val identityBase58 = identityId.toBase58String() val stored = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) // A row for another public key under this key id carries that // key's derivation and private-key linkage, which must not pass to @@ -2055,12 +2116,51 @@ class PlatformWalletPersistenceHandler( val heldSlot = existing?.derivationIdentityIndex?.let { identity -> existing.derivationKeyIndex?.let { key -> identity to key } } - val refuted = heldSlot != null && heldSlot in refutedSlots && + val slotRefuted = heldSlot != null && heldSlot in refutedSlots && existing.derivationWalletId?.contentEquals(walletId) == true - if (refutedSlots.isNotEmpty()) { - val drop = dropPending(refuted) - if (round != null) round.pendingKeyDeltas.add(drop) else dropAfterCommit = drop + // A scalar stored from the refuted breadcrumb (an older SDK guessed + // the breadcrumb of an external key, and the persist-time store + // does not check the derived public key) cannot sign for this key, + // and the signer finds scalars by public key, not through the + // row. So the rejection takes effect only once the deriver has + // settled the stored scalar: deleted when it is the slot's scalar + // and the slot's derived public key is not this key (as the repair + // path checks a derive), kept when it reproduces the key or is + // another one. The delete happens here, before the row commits; it + // is right whatever the transaction does, since the scalar is + // wrong for every row of the key. Unsettled (no seed, a closed + // authentication window, a failed delete), the breadcrumb and the + // identifiers stay, so the next rejection checks again. + val verdict: StoredKeyVerdict? = when { + !slotRefuted || heldSlot == null -> null + deriver == null -> StoredKeyVerdict.KEPT + else -> try { + deriver.discardStoredKeyRefutedBy( + walletId = walletId, + publicKeyData = publicKeyData, + identityIndex = heldSlot.first, + keyIndex = heldSlot.second, + keyType = keyType.toInt() and 0xFF, + ) + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + throw cancellation + } catch (t: Throwable) { + Log.w( + TAG, + "could not settle the stored scalar of refuted key $pendingHex " + + "(slot ${heldSlot.first}/${heldSlot.second}); the rejection " + + "is not applied and the next one retries", + t, + ) + null + } } + val refuted = slotRefuted && verdict != null + val deferred = slotRefuted && verdict == null + // No scalar is stored for the key any more: an identifier on any of + // its rows names nothing, and every row of the key is unusable. + val scalarGone = verdict == StoredKeyVerdict.ABSENT || + verdict == StoredKeyVerdict.DISCARDED // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2120,9 +2220,41 @@ class PlatformWalletPersistenceHandler( lastAccessed = now(), ) if (stored == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) + if (scalarGone) { + // The alias is the key's, shared by every row of it. + for (other in db.publicKeyDao().getByPublicKeyData(publicKeyData)) { + if (other.privateKeyKeychainIdentifier != null) { + db.publicKeyDao().update(other.copy(privateKeyKeychainIdentifier = null)) + } + } + } + if (refutedSlots.isNotEmpty() && !deferred) { + // The map this upsert's delta will see when it is published: + // the live map with the round's earlier staged deltas applied. + // With no entry this rejection cancels, and no scalar gone, + // nothing can change, so the rows are not scanned. A deferred + // rejection changes nothing: its breadcrumb stays, and with it + // the repair a restart rebuilds. + val projected = round?.pendingKeyDeltas + ?.fold(_pendingIdentityKeys.value) { acc, delta -> delta(acc) } + ?: _pendingIdentityKeys.value + val cancels = projected[pendingHex]?.let(::refutes) == true + if (cancels || scalarGone) { + val nowMs = System.currentTimeMillis() + val successor = db.publicKeyDao().getByPublicKeyData(publicKeyData) + .sortedBy { it.id } + .firstNotNullOfOrNull { other -> + pendingRepairEntry(db, other, REFUTED_SCALAR_REASON, nowMs) + } + stageWithRow(handOffPending(successor, seedIfAbsent = scalarGone)) + } + } + } + // Standalone path: the write above committed (a failed one threw + // before this line, so nothing below runs). + if (afterCommit.isNotEmpty()) { + _pendingIdentityKeys.update { map -> afterCommit.fold(map) { acc, delta -> delta(acc) } } } - // Standalone path: the write above committed (a failed one threw). - dropAfterCommit?.let { _pendingIdentityKeys.update(it) } 0 } @@ -3933,16 +4065,33 @@ class PlatformWalletPersistenceHandler( // row, never from the caller. A row lacking breadcrumbs cannot be // safely repaired (we would have to guess the slot), so fail WITHOUT // clearing pending. - val breadcrumbs = database.publicKeyDao().getByPublicKeyData(publicKeyData) - .firstNotNullOfOrNull { row -> - val identityIndex = row.derivationIdentityIndex - val keyIndex = row.derivationKeyIndex - if (identityIndex != null && keyIndex != null) { - Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0) - } else { - null - } - } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + // Several rows can carry the key (identities, wallets) with different + // breadcrumbs. The repair derives under [walletId] (the host passes + // the wallet whose key-health view is open), so it derives at the row + // its pending entry names when that entry is filed under this wallet + // (after a hand-off, the successor row), then at a row this wallet + // derived, and only then at any row with breadcrumbs. + val rows = database.publicKeyDao().getByPublicKeyData(publicKeyData) + .filter { it.derivationIdentityIndex != null && it.derivationKeyIndex != null } + .sortedBy { it.id } + val pending = _pendingIdentityKeys.value[pubkeyHex] + ?.takeIf { it.walletIdHex == walletId.toHex() } + val breadcrumbRow = rows.firstOrNull { row -> + pending != null && row.identityId == pending.identityIdBase58 && + row.keyId == pending.keyId && + row.derivationIdentityIndex == pending.identityIndex && + row.derivationKeyIndex == pending.keyIndex + } ?: rows.firstOrNull { it.derivationWalletId?.contentEquals(walletId) == true } + ?: rows.firstOrNull() + val breadcrumbs = breadcrumbRow?.let { row -> + val identityIndex = row.derivationIdentityIndex + val keyIndex = row.derivationKeyIndex + if (identityIndex != null && keyIndex != null) { + Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0) + } else { + null + } + } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( "cannot repair identity key $pubkeyHex: no derivation breadcrumbs are " + "persisted for it (derivationIdentityIndex/derivationKeyIndex are " + "null) — the correct slot is unknown; pending state left intact", @@ -4044,56 +4193,20 @@ class PlatformWalletPersistenceHandler( * Called by `PlatformWalletManager.loadPersistedWallets` after the Room * rows are loaded, before the manager is handed to the host; the wallet * scoping comes from each row's identity (network + wallet id), matching - * this handler's [network] when set. + * this handler's [network] when set. [isPrivateKeyDecryptable] defaults + * to the handler's [isPrivateKeyUsable], the probe a live hand-off uses. */ internal suspend fun reconstructPendingIdentityKeysFromPersistence( - isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean, + isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean = isPrivateKeyUsable, nowMs: Long = System.currentTimeMillis(), reason: String = "reconstructed from persistence after restart", ) { val rows = database.publicKeyDao().getWithDerivationBreadcrumbs() if (rows.isEmpty()) return - val entries = mutableListOf() - for (row in rows) { - if (row.readOnly) continue - val identityIndex = row.derivationIdentityIndex ?: continue - val keyIndex = row.derivationKeyIndex ?: continue - val identityIdData = row.identityIdData ?: continue - val identity = database.identityDao().getByIdentityId(identityIdData) ?: continue - val networkRaw = network?.ffiValue - if (networkRaw != null && identity.networkRaw != networkRaw) continue - // The breadcrumb's own wallet: the identity may since have been - // re-associated with another wallet, whose seed is another - // derivation. A row with no recorded owner (written before schema - // 16) falls back to the identity's wallet, which may be the wrong - // one; that wallet's rejection of the slot drops the entry. - val walletId = row.derivationWalletId ?: identity.walletId ?: continue - val pubkeyHex = row.publicKeyData.toHex() - val usable = row.privateKeyKeychainIdentifier != null && - try { - isPrivateKeyDecryptable(pubkeyHex) - } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { - // The probe is suspend; runCatching turned its cancellation - // into `false`, marking the row pending and returning - // normally so callers (loadPersistedWallets, invalidation - // bookkeeping) never observed the cancellation. Rethrow to - // preserve structured concurrency; only genuine probe - // failures become an unusable result (dashpay/platform#4183). - throw cancellation - } catch (_: Throwable) { - false - } - if (usable) continue - entries += PendingIdentityKey( - walletIdHex = walletId.toHex(), - identityIdBase58 = row.identityId, - keyId = row.keyId, - publicKeyHex = pubkeyHex, - identityIndex = identityIndex, - keyIndex = keyIndex, - reason = reason, - failedAtMs = nowMs, - ) + // In row order, so the first row of a key seeds its entry: the row a + // live hand-off ([onPersistIdentityKeyUpsert]) picks as well. + val entries = rows.sortedBy { it.id }.mapNotNull { row -> + pendingRepairEntry(database, row, reason, nowMs, isPrivateKeyDecryptable) } if (entries.isEmpty()) return _pendingIdentityKeys.update { map -> @@ -4103,6 +4216,78 @@ class PlatformWalletPersistenceHandler( } } + /** + * The pending-repair entry [row] seeds, or null when it seeds none: it is + * no repair candidate ([pendingRepairCandidate]), or its private half is + * usable (a recorded identifier whose stored scalar [isUsable] accepts). + * The one predicate behind both the restart rebuild and a live hand-off, + * so the two agree on every row. + */ + private suspend fun pendingRepairEntry( + db: DashDatabase, + row: PublicKeyEntity, + reason: String, + nowMs: Long, + isUsable: suspend (pubkeyHex: String) -> Boolean = isPrivateKeyUsable, + ): PendingIdentityKey? { + val entry = pendingRepairCandidate(db, row, reason, nowMs) ?: return null + val usable = row.privateKeyKeychainIdentifier != null && + try { + isUsable(entry.publicKeyHex) + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + // The probe is suspend; runCatching turned its cancellation + // into `false`, marking the row pending and returning + // normally so callers (loadPersistedWallets, invalidation + // bookkeeping) never observed the cancellation. Rethrow to + // preserve structured concurrency; only genuine probe + // failures become an unusable result (dashpay/platform#4183). + throw cancellation + } catch (_: Throwable) { + false + } + return if (usable) null else entry + } + + /** + * The pending-repair entry [row] would seed, ignoring whether its private + * half is usable, or null when it cannot seed one: a read-only key, a row + * without derivation breadcrumbs, or one whose identity is gone or on + * another network than this handler's. + * + * The breadcrumb's own wallet ([PublicKeyEntity.derivationWalletId]) owns + * the entry: the identity may since have been re-associated with another + * wallet, whose seed is another derivation. A row with no recorded owner + * (written before schema 16) is offered for repair under the identity's + * wallet, which may be the wrong one, so the entry keeps its owner + * unknown ([PendingIdentityKey.ownerKnown]) and no rejection cancels it. + */ + private suspend fun pendingRepairCandidate( + db: DashDatabase, + row: PublicKeyEntity, + reason: String, + nowMs: Long, + ): PendingIdentityKey? { + if (row.readOnly) return null + val identityIndex = row.derivationIdentityIndex ?: return null + val keyIndex = row.derivationKeyIndex ?: return null + val identityIdData = row.identityIdData ?: return null + val identity = db.identityDao().getByIdentityId(identityIdData) ?: return null + val networkRaw = network?.ffiValue + if (networkRaw != null && identity.networkRaw != networkRaw) return null + val walletId = row.derivationWalletId ?: identity.walletId ?: return null + return PendingIdentityKey( + walletIdHex = walletId.toHex(), + identityIdBase58 = row.identityId, + keyId = row.keyId, + publicKeyHex = row.publicKeyData.toHex(), + identityIndex = identityIndex, + keyIndex = keyIndex, + reason = reason, + failedAtMs = nowMs, + ownerKnown = row.derivationWalletId != null, + ) + } + // ── Error / threading guards ────────────────────────────────────── /** @@ -4170,6 +4355,10 @@ class PlatformWalletPersistenceHandler( companion object { internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 + + /** Reason of a repair seeded when a settled rejection leaves the key no stored scalar. */ + internal const val REFUTED_SCALAR_REASON: String = + "no stored private key reproduces the public key" internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 internal const val CAPABILITY_ASSET_LOCK_FUNDING_INDICES: Long = 0x04 @@ -4300,6 +4489,33 @@ interface PrivateKeyDeriver { * the cleanup record alive until a deletion succeeds. */ fun deleteUnownedStored(pubkeyHexes: Collection, excludingWalletId: ByteArray): Set + + /** + * Settle the scalar stored under [publicKeyData]'s alias against a + * rejection of the slot ([identityIndex], [keyIndex]) on [walletId], and + * delete it when it provably does NOT reproduce [publicKeyData]: the + * slot's derived public key is not [publicKeyData] (checked as the + * [force] path of [deriveAndStore] checks a derive, per [keyType]) and + * the stored scalar is the slot's derived scalar. The delete compares + * against the blob that was judged, so a scalar a repair or add-key flow + * stored meanwhile is never deleted. + * + * Returns [StoredKeyVerdict.ABSENT] when nothing is stored, + * [StoredKeyVerdict.DISCARDED] after deleting a wrong scalar, and + * [StoredKeyVerdict.KEPT] when the slot reproduces the key or the stored + * scalar is another one. THROWS when it cannot settle the question (no + * seed to derive from, a closed authentication window, a blob replaced + * during the check, a failed delete): the caller then leaves the + * rejection unapplied so a later one retries. The default settles + * nothing about storage and keeps whatever is stored. + */ + fun discardStoredKeyRefutedBy( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + keyType: Int, + ): StoredKeyVerdict = StoredKeyVerdict.KEPT } /** @@ -4314,6 +4530,18 @@ interface PrivateKeyDeriver { */ data class DerivedKeyStoreResult(val identifier: String, val wasNewlyCreated: Boolean) +/** Outcome of [PrivateKeyDeriver.discardStoredKeyRefutedBy]. */ +enum class StoredKeyVerdict { + /** No scalar is stored for the key. */ + ABSENT, + + /** The stored scalar reproduces the key, or is not the refuted slot's: kept. */ + KEPT, + + /** The stored scalar was the refuted slot's, which does not reproduce the key: deleted. */ + DISCARDED, +} + /** * Names a stored transaction's input outpoints for the sweep pass, which * keys its hold by OUTPOINT: a swept loser's inputs are read from its own diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt index b0864a46cd4..ac6c988d5f7 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt @@ -5,6 +5,7 @@ import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.ffi.IdentityNative import org.dashfoundation.dashsdk.persistence.DerivedKeyStoreResult import org.dashfoundation.dashsdk.persistence.PrivateKeyDeriver +import org.dashfoundation.dashsdk.persistence.StoredKeyVerdict import org.dashfoundation.dashsdk.persistence.toHex /** @@ -161,6 +162,56 @@ class IdentityKeyPrivateKeyDeriver( // succeeds). runBlocking { walletStorage.deleteUnownedPrivateKeys(pubkeyHexes, excludingWalletId) } + override fun discardStoredKeyRefutedBy( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + keyType: Int, + ): StoredKeyVerdict = runBlocking { + val pubkeyHex = publicKeyData.toHex() + // The blob being judged; the delete below fires only while it is + // still the stored one. + val blob = walletStorage.storedPrivateKeyBlob(pubkeyHex) + ?: return@runBlocking StoredKeyVerdict.ABSENT + // The same pure, lock-free keypair derive the repair path verifies + // with: Kotlin computes no public key itself, so the stored scalar is + // judged by whether it IS the refuted slot's scalar, whose public half + // Rust derived. A missing seed throws: unsettled. + val pair = IdentityNative.deriveIdentityKeyPairWithResolver( + networkOrd = network.ffiValue, + walletId = walletId, + resolverHandle = mnemonicResolverHandle, + identityIndex = identityIndex, + keyIndex = keyIndex, + ) + val derivedPrivate = pair.getOrNull(0) + try { + check(pair.size == 2) { "keypair derive returned ${pair.size} elements" } + if (derivedPublicKeyMatches(pair[1], publicKeyData, keyType)) { + return@runBlocking StoredKeyVerdict.KEPT + } + // A closed authentication window throws here: unsettled. A blob + // nothing opens cannot sign at all; a repair replaces it. + val stored = walletStorage.retrievePrivateKey(pubkeyHex) + ?: return@runBlocking StoredKeyVerdict.KEPT + val isSlotScalar = try { + java.security.MessageDigest.isEqual(stored, derivedPrivate) + } finally { + stored.fill(0) + } + when { + !isSlotScalar -> StoredKeyVerdict.KEPT + walletStorage.deletePrivateKeyIfUnchanged(pubkeyHex, blob) -> StoredKeyVerdict.DISCARDED + else -> throw IllegalStateException( + "the stored scalar of $pubkeyHex changed while it was checked", + ) + } + } finally { + derivedPrivate?.fill(0) + } + } + internal companion object { /** Matches `WalletStorage`'s private `PRIVKEY_PREFIX`. */ const val PRIVKEY_IDENTIFIER_PREFIX = "privkey." diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index 318c3700ee1..c6bd93701e8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -6,6 +6,7 @@ import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.UserNotAuthenticatedException import android.util.Log import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit @@ -1367,26 +1368,52 @@ class WalletStorage( private suspend fun deletePrivateKeysLocked(pubkeyHexes: Collection) { if (pubkeyHexes.isEmpty()) return val normalized = pubkeyHexes.map { it.lowercase() }.toSet() - store.edit { prefs -> - for (pubkeyHex in normalized) { - prefs.remove(privateKeyKey(pubkeyHex)) - prefs.remove(privateKeyFingerprintKey(pubkeyHex)) - prefs.remove(privateKeyAliasKey(pubkeyHex)) - } - // Keep every owner index accurate in the same atomic commit: - // a deleted alias must leave all wallets' index sets, or a - // later wallet deletion would "discover" a ghost. - prefs.asMap().keys - .filter { it.name.startsWith(PRIVKEY_OWNERS_PREFIX) } - .forEach { prefKey -> - val setKey = stringSetPreferencesKey(prefKey.name) - val current = prefs[setKey] ?: return@forEach - val next = current - normalized - if (next.size != current.size) { - if (next.isEmpty()) prefs.remove(setKey) else prefs[setKey] = next - } + store.edit { prefs -> removePrivateKeyEntries(prefs, normalized) } + } + + /** + * Delete [pubkeyHex]'s private key only while its stored blob is still + * [expectedBlob] (from [storedPrivateKeyBlob]), comparing and deleting in + * one DataStore edit under one [privateKeyMutex] hold, so a repair or + * add-key store that replaced the blob after the caller judged it is + * never deleted. Returns whether it deleted. + */ + suspend fun deletePrivateKeyIfUnchanged(pubkeyHex: String, expectedBlob: String): Boolean = + privateKeyMutex.withLock { + val normalized = pubkeyHex.lowercase() + var deleted = false + store.edit { prefs -> + if (prefs[privateKeyKey(normalized)] == expectedBlob) { + removePrivateKeyEntries(prefs, setOf(normalized)) + deleted = true } + } + deleted } + + /** The encoded blob stored for [pubkeyHex], or null when none is. */ + suspend fun storedPrivateKeyBlob(pubkeyHex: String): String? = + store.data.first()[privateKeyKey(pubkeyHex.lowercase())] + + private fun removePrivateKeyEntries(prefs: MutablePreferences, normalized: Set) { + for (pubkeyHex in normalized) { + prefs.remove(privateKeyKey(pubkeyHex)) + prefs.remove(privateKeyFingerprintKey(pubkeyHex)) + prefs.remove(privateKeyAliasKey(pubkeyHex)) + } + // Keep every owner index accurate in the same atomic commit: + // a deleted alias must leave all wallets' index sets, or a + // later wallet deletion would "discover" a ghost. + prefs.asMap().keys + .filter { it.name.startsWith(PRIVKEY_OWNERS_PREFIX) } + .forEach { prefKey -> + val setKey = stringSetPreferencesKey(prefKey.name) + val current = prefs[setKey] ?: return@forEach + val next = current - normalized + if (next.size != current.size) { + if (next.isEmpty()) prefs.remove(setKey) else prefs[setKey] = next + } + } } suspend fun hasPrivateKey(pubkeyHex: String): Boolean = diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 1fe2572e8fc..4a3a3edd633 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -491,6 +491,9 @@ class PlatformWalletManager( database = database, privateKeyDeriver = deriver, network = network, + // The usability probe behind pendingIdentityKeys: the restart + // rebuild below and a live repair hand-off share it. + isPrivateKeyUsable = { walletStorage.isPrivateKeyDecryptable(it) }, ) CoreChildren(resolver, keySigner, deriver, handler) } catch (constructionFailure: Throwable) { @@ -1195,9 +1198,8 @@ class PlatformWalletManager( // signal recorded before a process death (or a blob stranded by a // Keystore keypair replacement) resurfaces on every launch. try { - persistenceHandler.reconstructPendingIdentityKeysFromPersistence( - isPrivateKeyDecryptable = { walletStorage.isPrivateKeyDecryptable(it) }, - ) + // The handler's own usability probe (WalletStorage's CHEAP check). + persistenceHandler.reconstructPendingIdentityKeysFromPersistence() } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { // NEVER swallow structured-concurrency cancellation from the // suspend reconstruction — rethrow so a cancelled load propagates diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index 4eba61051dd..ec57701a9b4 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -90,12 +90,16 @@ class PlatformWalletPersistenceHandlerTest { } /** A handler over [db] wired to the test decoder — also the suite's "restart" idiom. */ - private fun newHandler(deriver: PrivateKeyDeriver? = null): PlatformWalletPersistenceHandler = + private fun newHandler( + deriver: PrivateKeyDeriver? = null, + isPrivateKeyUsable: suspend (pubkeyHex: String) -> Boolean = { false }, + ): PlatformWalletPersistenceHandler = PlatformWalletPersistenceHandler( db, Dispatchers.Unconfined, deriver, storedTransactionInputs = storedInputs, + isPrivateKeyUsable = isPrivateKeyUsable, ) /** @@ -1580,10 +1584,13 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun aRejectionDropsAPendingRepairFiledUnderItsWalletWhenTheRowHasNoOwner() = runTest { - // A row written before schema 16 has no recorded owner, so the row - // keeps its breadcrumb, but the pending entry filed under this wallet - // at the refuted slot is this wallet's to drop. + fun aRejectionOfALiveRepairLeavesTheRepairARestartRebuildsFromARowWithNoOwner() = runTest { + // The live entry was recorded from Rust's breadcrumb, so its owner is + // known; the stored row's owner has since been cleared (the shape a + // pre-schema-16 row has). That owner's rejection of the entry's own + // slot cancels the live entry, but the row with no recorded owner + // keeps its breadcrumb, so the repair stays open with its owner + // unknown: the same entry a restart rebuilds from the row. handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) @@ -1592,10 +1599,766 @@ class PlatformWalletPersistenceHandlerTest { val dao = db.publicKeyDao() val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! dao.update(row.copy(derivationWalletId = null)) + assertTrue(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) rejectIdentityKey(pubkey, identityId) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + val kept = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertFalse(kept.ownerKnown) + assertEquals(walletId.toHex(), kept.walletIdHex) assertEquals(5, dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) + + // Now unknown, the owner's rejection no longer cancels it. + rejectIdentityKey(pubkey, identityId) + assertEquals(kept, handler.pendingIdentityKeys.value[pubkey.toHex()]) + + val restarted = newHandler() + restarted.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { false }, + nowMs = kept.failedAtMs, + reason = kept.reason, + ) + assertEquals(kept, restarted.pendingIdentityKeys.value[pubkey.toHex()]) + } + + /** Seed the wallet + identity rows a public-key row FKs onto, under [wallet]. */ + private fun seedIdentityUnder(wallet: ByteArray, identityId: ByteArray) { + handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) + handler.onChangesetBegin(wallet) + handler.onPersistIdentityUpsert( + wallet, identityId, 1, 0, true, 0, 2, true, wallet, + emptyArray(), longArrayOf(), false, null, null, null, + ByteArray(32), false, ByteArray(8), false, null, + ) + handler.onChangesetEnd(wallet, success = true) + } + + /** A breadcrumb upsert of [pubkey] at `(identityIndex, keyIndex)` under [wallet]'s seed. */ + private fun upsertKeyAt( + wallet: ByteArray, + identityId: ByteArray, + pubkey: ByteArray, + identityIndex: Int, + keyIndex: Int, + ) { + handler.onChangesetBegin(wallet) + handler.onPersistIdentityKeyUpsert( + wallet, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), true, wallet, + true, identityIndex, keyIndex, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(wallet, success = true) + } + + @Test + fun aRepairRebuiltFromALegacyRowSurvivesTheReassociatedWalletsRejection() = runTest { + // Upgrade: the breadcrumb was written before schema 16, so its owner + // is unknown (NULL). Reassociation: a full load through wallet B moves + // the identity to B. Restart: the rebuilt repair is offered under B, + // the identity's wallet, but its owner stays unknown. B's rejection of + // the slot shows only that B's seed does not derive the key there, not + // whose breadcrumb it is, so the repair and the breadcrumb both stay. + handler = newHandler(ThrowingDeriver()) + val walletA = walletId + val walletB = ByteArray(32) { 6 } + val identityId = ByteArray(32) { 23 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 23 } + upsertIdentityKey(pubkey, identityId) + val dao = db.publicKeyDao() + val legacy = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + dao.update(legacy.copy(derivationWalletId = null)) + + seedIdentityUnder(walletB, identityId) + assertTrue(walletB.contentEquals(db.identityDao().getByIdentityId(identityId)!!.walletId)) + + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(walletB.toHex(), rebuilt.walletIdHex) + assertFalse(rebuilt.ownerKnown) + + // Neither a round nor a standalone rejection by B, nor one by A, cancels it. + rejectIdentityKey(pubkey, identityId, from = walletB) + rejectIdentityKey(pubkey, identityId, from = walletB, round = null) + rejectIdentityKey(pubkey, identityId, from = walletA) + assertEquals(rebuilt, handler.pendingIdentityKeys.value[pubkey.toHex()]) + val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertEquals(5, row.derivationKeyIndex) + assertNull(row.derivationWalletId) + + // The next restart rebuilds the same unresolved repair. + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + assertFalse(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) + } + + @Test + fun aRejectionByTheRecordedOwnerCancelsARepairRebuiltAtRestart() = runTest { + // The row records its owner, so the rebuilt repair's owner is known: + // that wallet's rejection of the repair's slot cancels it with the row. + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 24 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 24 } + upsertIdentityKey(pubkey, identityId) + + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + assertTrue(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) + + rejectIdentityKey(pubkey, identityId, slot = intArrayOf(3, 4)) + assertNotNull("another slot's rejection keeps it", handler.pendingIdentityKeys.value[pubkey.toHex()]) + rejectIdentityKey(pubkey, identityId) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) + } + + @Test + fun aRejectionOfOneRowKeepsTheRepairOfAnotherRowOfTheSamePublicKey() = runTest { + // One public key, two rows with different repair scopes: identity X + // at (3, 5) under wallet A, identity Y at (2, 9) under wallet B. The + // pending map holds Y's repair. A's rejection of (3, 5) refutes X's + // row, which says nothing of Y's coordinates. + handler = newHandler(ThrowingDeriver()) + val walletA = walletId + val walletB = ByteArray(32) { 7 } + val identityX = ByteArray(32) { 25 } + val identityY = ByteArray(32) { 26 } + val pubkey = ByteArray(33) { 25 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(walletB.toHex(), held.walletIdHex) + + rejectIdentityKey(pubkey, identityX, from = walletA) + assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) + val dao = db.publicKeyDao() + assertNull("X's row is refuted", dao.getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) + assertEquals(9, dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.derivationKeyIndex) + } + + @Test + fun aCancelledRepairPassesToAnotherRowOfTheSamePublicKey() = runTest { + // Same two scopes, but the pending map holds X's repair. A's rejection + // of (3, 5) cancels X's repair; Y's row is still an unresolved + // derivation of the key, so the repair passes to it instead of + // vanishing until the next restart. A rolled-back round does neither. + handler = newHandler(ThrowingDeriver()) + val walletA = walletId + val walletB = ByteArray(32) { 8 } + val identityX = ByteArray(32) { 27 } + val identityY = ByteArray(32) { 28 } + val pubkey = ByteArray(33) { 27 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(identityX.toBase58String(), held.identityIdBase58) + + rejectIdentityKey(pubkey, identityX, from = walletA, round = false) + assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) + + rejectIdentityKey(pubkey, identityX, from = walletA) + val passed = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(walletB.toHex(), passed.walletIdHex) + assertEquals(identityY.toBase58String(), passed.identityIdBase58) + assertEquals(2, passed.identityIndex) + assertEquals(9, passed.keyIndex) + assertTrue(passed.ownerKnown) + assertEquals(held.reason, passed.reason) + + // B's rejection of Y's own slot then cancels the last repair. + rejectIdentityKey(pubkey, identityY, from = walletB, slot = intArrayOf(2, 9)) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + /** + * A keystore stand-in for the hand-off and refuted-scalar tests: one + * scalar per public key (the shared `privkey.` alias), persist-time + * stores that fail while [failStores] is set, force repairs that record + * their slot, and a verdict on stored scalars scripted by [wrongScalars] + * (a wrong scalar is deleted as it is judged, unless [failDelete]). + */ + private class ScriptedDeriver : PrivateKeyDeriver { + var failStores = true + val stored = mutableSetOf() + val forceCalls = mutableListOf>() + + /** When set, a force repair at any other (wallet hex, slot) derives another key and throws. */ + var reproducingSlot: Triple? = null + + /** Keys whose stored scalar is the refuted slot's and does not reproduce them. */ + val wrongScalars = mutableSetOf() + val verdicts = mutableListOf>() + var verdictThrows = false + val deleted = mutableListOf() + + override fun deriveAndStore( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + keyType: Int, + force: Boolean, + ): DerivedKeyStoreResult? { + val hex = publicKeyData.toHex() + if (force) { + val slot = Triple(walletId.toHex(), identityIndex, keyIndex) + forceCalls += slot + reproducingSlot?.let { expected -> + if (slot != expected) { + throw org.dashfoundation.dashsdk.security.IdentityKeyDerivationMismatchException( + "slot $slot does not derive $hex", + ) + } + } + stored += hex + return DerivedKeyStoreResult("privkey.$hex", wasNewlyCreated = true) + } + if (failStores) return null + return DerivedKeyStoreResult("privkey.$hex", wasNewlyCreated = stored.add(hex)) + } + + override fun deleteUnownedStored( + pubkeyHexes: Collection, + excludingWalletId: ByteArray, + ): Set = emptySet() + + var failDelete = false + + override fun discardStoredKeyRefutedBy( + walletId: ByteArray, + publicKeyData: ByteArray, + identityIndex: Int, + keyIndex: Int, + keyType: Int, + ): StoredKeyVerdict { + val hex = publicKeyData.toHex() + verdicts += Triple(walletId.toHex(), identityIndex, keyIndex) + if (verdictThrows) throw IllegalStateException("authentication window closed") + return when { + hex !in stored -> StoredKeyVerdict.ABSENT + hex !in wrongScalars -> StoredKeyVerdict.KEPT + failDelete -> throw IllegalStateException("simulated DataStore edit failure") + else -> { + deleted += hex + stored -= hex + StoredKeyVerdict.DISCARDED + } + } + } + } + + /** A handler over [deriver] whose usability probe is [deriver]'s store. */ + private fun scriptedHandler(deriver: ScriptedDeriver): PlatformWalletPersistenceHandler = + newHandler(deriver, isPrivateKeyUsable = { it in deriver.stored }) + + /** What a restart over [deriver]'s store rebuilds for [pubkey]. */ + private suspend fun rebuiltAfterRestart(deriver: ScriptedDeriver, pubkey: ByteArray): PlatformWalletPersistenceHandler.PendingIdentityKey? { + val restarted = scriptedHandler(deriver) + restarted.reconstructPendingIdentityKeysFromPersistence() + return restarted.pendingIdentityKeys.value[pubkey.toHex()] + } + + @Test + fun aCancelledRepairPassesToNoRowWhoseScalarIsUsable() = runTest { + // Row Y holds a usable scalar of the key; row X's derive failed, so + // the map holds X's repair. A's rejection of X's slot cancels it, and + // Y owes no repair: the restart rebuild skips a usable row, and so + // does the hand-off, which leaves no entry. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletA = walletId + val walletB = ByteArray(32) { 9 } + val identityX = ByteArray(32) { 29 } + val identityY = ByteArray(32) { 30 } + val pubkey = ByteArray(33) { 29 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + deriver.failStores = false + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + deriver.failStores = true + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + val dao = db.publicKeyDao() + assertNotNull(dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.privateKeyKeychainIdentifier) + assertEquals(identityX.toBase58String(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.identityIdBase58) + + rejectIdentityKey(pubkey, identityX, from = walletA) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + assertNull(rebuiltAfterRestart(deriver, pubkey)) + } + + @Test + fun aRejectionOfAnotherIdentitysRowAtTheSameSlotKeepsTheRepair() = runTest { + // Identities X and Y both carry the key at (3, 5) under wallet A; row + // Z of identity Z carries it at (2, 9) under wallet B. The map holds + // Y's repair. A rejection persisted for X's row refutes X's row, not + // the repair Y owes at the same coordinates. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletA = walletId + val walletB = ByteArray(32) { 10 } + val identityX = ByteArray(32) { 31 } + val identityY = ByteArray(32) { 32 } + val identityZ = ByteArray(32) { 33 } + val pubkey = ByteArray(33) { 31 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletA, identityY) + seedIdentityUnder(walletB, identityZ) + upsertKeyAt(walletB, identityZ, pubkey, 2, 9) + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + upsertKeyAt(walletA, identityY, pubkey, 3, 5) + val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(identityY.toBase58String(), held.identityIdBase58) + + rejectIdentityKey(pubkey, identityX, from = walletA) + assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) + val dao = db.publicKeyDao() + assertNull(dao.getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) + assertEquals(5, dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.derivationKeyIndex) + + // Y's own rejection cancels it, and the repair passes to Z. + rejectIdentityKey(pubkey, identityY, from = walletA) + val passed = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(identityZ.toBase58String(), passed.identityIdBase58) + assertEquals(rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = passed.reason, failedAtMs = passed.failedAtMs), passed) + } + + @Test + fun aRepairDerivesAtTheRowItsPendingEntryNames() = runTest { + // Rows X (3, 5) under A and Y (2, 9) under B; the map holds Y's + // repair. X is the first row with breadcrumbs, but the repair under + // B derives at Y's slot, the one its entry names. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletA = walletId + val walletB = ByteArray(32) { 11 } + val identityX = ByteArray(32) { 34 } + val identityY = ByteArray(32) { 35 } + val pubkey = ByteArray(33) { 34 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + assertEquals(identityY.toBase58String(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.identityIdBase58) + + handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }) + assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun aRepairDerivesAtTheLegacyRowARebuiltEntryNames() = runTest { + // X (wallet A, (3, 5)) is the first row of the key and holds a usable + // scalar. Y's row was written before schema 16 (no recorded owner), + // so the restart rebuild files Y's repair under its identity's wallet + // B with the owner unknown. A repair under B derives at Y's slot, the + // one the entry names; no row records B as its owner, so without the + // entry it would derive X's slot under B's seed, which does not + // reproduce the key. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletA = walletId + val walletB = ByteArray(32) { 18 } + val identityX = ByteArray(32) { 55 } + val identityY = ByteArray(32) { 56 } + val pubkey = ByteArray(33) { 55 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + deriver.failStores = false + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + deriver.failStores = true + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + val dao = db.publicKeyDao() + val legacy = dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!! + dao.update(legacy.copy(derivationWalletId = null)) + + handler = scriptedHandler(deriver) + handler.reconstructPendingIdentityKeysFromPersistence() + val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(identityY.toBase58String(), rebuilt.identityIdBase58) + assertEquals(walletB.toHex(), rebuilt.walletIdHex) + assertFalse(rebuilt.ownerKnown) + + deriver.reproducingSlot = Triple(walletB.toHex(), 2, 9) + assertEquals( + "privkey.${pubkey.toHex()}", + handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }), + ) + assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun aRepairUnderAnotherWalletDerivesAtTheRowThatWalletDerived() = runTest { + // The map holds Y's repair, filed under B, but the host repairs under + // A (the wallet whose key-health view is open): the repair derives at + // X's slot, the one A derived, not at Y's slot under A's seed. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletA = walletId + val walletB = ByteArray(32) { 16 } + val identityX = ByteArray(32) { 51 } + val identityY = ByteArray(32) { 52 } + val pubkey = ByteArray(33) { 51 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + assertEquals(walletB.toHex(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.walletIdHex) + + assertEquals( + "privkey.${pubkey.toHex()}", + handler.repairIdentityKeyDurably(walletA, pubkey, verifyRecoverable = { true }), + ) + assertEquals(listOf(Triple(walletA.toHex(), 3, 5)), deriver.forceCalls) + } + + @Test + fun aRepairWithNoPendingEntryDerivesAtTheRowItsWalletDerived() = runTest { + // No repair is pending; X (under A) is the first row with breadcrumbs, + // but a repair under B derives at Y's slot, the one B derived. + val deriver = ScriptedDeriver() + deriver.failStores = false + handler = scriptedHandler(deriver) + val walletB = ByteArray(32) { 17 } + val identityX = ByteArray(32) { 53 } + val identityY = ByteArray(32) { 54 } + val pubkey = ByteArray(33) { 53 } + seedIdentityUnder(walletId, identityX) + seedIdentityUnder(walletB, identityY) + upsertKeyAt(walletId, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }) + assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) + } + + /** Rows X (wallet A, (3, 5)) and Y (wallet B, (2, 9)) of one key whose stored scalar is the one at X's slot. */ + private suspend fun seedTwoRowsSharingAWrongScalar( + deriver: ScriptedDeriver, + walletB: ByteArray, + identityX: ByteArray, + identityY: ByteArray, + pubkey: ByteArray, + ) { + seedIdentityUnder(walletId, identityX) + seedIdentityUnder(walletB, identityY) + deriver.failStores = false + upsertKeyAt(walletId, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + deriver.wrongScalars += pubkey.toHex() + } + + private suspend fun identifiersOf(vararg identities: ByteArray): List = + identities.map { + db.publicKeyDao().getByIdentityAndKeyId(it.toBase58String(), 0)!!.privateKeyKeychainIdentifier + } + + private suspend fun keyIndexOf(identityId: ByteArray): Int? = + db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex + + @Test + fun aRejectionDeletesAStoredScalarThatDoesNotReproduceTheKey() = runTest { + // An older SDK guessed (3, 5) for an external key and the persist-time + // store kept the scalar derived there; row Y of the key shares the + // alias. Rust's rejection of (3, 5) refutes the breadcrumb, and the + // stored scalar is the slot's, which does not reproduce the key: it is + // deleted, no row names it, and Y's breadcrumb owes the repair, as a + // restart rebuilds it. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletB = ByteArray(32) { 12 } + val identityX = ByteArray(32) { 36 } + val identityY = ByteArray(32) { 37 } + val pubkey = ByteArray(33) { 36 } + val hex = pubkey.toHex() + seedTwoRowsSharingAWrongScalar(deriver, walletB, identityX, identityY, pubkey) + assertEquals(listOf("privkey.$hex", "privkey.$hex"), identifiersOf(identityX, identityY)) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + // A rolled-back round never reaches the check. + rejectIdentityKey(pubkey, identityX, round = false) + assertTrue(deriver.deleted.isEmpty()) + assertEquals(listOf("privkey.$hex", "privkey.$hex"), identifiersOf(identityX, identityY)) + + rejectIdentityKey(pubkey, identityX) + assertEquals(listOf(Triple(walletId.toHex(), 3, 5)), deriver.verdicts) + assertEquals(listOf(hex), deriver.deleted) + assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) + assertNull(keyIndexOf(identityX)) + val seeded = handler.pendingIdentityKeys.value[hex]!! + assertEquals(identityY.toBase58String(), seeded.identityIdBase58) + assertEquals(PlatformWalletPersistenceHandler.REFUTED_SCALAR_REASON, seeded.reason) + assertEquals( + rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = seeded.reason, failedAtMs = seeded.failedAtMs), + seeded, + ) + } + + @Test + fun aRejectionWhoseCommitFailsAfterTheDeleteIsSettledByTheNextOne() = runTest { + // The wrong scalar is deleted as it is judged, inside the round's + // transaction; a later row's foreign key then fails the commit. The + // delete stands (the scalar was wrong whatever Room does), the rows + // keep their breadcrumb, and the next rejection finds nothing stored + // and applies. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletB = ByteArray(32) { 14 } + val identityX = ByteArray(32) { 46 } + val identityY = ByteArray(32) { 47 } + val pubkey = ByteArray(33) { 46 } + val hex = pubkey.toHex() + seedTwoRowsSharingAWrongScalar(deriver, walletB, identityX, identityY, pubkey) + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityX, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(3, 5), + ) + handler.onPersistIdentityKeyUpsert( + walletId, ByteArray(32) { 99 }, 0, 0, 0, 0, false, false, 0, + ByteArray(33) { 99 }, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + ) + assertTrue("not before the round ends", deriver.deleted.isEmpty()) + assertEquals(1, handler.onChangesetEnd(walletId, success = true)) + assertEquals(listOf(hex), deriver.deleted) + assertEquals(5, keyIndexOf(identityX)) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + rejectIdentityKey(pubkey, identityX) + assertEquals(listOf(hex), deriver.deleted) + assertNull(keyIndexOf(identityX)) + assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) + val seeded = handler.pendingIdentityKeys.value[hex]!! + assertEquals(identityY.toBase58String(), seeded.identityIdBase58) + assertEquals( + rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = seeded.reason, failedAtMs = seeded.failedAtMs), + seeded, + ) + } + + @Test + fun aRejectionWhoseScalarDeleteFailsIsNotApplied() = runTest { + // Y stored the key's scalar; X's own derive failed, so the map holds + // X's repair. The scalar is the one at X's refuted slot, but deleting + // it fails: the rejection is not applied, so X keeps its breadcrumb and + // repair, Y its identifier, and the next rejection retries. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val walletB = ByteArray(32) { 15 } + val identityX = ByteArray(32) { 48 } + val identityY = ByteArray(32) { 49 } + val pubkey = ByteArray(33) { 48 } + val hex = pubkey.toHex() + seedIdentityUnder(walletId, identityX) + seedIdentityUnder(walletB, identityY) + deriver.failStores = false + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + deriver.failStores = true + upsertKeyAt(walletId, identityX, pubkey, 3, 5) + val held = handler.pendingIdentityKeys.value[hex]!! + assertEquals(identityX.toBase58String(), held.identityIdBase58) + deriver.wrongScalars += hex + deriver.failDelete = true + + rejectIdentityKey(pubkey, identityX) + rejectIdentityKey(pubkey, identityX, round = null) + assertTrue(deriver.deleted.isEmpty()) + assertEquals(5, keyIndexOf(identityX)) + assertEquals(listOf(null, "privkey.$hex"), identifiersOf(identityX, identityY)) + assertEquals(held, handler.pendingIdentityKeys.value[hex]) + + deriver.failDelete = false + rejectIdentityKey(pubkey, identityX) + assertEquals(listOf(hex), deriver.deleted) + assertNull(keyIndexOf(identityX)) + assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) + val passed = handler.pendingIdentityKeys.value[hex]!! + assertEquals(identityY.toBase58String(), passed.identityIdBase58) + assertEquals(held.reason, passed.reason) + assertEquals( + rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = passed.reason, failedAtMs = passed.failedAtMs), + passed, + ) + } + + @Test + fun aRejectionKeepsAStoredScalarThatReproducesTheKeyOrCannotBeChecked() = runTest { + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val identityId = ByteArray(32) { 38 } + val pubkey = ByteArray(33) { 38 } + val hex = pubkey.toHex() + seedIdentityUnder(walletId, identityId) + deriver.failStores = false + upsertKeyAt(walletId, identityId, pubkey, 3, 5) + + // The check cannot be settled (no seed, closed authentication + // window): the rejection is not applied, so the breadcrumb stays for + // a later rejection to check again. + deriver.verdictThrows = true + deriver.wrongScalars += hex + rejectIdentityKey(pubkey, identityId, round = null) + rejectIdentityKey(pubkey, identityId) + assertEquals(5, keyIndexOf(identityId)) + assertEquals(listOf("privkey.$hex"), identifiersOf(identityId)) + assertTrue(deriver.deleted.isEmpty()) + + // A scalar that reproduces the key stays; the breadcrumb goes. + deriver.verdictThrows = false + deriver.wrongScalars.clear() + rejectIdentityKey(pubkey, identityId, round = null) + assertNull(keyIndexOf(identityId)) + assertEquals(listOf("privkey.$hex"), identifiersOf(identityId)) + assertTrue(deriver.deleted.isEmpty()) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + } + + @Test + fun aStandaloneRejectionDeletesAWrongScalar() = runTest { + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val identityId = ByteArray(32) { 39 } + val pubkey = ByteArray(33) { 39 } + seedIdentityUnder(walletId, identityId) + deriver.failStores = false + upsertKeyAt(walletId, identityId, pubkey, 3, 5) + deriver.wrongScalars += pubkey.toHex() + + rejectIdentityKey(pubkey, identityId, round = null) + assertEquals(listOf(pubkey.toHex()), deriver.deleted) + assertEquals(listOf(null), identifiersOf(identityId)) + // The only row lost its breadcrumb, so no repair is owed. + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + assertNull(rebuiltAfterRestart(deriver, pubkey)) + } + + @Test + fun aRepairRecordedAndRejectedInOneRoundIsCancelled() = runTest { + // One round records X's failed derive and then rejects X's slot. The + // key's scalar (stored by another path) is not the slot's, so it + // stays, and only the staged record shows the repair the rejection + // cancels: once the round commits, no repair is left, as a restart + // rebuilds none. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + val identityId = ByteArray(32) { 50 } + val pubkey = ByteArray(33) { 50 } + seedIdentityUnder(walletId, identityId) + deriver.stored += pubkey.toHex() + + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), true, walletId, + true, 3, 5, 0, ByteArray(32), null, + ) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(3, 5), + ) + handler.onChangesetEnd(walletId, success = true) + assertNull(keyIndexOf(identityId)) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + assertNull(rebuiltAfterRestart(deriver, pubkey)) + } + + @Test + fun aRejectionWithNoRepairToCancelReadsNoOtherRow() = runTest { + // No entry for the key and no scalar to discard: the rejection + // changes no pending state, so it does not scan the key's rows (the + // usability probe would run for row Y's identifier). + val deriver = ScriptedDeriver() + var probes = 0 + handler = newHandler(deriver, isPrivateKeyUsable = { probes++; it in deriver.stored }) + val walletA = walletId + val walletB = ByteArray(32) { 13 } + val identityX = ByteArray(32) { 40 } + val identityY = ByteArray(32) { 41 } + val pubkey = ByteArray(33) { 40 } + seedIdentityUnder(walletA, identityX) + seedIdentityUnder(walletB, identityY) + deriver.failStores = false + upsertKeyAt(walletA, identityX, pubkey, 3, 5) + upsertKeyAt(walletB, identityY, pubkey, 2, 9) + assertTrue(handler.pendingIdentityKeys.value.isEmpty()) + + rejectIdentityKey(pubkey, identityX, from = walletA) + rejectIdentityKey(pubkey, identityX, from = walletA, round = null) + assertEquals(0, probes) + assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) + } + + @Test + fun aStandaloneUpsertWhoseWriteFailsLeavesThePendingMapUnchanged() = runTest { + // The identity row is missing, so the row's foreign key fails the + // standalone write. Neither the recorded failure nor the clear of an + // existing repair may be published for a row that never landed. + val deriver = ScriptedDeriver() + handler = scriptedHandler(deriver) + handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) + val missing = ByteArray(32) { 42 } + val pubkey = ByteArray(33) { 42 } + fun standaloneUpsert(identityId: ByteArray): Int = + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), true, walletId, + true, 3, 5, 0, ByteArray(32), null, + ) + + assertEquals(1, standaloneUpsert(missing)) + assertTrue("no phantom repair", handler.pendingIdentityKeys.value.isEmpty()) + + val present = ByteArray(32) { 43 } + seedIdentityUnder(walletId, present) + assertEquals(0, standaloneUpsert(present)) + val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + deriver.failStores = false + assertEquals(1, standaloneUpsert(missing)) + assertEquals("the repair is not cleared", held, handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun aReplacementPublicKeyUnderTheSameKeyIdDropsTheOldKeysLinkage() = runTest { + // Mirror of the Swift handler's replacement test: the row's private-key + // identifier and derivation owner belonged to the old key. + val deriver = ScriptedDeriver() + deriver.failStores = false + handler = scriptedHandler(deriver) + val identityId = ByteArray(32) { 44 } + seedIdentityUnder(walletId, identityId) + val oldKey = ByteArray(33) { 44 } + upsertKeyAt(walletId, identityId, oldKey, 3, 5) + val dao = db.publicKeyDao() + val before = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertEquals("privkey.${oldKey.toHex()}", before.privateKeyKeychainIdentifier) + assertTrue(walletId.contentEquals(before.derivationWalletId)) + + val newKey = ByteArray(33) { 45 } + handler.onChangesetBegin(walletId) + handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + newKey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletId, success = true) + val after = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertTrue(newKey.contentEquals(after.publicKeyData)) + assertNull(after.privateKeyKeychainIdentifier) + assertNull(after.derivationWalletId) + assertNull(after.derivationIdentityIndex) + assertNull(after.derivationKeyIndex) } @Test diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index 157e24e6d76..0364fa42ea7 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -609,6 +609,29 @@ class WalletStorageUpgradeMatrixTest { } assertEquals(0, fake.legacyRsaFallbackCalls) // never fell through to the fallback } + + // ── Compare-and-delete ────────────────────────────────────────────── + + /** + * A refuted-scalar delete names the blob it judged: a blob a repair or + * add-key store wrote since is not deleted, the judged one is. + */ + @Test + fun deletePrivateKeyIfUnchangedDeletesOnlyTheJudgedBlob() = runBlocking { + fake.scheme = FakeKeystoreManager.Scheme.CURRENT + storage.storePrivateKey(pub, secret) + val judged = storage.storedPrivateKeyBlob(pub)!! + val replacement = ByteArray(32) { 7 } + storage.storePrivateKey(pub, replacement) + assertTrue(judged != storage.storedPrivateKeyBlob(pub)) + + assertFalse(storage.deletePrivateKeyIfUnchanged(pub, judged)) + assertArrayEquals(replacement, storage.retrievePrivateKey(pub)) + + assertTrue(storage.deletePrivateKeyIfUnchanged(pub, storage.storedPrivateKeyBlob(pub)!!)) + assertNull(storage.storedPrivateKeyBlob(pub)) + assertFalse(storage.hasPrivateKey(pub)) + } } /** From 03331ba74b7a3ae6453473350429af58d6a58aa5 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:37:38 +0300 Subject: [PATCH 44/72] refactor(kotlin-sdk): apply Rust's key-origin decisions mechanically in the persister The persister chose a successor row for a cancelled repair, judged and deleted stored scalars, deferred a rejection it could not settle (while still acknowledging the callback) and picked a repair's source row: provenance decisions that belong in the Rust wallet. It now only applies Rust's decisions: a rejection clears the breadcrumb of the row it names when the recorded owner is the rejecting wallet, and cancels the pending repair only when that entry's owner is recorded, is the rejecting wallet, and the entry names the same row and a rejected slot. A cancelled repair is removed. The restart rebuild files a repair under the breadcrumb's recorded owner, marks one with no recorded owner as unknown, and walks rows in id order. A replaced public key drops the old key's repair of that row. Every pending-map change is published after its write commits. The scalar discard, successor hand-off and repair-row selection are gone, and repairIdentityKeyDurably is as on the base branch. Co-Authored-By: Claude Opus 5.5 --- .../PlatformWalletPersistenceHandler.kt | 363 +++-------- .../security/IdentityKeyPrivateKeyDeriver.kt | 51 -- .../dashsdk/security/WalletStorage.kt | 63 +- .../dashsdk/wallet/PlatformWalletManager.kt | 8 +- .../PlatformWalletPersistenceHandlerTest.kt | 599 ++++-------------- .../WalletStorageUpgradeMatrixTest.kt | 23 - 6 files changed, 249 insertions(+), 858 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index 224ad4d8201..ea33db7e750 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -140,16 +140,6 @@ class PlatformWalletPersistenceHandler( */ private val storedTransactionInputs: StoredTransactionInputs = NativeStoredTransactionInputs(network), - /** - * The CHEAP signing-capability check for the scalar stored under a - * public-key hex (production: `WalletStorage.isPrivateKeyDecryptable`). - * The one usability predicate behind [pendingIdentityKeys]: the restart - * rebuild ([reconstructPendingIdentityKeysFromPersistence]) and a live - * hand-off of a cancelled repair both skip a row it reports usable, so - * the live map is the map a restart rebuilds. The default reports - * nothing usable (no storage wired). - */ - private val isPrivateKeyUsable: suspend (pubkeyHex: String) -> Boolean = { false }, ) : NativePersistenceBridge(), AutoCloseable { override fun persistenceCapabilitiesVersion(): Int = PERSISTENCE_CAPABILITIES_VERSION @@ -1960,54 +1950,26 @@ class PlatformWalletPersistenceHandler( .map { rejectedDerivations[2 * it] to rejectedDerivations[2 * it + 1] } .toSet() } - // A pending repair entry for the key needs no re-derive once this - // rejection refutes the entry's OWN coordinates: it describes the row - // being upserted (same identity and key id), its recorded owner is - // this wallet, and its slot is among the refuted ones. The map is - // keyed by public key, so the entry may describe another row of the - // same key (another identity, key id, wallet, or slot); a rejection - // persisted for this row says nothing about it. An entry whose owner - // is unknown (rebuilt from a row with no recorded owner and filed - // under the identity's wallet) is never cancelled: that wallet's - // rejection does not show the breadcrumb was its own. + // Rust's rejection cancels exactly the repair it refutes: the pending + // entry names the row being upserted (same identity and key id), its + // recorded owner is the rejecting wallet, and its slot is among the + // rejected ones. The map is keyed by public key, so the entry may + // name another row of the same key (another identity, key id, wallet, + // or slot); a rejection persisted for this row says nothing about + // it. An entry whose owner is unknown (rebuilt from a row with no + // recorded owner and filed under the identity's wallet) is never + // cancelled: that wallet's rejection does not show the breadcrumb + // was its own. val pendingHex = publicKeyData.toHex() val identityBase58 = identityId.toBase58String() fun refutes(entry: PendingIdentityKey): Boolean = entry.ownerKnown && entry.walletIdHex == roundKey && entry.identityIdBase58 == identityBase58 && entry.keyId == keyId && (entry.identityIndex to entry.keyIndex) in refutedSlots - // The cancelled entry hands the key's repair to the row of the same - // public key a restart would rebuild it from ([successor], read once - // the row is written, with the rebuild's own predicate), so one row's - // rejection never hides another row's obligation and the live map - // stays the map a restart rebuilds. When the key has no stored scalar - // once the rejection is settled (none was stored, or a wrong one was - // deleted: [seedIfAbsent]), no row of the key can sign, so an absent - // entry is seeded from the successor as the rebuild would seed it. The change is published with the row: - // through the round's staged deltas when a round is open, or after - // the standalone write commits. - fun handOffPending( - successor: PendingIdentityKey?, - seedIfAbsent: Boolean, - ): (Map) -> Map = + val cancelRefutedPending: (Map) -> Map = { map -> val pending = map[pendingHex] - when { - pending != null && refutes(pending) -> - if (successor == null) { - map - pendingHex - } else { - map + ( - pendingHex to successor.copy( - reason = pending.reason, - failedAtMs = pending.failedAtMs, - ) - ) - } - pending == null && seedIfAbsent && successor != null -> - map + (pendingHex to successor) - else -> map - } + if (pending != null && refutes(pending)) map - pendingHex else map } val round = buffers[roundKey] // Every change this upsert makes to [pendingIdentityKeys] takes @@ -2018,6 +1980,7 @@ class PlatformWalletPersistenceHandler( fun stageWithRow(delta: (Map) -> Map) { if (round != null) round.pendingKeyDeltas.add(delta) else afterCommit += delta } + if (refutedSlots.isNotEmpty()) stageWithRow(cancelRefutedPending) val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } val deriver = privateKeyDeriver @@ -2051,7 +2014,7 @@ class PlatformWalletPersistenceHandler( // round rolls back, alias cleanup deletes the newly // stored scalar, so the old watch-only row must keep // its repair signal (finding de3cf44a71fc). - stageWithRow(clearPendingKeyDelta(publicKeyData.toHex())) + stageWithRow(clearPendingKeyDelta(pendingHex)) } else { // NOT silent (dashpay/platform#4053): the key is being // persisted watch-only, so every signature with it will @@ -2063,7 +2026,7 @@ class PlatformWalletPersistenceHandler( Log.e( TAG, "identity private-key derive/store FAILED — key " + - "${publicKeyData.toHex()} (identity ${identityId.toBase58String()}, " + + "$pendingHex (identity $identityBase58, " + "keyId $keyId, slot $identityIndex/$keyIndex) is persisted " + "WATCH-ONLY and cannot sign until re-derived " + "(see PlatformWalletPersistenceHandler.pendingIdentityKeys): $reason", @@ -2077,9 +2040,9 @@ class PlatformWalletPersistenceHandler( recordPendingKeyDelta( PendingIdentityKey( walletIdHex = keyOwnerWalletId.toHex(), - identityIdBase58 = identityId.toBase58String(), + identityIdBase58 = identityBase58, keyId = keyId, - publicKeyHex = publicKeyData.toHex(), + publicKeyHex = pendingHex, identityIndex = identityIndex, keyIndex = keyIndex, reason = reason, @@ -2098,7 +2061,7 @@ class PlatformWalletPersistenceHandler( // (or a wallet wipe racing the round) can still find and delete it. if (deriveResult != null && deriveResult.wasNewlyCreated && buffers.containsKey(roundKey)) { pendingRoundAliases.getOrPut(roundKey) { mutableSetOf() } - .add(publicKeyData.toHex()) + .add(pendingHex) } stage(walletId) { db -> @@ -2116,51 +2079,8 @@ class PlatformWalletPersistenceHandler( val heldSlot = existing?.derivationIdentityIndex?.let { identity -> existing.derivationKeyIndex?.let { key -> identity to key } } - val slotRefuted = heldSlot != null && heldSlot in refutedSlots && + val refuted = heldSlot != null && heldSlot in refutedSlots && existing.derivationWalletId?.contentEquals(walletId) == true - // A scalar stored from the refuted breadcrumb (an older SDK guessed - // the breadcrumb of an external key, and the persist-time store - // does not check the derived public key) cannot sign for this key, - // and the signer finds scalars by public key, not through the - // row. So the rejection takes effect only once the deriver has - // settled the stored scalar: deleted when it is the slot's scalar - // and the slot's derived public key is not this key (as the repair - // path checks a derive), kept when it reproduces the key or is - // another one. The delete happens here, before the row commits; it - // is right whatever the transaction does, since the scalar is - // wrong for every row of the key. Unsettled (no seed, a closed - // authentication window, a failed delete), the breadcrumb and the - // identifiers stay, so the next rejection checks again. - val verdict: StoredKeyVerdict? = when { - !slotRefuted || heldSlot == null -> null - deriver == null -> StoredKeyVerdict.KEPT - else -> try { - deriver.discardStoredKeyRefutedBy( - walletId = walletId, - publicKeyData = publicKeyData, - identityIndex = heldSlot.first, - keyIndex = heldSlot.second, - keyType = keyType.toInt() and 0xFF, - ) - } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { - throw cancellation - } catch (t: Throwable) { - Log.w( - TAG, - "could not settle the stored scalar of refuted key $pendingHex " + - "(slot ${heldSlot.first}/${heldSlot.second}); the rejection " + - "is not applied and the next one retries", - t, - ) - null - } - } - val refuted = slotRefuted && verdict != null - val deferred = slotRefuted && verdict == null - // No scalar is stored for the key any more: an identifier on any of - // its rows names nothing, and every row of the key is unusable. - val scalarGone = verdict == StoredKeyVerdict.ABSENT || - verdict == StoredKeyVerdict.DISCARDED // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2220,38 +2140,23 @@ class PlatformWalletPersistenceHandler( lastAccessed = now(), ) if (stored == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) - if (scalarGone) { - // The alias is the key's, shared by every row of it. - for (other in db.publicKeyDao().getByPublicKeyData(publicKeyData)) { - if (other.privateKeyKeychainIdentifier != null) { - db.publicKeyDao().update(other.copy(privateKeyKeychainIdentifier = null)) + // A new public key under this key id: a pending repair filed under + // the old key that names this row is for a key the row no longer + // carries. Dropped with the row, like every other change here. + if (stored != null && existing == null) { + val replacedHex = stored.publicKeyData.toHex() + stageWithRow { map -> + val pending = map[replacedHex] + if (pending != null && pending.identityIdBase58 == identityBase58 && pending.keyId == keyId) { + map - replacedHex + } else { + map } } } - if (refutedSlots.isNotEmpty() && !deferred) { - // The map this upsert's delta will see when it is published: - // the live map with the round's earlier staged deltas applied. - // With no entry this rejection cancels, and no scalar gone, - // nothing can change, so the rows are not scanned. A deferred - // rejection changes nothing: its breadcrumb stays, and with it - // the repair a restart rebuilds. - val projected = round?.pendingKeyDeltas - ?.fold(_pendingIdentityKeys.value) { acc, delta -> delta(acc) } - ?: _pendingIdentityKeys.value - val cancels = projected[pendingHex]?.let(::refutes) == true - if (cancels || scalarGone) { - val nowMs = System.currentTimeMillis() - val successor = db.publicKeyDao().getByPublicKeyData(publicKeyData) - .sortedBy { it.id } - .firstNotNullOfOrNull { other -> - pendingRepairEntry(db, other, REFUTED_SCALAR_REASON, nowMs) - } - stageWithRow(handOffPending(successor, seedIfAbsent = scalarGone)) - } - } } // Standalone path: the write above committed (a failed one threw - // before this line, so nothing below runs). + // before this line, so nothing is published). if (afterCommit.isNotEmpty()) { _pendingIdentityKeys.update { map -> afterCommit.fold(map) { acc, delta -> delta(acc) } } } @@ -4065,33 +3970,16 @@ class PlatformWalletPersistenceHandler( // row, never from the caller. A row lacking breadcrumbs cannot be // safely repaired (we would have to guess the slot), so fail WITHOUT // clearing pending. - // Several rows can carry the key (identities, wallets) with different - // breadcrumbs. The repair derives under [walletId] (the host passes - // the wallet whose key-health view is open), so it derives at the row - // its pending entry names when that entry is filed under this wallet - // (after a hand-off, the successor row), then at a row this wallet - // derived, and only then at any row with breadcrumbs. - val rows = database.publicKeyDao().getByPublicKeyData(publicKeyData) - .filter { it.derivationIdentityIndex != null && it.derivationKeyIndex != null } - .sortedBy { it.id } - val pending = _pendingIdentityKeys.value[pubkeyHex] - ?.takeIf { it.walletIdHex == walletId.toHex() } - val breadcrumbRow = rows.firstOrNull { row -> - pending != null && row.identityId == pending.identityIdBase58 && - row.keyId == pending.keyId && - row.derivationIdentityIndex == pending.identityIndex && - row.derivationKeyIndex == pending.keyIndex - } ?: rows.firstOrNull { it.derivationWalletId?.contentEquals(walletId) == true } - ?: rows.firstOrNull() - val breadcrumbs = breadcrumbRow?.let { row -> - val identityIndex = row.derivationIdentityIndex - val keyIndex = row.derivationKeyIndex - if (identityIndex != null && keyIndex != null) { - Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0) - } else { - null - } - } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( + val breadcrumbs = database.publicKeyDao().getByPublicKeyData(publicKeyData) + .firstNotNullOfOrNull { row -> + val identityIndex = row.derivationIdentityIndex + val keyIndex = row.derivationKeyIndex + if (identityIndex != null && keyIndex != null) { + Triple(identityIndex, keyIndex, row.keyType.toIntOrNull() ?: 0) + } else { + null + } + } ?: throw DashSdkError.PlatformWallet.SigningKeyUnavailable( "cannot repair identity key $pubkeyHex: no derivation breadcrumbs are " + "persisted for it (derivationIdentityIndex/derivationKeyIndex are " + "null) — the correct slot is unknown; pending state left intact", @@ -4193,20 +4081,60 @@ class PlatformWalletPersistenceHandler( * Called by `PlatformWalletManager.loadPersistedWallets` after the Room * rows are loaded, before the manager is handed to the host; the wallet * scoping comes from each row's identity (network + wallet id), matching - * this handler's [network] when set. [isPrivateKeyDecryptable] defaults - * to the handler's [isPrivateKeyUsable], the probe a live hand-off uses. + * this handler's [network] when set. */ internal suspend fun reconstructPendingIdentityKeysFromPersistence( - isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean = isPrivateKeyUsable, + isPrivateKeyDecryptable: suspend (pubkeyHex: String) -> Boolean, nowMs: Long = System.currentTimeMillis(), reason: String = "reconstructed from persistence after restart", ) { val rows = database.publicKeyDao().getWithDerivationBreadcrumbs() if (rows.isEmpty()) return - // In row order, so the first row of a key seeds its entry: the row a - // live hand-off ([onPersistIdentityKeyUpsert]) picks as well. - val entries = rows.sortedBy { it.id }.mapNotNull { row -> - pendingRepairEntry(database, row, reason, nowMs, isPrivateKeyDecryptable) + val entries = mutableListOf() + // In row order, so the same store always seeds the same entry for a + // key carried by several rows (the first row of the key wins). + for (row in rows.sortedBy { it.id }) { + if (row.readOnly) continue + val identityIndex = row.derivationIdentityIndex ?: continue + val keyIndex = row.derivationKeyIndex ?: continue + val identityIdData = row.identityIdData ?: continue + val identity = database.identityDao().getByIdentityId(identityIdData) ?: continue + val networkRaw = network?.ffiValue + if (networkRaw != null && identity.networkRaw != networkRaw) continue + // The breadcrumb's own wallet: the identity may since have been + // re-associated with another wallet, whose seed is another + // derivation. A row with no recorded owner (written before schema + // 16) falls back to the identity's wallet, which may be the wrong + // one, so its entry keeps the owner unknown and no rejection + // cancels it. + val walletId = row.derivationWalletId ?: identity.walletId ?: continue + val pubkeyHex = row.publicKeyData.toHex() + val usable = row.privateKeyKeychainIdentifier != null && + try { + isPrivateKeyDecryptable(pubkeyHex) + } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { + // The probe is suspend; runCatching turned its cancellation + // into `false`, marking the row pending and returning + // normally so callers (loadPersistedWallets, invalidation + // bookkeeping) never observed the cancellation. Rethrow to + // preserve structured concurrency; only genuine probe + // failures become an unusable result (dashpay/platform#4183). + throw cancellation + } catch (_: Throwable) { + false + } + if (usable) continue + entries += PendingIdentityKey( + walletIdHex = walletId.toHex(), + identityIdBase58 = row.identityId, + keyId = row.keyId, + publicKeyHex = pubkeyHex, + identityIndex = identityIndex, + keyIndex = keyIndex, + reason = reason, + failedAtMs = nowMs, + ownerKnown = row.derivationWalletId != null, + ) } if (entries.isEmpty()) return _pendingIdentityKeys.update { map -> @@ -4216,78 +4144,6 @@ class PlatformWalletPersistenceHandler( } } - /** - * The pending-repair entry [row] seeds, or null when it seeds none: it is - * no repair candidate ([pendingRepairCandidate]), or its private half is - * usable (a recorded identifier whose stored scalar [isUsable] accepts). - * The one predicate behind both the restart rebuild and a live hand-off, - * so the two agree on every row. - */ - private suspend fun pendingRepairEntry( - db: DashDatabase, - row: PublicKeyEntity, - reason: String, - nowMs: Long, - isUsable: suspend (pubkeyHex: String) -> Boolean = isPrivateKeyUsable, - ): PendingIdentityKey? { - val entry = pendingRepairCandidate(db, row, reason, nowMs) ?: return null - val usable = row.privateKeyKeychainIdentifier != null && - try { - isUsable(entry.publicKeyHex) - } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { - // The probe is suspend; runCatching turned its cancellation - // into `false`, marking the row pending and returning - // normally so callers (loadPersistedWallets, invalidation - // bookkeeping) never observed the cancellation. Rethrow to - // preserve structured concurrency; only genuine probe - // failures become an unusable result (dashpay/platform#4183). - throw cancellation - } catch (_: Throwable) { - false - } - return if (usable) null else entry - } - - /** - * The pending-repair entry [row] would seed, ignoring whether its private - * half is usable, or null when it cannot seed one: a read-only key, a row - * without derivation breadcrumbs, or one whose identity is gone or on - * another network than this handler's. - * - * The breadcrumb's own wallet ([PublicKeyEntity.derivationWalletId]) owns - * the entry: the identity may since have been re-associated with another - * wallet, whose seed is another derivation. A row with no recorded owner - * (written before schema 16) is offered for repair under the identity's - * wallet, which may be the wrong one, so the entry keeps its owner - * unknown ([PendingIdentityKey.ownerKnown]) and no rejection cancels it. - */ - private suspend fun pendingRepairCandidate( - db: DashDatabase, - row: PublicKeyEntity, - reason: String, - nowMs: Long, - ): PendingIdentityKey? { - if (row.readOnly) return null - val identityIndex = row.derivationIdentityIndex ?: return null - val keyIndex = row.derivationKeyIndex ?: return null - val identityIdData = row.identityIdData ?: return null - val identity = db.identityDao().getByIdentityId(identityIdData) ?: return null - val networkRaw = network?.ffiValue - if (networkRaw != null && identity.networkRaw != networkRaw) return null - val walletId = row.derivationWalletId ?: identity.walletId ?: return null - return PendingIdentityKey( - walletIdHex = walletId.toHex(), - identityIdBase58 = row.identityId, - keyId = row.keyId, - publicKeyHex = row.publicKeyData.toHex(), - identityIndex = identityIndex, - keyIndex = keyIndex, - reason = reason, - failedAtMs = nowMs, - ownerKnown = row.derivationWalletId != null, - ) - } - // ── Error / threading guards ────────────────────────────────────── /** @@ -4355,10 +4211,6 @@ class PlatformWalletPersistenceHandler( companion object { internal const val PERSISTENCE_CAPABILITIES_VERSION: Int = 1 - - /** Reason of a repair seeded when a settled rejection leaves the key no stored scalar. */ - internal const val REFUTED_SCALAR_REASON: String = - "no stored private key reproduces the public key" internal const val CAPABILITY_ATOMIC_CHANGESETS: Long = 0x01 internal const val CAPABILITY_INVITATIONS: Long = 0x02 internal const val CAPABILITY_ASSET_LOCK_FUNDING_INDICES: Long = 0x04 @@ -4489,33 +4341,6 @@ interface PrivateKeyDeriver { * the cleanup record alive until a deletion succeeds. */ fun deleteUnownedStored(pubkeyHexes: Collection, excludingWalletId: ByteArray): Set - - /** - * Settle the scalar stored under [publicKeyData]'s alias against a - * rejection of the slot ([identityIndex], [keyIndex]) on [walletId], and - * delete it when it provably does NOT reproduce [publicKeyData]: the - * slot's derived public key is not [publicKeyData] (checked as the - * [force] path of [deriveAndStore] checks a derive, per [keyType]) and - * the stored scalar is the slot's derived scalar. The delete compares - * against the blob that was judged, so a scalar a repair or add-key flow - * stored meanwhile is never deleted. - * - * Returns [StoredKeyVerdict.ABSENT] when nothing is stored, - * [StoredKeyVerdict.DISCARDED] after deleting a wrong scalar, and - * [StoredKeyVerdict.KEPT] when the slot reproduces the key or the stored - * scalar is another one. THROWS when it cannot settle the question (no - * seed to derive from, a closed authentication window, a blob replaced - * during the check, a failed delete): the caller then leaves the - * rejection unapplied so a later one retries. The default settles - * nothing about storage and keeps whatever is stored. - */ - fun discardStoredKeyRefutedBy( - walletId: ByteArray, - publicKeyData: ByteArray, - identityIndex: Int, - keyIndex: Int, - keyType: Int, - ): StoredKeyVerdict = StoredKeyVerdict.KEPT } /** @@ -4530,18 +4355,6 @@ interface PrivateKeyDeriver { */ data class DerivedKeyStoreResult(val identifier: String, val wasNewlyCreated: Boolean) -/** Outcome of [PrivateKeyDeriver.discardStoredKeyRefutedBy]. */ -enum class StoredKeyVerdict { - /** No scalar is stored for the key. */ - ABSENT, - - /** The stored scalar reproduces the key, or is not the refuted slot's: kept. */ - KEPT, - - /** The stored scalar was the refuted slot's, which does not reproduce the key: deleted. */ - DISCARDED, -} - /** * Names a stored transaction's input outpoints for the sweep pass, which * keys its hold by OUTPOINT: a swept loser's inputs are read from its own diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt index ac6c988d5f7..b0864a46cd4 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/IdentityKeyPrivateKeyDeriver.kt @@ -5,7 +5,6 @@ import org.dashfoundation.dashsdk.Network import org.dashfoundation.dashsdk.ffi.IdentityNative import org.dashfoundation.dashsdk.persistence.DerivedKeyStoreResult import org.dashfoundation.dashsdk.persistence.PrivateKeyDeriver -import org.dashfoundation.dashsdk.persistence.StoredKeyVerdict import org.dashfoundation.dashsdk.persistence.toHex /** @@ -162,56 +161,6 @@ class IdentityKeyPrivateKeyDeriver( // succeeds). runBlocking { walletStorage.deleteUnownedPrivateKeys(pubkeyHexes, excludingWalletId) } - override fun discardStoredKeyRefutedBy( - walletId: ByteArray, - publicKeyData: ByteArray, - identityIndex: Int, - keyIndex: Int, - keyType: Int, - ): StoredKeyVerdict = runBlocking { - val pubkeyHex = publicKeyData.toHex() - // The blob being judged; the delete below fires only while it is - // still the stored one. - val blob = walletStorage.storedPrivateKeyBlob(pubkeyHex) - ?: return@runBlocking StoredKeyVerdict.ABSENT - // The same pure, lock-free keypair derive the repair path verifies - // with: Kotlin computes no public key itself, so the stored scalar is - // judged by whether it IS the refuted slot's scalar, whose public half - // Rust derived. A missing seed throws: unsettled. - val pair = IdentityNative.deriveIdentityKeyPairWithResolver( - networkOrd = network.ffiValue, - walletId = walletId, - resolverHandle = mnemonicResolverHandle, - identityIndex = identityIndex, - keyIndex = keyIndex, - ) - val derivedPrivate = pair.getOrNull(0) - try { - check(pair.size == 2) { "keypair derive returned ${pair.size} elements" } - if (derivedPublicKeyMatches(pair[1], publicKeyData, keyType)) { - return@runBlocking StoredKeyVerdict.KEPT - } - // A closed authentication window throws here: unsettled. A blob - // nothing opens cannot sign at all; a repair replaces it. - val stored = walletStorage.retrievePrivateKey(pubkeyHex) - ?: return@runBlocking StoredKeyVerdict.KEPT - val isSlotScalar = try { - java.security.MessageDigest.isEqual(stored, derivedPrivate) - } finally { - stored.fill(0) - } - when { - !isSlotScalar -> StoredKeyVerdict.KEPT - walletStorage.deletePrivateKeyIfUnchanged(pubkeyHex, blob) -> StoredKeyVerdict.DISCARDED - else -> throw IllegalStateException( - "the stored scalar of $pubkeyHex changed while it was checked", - ) - } - } finally { - derivedPrivate?.fill(0) - } - } - internal companion object { /** Matches `WalletStorage`'s private `PRIVKEY_PREFIX`. */ const val PRIVKEY_IDENTIFIER_PREFIX = "privkey." diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt index c6bd93701e8..318c3700ee1 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/security/WalletStorage.kt @@ -6,7 +6,6 @@ import android.security.keystore.KeyPermanentlyInvalidatedException import android.security.keystore.UserNotAuthenticatedException import android.util.Log import androidx.datastore.core.DataStore -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit @@ -1368,52 +1367,26 @@ class WalletStorage( private suspend fun deletePrivateKeysLocked(pubkeyHexes: Collection) { if (pubkeyHexes.isEmpty()) return val normalized = pubkeyHexes.map { it.lowercase() }.toSet() - store.edit { prefs -> removePrivateKeyEntries(prefs, normalized) } - } - - /** - * Delete [pubkeyHex]'s private key only while its stored blob is still - * [expectedBlob] (from [storedPrivateKeyBlob]), comparing and deleting in - * one DataStore edit under one [privateKeyMutex] hold, so a repair or - * add-key store that replaced the blob after the caller judged it is - * never deleted. Returns whether it deleted. - */ - suspend fun deletePrivateKeyIfUnchanged(pubkeyHex: String, expectedBlob: String): Boolean = - privateKeyMutex.withLock { - val normalized = pubkeyHex.lowercase() - var deleted = false - store.edit { prefs -> - if (prefs[privateKeyKey(normalized)] == expectedBlob) { - removePrivateKeyEntries(prefs, setOf(normalized)) - deleted = true - } + store.edit { prefs -> + for (pubkeyHex in normalized) { + prefs.remove(privateKeyKey(pubkeyHex)) + prefs.remove(privateKeyFingerprintKey(pubkeyHex)) + prefs.remove(privateKeyAliasKey(pubkeyHex)) } - deleted - } - - /** The encoded blob stored for [pubkeyHex], or null when none is. */ - suspend fun storedPrivateKeyBlob(pubkeyHex: String): String? = - store.data.first()[privateKeyKey(pubkeyHex.lowercase())] - - private fun removePrivateKeyEntries(prefs: MutablePreferences, normalized: Set) { - for (pubkeyHex in normalized) { - prefs.remove(privateKeyKey(pubkeyHex)) - prefs.remove(privateKeyFingerprintKey(pubkeyHex)) - prefs.remove(privateKeyAliasKey(pubkeyHex)) - } - // Keep every owner index accurate in the same atomic commit: - // a deleted alias must leave all wallets' index sets, or a - // later wallet deletion would "discover" a ghost. - prefs.asMap().keys - .filter { it.name.startsWith(PRIVKEY_OWNERS_PREFIX) } - .forEach { prefKey -> - val setKey = stringSetPreferencesKey(prefKey.name) - val current = prefs[setKey] ?: return@forEach - val next = current - normalized - if (next.size != current.size) { - if (next.isEmpty()) prefs.remove(setKey) else prefs[setKey] = next + // Keep every owner index accurate in the same atomic commit: + // a deleted alias must leave all wallets' index sets, or a + // later wallet deletion would "discover" a ghost. + prefs.asMap().keys + .filter { it.name.startsWith(PRIVKEY_OWNERS_PREFIX) } + .forEach { prefKey -> + val setKey = stringSetPreferencesKey(prefKey.name) + val current = prefs[setKey] ?: return@forEach + val next = current - normalized + if (next.size != current.size) { + if (next.isEmpty()) prefs.remove(setKey) else prefs[setKey] = next + } } - } + } } suspend fun hasPrivateKey(pubkeyHex: String): Boolean = diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt index 4a3a3edd633..1fe2572e8fc 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/wallet/PlatformWalletManager.kt @@ -491,9 +491,6 @@ class PlatformWalletManager( database = database, privateKeyDeriver = deriver, network = network, - // The usability probe behind pendingIdentityKeys: the restart - // rebuild below and a live repair hand-off share it. - isPrivateKeyUsable = { walletStorage.isPrivateKeyDecryptable(it) }, ) CoreChildren(resolver, keySigner, deriver, handler) } catch (constructionFailure: Throwable) { @@ -1198,8 +1195,9 @@ class PlatformWalletManager( // signal recorded before a process death (or a blob stranded by a // Keystore keypair replacement) resurfaces on every launch. try { - // The handler's own usability probe (WalletStorage's CHEAP check). - persistenceHandler.reconstructPendingIdentityKeysFromPersistence() + persistenceHandler.reconstructPendingIdentityKeysFromPersistence( + isPrivateKeyDecryptable = { walletStorage.isPrivateKeyDecryptable(it) }, + ) } catch (cancellation: kotlin.coroutines.cancellation.CancellationException) { // NEVER swallow structured-concurrency cancellation from the // suspend reconstruction — rethrow so a cancelled load propagates diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index ec57701a9b4..ea496d06f04 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -90,16 +90,12 @@ class PlatformWalletPersistenceHandlerTest { } /** A handler over [db] wired to the test decoder — also the suite's "restart" idiom. */ - private fun newHandler( - deriver: PrivateKeyDeriver? = null, - isPrivateKeyUsable: suspend (pubkeyHex: String) -> Boolean = { false }, - ): PlatformWalletPersistenceHandler = + private fun newHandler(deriver: PrivateKeyDeriver? = null): PlatformWalletPersistenceHandler = PlatformWalletPersistenceHandler( db, Dispatchers.Unconfined, deriver, storedTransactionInputs = storedInputs, - isPrivateKeyUsable = isPrivateKeyUsable, ) /** @@ -1589,8 +1585,8 @@ class PlatformWalletPersistenceHandlerTest { // known; the stored row's owner has since been cleared (the shape a // pre-schema-16 row has). That owner's rejection of the entry's own // slot cancels the live entry, but the row with no recorded owner - // keeps its breadcrumb, so the repair stays open with its owner - // unknown: the same entry a restart rebuilds from the row. + // keeps its breadcrumb, so a restart rebuilds the repair with its + // owner unknown, and that entry no rejection cancels. handler = newHandler(ThrowingDeriver()) val identityId = ByteArray(32) { 22 } seedIdentity(identityId) @@ -1602,22 +1598,18 @@ class PlatformWalletPersistenceHandlerTest { assertTrue(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) rejectIdentityKey(pubkey, identityId) - val kept = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertFalse(kept.ownerKnown) - assertEquals(walletId.toHex(), kept.walletIdHex) + assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) assertEquals(5, dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertFalse(rebuilt.ownerKnown) + assertEquals(walletId.toHex(), rebuilt.walletIdHex) + // Now unknown, the owner's rejection no longer cancels it. rejectIdentityKey(pubkey, identityId) - assertEquals(kept, handler.pendingIdentityKeys.value[pubkey.toHex()]) - - val restarted = newHandler() - restarted.reconstructPendingIdentityKeysFromPersistence( - isPrivateKeyDecryptable = { false }, - nowMs = kept.failedAtMs, - reason = kept.reason, - ) - assertEquals(kept, restarted.pendingIdentityKeys.value[pubkey.toHex()]) + assertEquals(rebuilt, handler.pendingIdentityKeys.value[pubkey.toHex()]) } /** Seed the wallet + identity rows a public-key row FKs onto, under [wallet]. */ @@ -1740,11 +1732,11 @@ class PlatformWalletPersistenceHandlerTest { } @Test - fun aCancelledRepairPassesToAnotherRowOfTheSamePublicKey() = runTest { + fun aCancelledRepairIsRemovedAndARestartRebuildsAnotherRowOfTheSameKey() = runTest { // Same two scopes, but the pending map holds X's repair. A's rejection - // of (3, 5) cancels X's repair; Y's row is still an unresolved - // derivation of the key, so the repair passes to it instead of - // vanishing until the next restart. A rolled-back round does neither. + // of (3, 5) cancels X's repair and nothing more (a rolled-back round + // not even that). Y's row still owes a derivation of the key; the + // live map does not pick it up, but the next restart rebuilds it. handler = newHandler(ThrowingDeriver()) val walletA = walletId val walletB = ByteArray(32) { 8 } @@ -1762,39 +1754,25 @@ class PlatformWalletPersistenceHandlerTest { assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) rejectIdentityKey(pubkey, identityX, from = walletA) - val passed = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(walletB.toHex(), passed.walletIdHex) - assertEquals(identityY.toBase58String(), passed.identityIdBase58) - assertEquals(2, passed.identityIndex) - assertEquals(9, passed.keyIndex) - assertTrue(passed.ownerKnown) - assertEquals(held.reason, passed.reason) - - // B's rejection of Y's own slot then cancels the last repair. - rejectIdentityKey(pubkey, identityY, from = walletB, slot = intArrayOf(2, 9)) assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) + + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + assertEquals(identityY.toBase58String(), rebuilt.identityIdBase58) + assertEquals(walletB.toHex(), rebuilt.walletIdHex) + assertEquals(2, rebuilt.identityIndex) + assertEquals(9, rebuilt.keyIndex) } /** - * A keystore stand-in for the hand-off and refuted-scalar tests: one - * scalar per public key (the shared `privkey.` alias), persist-time - * stores that fail while [failStores] is set, force repairs that record - * their slot, and a verdict on stored scalars scripted by [wrongScalars] - * (a wrong scalar is deleted as it is judged, unless [failDelete]). + * A keystore stand-in: one scalar per public key (the shared + * `privkey.` alias), and persist-time stores that fail while + * [failStores] is set. */ private class ScriptedDeriver : PrivateKeyDeriver { var failStores = true val stored = mutableSetOf() - val forceCalls = mutableListOf>() - - /** When set, a force repair at any other (wallet hex, slot) derives another key and throws. */ - var reproducingSlot: Triple? = null - - /** Keys whose stored scalar is the refuted slot's and does not reproduce them. */ - val wrongScalars = mutableSetOf() - val verdicts = mutableListOf>() - var verdictThrows = false - val deleted = mutableListOf() override fun deriveAndStore( walletId: ByteArray, @@ -1805,20 +1783,7 @@ class PlatformWalletPersistenceHandlerTest { force: Boolean, ): DerivedKeyStoreResult? { val hex = publicKeyData.toHex() - if (force) { - val slot = Triple(walletId.toHex(), identityIndex, keyIndex) - forceCalls += slot - reproducingSlot?.let { expected -> - if (slot != expected) { - throw org.dashfoundation.dashsdk.security.IdentityKeyDerivationMismatchException( - "slot $slot does not derive $hex", - ) - } - } - stored += hex - return DerivedKeyStoreResult("privkey.$hex", wasNewlyCreated = true) - } - if (failStores) return null + if (failStores && !force) return null return DerivedKeyStoreResult("privkey.$hex", wasNewlyCreated = stored.add(hex)) } @@ -1826,71 +1791,18 @@ class PlatformWalletPersistenceHandlerTest { pubkeyHexes: Collection, excludingWalletId: ByteArray, ): Set = emptySet() - - var failDelete = false - - override fun discardStoredKeyRefutedBy( - walletId: ByteArray, - publicKeyData: ByteArray, - identityIndex: Int, - keyIndex: Int, - keyType: Int, - ): StoredKeyVerdict { - val hex = publicKeyData.toHex() - verdicts += Triple(walletId.toHex(), identityIndex, keyIndex) - if (verdictThrows) throw IllegalStateException("authentication window closed") - return when { - hex !in stored -> StoredKeyVerdict.ABSENT - hex !in wrongScalars -> StoredKeyVerdict.KEPT - failDelete -> throw IllegalStateException("simulated DataStore edit failure") - else -> { - deleted += hex - stored -= hex - StoredKeyVerdict.DISCARDED - } - } - } } - /** A handler over [deriver] whose usability probe is [deriver]'s store. */ - private fun scriptedHandler(deriver: ScriptedDeriver): PlatformWalletPersistenceHandler = - newHandler(deriver, isPrivateKeyUsable = { it in deriver.stored }) - /** What a restart over [deriver]'s store rebuilds for [pubkey]. */ - private suspend fun rebuiltAfterRestart(deriver: ScriptedDeriver, pubkey: ByteArray): PlatformWalletPersistenceHandler.PendingIdentityKey? { - val restarted = scriptedHandler(deriver) - restarted.reconstructPendingIdentityKeysFromPersistence() + private suspend fun rebuiltAfterRestart( + deriver: ScriptedDeriver, + pubkey: ByteArray, + ): PlatformWalletPersistenceHandler.PendingIdentityKey? { + val restarted = newHandler(deriver) + restarted.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { it in deriver.stored }) return restarted.pendingIdentityKeys.value[pubkey.toHex()] } - @Test - fun aCancelledRepairPassesToNoRowWhoseScalarIsUsable() = runTest { - // Row Y holds a usable scalar of the key; row X's derive failed, so - // the map holds X's repair. A's rejection of X's slot cancels it, and - // Y owes no repair: the restart rebuild skips a usable row, and so - // does the hand-off, which leaves no entry. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletA = walletId - val walletB = ByteArray(32) { 9 } - val identityX = ByteArray(32) { 29 } - val identityY = ByteArray(32) { 30 } - val pubkey = ByteArray(33) { 29 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - deriver.failStores = false - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - deriver.failStores = true - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - val dao = db.publicKeyDao() - assertNotNull(dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.privateKeyKeychainIdentifier) - assertEquals(identityX.toBase58String(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.identityIdBase58) - - rejectIdentityKey(pubkey, identityX, from = walletA) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - assertNull(rebuiltAfterRestart(deriver, pubkey)) - } - @Test fun aRejectionOfAnotherIdentitysRowAtTheSameSlotKeepsTheRepair() = runTest { // Identities X and Y both carry the key at (3, 5) under wallet A; row @@ -1898,7 +1810,7 @@ class PlatformWalletPersistenceHandlerTest { // Y's repair. A rejection persisted for X's row refutes X's row, not // the repair Y owes at the same coordinates. val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) + handler = newHandler(deriver) val walletA = walletId val walletB = ByteArray(32) { 10 } val identityX = ByteArray(32) { 31 } @@ -1920,337 +1832,42 @@ class PlatformWalletPersistenceHandlerTest { assertNull(dao.getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) assertEquals(5, dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.derivationKeyIndex) - // Y's own rejection cancels it, and the repair passes to Z. + // Y's own rejection cancels it. rejectIdentityKey(pubkey, identityY, from = walletA) - val passed = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(identityZ.toBase58String(), passed.identityIdBase58) - assertEquals(rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = passed.reason, failedAtMs = passed.failedAtMs), passed) - } - - @Test - fun aRepairDerivesAtTheRowItsPendingEntryNames() = runTest { - // Rows X (3, 5) under A and Y (2, 9) under B; the map holds Y's - // repair. X is the first row with breadcrumbs, but the repair under - // B derives at Y's slot, the one its entry names. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletA = walletId - val walletB = ByteArray(32) { 11 } - val identityX = ByteArray(32) { 34 } - val identityY = ByteArray(32) { 35 } - val pubkey = ByteArray(33) { 34 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - assertEquals(identityY.toBase58String(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.identityIdBase58) - - handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }) - assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - @Test - fun aRepairDerivesAtTheLegacyRowARebuiltEntryNames() = runTest { - // X (wallet A, (3, 5)) is the first row of the key and holds a usable - // scalar. Y's row was written before schema 16 (no recorded owner), - // so the restart rebuild files Y's repair under its identity's wallet - // B with the owner unknown. A repair under B derives at Y's slot, the - // one the entry names; no row records B as its owner, so without the - // entry it would derive X's slot under B's seed, which does not - // reproduce the key. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletA = walletId - val walletB = ByteArray(32) { 18 } - val identityX = ByteArray(32) { 55 } - val identityY = ByteArray(32) { 56 } - val pubkey = ByteArray(33) { 55 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - deriver.failStores = false - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - deriver.failStores = true - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - val dao = db.publicKeyDao() - val legacy = dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!! - dao.update(legacy.copy(derivationWalletId = null)) - - handler = scriptedHandler(deriver) - handler.reconstructPendingIdentityKeysFromPersistence() - val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(identityY.toBase58String(), rebuilt.identityIdBase58) - assertEquals(walletB.toHex(), rebuilt.walletIdHex) - assertFalse(rebuilt.ownerKnown) - - deriver.reproducingSlot = Triple(walletB.toHex(), 2, 9) - assertEquals( - "privkey.${pubkey.toHex()}", - handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }), - ) - assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) } - @Test - fun aRepairUnderAnotherWalletDerivesAtTheRowThatWalletDerived() = runTest { - // The map holds Y's repair, filed under B, but the host repairs under - // A (the wallet whose key-health view is open): the repair derives at - // X's slot, the one A derived, not at Y's slot under A's seed. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletA = walletId - val walletB = ByteArray(32) { 16 } - val identityX = ByteArray(32) { 51 } - val identityY = ByteArray(32) { 52 } - val pubkey = ByteArray(33) { 51 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - assertEquals(walletB.toHex(), handler.pendingIdentityKeys.value[pubkey.toHex()]!!.walletIdHex) - - assertEquals( - "privkey.${pubkey.toHex()}", - handler.repairIdentityKeyDurably(walletA, pubkey, verifyRecoverable = { true }), - ) - assertEquals(listOf(Triple(walletA.toHex(), 3, 5)), deriver.forceCalls) - } - - @Test - fun aRepairWithNoPendingEntryDerivesAtTheRowItsWalletDerived() = runTest { - // No repair is pending; X (under A) is the first row with breadcrumbs, - // but a repair under B derives at Y's slot, the one B derived. - val deriver = ScriptedDeriver() - deriver.failStores = false - handler = scriptedHandler(deriver) - val walletB = ByteArray(32) { 17 } - val identityX = ByteArray(32) { 53 } - val identityY = ByteArray(32) { 54 } - val pubkey = ByteArray(33) { 53 } - seedIdentityUnder(walletId, identityX) - seedIdentityUnder(walletB, identityY) - upsertKeyAt(walletId, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - - handler.repairIdentityKeyDurably(walletB, pubkey, verifyRecoverable = { true }) - assertEquals(listOf(Triple(walletB.toHex(), 2, 9)), deriver.forceCalls) - } - - /** Rows X (wallet A, (3, 5)) and Y (wallet B, (2, 9)) of one key whose stored scalar is the one at X's slot. */ - private suspend fun seedTwoRowsSharingAWrongScalar( - deriver: ScriptedDeriver, - walletB: ByteArray, - identityX: ByteArray, - identityY: ByteArray, - pubkey: ByteArray, - ) { - seedIdentityUnder(walletId, identityX) - seedIdentityUnder(walletB, identityY) - deriver.failStores = false - upsertKeyAt(walletId, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - deriver.wrongScalars += pubkey.toHex() - } - - private suspend fun identifiersOf(vararg identities: ByteArray): List = - identities.map { - db.publicKeyDao().getByIdentityAndKeyId(it.toBase58String(), 0)!!.privateKeyKeychainIdentifier - } - private suspend fun keyIndexOf(identityId: ByteArray): Int? = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex @Test - fun aRejectionDeletesAStoredScalarThatDoesNotReproduceTheKey() = runTest { - // An older SDK guessed (3, 5) for an external key and the persist-time - // store kept the scalar derived there; row Y of the key shares the - // alias. Rust's rejection of (3, 5) refutes the breadcrumb, and the - // stored scalar is the slot's, which does not reproduce the key: it is - // deleted, no row names it, and Y's breadcrumb owes the repair, as a - // restart rebuilds it. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletB = ByteArray(32) { 12 } - val identityX = ByteArray(32) { 36 } - val identityY = ByteArray(32) { 37 } - val pubkey = ByteArray(33) { 36 } - val hex = pubkey.toHex() - seedTwoRowsSharingAWrongScalar(deriver, walletB, identityX, identityY, pubkey) - assertEquals(listOf("privkey.$hex", "privkey.$hex"), identifiersOf(identityX, identityY)) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - - // A rolled-back round never reaches the check. - rejectIdentityKey(pubkey, identityX, round = false) - assertTrue(deriver.deleted.isEmpty()) - assertEquals(listOf("privkey.$hex", "privkey.$hex"), identifiersOf(identityX, identityY)) - - rejectIdentityKey(pubkey, identityX) - assertEquals(listOf(Triple(walletId.toHex(), 3, 5)), deriver.verdicts) - assertEquals(listOf(hex), deriver.deleted) - assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) - assertNull(keyIndexOf(identityX)) - val seeded = handler.pendingIdentityKeys.value[hex]!! - assertEquals(identityY.toBase58String(), seeded.identityIdBase58) - assertEquals(PlatformWalletPersistenceHandler.REFUTED_SCALAR_REASON, seeded.reason) - assertEquals( - rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = seeded.reason, failedAtMs = seeded.failedAtMs), - seeded, - ) - } - - @Test - fun aRejectionWhoseCommitFailsAfterTheDeleteIsSettledByTheNextOne() = runTest { - // The wrong scalar is deleted as it is judged, inside the round's - // transaction; a later row's foreign key then fails the commit. The - // delete stands (the scalar was wrong whatever Room does), the rows - // keep their breadcrumb, and the next rejection finds nothing stored - // and applies. - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletB = ByteArray(32) { 14 } - val identityX = ByteArray(32) { 46 } - val identityY = ByteArray(32) { 47 } - val pubkey = ByteArray(33) { 46 } - val hex = pubkey.toHex() - seedTwoRowsSharingAWrongScalar(deriver, walletB, identityX, identityY, pubkey) - - handler.onChangesetBegin(walletId) - handler.onPersistIdentityKeyUpsert( - walletId, identityX, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(3, 5), - ) - handler.onPersistIdentityKeyUpsert( - walletId, ByteArray(32) { 99 }, 0, 0, 0, 0, false, false, 0, - ByteArray(33) { 99 }, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - ) - assertTrue("not before the round ends", deriver.deleted.isEmpty()) - assertEquals(1, handler.onChangesetEnd(walletId, success = true)) - assertEquals(listOf(hex), deriver.deleted) - assertEquals(5, keyIndexOf(identityX)) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - - rejectIdentityKey(pubkey, identityX) - assertEquals(listOf(hex), deriver.deleted) - assertNull(keyIndexOf(identityX)) - assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) - val seeded = handler.pendingIdentityKeys.value[hex]!! - assertEquals(identityY.toBase58String(), seeded.identityIdBase58) - assertEquals( - rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = seeded.reason, failedAtMs = seeded.failedAtMs), - seeded, - ) - } - - @Test - fun aRejectionWhoseScalarDeleteFailsIsNotApplied() = runTest { - // Y stored the key's scalar; X's own derive failed, so the map holds - // X's repair. The scalar is the one at X's refuted slot, but deleting - // it fails: the rejection is not applied, so X keeps its breadcrumb and - // repair, Y its identifier, and the next rejection retries. + fun aRejectionClearsTheBreadcrumbAndLeavesTheStoredScalar() = runTest { + // The rejection is applied as Rust sent it: the owner's breadcrumb at + // the refuted slot goes. Whatever scalar is stored for the key stays + // (a repair or a re-derive replaces it), and with it the identifier. val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val walletB = ByteArray(32) { 15 } - val identityX = ByteArray(32) { 48 } - val identityY = ByteArray(32) { 49 } - val pubkey = ByteArray(33) { 48 } - val hex = pubkey.toHex() - seedIdentityUnder(walletId, identityX) - seedIdentityUnder(walletB, identityY) deriver.failStores = false - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - deriver.failStores = true - upsertKeyAt(walletId, identityX, pubkey, 3, 5) - val held = handler.pendingIdentityKeys.value[hex]!! - assertEquals(identityX.toBase58String(), held.identityIdBase58) - deriver.wrongScalars += hex - deriver.failDelete = true - - rejectIdentityKey(pubkey, identityX) - rejectIdentityKey(pubkey, identityX, round = null) - assertTrue(deriver.deleted.isEmpty()) - assertEquals(5, keyIndexOf(identityX)) - assertEquals(listOf(null, "privkey.$hex"), identifiersOf(identityX, identityY)) - assertEquals(held, handler.pendingIdentityKeys.value[hex]) - - deriver.failDelete = false - rejectIdentityKey(pubkey, identityX) - assertEquals(listOf(hex), deriver.deleted) - assertNull(keyIndexOf(identityX)) - assertEquals(listOf(null, null), identifiersOf(identityX, identityY)) - val passed = handler.pendingIdentityKeys.value[hex]!! - assertEquals(identityY.toBase58String(), passed.identityIdBase58) - assertEquals(held.reason, passed.reason) - assertEquals( - rebuiltAfterRestart(deriver, pubkey)!!.copy(reason = passed.reason, failedAtMs = passed.failedAtMs), - passed, - ) - } - - @Test - fun aRejectionKeepsAStoredScalarThatReproducesTheKeyOrCannotBeChecked() = runTest { - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 38 } val pubkey = ByteArray(33) { 38 } - val hex = pubkey.toHex() seedIdentityUnder(walletId, identityId) - deriver.failStores = false upsertKeyAt(walletId, identityId, pubkey, 3, 5) - // The check cannot be settled (no seed, closed authentication - // window): the rejection is not applied, so the breadcrumb stays for - // a later rejection to check again. - deriver.verdictThrows = true - deriver.wrongScalars += hex rejectIdentityKey(pubkey, identityId, round = null) - rejectIdentityKey(pubkey, identityId) - assertEquals(5, keyIndexOf(identityId)) - assertEquals(listOf("privkey.$hex"), identifiersOf(identityId)) - assertTrue(deriver.deleted.isEmpty()) - - // A scalar that reproduces the key stays; the breadcrumb goes. - deriver.verdictThrows = false - deriver.wrongScalars.clear() - rejectIdentityKey(pubkey, identityId, round = null) - assertNull(keyIndexOf(identityId)) - assertEquals(listOf("privkey.$hex"), identifiersOf(identityId)) - assertTrue(deriver.deleted.isEmpty()) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - } - - @Test - fun aStandaloneRejectionDeletesAWrongScalar() = runTest { - val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) - val identityId = ByteArray(32) { 39 } - val pubkey = ByteArray(33) { 39 } - seedIdentityUnder(walletId, identityId) - deriver.failStores = false - upsertKeyAt(walletId, identityId, pubkey, 3, 5) - deriver.wrongScalars += pubkey.toHex() - - rejectIdentityKey(pubkey, identityId, round = null) - assertEquals(listOf(pubkey.toHex()), deriver.deleted) - assertEquals(listOf(null), identifiersOf(identityId)) - // The only row lost its breadcrumb, so no repair is owed. + val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! + assertNull(row.derivationKeyIndex) + assertNull(row.derivationWalletId) + assertEquals("privkey.${pubkey.toHex()}", row.privateKeyKeychainIdentifier) assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - assertNull(rebuiltAfterRestart(deriver, pubkey)) } @Test fun aRepairRecordedAndRejectedInOneRoundIsCancelled() = runTest { - // One round records X's failed derive and then rejects X's slot. The - // key's scalar (stored by another path) is not the slot's, so it - // stays, and only the staged record shows the repair the rejection - // cancels: once the round commits, no repair is left, as a restart - // rebuilds none. + // One round records X's failed derive and then rejects X's slot: the + // cancel applies after the record it follows, so once the round + // commits no repair is left, as a restart rebuilds none. val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 50 } val pubkey = ByteArray(33) { 50 } seedIdentityUnder(walletId, identityId) @@ -2274,39 +1891,13 @@ class PlatformWalletPersistenceHandlerTest { assertNull(rebuiltAfterRestart(deriver, pubkey)) } - @Test - fun aRejectionWithNoRepairToCancelReadsNoOtherRow() = runTest { - // No entry for the key and no scalar to discard: the rejection - // changes no pending state, so it does not scan the key's rows (the - // usability probe would run for row Y's identifier). - val deriver = ScriptedDeriver() - var probes = 0 - handler = newHandler(deriver, isPrivateKeyUsable = { probes++; it in deriver.stored }) - val walletA = walletId - val walletB = ByteArray(32) { 13 } - val identityX = ByteArray(32) { 40 } - val identityY = ByteArray(32) { 41 } - val pubkey = ByteArray(33) { 40 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - deriver.failStores = false - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - - rejectIdentityKey(pubkey, identityX, from = walletA) - rejectIdentityKey(pubkey, identityX, from = walletA, round = null) - assertEquals(0, probes) - assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) - } - @Test fun aStandaloneUpsertWhoseWriteFailsLeavesThePendingMapUnchanged() = runTest { // The identity row is missing, so the row's foreign key fails the // standalone write. Neither the recorded failure nor the clear of an // existing repair may be published for a row that never landed. val deriver = ScriptedDeriver() - handler = scriptedHandler(deriver) + handler = newHandler(deriver) handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) val missing = ByteArray(32) { 42 } val pubkey = ByteArray(33) { 42 } @@ -2335,7 +1926,7 @@ class PlatformWalletPersistenceHandlerTest { // identifier and derivation owner belonged to the old key. val deriver = ScriptedDeriver() deriver.failStores = false - handler = scriptedHandler(deriver) + handler = newHandler(deriver) val identityId = ByteArray(32) { 44 } seedIdentityUnder(walletId, identityId) val oldKey = ByteArray(33) { 44 } @@ -2361,6 +1952,96 @@ class PlatformWalletPersistenceHandlerTest { assertNull(after.derivationKeyIndex) } + @Test + fun aRestartFilesARepairUnderTheBreadcrumbsRecordedOwner() = runTest { + // A's breadcrumb, then a full load through B re-associates the + // identity with B. The rebuilt repair is A's (the recorded owner), + // not the identity's current wallet's: B's rejection of the slot + // keeps it, A's cancels it. + handler = newHandler(ThrowingDeriver()) + val walletA = walletId + val walletB = ByteArray(32) { 19 } + val identityId = ByteArray(32) { 57 } + seedIdentity(identityId) + val key = ByteArray(33) { 57 } + handler.onChangesetBegin(walletA) + handler.onPersistIdentityKeyUpsert( + walletA, identityId, 0, 0, 0, 0, false, false, 0, + key, ByteArray(20), true, walletA, + true, 0, 7, 0, ByteArray(32), null, + ) + handler.onChangesetEnd(walletA, success = true) + seedIdentityUnder(walletB, identityId) + assertTrue(walletB.contentEquals(db.identityDao().getByIdentityId(identityId)!!.walletId)) + + handler = newHandler(ThrowingDeriver()) + handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) + val rebuilt = handler.pendingIdentityKeys.value[key.toHex()]!! + assertEquals(walletA.toHex(), rebuilt.walletIdHex) + assertTrue(rebuilt.ownerKnown) + + rejectIdentityKey(key, identityId, from = walletB, slot = intArrayOf(0, 7)) + assertEquals(rebuilt, handler.pendingIdentityKeys.value[key.toHex()]) + rejectIdentityKey(key, identityId, from = walletA, slot = intArrayOf(0, 7)) + assertNull(handler.pendingIdentityKeys.value[key.toHex()]) + } + + @Test + fun aStandaloneRejectionWhoseWriteFailsKeepsTheRepair() = runTest { + // The identity row is gone (its key row with it), so the rejection's + // standalone insert fails the foreign key: the cancel it carries is + // never published. + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 58 } + seedIdentity(identityId) + val pubkey = ByteArray(33) { 58 } + upsertIdentityKey(pubkey, identityId) + val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! + db.identityDao().deleteByIdentityId(identityId) + assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)) + + val result = handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + pubkey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + rejectedDerivations = intArrayOf(3, 5), + ) + assertEquals(1, result) + assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) + } + + @Test + fun aReplacementPublicKeyDropsTheOldKeysRepairOfThatRowOnceItCommits() = runTest { + // The map holds the old key's repair for (identity, key 0). A new + // public key under the same key id leaves the old key no row to + // repair: its entry goes with the committed write, not before, and a + // rolled-back round keeps it. + handler = newHandler(ThrowingDeriver()) + val identityId = ByteArray(32) { 59 } + seedIdentity(identityId) + val oldKey = ByteArray(33) { 59 } + val newKey = ByteArray(33) { 60 } + upsertIdentityKey(oldKey, identityId) + val held = handler.pendingIdentityKeys.value[oldKey.toHex()]!! + fun upsertNewKey() = handler.onPersistIdentityKeyUpsert( + walletId, identityId, 0, 0, 0, 0, false, false, 0, + newKey, ByteArray(20), false, ByteArray(32), + false, 0, 0, 0, ByteArray(32), null, + ) + + handler.onChangesetBegin(walletId) + upsertNewKey() + handler.onChangesetEnd(walletId, success = false) + assertEquals(held, handler.pendingIdentityKeys.value[oldKey.toHex()]) + + handler.onChangesetBegin(walletId) + upsertNewKey() + assertEquals("not before the round commits", held, handler.pendingIdentityKeys.value[oldKey.toHex()]) + handler.onChangesetEnd(walletId, success = true) + assertNull(handler.pendingIdentityKeys.value[oldKey.toHex()]) + assertTrue(newKey.contentEquals(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.publicKeyData)) + } + @Test fun deriverReturningNullIsAlsoRecordedAsPending() = runTest { handler = PlatformWalletPersistenceHandler( diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt index 0364fa42ea7..157e24e6d76 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/security/WalletStorageUpgradeMatrixTest.kt @@ -609,29 +609,6 @@ class WalletStorageUpgradeMatrixTest { } assertEquals(0, fake.legacyRsaFallbackCalls) // never fell through to the fallback } - - // ── Compare-and-delete ────────────────────────────────────────────── - - /** - * A refuted-scalar delete names the blob it judged: a blob a repair or - * add-key store wrote since is not deleted, the judged one is. - */ - @Test - fun deletePrivateKeyIfUnchangedDeletesOnlyTheJudgedBlob() = runBlocking { - fake.scheme = FakeKeystoreManager.Scheme.CURRENT - storage.storePrivateKey(pub, secret) - val judged = storage.storedPrivateKeyBlob(pub)!! - val replacement = ByteArray(32) { 7 } - storage.storePrivateKey(pub, replacement) - assertTrue(judged != storage.storedPrivateKeyBlob(pub)) - - assertFalse(storage.deletePrivateKeyIfUnchanged(pub, judged)) - assertArrayEquals(replacement, storage.retrievePrivateKey(pub)) - - assertTrue(storage.deletePrivateKeyIfUnchanged(pub, storage.storedPrivateKeyBlob(pub)!!)) - assertNull(storage.storedPrivateKeyBlob(pub)) - assertFalse(storage.hasPrivateKey(pub)) - } } /** From fdf8bf52fd700bdb6af9b628e7180db82518959a Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:30 +0300 Subject: [PATCH 45/72] refactor(platform-wallet): fold fetched identities without the key-row queue or key-origin rejections Trim to what the #5487 scenario tests showed real. Kept: the revision pin; a landed update that meets a cache already at or past its revision leaves the cache as it is and only stores the added keys with their breadcrumbs; the ordered fetch fold shared by refresh, load by index and discovery (an older fetch keeps the cache's revision, keys and disables, limits only rise, the balance follows the proof height); a new identity and its keys stored as one changeset; refresh storing the rows of the keys its fold adds or changes; a buffered key entry without a breadcrumb keeps the earlier one's. Dropped: external key ids (moving to their own PR with #5501), the derivation rejection sets, the pending key-row queue and the constraint tracing. Store errors are returned as on base. The lagging-fetch scenarios are kept as regression tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../src/changeset/changeset.rs | 173 +-- .../src/wallet/identity/network/discovery.rs | 119 +- .../src/wallet/identity/network/key_limits.rs | 5 +- .../identity/network/lagging_fetch_tests.rs | 374 +++++ .../src/wallet/identity/network/loading.rs | 524 +------ .../wallet/identity/network/signing_key.rs | 149 +- .../src/wallet/identity/network/update.rs | 503 ++----- .../state/managed_identity/identity_ops.rs | 1330 ++--------------- .../identity/state/managed_identity/mod.rs | 12 +- .../identity/state/managed_identity/sync.rs | 26 +- .../identity/state/manager/lifecycle.rs | 28 +- 11 files changed, 755 insertions(+), 2488 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c9e896d9ae4..6096b48b0c8 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -40,7 +40,6 @@ use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyID}; use dpp::prelude::{Identifier, Revision}; @@ -995,7 +994,7 @@ impl IdentityEntry { /// from the wallet's mnemonic at the DIP-9 identity authentication /// path — platform-wallet itself never carries or persists the key /// bytes. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyDerivationIndices { /// DIP-9 identity index (hardened). @@ -1007,8 +1006,7 @@ pub struct IdentityKeyDerivationIndices { /// A derivation breadcrumb as the raw `(wallet_id, identity_index, /// key_index)` triple passed to `ManagedIdentity::add_key` / `add_keys`. /// `Some` lets the client re-derive the private key from the wallet seed; -/// `None` gives none (see [`KeyWithBreadcrumb::derivation_rejected`] for -/// whether a stored one is kept or cleared). +/// `None` marks a watch-only key. pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// One public key paired with its derivation breadcrumb — the unit @@ -1019,32 +1017,22 @@ pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// key, and emits a breadcrumb (the DIP-9 coordinates) only when it matches. /// The scalar itself is never carried out — the client derives the key on /// demand from the Keychain seed at the breadcrumb path. `breadcrumb` is -/// `None` for a key this entry gives no coordinates for. +/// `None` for a watch-only key. pub struct KeyWithBreadcrumb { /// The DPP public-key record. pub key: dpp::identity::IdentityPublicKey, - /// Derivation coordinates for re-derivable keys; `None` gives none, its - /// meaning set by `derivation_rejected`. + /// Derivation coordinates for re-derivable keys; `None` for watch-only. pub breadcrumb: Option, - /// With no `breadcrumb`: the derivation coordinates this key was - /// checked at, under the persisting wallet's seed, and found not to come - /// from (a candidate derived there did not reproduce it, or the caller - /// declared the key external), so a stored breadcrumb naming one of them - /// is cleared; any other stored breadcrumb, and every one when the set is - /// empty (the origin is unknown), is kept. See - /// [`IdentityKeyEntry::derivation_rejected`]. - pub derivation_rejected: BTreeSet, } /// A single identity-key entry in an [`IdentityKeysChangeSet`]. /// -/// Carries the DPP public-key record and, when its origin was verified, a -/// breadcrumb pointing at the wallet derivation that produced it. No private -/// material crosses here: the client derives the key on demand from the -/// Keychain seed at the breadcrumb path -/// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). An entry with no -/// `derivation_indices` gives no coordinates; whether a persister then keeps -/// the breadcrumb it holds for the key or clears it is `derivation_rejected`. +/// Carries the DPP public-key record and a breadcrumb pointing at the wallet +/// derivation that produced it. No private material crosses here: the client +/// derives the key on demand from the Keychain seed at the breadcrumb path +/// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). When +/// `derivation_indices` is `None` the key is watch-only from this wallet's +/// point of view. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { @@ -1062,61 +1050,9 @@ pub struct IdentityKeyEntry { /// watched-only identities with no wallet association. pub wallet_id: Option<[u8; 32]>, /// DIP-9 `(identity_index, key_index)` pair the client needs to - /// re-derive the private key. `None` gives none: whether the key is - /// settled as not derived or its origin is unknown here is - /// `derivation_rejected`. + /// re-derive the private key. `None` means "view-only, no + /// private key recoverable". pub derivation_indices: Option, - /// With no `derivation_indices`, what the entry knows about the key's - /// origin. Empty: unknown here (a refresh, a limits update, a disable), - /// so a persister keeps the breadcrumb a row already holds. Otherwise: - /// the coordinates the key was checked at and found not to come from, - /// under the seed of the wallet persisting the entry (a load or discovery - /// derived a candidate there and it did not reproduce the key, or the - /// caller of an update declared the key external), so a persister clears - /// a stored breadcrumb that wallet holds at one of them, a stale inferred - /// one, and keeps any other: only the derivations that were checked are - /// refuted, and the same coordinates under another wallet's seed are - /// another derivation. A set, so entries for one key combine by union - /// and give what applying them one by one gives. Ignored with - /// `derivation_indices`. - #[cfg_attr(feature = "serde", serde(default))] - pub derivation_rejected: BTreeSet, -} - -impl IdentityKeyEntry { - /// When this entry gives no breadcrumb, take what `earlier` (an earlier - /// row for the same key) settled about the key's origin: its breadcrumb, - /// as every persister keeps a stored one, unless one of this entry's own - /// rejections names those coordinates; or its rejections, joined with - /// this entry's, so a later write (a disable, a limits update, another - /// rejection) does not let a persister keep a stale inferred breadcrumb - /// an earlier rejection cleared: combining two entries gives what - /// applying them one after the other gives. A key's origin belongs to one public - /// key, so `earlier` must carry the same public key, and a breadcrumb the - /// same scope. - pub fn inherit_origin(&mut self, earlier: &IdentityKeyEntry) { - let same_key = earlier.public_key_hash == self.public_key_hash - && earlier.public_key.data() == self.public_key.data(); - if self.derivation_indices.is_some() || !same_key { - return; - } - if let Some(indices) = earlier.derivation_indices { - let same_scope = self - .wallet_id - .zip(earlier.wallet_id) - .is_none_or(|(own, other)| own == other); - // This entry's own rejections refute exactly those coordinates. - if same_scope && !self.derivation_rejected.contains(&indices) { - self.wallet_id = earlier.wallet_id; - self.derivation_indices = Some(indices); - self.derivation_rejected = BTreeSet::new(); - } - } else { - // Rejections compose: each clears what it names, in any order. - self.derivation_rejected - .extend(earlier.derivation_rejected.iter().copied()); - } - } } /// Changes to per-identity key storage. @@ -1141,16 +1077,18 @@ impl Merge for IdentityKeysChangeSet { // used by other scalar-entry changesets in this module; the // "don't emit both upsert + remove for the same key in one // mutation" invariant is enforced on the emitter side. A later - // entry that leaves the key's origin unknown takes what the earlier - // one settled for the same public key (its breadcrumb, as every - // persister keeps a stored one, or its rejection): a buffered write - // that only knows the key's new state must neither turn a - // wallet-derived key watch-only nor bring back a breadcrumb a - // rejection cleared. Its own breadcrumb wins, and its own rejection - // wins over an earlier breadcrumb at exactly those coordinates. + // entry without a derivation breadcrumb (a refresh, a limits + // update) keeps the earlier one's, as every persister keeps a + // stored one: a buffered write that only knows the key's new state + // must not turn a wallet-derived key watch-only. for (id, mut entry) in other.upserts { - if let Some(earlier) = self.upserts.get(&id) { - entry.inherit_origin(earlier); + if entry.derivation_indices.is_none() { + if let Some(earlier) = self.upserts.get(&id) { + if earlier.derivation_indices.is_some() { + entry.wallet_id = earlier.wallet_id; + entry.derivation_indices = earlier.derivation_indices; + } + } } self.upserts.insert(id, entry); } @@ -2623,7 +2561,6 @@ mod tests { identity_index: 0, key_index, }), - derivation_rejected: BTreeSet::new(), }; let changeset = |entry: IdentityKeyEntry| { let mut cs = IdentityKeysChangeSet::default(); @@ -2638,66 +2575,10 @@ mod tests { merged.merge(changeset(entry(Some(8), Some(4)))); assert_eq!(merged.upserts[&(id, 3)], entry(Some(8), Some(4))); - // A rejection of other coordinates does not refute the breadcrumb. - let mut elsewhere = entry(Some(9), None); - elsewhere.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 3, - }]); - merged.merge(changeset(elsewhere)); - assert_eq!(merged.upserts[&(id, 3)], entry(Some(9), Some(4))); - - // A rejection of exactly its coordinates clears it. - let mut rejected = entry(Some(9), None); - rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 4, - }]); - merged.merge(changeset(rejected.clone())); - assert_eq!(merged.upserts[&(id, 3)], rejected); - - // Two rejections combine: each still refutes what it names. - let mut first = entry(Some(11), None); - first.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 1, - key_index: 3, - }]); - let mut combined = changeset(first); - combined.merge(changeset(rejected.clone())); - assert_eq!( - combined.upserts[&(id, 3)].derivation_rejected, - BTreeSet::from([ - IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 4, - }, - IdentityKeyDerivationIndices { - identity_index: 1, - key_index: 3, - }, - ]) - ); - - // A later unknown-origin entry (a disable) keeps the rejection. - let later = entry(Some(10), None); - merged.merge(changeset(later.clone())); - let mut kept = later; - kept.derivation_rejected = rejected.derivation_rejected; - assert_eq!(merged.upserts[&(id, 3)], kept); - - // A breadcrumb belongs to one public key: another key under the same - // row id does not take it. - let mut merged = changeset(entry(None, Some(3))); - let mut other = entry(None, None); - other.public_key = IdentityPublicKeyV0 { - id: 3, - data: vec![7; 33].into(), - ..Default::default() - } - .into(); - other.public_key_hash = [7; 20]; - merged.merge(changeset(other.clone())); - assert_eq!(merged.upserts[&(id, 3)], other); + // Nothing to keep: the later entry stands as it is. + let mut merged = changeset(entry(None, None)); + merged.merge(changeset(entry(Some(9), None))); + assert_eq!(merged.upserts[&(id, 3)], entry(Some(9), None)); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs index aa84ad50b7d..699ca64a802 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/discovery.rs @@ -3,7 +3,6 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::Identity; use key_wallet::bip32::ExtendedPrivKey; -use std::collections::BTreeSet; use crate::error::PlatformWalletError; use crate::wallet::identity::network::contact_requests::budget_spent; @@ -75,34 +74,14 @@ fn breadcrumb_decisions( .public_keys() .iter() .map(|(key_id, on_chain_key)| { - // `None` when there is no candidate (its derivation failed) or - // the check failed on an ECDSA key: the origin stays unknown. A - // check that cannot run on another key type (BLS, EdDSA, a script - // hash) settles it as not this wallet's: the wallet only derives - // ECDSA identity keys. - let ecdsa = matches!( - on_chain_key.key_type(), - KeyType::ECDSA_SECP256K1 | KeyType::ECDSA_HASH160 - ); - let checked = candidate_scalars.get(key_id).and_then(|scalar| { - match on_chain_key.validate_private_key_bytes(scalar, network) { - Ok(reproduces) => Some(reproduces), - Err(_) if !ecdsa => Some(false), - Err(_) => None, - } - }); - let reproduces = checked == Some(true); - // A candidate that does not reproduce the key refutes exactly the - // coordinates it was derived at, `(identity_index, key_id)`, which - // clears a stale breadcrumb a client holds there; a breadcrumb - // stored at other coordinates is kept. - let derivation_rejected: BTreeSet<_> = (checked == Some(false)) - .then_some(crate::changeset::IdentityKeyDerivationIndices { - identity_index, - key_index: *key_id, + let reproduces = candidate_scalars + .get(key_id) + .map(|scalar| { + on_chain_key + .validate_private_key_bytes(scalar, network) + .unwrap_or(false) }) - .into_iter() - .collect(); + .unwrap_or(false); let breadcrumb = if reproduces { // The candidate scalar reproduced the on-chain key, so this // wallet owns it: emit the DIP-9 coordinates. The scalar is @@ -127,7 +106,6 @@ fn breadcrumb_decisions( crate::changeset::KeyWithBreadcrumb { key: on_chain_key.clone(), breadcrumb, - derivation_rejected, } }) .collect() @@ -476,6 +454,8 @@ impl IdentityWallet { })?; let is_new = info_guard.identity_manager.identity(&identity_id).is_none(); if is_new { + // Stored below, with its verified keys, as one + // changeset. info_guard.identity_manager.add_identity_unstored( identity.clone(), identity_index, @@ -496,19 +476,22 @@ impl IdentityWallet { // imported identity cannot sign with its HIGH / // CRITICAL authentication keys. A failed persist here // would silently leave the identity watch-only after - // restart, so surface it. - // A fetch older than a known identity's cache stores - // only the keys the cache has, with their disables. - // A known identity is folded as loading and refresh - // fold it (a newer fetch's revision and keys, the - // balance by proof block), stored with its keys, so - // an update is not built from a stale revision. - managed.store_fetched_identity_keys( - &identity, - key_decisions, - block_time, - &self.persister, - )?; + // restart, so surface it. A known identity is folded + // as loading and refresh fold it (a fetch older than + // the cache keeps the cache's revision, keys and + // disables; the balance follows the proof's block). + managed + .store_fetched_identity_keys( + &identity, + key_decisions, + block_time, + &self.persister, + ) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity keys not persisted during discovery: {e}" + )) + })?; } drop(wm_guard); @@ -867,7 +850,6 @@ mod tests { use key_wallet::mnemonic::Mnemonic; use key_wallet::Network; use std::collections::BTreeMap; - use std::collections::BTreeSet; const TEST_MNEMONIC: &str = "abandon abandon abandon abandon abandon abandon \ abandon abandon abandon abandon abandon about"; @@ -983,23 +965,6 @@ mod tests { "reproducible key breadcrumbed" ); assert_eq!(by_id[&1].breadcrumb, None, "foreign key left watch-only"); - assert!(by_id[&0].derivation_rejected.is_empty()); - assert_eq!( - by_id[&1].derivation_rejected, - BTreeSet::from([crate::changeset::IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 1, - }]), - "a checked candidate that does not reproduce the key refutes its coordinates" - ); - - // With no candidate for a key (its derivation failed), its origin - // stays unknown. - scalars.remove(&1); - let decisions = breadcrumb_decisions(&identity, 0, wallet_id, Network::Testnet, &scalars); - let unchecked = decisions.iter().find(|d| d.key.id() == 1).unwrap(); - assert_eq!(unchecked.breadcrumb, None); - assert!(unchecked.derivation_rejected.is_empty()); } /// An on-chain key typed `ECDSA_HASH160` (data = the 20-byte hash of @@ -1042,40 +1007,6 @@ mod tests { ); } - /// A key of a type the wallet never derives (here a script hash), whose - /// check cannot run, is settled as not this wallet's: no breadcrumb, and - /// its candidate's coordinates refuted. - #[test] - fn breadcrumb_decisions_refutes_a_key_type_the_wallet_never_derives() { - let master = test_master(); - let wallet_id = [0xAB; 32]; - let kp = derive_ecdsa_identity_auth_keypair_from_master(&master, Network::Testnet, 0, 2) - .expect("derive"); - let key = IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: 2, - purpose: Purpose::TRANSFER, - security_level: SecurityLevel::CRITICAL, - contract_bounds: None, - key_type: KeyType::BIP13_SCRIPT_HASH, - read_only: false, - data: dpp::platform_value::BinaryData::new(vec![0x11; 20]), - disabled_at: None, - }); - let mut scalars = BTreeMap::new(); - scalars.insert(2u32, kp.private_key); - let identity = identity_with_keys(vec![key]); - - let decisions = breadcrumb_decisions(&identity, 4, wallet_id, Network::Testnet, &scalars); - assert_eq!(decisions[0].breadcrumb, None); - assert_eq!( - decisions[0].derivation_rejected, - BTreeSet::from([crate::changeset::IdentityKeyDerivationIndices { - identity_index: 4, - key_index: 2, - }]), - ); - } - /// Decide key ownership from the candidate's compressed PUBLIC key alone, /// reproducing `IdentityPublicKey::validate_private_key_bytes` without the /// secret scalar: an `ECDSA_SECP256K1` key matches when the on-chain data diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs index 293ec287ad6..003b81803ba 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/key_limits.rs @@ -33,10 +33,7 @@ impl IdentityWallet { /// /// Resolves to the key as stored after the update, which is also laid over the cached /// identity and upserted through the persister, as - /// [`Self::update_identity_with_external_signer`] does for an added key. The key returned is - /// Platform's; the cache and the store hold it folded over the cache's copy (see - /// `ManagedIdentity::replace_key`), which can carry a disable or a higher limit a refresh - /// brought while the update was in flight. + /// [`Self::update_identity_with_external_signer`] does for an added key. pub async fn update_identity_key_limits_with_external_signer( &self, identity_id: &Identifier, diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs new file mode 100644 index 00000000000..f1a82c31ece --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs @@ -0,0 +1,374 @@ +//! Network ordering and the fetch fold, end to end: a load by index or a +//! refresh answered by a node behind the block an update landed in, and a +//! fetch that lands between an update's snapshot and its post-broadcast +//! apply. +//! +//! What is real and what is modelled: +//! - The fetches are real: `load_identity_by_index_from_master` (the FFI +//! `platform_wallet_load_identity_at_index` path DashConnect calls before +//! every update) and `refresh_identity`, answered by mock DAPI expectations. +//! A lagging node is modelled by answering with the identity as it was one +//! block before the update landed. The mock reports every proof at height +//! 0, which stands for "below the height the cache's balance came from". +//! - The update's broadcast is not run: the mock SDK cannot answer +//! `broadcast_and_wait`. What runs instead is the update's post-broadcast +//! local apply, `apply_landed_update`. +//! - The key-limits update's broadcast is not run either; its post-broadcast +//! apply (`ManagedIdentity::replace_key`) is called as is. +//! - The balance a client read after the update (`refresh_identity_balance`) +//! is recorded with `persist_refreshed_balance`, the call that path makes, +//! at height 100 instead of the mock's flat 0. + +use std::sync::Arc; + +use dash_sdk::platform::transition::update_identity_key_limits::raised_key_limits; +use dash_sdk::platform::types::identity::PublicKeyHash; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; +use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; +use dpp::prelude::Identifier; +use dpp::version::PlatformVersion; +use key_wallet::bip32::ExtendedPrivKey; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; + +use super::super::identity_handle::{derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX}; +use super::IdentityWallet; +use crate::events::{EventHandler, PlatformEventHandler}; +use crate::wallet::persister::NoPlatformPersistence; +use crate::{BlockTime, PlatformWallet, PlatformWalletManager}; + +const SEED: [u8; 64] = [7; 64]; +/// Balance before the update, and after it paid its fee. +const BALANCE_BEFORE: u64 = 10_000_000; +const BALANCE_AFTER: u64 = 9_000_000; +/// The block the update landed in; a lagging node answers from the one below. +const LANDED_AT: u64 = 100; + +fn key(id: u32, level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: level, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![id as u8 + 1; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into() +} + +/// The identity on Platform at revision 1: MASTER 0, HIGH 1, CRITICAL 2. +fn identity_at_revision_1() -> Identity { + let mut identity = Identity::default_versioned(PlatformVersion::latest()).unwrap(); + identity.add_public_key(key(0, SecurityLevel::MASTER)); + identity.add_public_key(key(1, SecurityLevel::HIGH)); + identity.add_public_key(key(2, SecurityLevel::CRITICAL)); + identity.set_revision(1); + identity.set_balance(BALANCE_BEFORE); + identity +} + +/// The update every scenario lands: add HIGH key 3, disable key 1. +fn update_payload() -> (Vec, Vec) { + (vec![key(3, SecurityLevel::HIGH)], vec![1]) +} + +/// The identity on Platform once the update landed at revision 2. +fn identity_at_revision_2() -> Identity { + let mut identity = identity_at_revision_1(); + let mut key_1 = identity.public_keys()[&1].clone(); + key_1.set_disabled_at(1_700_000_000_000); + identity.add_public_key(key_1); + identity.add_public_key(key(3, SecurityLevel::HIGH)); + identity.set_revision(2); + identity.set_balance(BALANCE_AFTER); + identity +} + +fn master() -> ExtendedPrivKey { + ExtendedPrivKey::new_master(Network::Testnet, &SEED).unwrap() +} + +/// The unique-hash probe a load by index 0 sends. +fn probe() -> PublicKeyHash { + PublicKeyHash( + derive_identity_auth_key_hash_from_master(&master(), Network::Testnet, 0, MASTER_KEY_INDEX) + .unwrap(), + ) +} + +/// A wallet from `SEED` holding the identity at revision 1 at index 0, as a +/// registration or an earlier load leaves it. +async fn wallet_on(sdk: dash_sdk::Sdk) -> Arc { + struct Events; + impl EventHandler for Events {} + impl PlatformEventHandler for Events {} + let manager = PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoPlatformPersistence), + Arc::new(Events), + ); + let wallet = manager + .create_wallet_from_seed_bytes( + Network::Testnet, + &SEED, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .unwrap(); + let mut wm = manager.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id()) + .unwrap() + .identity_manager + .add_identity( + identity_at_revision_1(), + 0, + wallet.wallet_id(), + &wallet.identity().persister, + ) + .unwrap(); + drop(wm); + wallet +} + +async fn cached(wallet: &IdentityWallet) -> Identity { + let wm = wallet.wallet_manager.read().await; + wm.get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(&Identifier::default()) + .unwrap() + .identity + .clone() +} + +/// What an identity update reads from the cache when it starts: the +/// revision its transition carries (cache + 1, the same in both trees). +async fn update_snapshot(wallet: &IdentityWallet) -> u64 { + cached(wallet).await.revision() + 1 +} + +/// The post-broadcast local apply of an update Drive accepted at +/// `transition_revision`. +async fn land_update( + wallet: &IdentityWallet, + transition_revision: u64, + added: Vec, + disabled: Vec, +) { + wallet + .apply_landed_update( + &Identifier::default(), + Some(0), + transition_revision, + added, + disabled, + ) + .await + .unwrap(); +} + +/// A client read the balance after the update from an up-to-date node. +async fn balance_read_after_update(wallet: &IdentityWallet) { + let mut wm = wallet.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap(); + managed + .persist_refreshed_balance( + BALANCE_AFTER, + BlockTime::new(LANDED_AT, 0, 1_700_000_000_000), + &wallet.persister, + ) + .unwrap(); +} + +#[derive(Debug)] +struct Seen { + revision: u64, + key_1_disabled: bool, + has_key_3: bool, + balance: u64, +} + +fn seen(identity: &Identity) -> Seen { + Seen { + revision: identity.revision(), + key_1_disabled: identity.public_keys()[&1].is_disabled(), + has_key_3: identity.public_keys().contains_key(&3), + balance: identity.balance(), + } +} + +#[derive(Clone, Copy, Debug)] +enum Fetch { + /// `load_identity_by_index_from_master(0)`: DashConnect before an + /// update, pull-to-refresh in Storage Explorer. + Load, + /// `refresh_identity`: the post-trade refresh of the DPNS marketplace. + Refresh, +} + +/// A mock SDK whose one node answers `fetch` with `answer`. +async fn sdk_answering(fetch: Fetch, answer: Identity) -> dash_sdk::Sdk { + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + match fetch { + Fetch::Load => { + sdk.mock() + .expect_fetch(probe(), Some(answer)) + .await + .unwrap(); + } + Fetch::Refresh => { + sdk.mock() + .expect_fetch(Identifier::default(), Some(answer)) + .await + .unwrap(); + } + } + sdk +} + +async fn run_fetch(wallet: &IdentityWallet, fetch: Fetch) { + match fetch { + Fetch::Load => { + wallet + .load_identity_by_index_from_master(0, &master()) + .await + .unwrap() + .unwrap(); + } + Fetch::Refresh => { + wallet + .refresh_identity(&Identifier::default()) + .await + .unwrap(); + } + } +} + +/// A load or refresh answered by a node one block behind an update (the +/// identity at revision 1, after the update landed at 2) keeps the cache at +/// the update: its revision, the disable, the added key and the balance the +/// update left, so the next update carries the revision Drive takes. +#[tokio::test] +async fn should_keep_a_landed_update_when_a_lagging_fetch_answers_older() { + for fetch in [Fetch::Load, Fetch::Refresh] { + let wallet = wallet_on(sdk_answering(fetch, identity_at_revision_1()).await).await; + let wallet = wallet.identity(); + + let transition_revision = update_snapshot(wallet).await; + let (added, disabled) = update_payload(); + land_update(wallet, transition_revision, added, disabled).await; + balance_read_after_update(wallet).await; + let after_update = seen(&cached(wallet).await); + assert_eq!(after_update.revision, 2, "{fetch:?}"); + assert!(after_update.key_1_disabled && after_update.has_key_3); + assert_eq!(after_update.balance, BALANCE_AFTER); + + run_fetch(wallet, fetch).await; + let after_fetch = seen(&cached(wallet).await); + assert_eq!(after_fetch.revision, 2, "{fetch:?}: cache revision kept"); + assert!(after_fetch.key_1_disabled, "{fetch:?}: disabled key stays"); + assert!(after_fetch.has_key_3, "{fetch:?}: added key stays"); + assert_eq!( + after_fetch.balance, BALANCE_AFTER, + "{fetch:?}: balance from the newer proof stays" + ); + // The next update carries cache + 1, which Drive takes: chain + 1. + assert_eq!(after_fetch.revision + 1, 3, "{fetch:?}"); + } +} + +/// A key-limits update raises key 3's budget 1_000 -> 5_000 and expiry +/// T1 -> T2 (no revision is claimed); a load or refresh answered by a node +/// one block behind, with the old limits at the same revision, keeps the +/// raised ones, so the next top-up is computed from what the chain holds. +#[tokio::test] +async fn should_keep_raised_key_limits_when_a_lagging_fetch_answers_older() { + const T1: u64 = 1_800_000_000_000; + const T2: u64 = 1_900_000_000_000; + let limited = + |budget, expires| key(3, SecurityLevel::HIGH).with_limits(Some(budget), Some(expires)); + let mut before = identity_at_revision_1(); + before.add_public_key(limited(1_000, T1)); + + for fetch in [Fetch::Load, Fetch::Refresh] { + let wallet = wallet_on(sdk_answering(fetch, before.clone()).await).await; + let wallet = wallet.identity(); + { + // The key as registered: the cache took it with the update that added it. + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap() + .identity = before.clone(); + } + { + // Post-broadcast apply of `update_identity_key_limits_with_external_signer`. + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap() + .replace_key(limited(5_000, T2), &wallet.persister) + .unwrap(); + } + + run_fetch(wallet, fetch).await; + let held = cached(wallet).await; + let key_3 = held.public_keys()[&3].clone(); + assert_eq!(key_3.total_budget(), Some(5_000), "{fetch:?}: budget kept"); + assert_eq!(key_3.expires_at(), Some(T2), "{fetch:?}: expiry kept"); + // The next top-up of 500 is computed from the cached key: above the + // 5_000 the chain holds, so consensus takes it as a raise. + let next_top_up = raised_key_limits(&held, 3, Some(500), None).unwrap(); + assert_eq!(next_top_up.0, Some(5_500), "{fetch:?}"); + } +} + +/// The update read revision 1 from the cache (transition at 2); while it +/// waits for its result, a load or refresh is answered by an up-to-date +/// node, which already holds the update (revision 2); then the +/// post-broadcast apply runs. The cache stays at the chain's revision, so +/// the next update is not refused, and keeps the on-chain `disabled_at` +/// rather than the local clock's. +#[tokio::test] +async fn should_not_put_the_cache_past_the_chain_when_a_fetch_lands_before_the_apply() { + for fetch in [Fetch::Load, Fetch::Refresh] { + let wallet = wallet_on(sdk_answering(fetch, identity_at_revision_2()).await).await; + let wallet = wallet.identity(); + + let transition_revision = update_snapshot(wallet).await; + assert_eq!(transition_revision, 2); + // ... nonce fetch, broadcast, the block commits at revision 2 ... + run_fetch(wallet, fetch).await; + assert_eq!( + cached(wallet).await.revision(), + 2, + "{fetch:?}: fetch took the landed update" + ); + let (added, disabled) = update_payload(); + land_update(wallet, transition_revision, added, disabled).await; + + let held = cached(wallet).await; + assert_eq!(held.revision(), 2, "{fetch:?}: cache at the chain"); + assert_eq!( + held.public_keys()[&1].disabled_at(), + identity_at_revision_2().public_keys()[&1].disabled_at(), + "{fetch:?}: on-chain disable time kept" + ); + assert!(held.public_keys().contains_key(&3), "{fetch:?}"); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 8f3d2bd7866..02f8a31d799 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -246,8 +246,7 @@ impl IdentityWallet { "Wallet info not found in wallet manager".to_string(), ) })?; - let is_new = info.identity_manager.identity(&identity_id).is_none(); - if is_new { + if info.identity_manager.identity(&identity_id).is_none() { // Stored below, with its verified keys, as one changeset. info.identity_manager.add_identity_unstored( identity.clone(), @@ -263,21 +262,28 @@ impl IdentityWallet { // stale key set (keys added from another device never // appeared, and contact requests referencing them failed // key-index validation). Same fold `refresh_identity` - // does: a fetch older than the cache keeps the cache's - // revision, key set and every disable, and the balance - // follows the proof's block, not the revision. The keys - // and the folded scalar snapshot reach the store together. + // does: a fetch older than the cache (a node behind the + // block an update landed in) keeps the cache's revision, key + // set and every disable, and the balance follows the proof's + // block, not the revision. The keys and the folded scalar + // snapshot reach the store together. managed.status = IdentityStatus::Active; managed.wallet_id = Some(wallet_id); // Breadcrumbs for every re-derivable key (was MASTER-only). A // failed persist would silently leave the identity watch-only // after restart, so surface it rather than swallow. - managed.store_fetched_identity_keys( - &identity, - key_decisions, - block_time, - &self.persister, - )?; + managed + .store_fetched_identity_keys( + &identity, + key_decisions, + block_time, + &self.persister, + ) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity keys not persisted during load: {e}" + )) + })?; // The cache as folded. identity = managed.identity.clone(); } @@ -318,16 +324,13 @@ impl IdentityWallet { /// balance, at any revision, is taken only when the fetch's proof is at a /// block above the cache's balance watermark. A fetch at a lower revision /// than the cache's was answered before an update the cache already - /// holds: the cache keeps its revision, key set and every disable, and - /// takes only a disable or a raised limit the fetch shows - /// ([`ManagedIdentity::take_fetched_identity`]). The row of every key the + /// holds (a node behind the block it landed in): the cache keeps its + /// revision, key set and every disable, and takes only a disable or a + /// raised limit the fetch shows + /// (`ManagedIdentity::take_fetched_identity`). The row of every key the /// fold adds or changes is stored with the scalar snapshot, without a - /// derivation breadcrumb (the persisters keep the one they hold, and a - /// load or discovery gives a new key its own), and so are key rows an - /// earlier refused store queued; when that store fails, the error - /// is returned, memory keeps the fold and the rows stay queued for the - /// identity's next key store (a refresh, load, discovery or landed - /// update; [`ManagedIdentity::store_fetched_identity`]). + /// derivation breadcrumb (the persisters keep the one they hold); a + /// failed store is logged, as the status write it replaces was. /// /// Returns the identity the cache holds afterwards. /// @@ -384,7 +387,11 @@ impl IdentityWallet { })?; if let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) { managed.status = IdentityStatus::Active; - managed.store_fetched_identity(&identity, block_time, &self.persister)?; + if let Err(e) = + managed.store_fetched_identity(&identity, block_time, &self.persister) + { + tracing::error!("Failed to persist changeset: {}", e); + } // The cache as folded. identity = managed.identity.clone(); } @@ -528,485 +535,12 @@ mod tests { use super::super::identity_handle::{ derive_identity_auth_key_hash, derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, }; - use super::super::signing_key::tests::{ - identity, wallet_with_signing_keys_on, CapturingStore, FailingStore, - }; - use super::super::{IdentityDiscoveryOptions, IdentityWallet}; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; - use crate::error::PlatformWalletError; - use crate::wallet::persister::NoPlatformPersistence; - use crate::BlockTime; - use dash_sdk::platform::types::identity::PublicKeyHash; - use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; - use dpp::identity::identity_public_key::accessors::v0::{ - IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, - }; - use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::Identity; - use dpp::identity::{KeyType, Purpose, SecurityLevel}; - use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; - use std::sync::Arc; - - /// Platform's answer in the fold tests: keys 1 and 2 live, key 4 (which - /// the cache never saw), a balance of 777, at `revision`. - fn answer_at(revision: u64) -> Identity { - let mut answer = identity( - Purpose::AUTHENTICATION, - SecurityLevel::HIGH, - KeyType::ECDSA_SECP256K1, - ); - answer.set_revision(revision); - answer.set_balance(777); - answer.add_public_key( - IdentityPublicKeyV0 { - id: 4, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::MEDIUM, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![4; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(), - ); - answer - } - - /// Put the fixture's cached identity at revision 6, with key 1 disabled - /// by an update the answer below 6 predates. - async fn cache_at_6_with_key_1_disabled(wallet: &IdentityWallet) { - let mut wm = wallet.wallet_manager.write().await; - let identity = &mut wm - .get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&Identifier::default()) - .unwrap() - .identity; - identity.set_revision(6); - let mut key_1 = identity.public_keys()[&1].clone(); - key_1.set_disabled_at(9); - identity.add_public_key(key_1); - } - - async fn held(wallet: &IdentityWallet) -> Identity { - let wm = wallet.wallet_manager.read().await; - wm.get_wallet_info(&wallet.wallet_id) - .unwrap() - .identity_manager - .identity(&Identifier::default()) - .unwrap() - .identity - .clone() - } - - /// A refresh answered before an update the cache already took (the - /// cache at 6, Platform's answer at 5) keeps the cache's revision, key - /// set and every disable; an answer at 6 or above replaces the cache but - /// keeps the disables. The balance follows the proof, not the revision: - /// the cache holds none, so every answer's balance is taken. Either way - /// the call returns what the cache holds afterwards. - #[tokio::test] - async fn should_not_take_the_cache_back_with_an_older_refresh() { - let identity_id = Identifier::default(); - for (answered, settled) in [(5, 6), (6, 6), (7, 7)] { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(identity_id, Some(answer_at(answered))) - .await - .unwrap(); - let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - let cached_balance = held(wallet).await.balance(); - assert_ne!(cached_balance, 777); - - let returned = wallet.refresh_identity(&identity_id).await.unwrap(); - let held = held(wallet).await; - assert_eq!(returned, held, "answered at {answered}"); - assert_eq!(held.revision(), settled, "answered at {answered}"); - let older = answered < 6; - assert_eq!(held.balance(), 777, "answered at {answered}"); - assert!( - held.public_keys()[&1].is_disabled(), - "answered at {answered}" - ); - assert_eq!( - held.public_keys().contains_key(&4), - !older, - "answered at {answered}" - ); - } - } - - /// The same fold through a load by index, in memory, in the returned - /// identity and in the key rows it stores, plus the identity's revision. - #[tokio::test] - async fn should_keep_a_newer_cache_when_a_load_answers_older() { - // The probe the loader sends for index 0 of the fixture's seed. - let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); - let probe = derive_load_probe_hash( - ResolvedLoadKeyHashSource::Master(&master), - Network::Testnet, - 0, - ) - .unwrap(); - - for answered in [5, 6] { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe), Some(answer_at(answered))) - .await - .unwrap(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - let cached_balance = held(wallet).await.balance(); - assert_ne!(cached_balance, 777); - store.clear(); - - let returned = wallet - .load_identity_by_index_from_master(0, &master) - .await - .unwrap() - .unwrap(); - let held = held(wallet).await; - assert_eq!(returned, held, "answered at {answered}"); - assert_eq!(held.revision(), 6, "answered at {answered}"); - assert_eq!(store.stored_revision(Identifier::default()), Some(6)); - let older = answered < 6; - assert_eq!(held.balance(), 777, "answered at {answered}"); - assert!( - held.public_keys()[&1].is_disabled(), - "answered at {answered}" - ); - assert_eq!( - held.public_keys().contains_key(&4), - !older, - "answered at {answered}" - ); - - let rows = store.key_rows(); - assert!(!rows.is_empty(), "answered at {answered}"); - for row in &rows { - assert_eq!( - row.public_key, - held.public_keys()[&row.key_id], - "answered at {answered}" - ); - } - assert_eq!( - rows.iter().any(|row| row.key_id == 4), - !older, - "answered at {answered}" - ); - } - } - - /// A load whose store fails returns the failure and queues its key rows, - /// and a refresh, which stores no rows of its own - /// for keys memory already holds, stores them. - #[tokio::test] - async fn should_store_a_failed_load_s_key_rows_with_the_next_refresh() { - let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); - let probe = derive_load_probe_hash( - ResolvedLoadKeyHashSource::Master(&master), - Network::Testnet, - 0, - ) - .unwrap(); - let mut answer = answer_at(7); - let mut key_2 = answer.public_keys()[&2].clone(); - key_2.set_disabled_at(4); - answer.add_public_key(key_2); - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe), Some(answer.clone())) - .await - .unwrap(); - sdk.mock() - .expect_fetch(Identifier::default(), Some(answer)) - .await - .unwrap(); - let store = Arc::new(FailingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - store.arm(); - - let result = wallet.load_identity_by_index_from_master(0, &master).await; - assert!( - matches!(result, Err(PlatformWalletError::PersisterStore(_))), - "{result:?}" - ); - let after = held(wallet).await; - assert_eq!(after.public_keys()[&2].disabled_at(), Some(4)); - assert_eq!(after.revision(), 7); - assert!(after.public_keys().contains_key(&4)); - - store.disarm(); - store.taken().clear(); - wallet - .refresh_identity(&Identifier::default()) - .await - .unwrap(); - let stored = |key_id| store.taken().stored_key(Identifier::default(), key_id); - assert_eq!( - stored(2).map(|row| row.public_key.disabled_at()), - Some(Some(4)) - ); - let key_4 = stored(4).expect("the new key's row is stored"); - assert_eq!(key_4.public_key, held(wallet).await.public_keys()[&4]); - assert_eq!( - store.taken().stored_revision(Identifier::default()), - Some(7) - ); - } - - /// A load that finds a new identity stores it and its keys, with the - /// breadcrumbs verified against the seed, as one changeset: there is no - /// breadcrumb-less snapshot of its keys written ahead of them. A store - /// that takes one write and refuses the next is enough for the load (a - /// second write was the one that could fail after the first committed - /// watch-only rows), and a store that refuses it leaves nothing behind. - #[tokio::test] - async fn should_store_a_newly_loaded_identity_with_its_keys_in_one_changeset() { - let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); - let probe = derive_load_probe_hash( - ResolvedLoadKeyHashSource::Master(&master), - Network::Testnet, - 0, - ) - .unwrap(); - // How many writes the store takes before it refuses; `None` takes - // every write. - for takes in [None, Some(1), Some(0)] { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe), Some(answer_at(7))) - .await - .unwrap(); - let store = Arc::new(FailingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; - let wallet = wallet.identity(); - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .remove_identity(&Identifier::default(), &wallet.persister) - .unwrap(); - } - store.taken().clear(); - if let Some(takes) = takes { - store.arm_after(takes); - } - - let result = wallet.load_identity_by_index_from_master(0, &master).await; - let stores = store.taken().changeset_count(); - if takes == Some(0) { - assert!(result.is_err()); - assert_eq!(stores, 0, "nothing of a refused load is stored"); - continue; - } - result.unwrap(); - assert_eq!(stores, 1, "the identity and its keys go out together"); - assert_eq!( - store.taken().stored_revision(Identifier::default()), - Some(7) - ); - // The fixture's keys are not derived from this seed, so they - // carry no breadcrumb; what matters is that they ride the - // identity's changeset. - assert_eq!(store.taken().key_rows().len(), 3); - } - } - - /// A same-revision answer whose proof is not above the cache's balance - /// watermark (mock answers are at height 0) takes the answer's keys but - /// keeps the cache's balance, which the revision does not order. - #[tokio::test] - async fn should_keep_a_balance_a_refresh_s_proof_does_not_order_after() { - let identity_id = Identifier::default(); - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(identity_id, Some(answer_at(6))) - .await - .unwrap(); - let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - let watermark = BlockTime::new(1, 1, 1); - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap() - .last_updated_balance_block_time = Some(watermark); - } - let cached_balance = held(wallet).await.balance(); - - let returned = wallet.refresh_identity(&identity_id).await.unwrap(); - assert_eq!(returned, held(wallet).await); - assert_eq!(returned.balance(), cached_balance); - assert!(returned.public_keys().contains_key(&4)); - assert!(returned.public_keys()[&1].is_disabled()); - } - - /// A disable a refresh brings, from an answer older or newer than the - /// cache, is stored with its key row, as is a key a newer answer adds. A - /// store that refuses them fails the - /// refresh and queues the row, which the next refresh stores although - /// memory already holds the disable. - #[tokio::test] - async fn should_store_a_refresh_s_disable_or_queue_it_for_the_next_store() { - let identity_id = Identifier::default(); - for revision in [5, 7] { - let mut answer = answer_at(revision); - let mut key_2 = answer.public_keys()[&2].clone(); - key_2.set_disabled_at(4); - answer.add_public_key(key_2); - // Key 1 is disabled in the cache at 9, a placeholder; Platform's - // timestamp is 3. - let mut key_1 = answer.public_keys()[&1].clone(); - key_1.set_disabled_at(3); - answer.add_public_key(key_1); - let mock = || async { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(identity_id, Some(answer.clone())) - .await - .unwrap(); - sdk - }; - - let store = Arc::new(FailingStore::default()); - let wallet = wallet_with_signing_keys_on(mock().await, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - assert_ne!(held(wallet).await.balance(), 777); - store.arm(); - let result = wallet.refresh_identity(&identity_id).await; - assert!( - matches!(result, Err(PlatformWalletError::PersisterStore(_))), - "answered at {revision}: {result:?}" - ); - // Memory holds the fold: the disables, the revision, and the - // answer's balance (the cache holds no watermark), queued for - // the next store with the refused rows. - let after = held(wallet).await; - assert_eq!(after.public_keys()[&2].disabled_at(), Some(4)); - assert_eq!(after.public_keys()[&1].disabled_at(), Some(3)); - assert_eq!(after.revision(), revision.max(6), "answered at {revision}"); - assert_eq!(after.balance(), 777); - store.disarm(); - store.taken().clear(); - wallet.refresh_identity(&identity_id).await.unwrap(); - let after = held(wallet).await; - for (key_id, disabled_at) in [(1, 3), (2, 4)] { - assert_eq!( - after.public_keys()[&key_id].disabled_at(), - Some(disabled_at) - ); - assert_eq!( - store - .taken() - .stored_key(identity_id, key_id) - .map(|row| row.public_key.disabled_at()), - Some(Some(disabled_at)), - "key {key_id} retried at {revision}" - ); - } - - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_with_signing_keys_on(mock().await, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - wallet.refresh_identity(&identity_id).await.unwrap(); - for (key_id, disabled_at) in [(1, 3), (2, 4)] { - assert_eq!( - store - .stored_key(identity_id, key_id) - .map(|row| row.public_key.disabled_at()), - Some(Some(disabled_at)), - "key {key_id} answered at {revision}" - ); - } - // A key a newer answer adds is stored too, without the - // breadcrumb a refresh cannot know; an older answer adds none. - let key_4 = store.stored_key(identity_id, 4); - assert_eq!(key_4.is_some(), revision > 6, "answered at {revision}"); - assert!(key_4.is_none_or(|row| row.derivation_indices.is_none())); - } - } - - /// Discovery folds an identity it already knows the way loading does: an - /// answer at a newer revision brings its revision, keys and balance into - /// the cache and the store; an older one keeps the cache's. - #[tokio::test] - async fn should_fold_a_known_identity_discovery_finds() { - let master = ExtendedPrivKey::new_master(Network::Testnet, &[7; 64]).unwrap(); - let probe = |index| { - derive_identity_auth_key_hash_from_master( - &master, - Network::Testnet, - index, - MASTER_KEY_INDEX, - ) - .unwrap() - }; - let identity_id = Identifier::default(); - for (answered, settled) in [(5, 6), (7, 7)] { - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe(0)), Some(answer_at(answered))) - .await - .unwrap(); - sdk.mock() - .expect_fetch(PublicKeyHash(probe(1)), None::) - .await - .unwrap(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_with_signing_keys_on(sdk, store.clone()).await; - let wallet = wallet.identity(); - cache_at_6_with_key_1_disabled(wallet).await; - assert_ne!(held(wallet).await.balance(), 777); - store.clear(); - - let found = wallet - .discover_from_master( - IdentityDiscoveryOptions { - start_index: Some(0), - gap_limit: 1, - }, - &master, - ) - .await - .unwrap(); - assert!(found.is_empty(), "the identity was already known"); - let held = held(wallet).await; - let older = answered < 6; - assert_eq!(held.revision(), settled, "answered at {answered}"); - assert_eq!(store.stored_revision(identity_id), Some(settled)); - assert_eq!(held.balance(), 777, "answered at {answered}"); - assert!( - held.public_keys()[&1].is_disabled(), - "answered at {answered}" - ); - assert_eq!( - held.public_keys().contains_key(&4), - !older, - "answered at {answered}" - ); - } - } /// English BIP-39 test vector (all-zero entropy). Same fixture the /// FFI-side derive tests and the `identity_handle` tests use, so the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index dc2b987a137..6aefc3fba65 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -117,23 +117,13 @@ pub(super) fn credit_signing_key<'a>( #[cfg(test)] pub(super) mod tests { use super::*; - use crate::changeset::{ - ClientStartState, IdentityKeyEntry, PersistenceError, PlatformWalletChangeSet, - PlatformWalletPersistence, - }; - use crate::wallet::persister::NoPlatformPersistence; - use crate::wallet::platform_wallet::WalletId; - use crate::PlatformWallet; use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use dpp::identity::KeyID; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::version::PlatformVersion; - use std::sync::atomic::{AtomicBool, Ordering}; - use std::sync::{Arc, Mutex}; /// Test signer that can sign with the keys `F` accepts; selection must never sign. pub(crate) struct KeyFilter bool>(pub F); @@ -376,134 +366,54 @@ pub(super) mod tests { /// Records every changeset, so a test can read what reached the store. #[derive(Default)] - pub(crate) struct CapturingStore(Mutex>); + pub(crate) struct CapturingStore( + std::sync::Mutex>, + ); - impl PlatformWalletPersistence for CapturingStore { + impl crate::changeset::PlatformWalletPersistence for CapturingStore { fn store( &self, - _: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { + _: crate::wallet::platform_wallet::WalletId, + changeset: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { self.0.lock().unwrap().push(changeset); Ok(()) } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { + fn flush( + &self, + _: crate::wallet::platform_wallet::WalletId, + ) -> Result<(), crate::changeset::PersistenceError> { Ok(()) } - fn load(&self) -> Result { - Ok(ClientStartState::default()) + fn load( + &self, + ) -> Result + { + Ok(Default::default()) } } impl CapturingStore { - /// Every key row stored, oldest first. - pub(crate) fn key_rows(&self) -> Vec { - self.0 - .lock() - .unwrap() - .iter() - .flat_map(|changeset| changeset.identity_keys.iter()) - .flat_map(|keys| keys.upserts.values().cloned()) - .collect() - } - /// The last key row stored for `key_id`. pub(crate) fn stored_key( &self, identity_id: Identifier, - key_id: KeyID, - ) -> Option { - self.key_rows() - .into_iter() + key_id: dpp::identity::KeyID, + ) -> Option { + self.0 + .lock() + .unwrap() + .iter() .rev() - .find(|row| row.identity_id == identity_id && row.key_id == key_id) - } - - /// The last revision stored for `identity_id`. - pub(crate) fn stored_revision(&self, identity_id: Identifier) -> Option { - self.0.lock().unwrap().iter().rev().find_map(|changeset| { - Some( - changeset - .identities - .as_ref()? - .identities - .get(&identity_id)? - .revision, - ) - }) - } - - pub(crate) fn changeset_count(&self) -> usize { - self.0.lock().unwrap().len() - } - - pub(crate) fn clear(&self) { - self.0.lock().unwrap().clear(); - } - } - - /// Takes every changeset until armed, then refuses each; what it takes is - /// recorded as [`CapturingStore`] records it. - #[derive(Default)] - pub(crate) struct FailingStore( - AtomicBool, - CapturingStore, - AtomicBool, - std::sync::atomic::AtomicUsize, - ); - - impl FailingStore { - /// What the store took, while disarmed. - pub(crate) fn taken(&self) -> &CapturingStore { - &self.1 - } - - pub(crate) fn arm(&self) { - self.0.store(true, Ordering::SeqCst); - } - - pub(crate) fn disarm(&self) { - self.0.store(false, Ordering::SeqCst); - self.2.store(false, Ordering::SeqCst); - } - - /// Take the next `stores` changesets, then refuse each. - pub(crate) fn arm_after(&self, stores: usize) { - self.3.store(stores, Ordering::SeqCst); - self.2.store(true, Ordering::SeqCst); - } - } - - impl PlatformWalletPersistence for FailingStore { - fn store( - &self, - wallet_id: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - if self.0.load(Ordering::SeqCst) { - return Err(PersistenceError::backend("store armed to fail")); - } - if self.2.load(Ordering::SeqCst) { - let left = self.3.load(Ordering::SeqCst); - if left == 0 { - return Err(PersistenceError::backend("store armed to fail")); - } - self.3.store(left - 1, Ordering::SeqCst); - } - self.1.store(wallet_id, changeset) - } - fn flush(&self, _: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - Ok(ClientStartState::default()) + .filter_map(|changeset| changeset.identity_keys.as_ref()) + .find_map(|keys| keys.upserts.get(&(identity_id, key_id)).cloned()) } } - pub(crate) async fn wallet_with_signing_keys() -> Arc { + pub(crate) async fn wallet_with_signing_keys() -> std::sync::Arc { wallet_with_signing_keys_on( dash_sdk::SdkBuilder::new_mock().build().unwrap(), - Arc::new(NoPlatformPersistence), + std::sync::Arc::new(crate::wallet::persister::NoPlatformPersistence), ) .await } @@ -512,14 +422,15 @@ pub(super) mod tests { /// that sets mock expectations first or reads what was persisted. pub(crate) async fn wallet_with_signing_keys_on

( sdk: dash_sdk::Sdk, - persistence: Arc

, - ) -> Arc + persistence: std::sync::Arc

, + ) -> std::sync::Arc where - P: PlatformWalletPersistence + 'static, + P: crate::changeset::PlatformWalletPersistence + 'static, { use crate::events::{EventHandler, PlatformEventHandler}; use crate::PlatformWalletManager; use key_wallet::wallet::initialization::WalletAccountCreationOptions; + use std::sync::Arc; struct Events; impl EventHandler for Events {} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index d2e617b8cfa..ed70fcd7947 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -3,13 +3,11 @@ use async_trait::async_trait; use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentityGettersV0; -use std::collections::BTreeSet; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; -use dpp::identity::KeyID; use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; @@ -22,7 +20,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::changeset::{IdentityKeyDerivationIndices, KeyWithBreadcrumb, PersistenceError}; +use crate::changeset::KeyWithBreadcrumb; use crate::error::PlatformWalletError; use super::signing_key::AvailableSigningKey; @@ -81,27 +79,19 @@ impl IdentityWallet { /// in the typical case). The signer here only signs the update /// transition itself; it does not derive the new keys. /// - /// Every added key is stored as derived from the wallet seed at the - /// identity's index and the key's id. A caller adding a key from - /// outside the wallet (a browser session key, say) declares it through - /// [`Self::update_identity_with_external_signer_at_revision`]'s - /// `external_key_ids`, so it is stored without derivation coordinates. - /// /// CACHE: after a successful broadcast the in-process /// `IdentityManager` takes the update locally (see /// `apply_landed_update`): the cached revision moves to the /// transition's and the added and disabled keys are applied, so a /// follow-up update needs no refresh in between. A cache already - /// refreshed to the transition or past it keeps its keys and stores - /// them, taking from the update only an added key it lacks. A refresh that - /// fetched the identity before the update landed and finishes after - /// this step does not take the cache back: a fetched identity at a - /// lower revision than the cache's keeps the cache's revision, key set - /// and every disable (see `ManagedIdentity::take_fetched_identity`). - /// When the call fails after the broadcast (the proof wait timed out, - /// say), the update may have landed without this step: refresh before - /// the next update. A `Persistence` error means it landed and is in the - /// cache, but was not stored: do not resubmit it. + /// refreshed to the transition or past it holds the update as Platform + /// has it and keeps it. A refresh that fetched the identity before the + /// update landed and finishes after this step does not take the cache + /// back: a fetched identity at a lower revision than the cache's keeps + /// the cache's revision, key set and every disable (see + /// `ManagedIdentity::take_fetched_identity`). When the call fails + /// after the broadcast (the proof wait timed out, say), the update may + /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -116,7 +106,6 @@ impl IdentityWallet { self.update_identity_with_external_signer_at_revision( identity_id, add_public_keys, - &[], disable_public_keys, None, signer, @@ -138,20 +127,10 @@ impl IdentityWallet { /// signed or broadcast, so the revision the caller recorded is the only /// one this update can land at. `None` takes whatever the cache gives, /// as [`Self::update_identity_with_external_signer`] does. - /// - /// `external_key_ids` names the added keys that do not come from the - /// wallet's derivation (a key the caller generated or received, whose - /// private key the wallet cannot derive from its seed). They are stored - /// without derivation coordinates, so no client takes them for keys it - /// can sign with by deriving; every other added key is stored as derived - /// at the identity's index and the key's id. An id that names no added - /// key is refused before anything is signed. - #[allow(clippy::too_many_arguments)] pub async fn update_identity_with_external_signer_at_revision( &self, identity_id: &Identifier, add_public_keys: Vec, - external_key_ids: &[KeyID], disable_public_keys: Vec, revision: Option, signer: &S, @@ -192,15 +171,6 @@ impl IdentityWallet { (identity, index) }; - if let Some(id) = external_key_ids - .iter() - .find(|id| !add_public_keys.iter().any(|key| key.id() == **id)) - { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "external key id {id} names no key this update adds" - ))); - } - // Increment revision for the update transition. let transition_revision = update_revision(identity_id, identity.revision(), revision)?; identity.set_revision(transition_revision); @@ -271,7 +241,6 @@ impl IdentityWallet { identity_index, transition_revision, added_keys_for_local_apply, - external_key_ids, disabled_ids_for_local_apply, ) .await @@ -288,55 +257,32 @@ impl IdentityWallet { /// duplicate. /// - The Swift `PersistentPublicKey` rows never receive /// the new keys (rows arrive via the - /// `IdentityKeysChangeSet` persister callback). + /// `IdentityKeysChangeSet` persister callback that + /// `add_key` fires). /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// - /// `ManagedIdentity::apply_landed_keys` applies the added and - /// disabled keys in memory and stores them with the landed - /// revision as one changeset, under a write lock; the breadcrumb - /// lets the iOS Keychain re-derive the matching private bytes + /// `ManagedIdentity::add_key` handles both the in-memory + /// mutation AND the `IdentityKeysChangeSet` upsert. Run it + /// on each new key under a write lock; the breadcrumb lets + /// the iOS Keychain re-derive the matching private bytes /// from the wallet seed + DIP-9 path. /// - /// A cache already refreshed to `transition_revision` or past it - /// holds the update as Platform has it: the payload is not applied, - /// and the touched keys are stored from the cache's copies (an added - /// key the cache lacks from the payload, taken into the cache too). - /// Either way a store failure is returned as an update that landed - /// but was not stored. - /// - /// An added key named in `external_key_ids` is stored without a - /// breadcrumb and refutes its slot's coordinates on both paths, which - /// clears a breadcrumb a persister holds there (one an older update - /// inferred); every other one with the breadcrumb of its slot, - /// `(wallet_id, identity_index, key_id)`. + /// A cache already refreshed to `transition_revision` or past it (a + /// fetch answered between the update's snapshot and this step) holds the + /// update as Platform has it, on-chain `disabled_at` included, and + /// whatever later updates did: the payload is not applied over it. A + /// refresh stores the keys it adds without a breadcrumb, so the added + /// keys are stored here from the cache's own copies with the breadcrumb + /// the update gives them. async fn apply_landed_update( &self, identity_id: &Identifier, identity_index: Option, transition_revision: u64, added_keys: Vec, - external_key_ids: &[KeyID], disabled_ids: Vec, ) -> Result<(), PlatformWalletError> { - let added_breadcrumb = |id: KeyID| { - identity_index - .filter(|_| !external_key_ids.contains(&id)) - .map(|idx| (self.wallet_id, idx, id)) - }; - // A declared external key refutes the coordinates an update would - // have inferred for it (older code stored them), so a persister clears - // exactly that breadcrumb. - let external_slot = |id: KeyID| -> BTreeSet { - identity_index - .filter(|_| external_key_ids.contains(&id)) - .map(|identity_index| IdentityKeyDerivationIndices { - identity_index, - key_index: id, - }) - .into_iter() - .collect() - }; let mut wm = self.wallet_manager.write().await; let info = wm.get_wallet_info_mut(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -347,92 +293,60 @@ impl IdentityWallet { return Ok(()); }; - // A cache refreshed to the transition or past it already holds - // this update's keys as Platform has them (on-chain `disabled_at` - // included) and whatever later updates did to them: applying the - // payload would take those back. A refresh stores the keys it adds - // or changes without breadcrumbs, though, so the touched keys are - // stored here from the cache's own copies, with the breadcrumbs the - // path below would give them: an added key its slot's unless the - // caller declared it external, a disabled key none (the persisters - // keep the one they hold). - // A key the update both adds and disables is taken as added, on - // both paths. - let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let disabled: Vec = disabled_ids - .into_iter() - .filter(|id| !added_ids.contains(id)) - .collect(); - if managed.identity.revision() >= transition_revision { - let mut keys = Vec::new(); - // An added key the cache lacks is stored, and taken into the - // cache, as the update registered it. - for key in &added_keys { - let id = key.id(); - keys.push(KeyWithBreadcrumb { + let keys = added_keys + .into_iter() + .map(|key| KeyWithBreadcrumb { + breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), key: managed .identity .public_keys() - .get(&id) - .unwrap_or(key) - .clone(), - breadcrumb: added_breadcrumb(id), - derivation_rejected: external_slot(id), - }); - } - for &id in &disabled { - let Some(key) = managed.identity.public_keys().get(&id) else { - tracing::warn!( - identity = %identity_id, - key_id = id, - "landed update: a disabled key id is not on the cached identity; skipping", - ); - continue; - }; - keys.push(KeyWithBreadcrumb { - key: key.clone(), - breadcrumb: None, - derivation_rejected: BTreeSet::new(), - }); - } - // Stored with the identity's snapshot even when no touched key - // is in the cache, so the revision reaches the store too. - return managed - .store_keys_with_snapshot(keys, &self.persister) - .map_err(landed_but_not_stored); + .get(&key.id()) + .cloned() + .unwrap_or(key), + }) + .collect(); + return managed.add_keys(keys, &self.persister).map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }); } // The identity is at the transition's revision now, so a // subsequent update must not reuse the pre-broadcast value. managed.identity.set_revision(transition_revision); - // The added and disabled keys and the landed revision go to the - // store as one changeset, which holds the revision even when no key - // row matched. Disabled keys get no breadcrumb (the persisters keep - // the one they hold) and a local wall-clock `disabled_at` placeholder: a later fetch brings - // the on-chain block time into memory and the store. Memory holds - // the whole update even when the store fails, and the rows stay - // queued for the identity's next key store. - let added = added_keys - .into_iter() - .map(|key| { - let breadcrumb = added_breadcrumb(key.id()); - let derivation_rejected = external_slot(key.id()); - KeyWithBreadcrumb { - key, - breadcrumb, - derivation_rejected, - } - }) - .collect(); - let disabled_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); - managed - .apply_landed_keys(added, &disabled, disabled_at, &self.persister) - .map_err(landed_but_not_stored) + for key in added_keys { + let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); + managed + .add_key(key, breadcrumb, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + })?; + } + + if !disabled_ids.is_empty() { + // Disable-side counterpart to the `add_key` loop + // above: stamp `disabled_at` on the matching cached + // keys and fire the persister so the Swift + // `PersistentPublicKey.disabledAt` rows flip without + // a network re-fetch. The local wall-clock timestamp + // is a placeholder — the next Platform refresh + // reconciles it to the authoritative on-chain block + // time. `disable_keys` rebuilds the derivation breadcrumb + // from the managed identity's own wallet slot, so the + // disabled key keeps its private-key linkage. + let disabled_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + managed.disable_keys(&disabled_ids, disabled_at, &self.persister); + } + + Ok(()) } /// Update an identity using an externally-provided identity and signer. @@ -524,17 +438,6 @@ fn update_revision( } } -/// The error for a landed update the wallet could not store. The update is -/// on Platform and in the cache either way, so the caller must not resubmit -/// it. Its key rows stay queued, and the identity's next key store (a -/// refresh, load, discovery or another landed update) carries them. -fn landed_but_not_stored(e: PersistenceError) -> PlatformWalletError { - PlatformWalletError::Persistence(format!( - "identity update landed on Platform and is in the wallet's cache, but the wallet could not \ - store it ({e}); do not resubmit it" - )) -} - #[cfg(test)] mod update_revision_tests { use super::*; @@ -574,9 +477,7 @@ mod update_revision_tests { #[cfg(test)] mod update_identity_at_revision_tests { - use super::super::signing_key::tests::{ - wallet_with_signing_keys_on, CapturingStore, FailingStore, - }; + use super::super::signing_key::tests::{wallet_with_signing_keys_on, CapturingStore}; use super::*; use crate::changeset::PlatformWalletPersistence; use crate::wallet::persister::NoPlatformPersistence; @@ -679,7 +580,6 @@ mod update_identity_at_revision_tests { .update_identity_with_external_signer_at_revision( &identity_id, vec![], - &[], vec![1], Some(expected), &signer, @@ -709,7 +609,6 @@ mod update_identity_at_revision_tests { .update_identity_with_external_signer_at_revision( &identity_id, vec![], - &[], vec![1], Some(5), &signer, @@ -727,6 +626,19 @@ mod update_identity_at_revision_tests { assert!(signer.0.load(Ordering::SeqCst) > 0); } + /// The key the landed-update tests add. + fn key_3() -> IdentityPublicKey { + IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::HIGH, + key_type: KeyType::ECDSA_SECP256K1, + data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), + ..Default::default() + } + .into() + } + #[tokio::test] async fn should_take_a_landed_update_only_into_a_cache_behind_it() { let identity_id = Identifier::default(); @@ -736,7 +648,7 @@ mod update_identity_at_revision_tests { let wallet = wallet_at_revision(4).await; let wallet = wallet.identity(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], &[], vec![]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -746,7 +658,7 @@ mod update_identity_at_revision_tests { let wallet = wallet_at_revision(4).await; let wallet = wallet.identity(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; @@ -754,7 +666,8 @@ mod update_identity_at_revision_tests { assert!(identity.public_keys()[&1].disabled_at().is_some()); // A cache refreshed to 5 or past it already holds the update and - // what came after, and is left as it is. + // what came after, and is left as it is: one past the chain would + // get the next update refused. for cached in [5, 6] { let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); @@ -770,7 +683,7 @@ mod update_identity_at_revision_tests { } let before = cached_identity(wallet, &identity_id).await; wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], &[], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![added.clone()], vec![1]) .await .unwrap(); assert_eq!( @@ -782,44 +695,37 @@ mod update_identity_at_revision_tests { } /// An update at 5 added key 3, a later one at 6 disabled it, and the - /// cache was refreshed to 6 before the confirmation of 5 came back: - /// key 3 stays disabled, and is stored that way with its breadcrumb (a - /// refresh stores its rows without one). A key the update disabled is - /// stored as the refresh has it, with the on-chain `disabled_at` and no - /// breadcrumb, which the persisters keep from the row they hold. + /// cache was refreshed to 6 before the confirmation of 5 came back: key + /// 3 stays disabled, and is stored that way with the breadcrumb the + /// update gives it (a refresh stores its rows without one). A cache at + /// the transition that lacks the added key takes the update's copy. #[tokio::test] - async fn should_store_the_newer_cache_keys_instead_of_an_older_landed_payload() { + async fn should_store_an_added_key_with_its_breadcrumb_when_the_cache_is_ahead() { let identity_id = Identifier::default(); - let added = key_3(); let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(6, store.clone()).await; let wallet = wallet.identity(); + let mut disabled = key_3(); + disabled.set_disabled_at(1); { let mut wm = wallet.wallet_manager.write().await; - let managed = wm - .get_wallet_info_mut(&wallet.wallet_id) + wm.get_wallet_info_mut(&wallet.wallet_id) .unwrap() .identity_manager .managed_identity_mut(&identity_id) - .unwrap(); - let mut disabled = added.clone(); - disabled.set_disabled_at(1); - managed.identity.add_public_key(disabled); - let mut key_1 = managed.identity.public_keys()[&1].clone(); - key_1.set_disabled_at(2); - managed.identity.add_public_key(key_1); + .unwrap() + .identity + .add_public_key(disabled.clone()); } wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![added], &[], vec![1]) + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; assert_eq!(identity.revision(), 6); - assert_eq!(identity.public_keys()[&3].disabled_at(), Some(1)); - assert_eq!(identity.public_keys()[&1].disabled_at(), Some(2)); - + assert_eq!(identity.public_keys()[&3], disabled); let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); - assert_eq!(stored.public_key.disabled_at(), Some(1)); + assert_eq!(stored.public_key, disabled); assert_eq!(stored.wallet_id, Some(wallet.wallet_id)); assert_eq!( stored @@ -827,221 +733,22 @@ mod update_identity_at_revision_tests { .map(|indices| (indices.identity_index, indices.key_index)), Some((0, 3)) ); - let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); - assert_eq!(stored.public_key.disabled_at(), Some(2)); - assert_eq!( - stored.derivation_indices, None, - "a disable says nothing about where the key came from" - ); - } - - /// A store failure after a landed update is returned on both paths, - /// as an update that landed but was not stored, and the cache keeps the - /// whole update in memory. - #[tokio::test] - async fn should_report_a_store_failure_after_a_landed_update() { - let identity_id = Identifier::default(); - // The cache behind the transition takes the payload. - let store = Arc::new(FailingStore::default()); - let wallet = wallet_at_revision_on(4, store.clone()).await; - let wallet = wallet.identity(); - store.arm(); - let error = wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![1]) - .await - .unwrap_err(); - assert!( - matches!(error, PlatformWalletError::Persistence(_)), - "{error:?}" - ); - let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), 5); - assert_eq!(identity.public_keys().get(&3), Some(&key_3())); - assert!(identity.public_keys()[&1].disabled_at().is_some()); - - // A cache at or past it only stores, and a disabled-only failure is - // reported too. - let store = Arc::new(FailingStore::default()); - let wallet = wallet_at_revision_on(6, store.clone()).await; - let wallet = wallet.identity(); - store.arm(); - let error = wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![1]) - .await - .unwrap_err(); - assert!( - matches!(error, PlatformWalletError::Persistence(_)), - "{error:?}" - ); - } - /// The landed revision reaches the store even when the update writes no - /// key row: here it disables a key the cache does not have. - #[tokio::test] - async fn should_store_the_landed_revision_on_its_own() { - let identity_id = Identifier::default(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(4, store.clone()).await; - let wallet = wallet.identity(); - store.clear(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![7]) - .await - .unwrap(); - assert!(store.key_rows().is_empty()); - assert_eq!(store.stored_revision(identity_id), Some(5)); - - // An update with keys stores them and the revision in one changeset. - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(4, store.clone()).await; - let wallet = wallet.identity(); - store.clear(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![1]) - .await - .unwrap(); - assert_eq!(store.changeset_count(), 1); - assert_eq!(store.stored_revision(identity_id), Some(5)); - assert_eq!(store.key_rows().len(), 2); - - // A cache already at the transition stores its snapshot too, even - // when no touched key is in it. let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(5, store.clone()).await; let wallet = wallet.identity(); - store.clear(); wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![], &[], vec![7]) - .await - .unwrap(); - assert_eq!(store.changeset_count(), 1); - assert_eq!(store.stored_revision(identity_id), Some(5)); - } - - /// A cache at the transition's revision that lacks a key the update - /// added takes the update's copy, in memory and in the store, with its - /// breadcrumb: Platform has it either way. - #[tokio::test] - async fn should_store_an_added_key_a_cache_at_its_revision_lacks() { - let identity_id = Identifier::default(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(5, store.clone()).await; - let wallet = wallet.identity(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![]) + .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], vec![]) .await .unwrap(); let identity = cached_identity(wallet, &identity_id).await; assert_eq!(identity.revision(), 5); assert_eq!(identity.public_keys().get(&3), Some(&key_3())); let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); - assert_eq!(stored.public_key, key_3()); - assert_eq!( - stored - .derivation_indices - .map(|indices| (indices.identity_index, indices.key_index)), - Some((0, 3)) - ); - } - - /// An added key the caller declares external is stored without a - /// derivation breadcrumb whether the cache is behind the update or - /// already at it, so no client takes it for a key it can derive. - #[tokio::test] - async fn should_store_an_external_added_key_without_a_breadcrumb() { - let identity_id = Identifier::default(); - for cached in [4, 5] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(cached, store.clone()).await; - let wallet = wallet.identity(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[3], vec![]) - .await - .unwrap(); - let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); - assert_eq!(stored.public_key, key_3(), "cached at {cached}"); - assert_eq!(stored.derivation_indices, None, "cached at {cached}"); - assert_eq!(stored.wallet_id, None, "cached at {cached}"); - assert_eq!( - stored.derivation_rejected, - BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 3, - }]), - "a declared external key refutes its slot's breadcrumb, cached at {cached}" - ); - } - } - - /// A key the update both adds and disables is taken as added on both - /// paths, and an out-of-wallet identity's declared external key leaves - /// its origin unknown (there is no slot of this wallet to refute). - #[tokio::test] - async fn should_take_an_added_and_disabled_key_as_added_and_leave_an_out_of_wallet_external_key_unknown( - ) { - let identity_id = Identifier::default(); - for cached in [4, 5] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(cached, store.clone()).await; - let wallet = wallet.identity(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, vec![key_3()], &[], vec![3]) - .await - .unwrap(); - let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); - assert_eq!(stored.public_key.disabled_at(), None, "cached at {cached}"); - assert!(stored.derivation_indices.is_some(), "cached at {cached}"); - - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(cached, store.clone()).await; - let wallet = wallet.identity(); - wallet - .apply_landed_update(&identity_id, None, 5, vec![key_3()], &[3], vec![]) - .await - .unwrap(); - let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); - assert_eq!(stored.derivation_indices, None, "cached at {cached}"); - assert!(stored.derivation_rejected.is_empty(), "cached at {cached}"); - } - } - - /// An external key id that names no key the update adds is refused - /// before anything is signed. - #[tokio::test] - async fn should_refuse_an_external_id_the_update_does_not_add() { - let identity_id = Identifier::default(); - let wallet = wallet_at_revision(4).await; - let signer = RecordingSigner::default(); - let error = wallet - .identity() - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![key_3()], - &[9], - vec![], - None, - &signer, - None, - ) - .await - .unwrap_err(); - assert!( - matches!(error, PlatformWalletError::InvalidIdentityData(_)), - "{error:?}" - ); - assert_eq!(signer.0.load(Ordering::SeqCst), 0); - } - - /// The key the landed-update tests add. - fn key_3() -> IdentityPublicKey { - IdentityPublicKeyV0 { - id: 3, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::HIGH, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![3; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into() + assert!(stored.derivation_indices.is_some()); } } + +#[cfg(test)] +#[path = "lagging_fetch_tests.rs"] +mod lagging_fetch_tests; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index f1470490177..04a19d27f4a 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -3,25 +3,21 @@ use super::key_storage::{DpnsFetch, DpnsNameInfo, IdentityStatus}; use super::ManagedIdentity; use crate::changeset::{ - IdentityChangeSet, IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, - IdentityKeysChangeSet, KeyWithBreadcrumb, PersistenceError, PersistenceErrorKind, - PlatformWalletChangeSet, + IdentityChangeSet, IdentityEntry, IdentityKeyEntry, IdentityKeysChangeSet, KeyWithBreadcrumb, + PersistenceError, }; -use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; -use crate::wallet::platform_wallet::WalletId; use crate::BlockTime; -use dpp::identity::accessors::IdentityGettersV0; -use dpp::identity::accessors::IdentitySettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; use dpp::identity::{Identity, IdentityPublicKey}; use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; use dpp::util::hash::ripemd160_sha256; use std::collections::BTreeMap; -use std::collections::BTreeSet; /// Compute the 20-byte RIPEMD160(SHA256) hash of the DPP public-key /// bytes. Shared between `keys_snapshot_changeset` so the FFI surface @@ -73,7 +69,6 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(pub_key), wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }, ); } @@ -93,7 +88,6 @@ impl ManagedIdentity { identity_index: Some(identity_index), last_updated_balance_block_time: None, pending_balance_snapshot: None, - pending_key_rows: BTreeMap::new(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -115,7 +109,6 @@ impl ManagedIdentity { identity_index: None, last_updated_balance_block_time: None, pending_balance_snapshot: None, - pending_key_rows: BTreeMap::new(), last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -412,10 +405,11 @@ impl ManagedIdentity { /// except that a key the cache holds disabled stays disabled (a disable /// is permanent on Platform). Revisions only grow on Platform, so a fetch /// at a lower revision was answered before an update the cache already - /// holds (one applied locally after it landed, or a newer refresh): - /// the cache keeps its revision and key set; it takes only a disable the - /// fetch shows (the fetch's timestamp is Platform's own) and a raised - /// limit. Returns whether it was taken whole. + /// holds (one applied locally after it landed, or a newer refresh, from a + /// node that lags behind): the cache keeps its revision and key set; it + /// takes only a disable the fetch shows (the fetch's timestamp is + /// Platform's own) and a raised limit. Returns whether it was taken + /// whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { @@ -443,29 +437,22 @@ impl ManagedIdentity { /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row /// of every key the fold adds or changes (a disable, a raised limit, a - /// key added at a newer revision) with the identity's scalar snapshot as - /// one changeset (see `store_keys_and_balance`), so any refresh, the - /// first after a restart included, brings the store up to the fetch. + /// key added at a newer revision, on this device or another) with the + /// identity's scalar snapshot as one changeset, so the stored key set + /// matches the stored revision after a restart. /// /// Used where a fetch comes without derived breadcrumbs (refresh), so the - /// rows carry none. A row without a breadcrumb says the key's origin is - /// not known here, not that the key is watch-only: every persister keeps - /// the breadcrumb a row already holds, and a load or discovery, which - /// verify a key against the seed, give a new key its breadcrumb. Until - /// one runs, a key a refresh adds is stored without one, rather than with - /// derivation coordinates inferred from the identity's wallet slot, or not - /// at all. + /// rows carry none. Every persister keeps the breadcrumb a row already + /// holds when an upsert carries none, and a load or discovery, which + /// verify a key against the seed, give a new key its breadcrumb. /// - /// A failed store is returned; memory holds the fold (the revision above - /// all, which the next update is built from), and the refused key rows - /// and a balance it took stay queued for the identity's next key store, - /// a fetch's or a landed update's (see `store_keys_and_balance`). + /// A failed store is returned; memory holds the fold. pub fn store_fetched_identity( &mut self, fetched: &Identity, block_time: BlockTime, persister: &WalletPersister, - ) -> Result<(), PlatformWalletError> { + ) -> Result<(), PersistenceError> { let before = self.identity.public_keys().clone(); self.take_fetched_identity(fetched, block_time); let identity_id = self.id(); @@ -483,7 +470,6 @@ impl ManagedIdentity { public_key_hash: pubkey_hash_of(key), wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }, ); } @@ -495,165 +481,22 @@ impl ManagedIdentity { /// breadcrumbs and the identity's scalar snapshot as one changeset (see /// `store_keys_and_balance`). Used where a fetch comes with derived /// breadcrumbs (loading and discovery); an identity added from this - /// fetch just before takes its balance watermark from it. A failed store - /// is returned; memory holds the fold, and the refused key rows (with - /// their breadcrumbs) stay queued for the identity's next key store, a - /// refresh included (see `store_keys_and_balance`). + /// fetch just before (see `IdentityManager::add_identity_unstored`) + /// reaches the store in the same changeset as its keys. A failed store + /// is returned; memory holds the fold. pub fn store_fetched_identity_keys( &mut self, fetched: &Identity, decisions: Vec, block_time: BlockTime, persister: &WalletPersister, - ) -> Result<(), PlatformWalletError> { + ) -> Result<(), PersistenceError> { let decisions = self.key_decisions_for_fetch(fetched, decisions); self.take_fetched_identity(fetched, block_time); let keys_cs = self.layer_keys(decisions); self.store_keys_and_balance(keys_cs, persister) } - /// Store `keys_cs` with the key rows a refused store queued (see - /// `pending_key_rows`) and the identity's scalar snapshot as one - /// changeset. A queued row `keys_cs` carries itself gives way to it (its - /// own row is newer) but lends it what it settled about the same public - /// key's origin (a breadcrumb or a rejection) when that row leaves it - /// unknown (see [`IdentityKeyEntry::inherit_origin`]). Every other one is stored with - /// the key as memory holds it now, so a row queued before a later change - /// to the key (a landed disable, a raised limit) does not take that - /// change back; a queued key memory no longer holds is dropped. When the - /// store fails, every row stays queued for the next one and the failure - /// is returned. - /// - /// A constraint failure (invalid data, which a retry would only repeat) - /// names nothing, so it is traced before anything is dropped. The scalar - /// snapshot is stored on its own first: when the store refuses it too, - /// no row is shown invalid and every row stays queued. When it lands, - /// each row (the batch's and the queued ones, merged) is stored on its - /// own, and only a row the store refuses by itself is dropped. A failure - /// of another kind on the way keeps that row and the rest queued. The - /// call fails, with the store's error, when a row of `keys_cs` was - /// refused or not stored; a queued row refused alone fails nothing. Two - /// rows refused only together are told apart by order, the batch's rows - /// first: the later one is dropped. - pub(crate) fn store_with_queued_key_rows( - &mut self, - keys_cs: IdentityKeysChangeSet, - persister: &WalletPersister, - ) -> Result<(), PersistenceError> { - // Every row stays queued until it is known to have landed or to be - // invalid, so a store that unwinds leaves it owed. - debug_assert!( - keys_cs.removed.is_empty(), - "identity key removals do not go through the key-row queue" - ); - let queued = self.pending_key_rows.clone(); - let combined = self.with_queued_key_rows(keys_cs.clone(), &queued); - self.pending_key_rows = combined.upserts.clone(); - let error = match self.store_with_snapshot(combined.clone(), persister) { - Ok(()) => { - self.pending_key_rows.clear(); - return Ok(()); - } - Err(e) => e, - }; - if error.kind() != Some(PersistenceErrorKind::Constraint) { - return Err(error); - } - tracing::warn!( - identity = %self.id(), - error = %error, - rows = combined.upserts.len(), - "identity store refused as invalid; storing the snapshot and each key row on its own", - ); - // The snapshot refused without any row shows no row invalid: every - // one stays queued. - self.store_with_snapshot(IdentityKeysChangeSet::default(), persister)?; - // The snapshot landed, so a row the store now refuses on its own is - // what it refused. The batch's rows go first: of two rows refused - // only together, the later one is dropped, and a queued row is the - // older of the two. - let mut failure = None; - let (own_rows, queued_rows): (Vec<_>, Vec<_>) = combined - .upserts - .into_iter() - .partition(|(id, _)| keys_cs.upserts.contains_key(id)); - for (id, row) in own_rows.into_iter().chain(queued_rows) { - let own = keys_cs.upserts.contains_key(&id); - let mut row_cs = IdentityKeysChangeSet::default(); - row_cs.upserts.insert(id, row); - match self.store_with_snapshot(row_cs, persister) { - Ok(()) => { - self.pending_key_rows.remove(&id); - } - Err(e) if e.kind() == Some(PersistenceErrorKind::Constraint) => { - tracing::warn!( - identity = %self.id(), - key_id = id.1, - error = %e, - "identity key row refused as invalid; dropped", - ); - self.pending_key_rows.remove(&id); - if own { - failure.get_or_insert(e); - } - } - Err(e) => { - // This row and the rest stay queued. - tracing::warn!( - identity = %self.id(), - error = %e, - "identity key rows not stored; kept for the next store", - ); - let unstored_own = self - .pending_key_rows - .keys() - .any(|id| keys_cs.upserts.contains_key(id)); - if unstored_own { - return Err(failure.unwrap_or(e)); - } - break; - } - } - } - failure.map_or(Ok(()), Err) - } - - /// `keys_cs` with the `queued` key rows: a queued row `keys_cs` carries - /// itself gives way to it but lends it what it settled about the same - /// public key's origin (see [`IdentityKeyEntry::inherit_origin`]); every - /// other one takes the key as memory holds it now, its origin dropped - /// when that is a different public key; one memory no longer holds is - /// left out. - fn with_queued_key_rows( - &self, - mut keys_cs: IdentityKeysChangeSet, - queued: &BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, - ) -> IdentityKeysChangeSet { - for (&id, entry) in queued { - if let Some(own) = keys_cs.upserts.get_mut(&id) { - own.inherit_origin(entry); - continue; - } - let Some(key) = self.identity.public_keys().get(&entry.key_id) else { - continue; - }; - let mut entry = entry.clone(); - // A breadcrumb belongs to the key it was derived for: a queued - // row whose key memory now holds differently leaves its origin - // unknown. - let public_key_hash = pubkey_hash_of(key); - if public_key_hash != entry.public_key_hash || key.data() != entry.public_key.data() { - entry.wallet_id = None; - entry.derivation_indices = None; - entry.derivation_rejected = BTreeSet::new(); - } - entry.public_key_hash = public_key_hash; - entry.public_key = key.clone(); - keys_cs.upserts.insert(id, entry); - } - keys_cs - } - /// The key decisions to store for `decisions` built from `fetched`, each /// key folded as [`Self::take_fetched_identity`] folds it, so storing its /// breadcrumb (a key's derivation never changes) never takes back a @@ -661,10 +504,10 @@ impl ManagedIdentity { /// revision or above that is the fetched key, disabled when the cache's /// copy is. For an older fetch it is the cache's copy, disabled when the /// fetch shows a disable; either way each limit is the higher of the two. - /// A key the cache lacks is left out, since an older answer cannot show - /// whether a later update disabled it, and a fetch at least as new will - /// bring it. - pub fn key_decisions_for_fetch( + /// A key the cache lacks is left out of an older fetch's decisions, since + /// an older answer cannot show whether a later update disabled it, and a + /// fetch at least as new will bring it. + fn key_decisions_for_fetch( &self, fetched: &Identity, decisions: Vec, @@ -680,12 +523,6 @@ impl ManagedIdentity { .collect() } - /// The wallet slot `(wallet_id, identity_index)` a wallet-owned - /// identity's key breadcrumbs are built from, or `None` out of wallet. - pub fn wallet_slot(&self) -> Option<(WalletId, u32)> { - self.wallet_id.zip(self.identity_index) - } - /// Layer an `IdentityPublicKey` onto this identity's /// `public_keys` map and emit a single-key /// [`IdentityKeysChangeSet`] upsert carrying the full derivation @@ -694,9 +531,7 @@ impl ManagedIdentity { /// `wallet_id` and `(identity_index, key_index)` together let the /// client (iOS Keychain) re-derive the matching private key from /// the wallet seed at the DIP-9 identity authentication path. - /// Pass `None` for a key the wallet didn't derive (the row then keeps - /// a breadcrumb the persister holds; see - /// [`KeyWithBreadcrumb::derivation_rejected`]). + /// Pass `None` for a watch-only key the wallet didn't derive. pub fn add_key( &mut self, public_key: dpp::identity::IdentityPublicKey, @@ -709,7 +544,6 @@ impl ManagedIdentity { vec![crate::changeset::KeyWithBreadcrumb { key: public_key, breadcrumb: derivation_breadcrumb, - derivation_rejected: BTreeSet::new(), }], persister, ) @@ -718,9 +552,7 @@ impl ManagedIdentity { /// Layer several `IdentityPublicKey`s onto this identity and emit ONE /// batched [`IdentityKeysChangeSet`] carrying each key's derivation /// breadcrumb (`Some((wallet_id, identity_index, key_index))`) or - /// `None` for a key the wallet can't re-derive (set - /// `derivation_rejected` to the coordinates checked when that is settled, - /// so a stored breadcrumb naming them is cleared). + /// `None` for a watch-only key the wallet can't re-derive. /// /// The single-write batch form of [`Self::add_key`], used by discovery /// to materialize every re-derivable key of a freshly found identity in @@ -736,15 +568,18 @@ impl ManagedIdentity { return Ok(()); } let keys_cs = self.layer_keys(keys); + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; // Surface the persist failure — these rows carry the per-key // derivation breadcrumb + verified scalar that make an imported / // restored identity signable. Swallowing a failed store here would // leave the keys in memory but absent from the client store, so the - // identity comes back watch-only after restart with no signal. The - // refused rows, breadcrumbs included, also ride the identity's next - // key store (see `store_with_queued_key_rows`): a refresh that finds - // the same keys has no row of its own to give them. - self.store_with_queued_key_rows(keys_cs, persister) + // identity comes back watch-only after restart with no signal. + persister.store(cs)?; + Ok(()) } /// Layer `keys` onto this identity in memory, returning their key rows. @@ -755,7 +590,6 @@ impl ManagedIdentity { for KeyWithBreadcrumb { key: public_key, breadcrumb, - derivation_rejected, } in keys { let key_id = public_key.id(); @@ -764,7 +598,7 @@ impl ManagedIdentity { let (wallet_id, derivation_indices) = match breadcrumb { Some((wallet_id, identity_index, key_index)) => ( Some(wallet_id), - Some(IdentityKeyDerivationIndices { + Some(crate::changeset::IdentityKeyDerivationIndices { identity_index, key_index, }), @@ -780,11 +614,6 @@ impl ManagedIdentity { public_key_hash, wallet_id, derivation_indices, - derivation_rejected: if derivation_indices.is_none() { - derivation_rejected - } else { - BTreeSet::new() - }, }, ); } @@ -796,31 +625,21 @@ impl ManagedIdentity { /// after a key limits update, and emit a single-key [`IdentityKeysChangeSet`] upsert for /// it: the limits-side counterpart to [`Self::add_key`] and [`Self::disable_keys`]. /// - /// The key is layered by id, so a key the identity did not hold is added. It is folded over - /// the cache's copy as a fetched key is (see `fold_fetched_key`), so a disable or a higher - /// limit a refresh brought while the update was in flight is not taken back. The entry carries + /// The key is layered by id, so a key the identity did not hold is added. The entry carries /// no derivation breadcrumb: a limits update changes nothing about where the key came from, /// and the managed identity does not know per key whether it was derived from the wallet /// seed or supplied from outside. Every persister keeps the breadcrumb it already holds for /// the key when an upsert arrives without one, so the private-key linkage survives the /// upsert; inventing coordinates here would overwrite it for an external key. /// - /// A failed store is returned, and the row stays queued for the identity's next key store - /// (see [`Self::store_with_queued_key_rows`]). - /// /// Does **not** touch the identity revision: a key limits update claims none. pub fn replace_key( &mut self, public_key: dpp::identity::IdentityPublicKey, persister: &WalletPersister, ) -> Result<(), crate::changeset::PersistenceError> { - use dpp::identity::accessors::IdentitySettersV0; - let identity_id = self.id(); let key_id = public_key.id(); - let public_key = - fold_fetched_key(self.identity.public_keys().get(&key_id), &public_key, true) - .unwrap_or(public_key); let public_key_hash = pubkey_hash_of(&public_key); let mut keys = self.identity.public_keys().clone(); @@ -837,10 +656,15 @@ impl ManagedIdentity { public_key_hash, wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }, ); - self.store_with_queued_key_rows(keys_cs, persister) + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; + persister.store(cs)?; + Ok(()) } /// Stamp `disabled_at` on the public keys named by `key_ids` and @@ -852,11 +676,13 @@ impl ManagedIdentity { /// `Identity` (so every signing / introspection path sees the /// disabled flag immediately) and a combined changeset is persisted /// so the client's `PersistentPublicKey.disabledAt` rows flip - /// without a network re-fetch. Each emitted entry carries no - /// derivation breadcrumb: a disable changes nothing about where the - /// key came from, every persister keeps the breadcrumb it already - /// holds for the key, and the identity's wallet slot does not say - /// whether this key was derived there or supplied from outside. + /// without a network re-fetch. Each emitted entry reuses the same + /// `(wallet_id, identity_index, key_index)` derivation breadcrumb + /// `add_key` carries, so the client re-derives idempotently and + /// keeps the key's existing private-key linkage rather than + /// dropping it on the upsert. Out-of-wallet identities (no + /// derivation context) emit a breadcrumb-less entry, matching + /// their watch-only state. /// /// `key_ids` not present on the identity are skipped (logged) and /// no changeset is emitted when nothing matched. `disabled_at` is @@ -865,81 +691,24 @@ impl ManagedIdentity { /// the authoritative on-chain block time. /// /// Does **not** touch the identity revision — the caller bumps it - /// alongside the update transition. A store failure is logged (its rows - /// queued for the next key store, see `store_with_queued_key_rows`), not - /// returned; a landed update goes through [`Self::apply_landed_keys`], - /// which returns it and stores the revision too. + /// alongside the update transition. pub fn disable_keys( &mut self, key_ids: &[KeyID], disabled_at: TimestampMillis, persister: &WalletPersister, ) { - let keys_cs = self.stamp_disabled(key_ids, disabled_at); - // Nothing matched — leave state untouched rather than churning a - // no-op changeset. - if keys_cs.upserts.is_empty() { - return; - } - if let Err(e) = self.store_with_queued_key_rows(keys_cs, persister) { - tracing::error!("Failed to persist changeset: {}", e); - } - } - - /// Apply a landed identity update's keys in memory, `added` layered and - /// `disabled` stamped, and store them with the identity's scalar - /// snapshot (the landed revision included) as one changeset, even when - /// no key row matched. The store's failure is returned; memory holds the - /// whole update either way, and the key rows stay queued for the next - /// identity store (see [`Self::store_with_queued_key_rows`]). - pub fn apply_landed_keys( - &mut self, - added: Vec, - disabled: &[KeyID], - disabled_at: TimestampMillis, - persister: &WalletPersister, - ) -> Result<(), PersistenceError> { - let mut keys_cs = self.layer_keys(added); - keys_cs - .upserts - .extend(self.stamp_disabled(disabled, disabled_at).upserts); - self.store_with_queued_key_rows(keys_cs, persister) - } - - /// Layer `keys` in memory and store them with the identity's scalar - /// snapshot as one changeset, even when `keys` is empty. The store's - /// failure is returned, and the key rows stay queued for the next - /// identity store (see [`Self::store_with_queued_key_rows`]). - pub fn store_keys_with_snapshot( - &mut self, - keys: Vec, - persister: &WalletPersister, - ) -> Result<(), PersistenceError> { - let keys_cs = self.layer_keys(keys); - self.store_with_queued_key_rows(keys_cs, persister) - } - - pub(super) fn store_with_snapshot( - &self, - keys_cs: IdentityKeysChangeSet, - persister: &WalletPersister, - ) -> Result<(), PersistenceError> { - persister.store(PlatformWalletChangeSet { - identities: Some(self.snapshot_changeset()), - identity_keys: Some(keys_cs), - ..Default::default() - }) - } - - /// Stamp `disabled_at` on the matching keys in memory, returning their - /// key rows (none when nothing matched). - fn stamp_disabled( - &mut self, - key_ids: &[KeyID], - disabled_at: TimestampMillis, - ) -> IdentityKeysChangeSet { let identity_id = self.id(); + // Reconstruct the derivation breadcrumb from the cached + // wallet/identity slot. Wallet-owned identities carry both + // halves; out-of-wallet ones have neither (and no private key + // to preserve), so they fall through to a watch-only entry. + let breadcrumb = match (self.wallet_id, self.identity_index) { + (Some(wallet_id), Some(identity_index)) => Some((wallet_id, identity_index)), + _ => None, + }; + // Operate on a clone of the key map, mirroring `add_key`, then // write it back once all matching keys have been stamped. let mut keys = self.identity.public_keys().clone(); @@ -950,7 +719,7 @@ impl ManagedIdentity { tracing::warn!( identity = %identity_id, key_id, - "stamping disabled keys: key id not present on identity; skipping", + "disable_keys: key id not present on identity; skipping", ); continue; }; @@ -958,6 +727,17 @@ impl ManagedIdentity { public_key.set_disabled_at(disabled_at); let public_key_hash = pubkey_hash_of(public_key); + 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), + }; + keys_cs.upserts.insert( (identity_id, key_id), IdentityKeyEntry { @@ -965,17 +745,28 @@ impl ManagedIdentity { key_id, public_key: public_key.clone(), public_key_hash, - wallet_id: None, - derivation_indices: None, - derivation_rejected: BTreeSet::new(), + wallet_id, + derivation_indices, }, ); } - if !keys_cs.upserts.is_empty() { - self.identity.set_public_keys(keys); + // Nothing matched — leave state untouched rather than churning a + // no-op changeset. + if keys_cs.upserts.is_empty() { + return; + } + + self.identity.set_public_keys(keys); + + let cs = crate::changeset::PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + }; + if let Err(e) = persister.store(cs) { + tracing::error!("Failed to persist changeset: {}", e); } - keys_cs } } @@ -1011,9 +802,8 @@ fn fold_fetched_key( /// expiry; anything else is refused as not raising it) and never adds or /// removes one, so the higher value is the newer one whichever answer /// carries it, and a delayed answer cannot take a raise back. A limit only -/// one copy has is therefore one the other copy lost (a row stored without -/// it, say), not one Platform removed or has yet to add, so it is taken -/// from whichever copy has it. +/// one copy has is therefore one the other copy lost, so it is taken from +/// whichever copy has it. fn with_raised_limits(key: IdentityPublicKey, other: &IdentityPublicKey) -> IdentityPublicKey { fn higher(own: Option, other: Option) -> Option { match (own, other) { @@ -1048,7 +838,6 @@ mod tests { }; use crate::wallet::identity::PaymentEntry; use crate::wallet::platform_wallet::WalletId; - use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::identity_public_key::v1::IdentityPublicKeyV1; use dpp::identity::v0::IdentityV0; @@ -1249,12 +1038,10 @@ mod tests { KeyWithBreadcrumb { key: key(3), breadcrumb: None, - derivation_rejected: BTreeSet::new(), }, KeyWithBreadcrumb { key: key(4), breadcrumb: None, - derivation_rejected: BTreeSet::new(), }, ] }; @@ -1287,656 +1074,18 @@ mod tests { let decisions = vec![KeyWithBreadcrumb { key: key(3).with_limits(Some(100), None), breadcrumb: None, - derivation_rejected: BTreeSet::new(), }]; let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); } } - /// A landed limits update is folded over the cache's copy: a disable or - /// a higher limit a refresh brought while it was in flight is not taken - /// back. - #[test] - fn replace_key_keeps_a_disable_and_a_higher_limit_the_cache_holds() { - let mut cached = key(1).with_limits(Some(2_000), Some(50)); - cached.set_disabled_at(9); - let identity = Identity::V0(IdentityV0 { - id: Identifier::from([1u8; 32]), - public_keys: BTreeMap::from([(1, cached)]), - balance: 0, - revision: 0, - }); - let mut managed = ManagedIdentity::new(identity, 0); - let p = WalletPersister::new( - [0xAB; 32], - std::sync::Arc::new(CapturingPersister::default()) as _, - ); - managed - .replace_key(key(1).with_limits(Some(1_500), Some(60)), &p) - .expect("replace_key persists in test"); - let mut expected = key(1).with_limits(Some(2_000), Some(60)); - expected.set_disabled_at(9); - assert_eq!(managed.identity.public_keys()[&1], expected); - } - - /// Persister that refuses every store while armed and records the rest. - #[derive(Default)] - struct ArmedPersister { - armed: std::sync::atomic::AtomicBool, - constraint: std::sync::atomic::AtomicBool, - /// Key ids whose rows the store refuses as invalid. - poison: Mutex>, - /// Store calls so far, and the one (1-based, 0 for none) that fails - /// as a transient error. - attempts: std::sync::atomic::AtomicUsize, - transient_at: std::sync::atomic::AtomicUsize, - /// The store call (1-based, 0 for none) that panics. - panic_at: std::sync::atomic::AtomicUsize, - /// Two key ids the store refuses as invalid together, as a unique - /// index would: a store carrying one of them while the other is in - /// it or already stored. - exclusive: Mutex>, - stores: Mutex>, - } - impl PlatformWalletPersistence for ArmedPersister { - fn store( - &self, - _wallet_id: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - let attempt = self - .attempts - .fetch_add(1, std::sync::atomic::Ordering::SeqCst) - + 1; - if attempt == self.transient_at.load(std::sync::atomic::Ordering::SeqCst) { - return Err(PersistenceError::backend("transient")); - } - if attempt == self.panic_at.load(std::sync::atomic::Ordering::SeqCst) { - panic!("store panicked"); - } - let poisoned = changeset.identity_keys.as_ref().is_some_and(|keys| { - let poison = self.poison.lock().unwrap(); - keys.upserts - .keys() - .any(|(_, key_id)| poison.contains(key_id)) - }); - let has = |cs: &PlatformWalletChangeSet, id: KeyID| { - cs.identity_keys - .as_ref() - .is_some_and(|keys| keys.upserts.keys().any(|(_, key_id)| *key_id == id)) - }; - let conflicting = self.exclusive.lock().unwrap().is_some_and(|(a, b)| { - let stores = self.stores.lock().unwrap(); - let present = |id| has(&changeset, id) || stores.iter().any(|cs| has(cs, id)); - (has(&changeset, a) && present(b)) || (has(&changeset, b) && present(a)) - }); - if poisoned || conflicting || self.constraint.load(std::sync::atomic::Ordering::SeqCst) - { - return Err(PersistenceError::backend_with_kind( - PersistenceErrorKind::Constraint, - "invalid row", - )); - } - if self.armed.load(std::sync::atomic::Ordering::SeqCst) { - return Err(PersistenceError::backend("armed")); - } - self.stores.lock().unwrap().push(changeset); - Ok(()) - } - fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - Ok(ClientStartState::default()) - } - } - - /// The rows of a refused store are queued and the next store carries - /// them, each with the key as memory holds it then (a later raise is - /// not taken back) and its breadcrumb; a row the next store carries - /// itself replaces the queued one but keeps its breadcrumb when it has - /// none. - #[test] - fn should_carry_a_refused_store_s_key_rows_into_the_next_one() { - let wallet_id: WalletId = [0xAB; 32]; - let mut managed = ManagedIdentity::new( - Identity::V0(IdentityV0 { - id: Identifier::from([1u8; 32]), - public_keys: BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - ); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { - breadcrumb: Some((wallet_id, 0, key.id())), - key, - derivation_rejected: BTreeSet::new(), - }; - let row = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .last() - .and_then(|cs| cs.identity_keys.as_ref()) - .and_then(|keys| keys.upserts.get(&(managed_id(), key_id)).cloned()) - }; - fn managed_id() -> Identifier { - Identifier::from([1u8; 32]) - } - - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - let limited = key(1).with_limits(Some(100), None); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(limited.clone())], &p) - .is_err()); - // A raise lands in memory before the retry. - managed - .identity - .add_public_key(key(1).with_limits(Some(200), None)); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) - .unwrap(); - let retried = row(1).expect("the refused row rides the next store"); - assert_eq!(retried.public_key, key(1).with_limits(Some(200), None)); - assert!(retried.derivation_indices.is_some()); - assert!(row(2).is_some()); - - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - let raised = key(2).with_limits(Some(5), None); - managed - .store_keys_with_snapshot( - vec![KeyWithBreadcrumb { - key: raised.clone(), - breadcrumb: None, - derivation_rejected: BTreeSet::new(), - }], - &p, - ) - .unwrap(); - // The new row's key, the queued row's breadcrumb. - let replaced = row(2).expect("the new row is stored"); - assert_eq!(replaced.public_key, raised); - assert!(replaced.derivation_indices.is_some()); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(row(2), None, "the queue is empty after a store succeeds"); - - // A row the store refuses on its own is dropped, and the call fails: - // a retry would only repeat the refusal. - persister.poison.lock().unwrap().insert(3); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(3))], &p) - .is_err()); - persister.poison.lock().unwrap().clear(); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(row(3), None); - - // A refusal of the scalar snapshot alone shows no row invalid: the - // batch's rows stay queued, and once the cause is corrected a store - // with no key change of its own (an unchanged refresh) writes them. - persister - .constraint - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(4))], &p) - .is_err()); - persister - .constraint - .store(false, std::sync::atomic::Ordering::SeqCst); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - let kept = row(4).expect("the row outlives a refusal of the snapshot"); - assert!(kept.derivation_indices.is_some()); - - // A row queued by an earlier refused store survives a later batch the - // store refuses as invalid, and rides the next store that succeeds. - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - persister - .constraint - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(6))], &p) - .is_err()); - persister - .constraint - .store(false, std::sync::atomic::Ordering::SeqCst); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - let kept = row(5).expect("the earlier queued row survives the constraint failure"); - assert!(kept.derivation_indices.is_some()); - assert!( - row(6).is_some(), - "a refusal the snapshot earns keeps the batch's own row too", - ); - - // However often the batches are refused, the queue stays: a refusal - // the batch earns on its own is no evidence against a queued row. - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(7))], &p) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - persister - .constraint - .store(true, std::sync::atomic::Ordering::SeqCst); - for _ in 0..3 { - assert!(managed.store_keys_with_snapshot(Vec::new(), &p).is_err()); - } - persister - .constraint - .store(false, std::sync::atomic::Ordering::SeqCst); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!( - row(7).is_some(), - "the queue outlives refusals the batch earns" - ); - } - - /// A constraint failure names no row, so the snapshot and then each row - /// are stored on their own: only the row the store refuses by itself is - /// dropped, the valid ones land, and a queued row the batch carries - /// itself keeps the batch's newer origin decision. + /// A refresh stores, with the scalar snapshot and in one changeset, the + /// row of every key its fold adds or changes (here a key another device + /// added and a disable), without a breadcrumb, and no row for a key it + /// leaves as it was. An older answer stores no key row. #[test] - fn should_drop_only_the_queued_row_a_constraint_failure_is_traced_to() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let mut managed = ManagedIdentity::new( - Identity::V0(IdentityV0 { - id, - public_keys: BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - ); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { - breadcrumb: Some((wallet_id, 0, key.id())), - key, - derivation_rejected: BTreeSet::new(), - }; - // The latest stored row of `key_id`, in any store. - let stored = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .iter() - .rev() - .find_map(|cs| { - cs.identity_keys - .as_ref() - .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) - }) - }; - let arm = |on: bool| { - persister - .armed - .store(on, std::sync::atomic::Ordering::SeqCst) - }; - - arm(true); - assert!(managed - .store_keys_with_snapshot( - vec![ - with_breadcrumb(key(1)), - with_breadcrumb(key(2)), - with_breadcrumb(key(3)), - ], - &p, - ) - .is_err()); - arm(false); - persister.poison.lock().unwrap().insert(2); - // Key 1's newer row rejects the slot its queued breadcrumb names. - managed - .store_keys_with_snapshot( - vec![ - KeyWithBreadcrumb { - key: key(1), - breadcrumb: None, - derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 1, - }]), - }, - with_breadcrumb(key(4)), - ], - &p, - ) - .expect("the batch lands once the refused queued row is traced"); - assert!(stored(4).is_some()); - let valid = stored(3).expect("a valid queued row lands"); - assert!(valid.derivation_indices.is_some()); - assert_eq!(stored(2), None, "the refused queued row never lands"); - let newer = stored(1).expect("the batch's row lands"); - assert_eq!( - newer.derivation_indices, None, - "the queued breadcrumb is not put back" - ); - assert!(!newer.derivation_rejected.is_empty()); - - // The refused row was dropped, so it no longer refuses later stores. - let stores = persister.stores.lock().unwrap().len(); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(persister.stores.lock().unwrap().len(), stores + 1); - assert_eq!(stored(2), None); - - // A transient failure while the queued rows are retried keeps the - // rest queued as merged, so the batch's decision for a key it - // carries is not taken back by the next store. - arm(true); - assert!(managed - .store_keys_with_snapshot( - vec![ - with_breadcrumb(key(5)), - with_breadcrumb(key(6)), - with_breadcrumb(key(7)), - ], - &p, - ) - .is_err()); - arm(false); - *persister.poison.lock().unwrap() = BTreeSet::from([7]); - // Combined (refused), snapshot alone (lands), key 6 alone (the - // batch's row, stored first: transient), so key 6 and the queued - // rows stay queued. - let attempts = persister.attempts.load(std::sync::atomic::Ordering::SeqCst); - persister - .transient_at - .store(attempts + 3, std::sync::atomic::Ordering::SeqCst); - managed - .store_keys_with_snapshot( - vec![KeyWithBreadcrumb { - key: key(6), - breadcrumb: None, - derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 6, - }]), - }], - &p, - ) - .expect_err("the batch's own row was not stored"); - assert_eq!(stored(5), None, "the queued rows wait behind the batch's"); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!(stored(5) - .expect("key 5 lands next") - .derivation_indices - .is_some()); - assert_eq!( - stored(6) - .expect("the batch's row landed") - .derivation_indices, - None, - "the queued breadcrumb is not put back", - ); - assert_eq!(stored(7), None); - } - - /// Tracing a constraint failure stores the batch's rows before the - /// queued ones, so of two rows refused only together the older, queued - /// one is dropped; a failure of another kind on a queued row, once the - /// batch's rows landed, fails nothing and keeps that row queued; and a - /// batch row refused alone is the error returned, ahead of a later - /// failure of another kind. - #[test] - fn should_trace_a_constraint_failure_batch_first_and_report_the_batch_s_own() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let mut managed = ManagedIdentity::new( - Identity::V0(IdentityV0 { - id, - public_keys: BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - ); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { - breadcrumb: Some((wallet_id, 0, key.id())), - key, - derivation_rejected: BTreeSet::new(), - }; - let stored = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .iter() - .rev() - .find_map(|cs| { - cs.identity_keys - .as_ref() - .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) - }) - }; - let arm = |on: bool| { - persister - .armed - .store(on, std::sync::atomic::Ordering::SeqCst) - }; - let attempts = || persister.attempts.load(std::sync::atomic::Ordering::SeqCst); - - // Key 2 queued, then a batch with key 5 that it conflicts with. - arm(true); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) - .is_err()); - arm(false); - *persister.exclusive.lock().unwrap() = Some((2, 5)); - managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) - .expect("the batch's row lands; the conflicting queued one is dropped"); - assert!(stored(5).is_some()); - assert_eq!(stored(2), None); - *persister.exclusive.lock().unwrap() = None; - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert_eq!(stored(2), None, "the dropped row is not queued"); - - // Key 9 queued; the next batch (key 1) is refused together with it, - // and key 9's own store then fails transiently: the batch landed. - arm(true); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(9))], &p) - .is_err()); - arm(false); - *persister.exclusive.lock().unwrap() = Some((1, 9)); - // Combined (refused), snapshot (lands), key 1 (lands), key 9 (transient). - persister - .transient_at - .store(attempts() + 4, std::sync::atomic::Ordering::SeqCst); - managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(1))], &p) - .expect("every row of the batch landed"); - assert!(stored(1).is_some()); - assert_eq!(stored(9), None); - *persister.exclusive.lock().unwrap() = None; - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!(stored(9) - .expect("key 9 stayed queued") - .derivation_indices - .is_some(),); - - // A batch row refused alone (3), then a transient failure on a later - // batch row (4): the constraint failure is the one returned. - persister.poison.lock().unwrap().insert(3); - // Combined (refused), snapshot (lands), key 3 (refused), key 4 (transient). - persister - .transient_at - .store(attempts() + 4, std::sync::atomic::Ordering::SeqCst); - let error = managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(3)), with_breadcrumb(key(4))], &p) - .expect_err("a batch row was refused"); - assert_eq!(error.kind(), Some(PersistenceErrorKind::Constraint)); - persister.poison.lock().unwrap().clear(); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!(stored(4).is_some(), "the unstored batch row stayed queued"); - assert_eq!(stored(3), None, "the refused batch row is dropped"); - } - - /// A store that unwinds while a constraint failure is traced leaves every - /// row not yet known to have landed queued: the queued row and the - /// batch's ride the next store. - #[test] - fn should_keep_the_rows_queued_when_a_store_unwinds_mid_trace() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let mut managed = ManagedIdentity::new( - Identity::V0(IdentityV0 { - id, - public_keys: BTreeMap::new(), - balance: 0, - revision: 0, - }), - 0, - ); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let with_breadcrumb = |key: IdentityPublicKey| KeyWithBreadcrumb { - breadcrumb: Some((wallet_id, 0, key.id())), - key, - derivation_rejected: BTreeSet::new(), - }; - let stored = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .iter() - .rev() - .find_map(|cs| { - cs.identity_keys - .as_ref() - .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) - }) - }; - - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .store_keys_with_snapshot(vec![with_breadcrumb(key(2))], &p) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - *persister.exclusive.lock().unwrap() = Some((2, 5)); - // Combined (refused), snapshot (lands), key 5 alone (panics). - let attempts = persister.attempts.load(std::sync::atomic::Ordering::SeqCst); - persister - .panic_at - .store(attempts + 3, std::sync::atomic::Ordering::SeqCst); - let unwound = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { - managed.store_keys_with_snapshot(vec![with_breadcrumb(key(5))], &p) - })); - assert!(unwound.is_err()); - *persister.exclusive.lock().unwrap() = None; - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!(stored(2) - .expect("the queued row stayed owed") - .derivation_indices - .is_some()); - assert!(stored(5) - .expect("the batch's row stayed owed") - .derivation_indices - .is_some()); - } - - /// A queued row whose key memory no longer holds is dropped, and the - /// rows a refused `add_identity` store carried ride the next key store. - #[test] - fn should_drop_a_queued_row_memory_lost_and_queue_a_refused_add_identity() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let stored = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .iter() - .rev() - .find_map(|cs| { - cs.identity_keys - .as_ref() - .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) - }) - }; - let arm = |on: bool| { - persister - .armed - .store(on, std::sync::atomic::Ordering::SeqCst) - }; - - let mut manager = crate::wallet::identity::state::manager::IdentityManager::new(); - arm(true); - manager - .add_identity( - Identity::V0(IdentityV0 { - id, - public_keys: BTreeMap::from([(1, key(1)), (2, key(2))]), - balance: 0, - revision: 0, - }), - 0, - wallet_id, - &p, - ) - .expect("a refused store is logged, not returned"); - arm(false); - let managed = manager.managed_identity_mut(&id).unwrap(); - // Key 2 leaves memory before the queue is retried. - managed - .identity - .set_public_keys(BTreeMap::from([(1, key(1))])); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - assert!( - stored(1).is_some(), - "the refused add_identity row rides the next store" - ); - assert_eq!(stored(2), None, "a queued key memory lost is dropped"); - } - - /// A key a refresh adds is stored without a breadcrumb, which a load's - /// row then gives it, and a later breadcrumb-less change keeps. - #[test] - fn should_store_a_key_a_refresh_adds_and_keep_a_breadcrumb_a_load_gives_it() { + fn should_store_the_keys_a_refresh_adds_or_changes() { let id = Identifier::from([1u8; 32]); let wallet_id: WalletId = [0xAB; 32]; let identity = |revision, keys: Vec| { @@ -1948,267 +1097,46 @@ mod tests { }) }; let block = |height| BlockTime::new(height, 1, height); - let rows = |persister: &CapturingPersister, key_id: KeyID| -> Vec { - persister - .stores - .lock() - .unwrap() - .iter() - .filter_map(|cs| { - cs.identity_keys - .as_ref()? - .upserts - .get(&(id, key_id)) - .cloned() - }) - .collect() - }; - let persister = std::sync::Arc::new(CapturingPersister::default()); let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let mut managed = ManagedIdentity::new(identity(6, vec![key(1)]), 0); + let mut managed = ManagedIdentity::new(identity(6, vec![key(1), key(2)]), 0); managed.wallet_id = Some(wallet_id); - managed - .store_fetched_identity(&identity(7, vec![key(1), key(4)]), block(1), &p) - .unwrap(); - let added = rows(&persister, 4); - assert_eq!( - added.len(), - 1, - "the added key is stored, its origin unknown" - ); - assert_eq!(added[0].derivation_indices, None); - managed - .store_keys_with_snapshot( - vec![KeyWithBreadcrumb { - key: key(4), - breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: BTreeSet::new(), - }], - &p, - ) - .unwrap(); - let mut disabled = key(4); + let mut disabled = key(2); disabled.set_disabled_at(5); managed - .store_fetched_identity(&identity(8, vec![key(1), disabled.clone()]), block(2), &p) - .unwrap(); - // The refresh's row carries no breadcrumb; the persisters keep the - // load's (SQLite: `should_keep_a_stored_breadcrumb_when_an_upsert_carries_none`). - let last = rows(&persister, 4).pop().unwrap(); - assert_eq!(last.public_key, disabled); - assert_eq!(last.derivation_indices, None); - } - - /// A refused row lends its breadcrumb only to the same public key: a later - /// row of another key under the same id, or a queued row whose key memory - /// now holds differently, leaves the origin unknown. A row that settles a - /// key as not derived takes nothing. - #[test] - fn should_lend_a_queued_breadcrumb_only_to_the_same_public_key() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let other_key = |key_id: KeyID| { - IdentityPublicKey::V0(IdentityPublicKeyV0 { - data: dpp::platform_value::BinaryData::new(vec![0x03; 33]), - ..match key(key_id) { - IdentityPublicKey::V0(v0) => v0, - _ => unreachable!(), - } - }) - }; - let mut managed = ManagedIdentity::new( - Identity::V0(IdentityV0 { - id, - public_keys: BTreeMap::from([(1, key(1))]), - balance: 0, - revision: 0, - }), - 0, - ); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); - let row = |key_id: KeyID| { - persister - .stores - .lock() - .unwrap() - .last() - .and_then(|cs| cs.identity_keys.as_ref()) - .and_then(|keys| keys.upserts.get(&(id, key_id)).cloned()) - }; - let refuse = |managed: &mut ManagedIdentity, key: IdentityPublicKey| { - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .add_keys( - vec![KeyWithBreadcrumb { - breadcrumb: Some((wallet_id, 0, key.id())), - key, - derivation_rejected: BTreeSet::new(), - }], - &p, - ) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - }; - - // Its own row for another public key. - refuse(&mut managed, key(2)); - managed - .store_keys_with_snapshot( - vec![KeyWithBreadcrumb { - key: other_key(2), - breadcrumb: None, - derivation_rejected: BTreeSet::new(), - }], - &p, - ) - .unwrap(); - assert_eq!(row(2).unwrap().derivation_indices, None); - - // A queued row whose key memory now holds differently. - refuse(&mut managed, key(3)); - managed.identity.add_public_key(other_key(3)); - managed.store_keys_with_snapshot(Vec::new(), &p).unwrap(); - let queued = row(3).unwrap(); - assert_eq!(queued.public_key, other_key(3)); - assert_eq!(queued.derivation_indices, None); - - // A row that settles the same key as not derived. - refuse(&mut managed, key(4)); - managed - .store_keys_with_snapshot( - vec![KeyWithBreadcrumb { - key: key(4), - breadcrumb: None, - derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 4, - }]), - }], + .store_fetched_identity( + &identity(7, vec![key(1), disabled.clone(), key(4)]), + block(1), &p, ) .unwrap(); - let settled = row(4).unwrap(); - assert_eq!(settled.derivation_indices, None); - assert!(!settled.derivation_rejected.is_empty()); - - // A refused rejection is kept by the next store's own row for the - // same key that leaves its origin unknown (a disable). - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .add_keys( - vec![KeyWithBreadcrumb { - key: key(5), - breadcrumb: None, - derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 5, - }]), - }], - &p, - ) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - managed.disable_keys(&[5], 9, &p); - let disabled = row(5).unwrap(); - assert_eq!(disabled.public_key.disabled_at(), Some(9)); - assert_eq!(disabled.derivation_rejected.len(), 1); - - // A refused rejection retried with a second, distinct one for the - // same key keeps both. - let rejecting = |identity_index| KeyWithBreadcrumb { - key: key(6), - breadcrumb: None, - derivation_rejected: BTreeSet::from([IdentityKeyDerivationIndices { - identity_index, - key_index: 6, - }]), - }; - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed.add_keys(vec![rejecting(0)], &p).is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); - managed.add_keys(vec![rejecting(1)], &p).unwrap(); - assert_eq!( - row(6).unwrap().derivation_rejected, - BTreeSet::from([ - IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 6, - }, - IdentityKeyDerivationIndices { - identity_index: 1, - key_index: 6, - }, - ]) - ); - assert!( - !disabled.derivation_rejected.is_empty(), - "the queued rejection survives" - ); - } - - /// A key row `add_keys` could not store, breadcrumb included, rides the - /// next key store, here a refresh that finds the same keys and so has no - /// row of its own to give them. - #[test] - fn should_store_a_refused_add_key_row_with_the_next_refresh() { - let id = Identifier::from([1u8; 32]); - let wallet_id: WalletId = [0xAB; 32]; - let identity = |keys: Vec| { - Identity::V0(IdentityV0 { - id, - public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), - balance: 0, - revision: 6, - }) - }; - let mut managed = ManagedIdentity::new(identity(vec![key(1)]), 0); - managed.wallet_id = Some(wallet_id); - let persister = std::sync::Arc::new(ArmedPersister::default()); - let p = WalletPersister::new(wallet_id, std::sync::Arc::clone(&persister) as _); + { + let stores = persister.stores.lock().unwrap(); + assert_eq!(stores.len(), 1, "keys and snapshot in one changeset"); + let snapshot = stores[0].identities.as_ref().unwrap(); + assert_eq!(snapshot.identities[&id].revision, 7); + let rows = &stores[0].identity_keys.as_ref().unwrap().upserts; + assert_eq!( + rows.keys().map(|(_, key_id)| *key_id).collect::>(), + vec![2, 4] + ); + assert_eq!(rows[&(id, 2)].public_key, disabled); + assert!(rows + .values() + .all(|row| row.derivation_indices.is_none() && row.wallet_id.is_none())); + } - persister - .armed - .store(true, std::sync::atomic::Ordering::SeqCst); - assert!(managed - .add_keys( - vec![KeyWithBreadcrumb { - key: key(4), - breadcrumb: Some((wallet_id, 0, 4)), - derivation_rejected: BTreeSet::new(), - }], - &p, - ) - .is_err()); - persister - .armed - .store(false, std::sync::atomic::Ordering::SeqCst); + // An answer from before the cache's revision changes no key. + persister.stores.lock().unwrap().clear(); managed - .store_fetched_identity(&identity(vec![key(1), key(4)]), BlockTime::new(1, 1, 1), &p) + .store_fetched_identity(&identity(6, vec![key(1), key(2)]), block(2), &p) .unwrap(); let stores = persister.stores.lock().unwrap(); - let row = stores - .last() - .and_then(|cs| cs.identity_keys.as_ref()) - .and_then(|keys| keys.upserts.get(&(id, 4))) - .expect("the refused row rides the refresh's store"); - assert_eq!(row.public_key, key(4)); - assert!(row.derivation_indices.is_some()); + assert_eq!(stores.len(), 1); + assert!(stores[0].identity_keys.as_ref().unwrap().upserts.is_empty()); + assert_eq!(managed.identity.revision(), 7); + assert_eq!(managed.identity.public_keys()[&2], disabled); } /// A batch of fetched names lands as one snapshot carrying every name, @@ -2429,12 +1357,10 @@ mod tests { crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: BTreeSet::new(), }, crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: BTreeSet::new(), }, ], &p, @@ -2477,6 +1403,8 @@ mod tests { /// breadcrumb when an upsert carries none. #[test] fn replace_key_carries_no_breadcrumb() { + use dpp::identity::identity_public_key::accessors::v1::IdentityPublicKeyGettersV1; + let identity = Identity::V0(IdentityV0 { id: Identifier::from([1u8; 32]), public_keys: BTreeMap::new(), @@ -2495,7 +1423,6 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(1), breadcrumb: None, - derivation_rejected: BTreeSet::new(), }], &p, ) @@ -2567,7 +1494,6 @@ mod tests { vec![crate::changeset::KeyWithBreadcrumb { key: key(0), breadcrumb: Some((wallet_id, 7, 0)), - derivation_rejected: BTreeSet::new(), }], &p, ); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index fabfa7fd609..436dbaaabf6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -20,10 +20,7 @@ pub use crate::wallet::identity::types::key_storage::{ self, DpnsFetch, DpnsNameInfo, IdentityStatus, KeyStorage, PrivateKeyData, }; -use crate::changeset::IdentityKeyEntry; -use dpp::identity::{Identity, KeyID}; -use dpp::prelude::Identifier; -use std::collections::BTreeMap; +use dpp::identity::Identity; /// A managed identity that combines an Identity with wallet-specific metadata. /// @@ -61,13 +58,6 @@ pub struct ManagedIdentity { /// retry. Query results are published only after that retry commits. pending_balance_snapshot: Option<(u64, BlockTime)>, - /// Key rows a fetch's fold or a landed update stored and the store - /// refused, owed to persistence. Every later such store carries them - /// (see `store_with_queued_key_rows`), each with the key as memory then - /// holds it, until they land; one the store refuses as invalid on its - /// own is dropped. - pending_key_rows: BTreeMap<(Identifier, KeyID), IdentityKeyEntry>, - /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 169fb997241..8bf238c99f7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -1,7 +1,7 @@ //! Synchronization and block time management for ManagedIdentity use super::ManagedIdentity; -use crate::changeset::IdentityKeysChangeSet; +use crate::changeset::{IdentityKeysChangeSet, PersistenceError, PlatformWalletChangeSet}; use crate::error::PlatformWalletError; use crate::wallet::persister::WalletPersister; use crate::BlockTime; @@ -100,23 +100,23 @@ impl ManagedIdentity { } } - /// Store `keys_cs`, the key rows an earlier refused store queued, and the - /// identity's scalar snapshot (status, revision and any queued balance) - /// as one changeset (see `store_with_queued_key_rows`). The store's - /// failure is returned, and its key rows are queued for the next one - /// (rows refused as invalid on their own excepted, see there). A store that - /// commits inline has committed the queued balance, which is then - /// cleared; a buffered one keeps it queued, so it rides later snapshots - /// until a balance write flushes it (see `retry_pending_balance`), and no - /// flush runs here. + /// Store `keys_cs` with the identity's scalar snapshot (status, revision + /// and any queued balance) as one changeset. The store's failure is + /// returned. A store that commits inline has committed the queued + /// balance, which is then cleared; a buffered one keeps it queued, so it + /// rides later snapshots until a balance write flushes it (see + /// `retry_pending_balance`), and no flush runs here. pub(crate) fn store_keys_and_balance( &mut self, keys_cs: IdentityKeysChangeSet, persister: &WalletPersister, - ) -> Result<(), PlatformWalletError> { + ) -> Result<(), PersistenceError> { let (balance, block_time) = self.balance_snapshot_for_persistence(); - self.store_with_queued_key_rows(keys_cs, persister) - .map_err(|e| persister.classify_store_failure(e))?; + persister.store(PlatformWalletChangeSet { + identities: Some(self.snapshot_changeset()), + identity_keys: Some(keys_cs), + ..Default::default() + })?; if self.pending_balance_snapshot.is_some() && persister.store_commits_inline() { self.identity.set_balance(balance); self.last_updated_balance_block_time = block_time; diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs index f5012742976..2efbf0f4345 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/lifecycle.rs @@ -7,7 +7,7 @@ //! [`IdentityManager::add_identity_unstored`], whose caller stores. use super::{IdentityLocation, IdentityManager}; -use crate::changeset::{IdentityChangeSet, IdentityEntry}; +use crate::changeset::{IdentityChangeSet, IdentityEntry, PlatformWalletChangeSet}; use crate::error::PlatformWalletError; use crate::wallet::identity::state::managed_identity::ManagedIdentity; use crate::wallet::persister::WalletPersister; @@ -47,11 +47,26 @@ impl IdentityManager { .managed_identity_mut(&identity_id) .ok_or(PlatformWalletError::IdentityNotFound(identity_id))?; - // The identity and its key rows go out as one changeset. A refused - // store is logged, not returned (the identity is already held), and - // its key rows stay queued for the identity's next key store. + let entry = IdentityEntry::from_managed(managed_identity); + // No `key_storage` to snapshot here anymore — the field is + // dropped from `ManagedIdentity`. Public keys still arrive + // via the separate `IdentityKeysChangeSet` path. let keys_cs = managed_identity.keys_snapshot_changeset(); - if let Err(e) = managed_identity.store_with_queued_key_rows(keys_cs, persister) { + + let mut id_cs = IdentityChangeSet::default(); + id_cs.identities.insert(identity_id, entry); + + let cs = PlatformWalletChangeSet { + identities: Some(id_cs), + identity_keys: if keys_cs.upserts.is_empty() { + None + } else { + Some(keys_cs) + }, + ..Default::default() + }; + + if let Err(e) = persister.store(cs) { tracing::error!("Failed to persist changeset: {}", e); } @@ -62,7 +77,8 @@ impl IdentityManager { /// and its keys itself, as one changeset. Load and discovery use it, so /// the keys they verified against the seed reach the store with the /// identity, never after a breadcrumb-less snapshot of them that could - /// commit on its own. + /// commit on its own (a disk that fills between the two writes would + /// otherwise leave every key row without its breadcrumb). pub(crate) fn add_identity_unstored( &mut self, identity: Identity, From ad09c6a27c8dcdd4d6ba8abee313df50dc61efbd Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:30 +0300 Subject: [PATCH 46/72] refactor(platform-wallet): return the identity FFI to base apart from the revision pin The update exports keep their base C ABI, plus platform_wallet_update_identity_with_signer_at_revision and code 62. The rejected-derivation fields leave IdentityKeyEntryFFI, and the shared panic guard goes back to living in the shielded send. A test pins that the at-revision export hands its revision to the guard before the signer is consulted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../src/identity_persistence.rs | 78 +--- .../src/identity_update.rs | 394 +++--------------- packages/rs-platform-wallet-ffi/src/lib.rs | 1 - .../rs-platform-wallet-ffi/src/panic_guard.rs | 53 --- .../src/shielded_send.rs | 51 ++- .../src/shielded_wallet_drain.rs | 5 +- 6 files changed, 106 insertions(+), 476 deletions(-) delete mode 100644 packages/rs-platform-wallet-ffi/src/panic_guard.rs diff --git a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs index ef907126101..e04c82a2ff0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_persistence.rs @@ -260,10 +260,8 @@ pub struct IdentityKeyEntryFFI { // re-derive the 32-byte ECDSA scalar from the named wallet's // mnemonic at the DIP-9 identity authentication path // `m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'` and - // persist it locally. When either flag is false the entry gives no - // breadcrumb: `rejected_derivations_ptr` / `_len` then say whether the - // client clears the one it holds (it names one of the rejected - // coordinates) or keeps it (empty: unknown origin). + // persist it locally. When either flag is false the key is + // watch-only. pub wallet_id_is_some: bool, pub wallet_id: [u8; 32], pub derivation_indices_is_some: bool, @@ -315,18 +313,6 @@ pub struct IdentityKeyEntryFFI { pub total_budget: u64, pub expires_at_is_some: bool, pub expires_at: u64, - - /// With `derivation_indices_is_some` false: the coordinates the key was - /// checked at under the persisting wallet's seed and found not to come - /// from (a candidate there did not reproduce it, or the caller declared - /// the key external), as `rejected_derivations_len` flat - /// `[identity_index, key_index]` pairs. The client clears a derivation - /// path that wallet holds for the key only when it names one of them. - /// Empty (null, 0) when the key's origin is unknown here: the client - /// keeps the path it holds. Owned by Rust, released by - /// [`free_identity_key_entry_ffi`]. - pub rejected_derivations_ptr: *mut u32, - pub rejected_derivations_len: usize, } /// Composite identifier for [`IdentityKeysChangeSet::removed`] entries @@ -379,11 +365,9 @@ pub struct IdentityKeyRemovalFFI { // 200 expires_at_is_some bool // 201..=207 (padding to 8) // 208..=215 expires_at u64 -// 216..=223 rejected_derivations_ptr *mut u32 -// 224..=231 rejected_derivations_len usize // -// Total size = 232, alignment = 8 (from u64 / pointer). -const _: [u8; 232] = [0u8; std::mem::size_of::()]; +// Total size = 216, alignment = 8 (from u64 / pointer). +const _: [u8; 216] = [0u8; std::mem::size_of::()]; const _: [u8; 8] = [0u8; std::mem::align_of::()]; // Compile-time guard for `IdentityEntryFFI`. Same rationale as the @@ -723,18 +707,6 @@ impl IdentityKeyEntryFFI { Some(idx) => (true, idx.identity_index, idx.key_index), None => (false, 0, 0), }; - let (rejected_derivations_ptr, rejected_derivations_len) = - if entry.derivation_indices.is_some() || entry.derivation_rejected.is_empty() { - (ptr::null_mut(), 0) - } else { - let pairs: Box<[u32]> = entry - .derivation_rejected - .iter() - .flat_map(|idx| [idx.identity_index, idx.key_index]) - .collect(); - let len = entry.derivation_rejected.len(); - (Box::into_raw(pairs) as *mut u32, len) - }; // Project the DPP `ContractBounds` enum into the kind / // id / doc-type-cstring trio so the Swift side can switch @@ -785,8 +757,6 @@ impl IdentityKeyEntryFFI { total_budget, expires_at_is_some, expires_at, - rejected_derivations_ptr, - rejected_derivations_len, } } } @@ -952,18 +922,6 @@ pub unsafe fn free_identity_key_entry_ffi(entry: &mut IdentityKeyEntryFFI) { let _ = unsafe { CString::from_raw(entry.contract_bounds_document_type as *mut c_char) }; entry.contract_bounds_document_type = ptr::null(); } - // The rejected-derivation pairs, a boxed `[u32]` of twice the length. - if !entry.rejected_derivations_ptr.is_null() && entry.rejected_derivations_len > 0 { - let slice = unsafe { - std::slice::from_raw_parts_mut( - entry.rejected_derivations_ptr, - entry.rejected_derivations_len * 2, - ) - }; - let _ = unsafe { Box::from_raw(slice as *mut [u32]) }; - entry.rejected_derivations_ptr = ptr::null_mut(); - entry.rejected_derivations_len = 0; - } } #[cfg(test)] @@ -976,7 +934,6 @@ mod tests { use platform_wallet::changeset::{ IdentityEntry, IdentityKeyDerivationIndices, IdentityKeyEntry, }; - use std::collections::BTreeSet; #[test] fn test_identity_entry_ffi_round_trip() { @@ -1264,7 +1221,6 @@ mod tests { identity_index: 3, key_index: 5, }), - derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.identity_id, [2u8; 32]); @@ -1307,33 +1263,10 @@ mod tests { public_key_hash: [0x00; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert!(!ffi.wallet_id_is_some); assert!(!ffi.derivation_indices_is_some); - assert!( - ffi.rejected_derivations_ptr.is_null() && ffi.rejected_derivations_len == 0, - "an unknown origin keeps a stored path" - ); - let settled = IdentityKeyEntry { - derivation_rejected: BTreeSet::from([ - IdentityKeyDerivationIndices { - identity_index: 2, - key_index: 5, - }, - IdentityKeyDerivationIndices { - identity_index: 3, - key_index: 5, - }, - ]), - ..entry.clone() - }; - let mut settled_ffi = IdentityKeyEntryFFI::from_entry(&settled); - assert_eq!(settled_ffi.rejected_derivations_len, 2); - let pairs = unsafe { std::slice::from_raw_parts(settled_ffi.rejected_derivations_ptr, 4) }; - assert_eq!(pairs, &[2, 5, 3, 5], "every refuted path is carried"); - unsafe { free_identity_key_entry_ffi(&mut settled_ffi) }; assert!(ffi.read_only); assert!(ffi.disabled_at_is_some); assert_eq!(ffi.disabled_at, 1_700_000_000); @@ -1365,7 +1298,6 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 3); @@ -1395,7 +1327,6 @@ mod tests { public_key_hash: [0x11; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 1); @@ -1428,7 +1359,6 @@ mod tests { public_key_hash: [0x22; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut ffi = IdentityKeyEntryFFI::from_entry(&entry); assert_eq!(ffi.contract_bounds_kind, 2); diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index a58ece91cb1..e22e7591ac4 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -34,7 +34,6 @@ use crate::handle::*; use crate::identity_registration_with_signer::{ decode_contract_bounds, key_with_row_limits, IdentityPubkeyFFI, }; -use crate::panic_guard::catch_panic_to_code; use crate::runtime::block_on_worker; use crate::types::*; use crate::{unwrap_option_or_return, unwrap_result_or_return}; @@ -303,12 +302,6 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// existing key IDs, signing the resulting `IdentityUpdateTransition` /// with the supplied `signer_handle`. /// -/// `external_key_ids` names the added keys that do not come from the -/// wallet's derivation (a key the caller generated or received): they are -/// stored without derivation coordinates. Every other added key is stored -/// as derived at the identity's index and the key's id. An id that names no -/// added key is refused before anything is signed. -/// /// # Safety /// - `wallet_handle` must come from the platform-wallet handle registry. /// - `identity_id` must be null (refused) or point at a 32-byte buffer. @@ -316,14 +309,9 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// point at a valid, aligned `[IdentityPubkeyFFI; add_public_keys_count]` /// array whose rows satisfy the contract documented on /// [`IdentityPubkeyFFI`]. -/// - When `external_key_ids_count` is non-zero, `external_key_ids` must -/// point at a valid, aligned `[u32; external_key_ids_count]` array. /// - When `disable_public_key_ids_count` is non-zero, /// `disable_public_key_ids` must point at a valid, aligned /// `[u32; disable_public_key_ids_count]` array. -/// - Any of the three arrays that is null with a non-zero count is refused -/// with `ErrorNullPointer` before anything is looked up or signed, never -/// read as empty. /// - `signer_handle` must be a valid, non-destroyed `SignerHandle` from /// rs-sdk-ffi's signer constructors (typically `KeychainSigner.handle`, /// made by `dash_sdk_signer_create_with_ctx`). It is borrowed until the @@ -340,8 +328,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, - external_key_ids: *const u32, - external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, signer_handle: *mut SignerHandle, @@ -351,8 +337,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( identity_id, add_public_keys, add_public_keys_count, - external_key_ids, - external_key_ids_count, disable_public_key_ids, disable_public_key_ids_count, None, @@ -376,8 +360,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, - external_key_ids: *const u32, - external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, revision: u64, @@ -388,8 +370,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision identity_id, add_public_keys, add_public_keys_count, - external_key_ids, - external_key_ids_count, disable_public_key_ids, disable_public_key_ids_count, Some(revision), @@ -397,117 +377,12 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision ) } -/// Post-panic guidance for the identity update exports. A panic can strike -/// after the transition was broadcast, so the outcome is ambiguous. -const UPDATE_PANIC_GUIDANCE: &str = - "The update may or may not have been broadcast: refresh the identity before deciding again."; - -/// The same, for the export that pinned a revision: a retry there cannot -/// land the update twice. -const UPDATE_AT_REVISION_PANIC_GUIDANCE: &str = "The update may or may not have been broadcast: \ - refresh the identity before deciding again. An update asked at a revision cannot land twice \ - there."; - -/// The shared body of both update exports, under `catch_unwind`: a panic -/// (most concretely `block_on_worker`'s `.expect` on a panicking worker) -/// must not reach the `extern "C"` frame, where it would abort the process. -/// It surfaces as `ErrorUnknown`, never as a definitive pre-broadcast -/// failure, since the transition may already be on Platform. #[allow(clippy::too_many_arguments)] unsafe fn update_identity_with_signer( wallet_handle: Handle, identity_id: *const u8, add_public_keys: *const IdentityPubkeyFFI, add_public_keys_count: usize, - external_key_ids: *const u32, - external_key_ids_count: usize, - disable_public_key_ids: *const u32, - disable_public_key_ids_count: usize, - revision: Option, - signer_handle: *mut SignerHandle, -) -> PlatformWalletFFIResult { - guard_update(revision.is_some(), || { - update_identity_with_signer_unguarded( - wallet_handle, - identity_id, - add_public_keys, - add_public_keys_count, - external_key_ids, - external_key_ids_count, - disable_public_key_ids, - disable_public_key_ids_count, - revision, - signer_handle, - ) - }) -} - -/// `body` under the update exports' panic guard; `at_revision` picks the -/// guidance for the export that pinned a revision. -fn guard_update( - at_revision: bool, - body: impl FnOnce() -> PlatformWalletFFIResult, -) -> PlatformWalletFFIResult { - let guidance = if at_revision { - UPDATE_AT_REVISION_PANIC_GUIDANCE - } else { - UPDATE_PANIC_GUIDANCE - }; - catch_panic_to_code( - "identity update", - PlatformWalletFFIResultCode::ErrorUnknown, - guidance, - body, - ) -} - -/// A null array with a non-zero count is refused rather than read as empty. -/// Read as empty, it would drop part of what the caller asked for and still -/// broadcast the rest: a null `disable_public_key_ids` turns "add a key and -/// revoke a compromised one" into an add-only update, a null `add_public_keys` -/// turns it into a revoke-only one, and a null `external_key_ids` stores the -/// caller's external keys as derived. -fn refuse_null_with_count( - is_null: bool, - count: usize, - name: &str, -) -> Result<(), PlatformWalletFFIResult> { - if is_null && count != 0 { - return Err(PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorNullPointer, - format!("{name} is null with a non-zero count"), - )); - } - Ok(()) -} - -/// Copies a caller's `[u32; count]` key-id array, under -/// [`refuse_null_with_count`]. A zero count reads as empty whatever the -/// pointer. -/// -/// # Safety -/// When `count` is non-zero and `ids` is non-null, `ids` must point at a -/// valid, aligned `[u32; count]` array. -unsafe fn read_key_ids( - ids: *const u32, - count: usize, - name: &str, -) -> Result, PlatformWalletFFIResult> { - refuse_null_with_count(ids.is_null(), count, name)?; - if count == 0 { - return Ok(Vec::new()); - } - Ok(slice::from_raw_parts(ids, count).to_vec()) -} - -#[allow(clippy::too_many_arguments)] -unsafe fn update_identity_with_signer_unguarded( - wallet_handle: Handle, - identity_id: *const u8, - add_public_keys: *const IdentityPubkeyFFI, - add_public_keys_count: usize, - external_key_ids: *const u32, - external_key_ids_count: usize, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, revision: Option, @@ -517,67 +392,57 @@ unsafe fn update_identity_with_signer_unguarded( let id = unwrap_result_or_return!(read_identifier(identity_id)); - // Every array is checked before any row is read, the wallet is looked up - // or the signer is consulted. - unwrap_result_or_return!(refuse_null_with_count( - add_public_keys.is_null(), - add_public_keys_count, - "add_public_keys" - )); - let external_ids = unwrap_result_or_return!(read_key_ids( - external_key_ids, - external_key_ids_count, - "external_key_ids" - )); - let disable_ids = unwrap_result_or_return!(read_key_ids( - disable_public_key_ids, - disable_public_key_ids_count, - "disable_public_key_ids" - )); + let add_keys: Vec = + if add_public_keys.is_null() || add_public_keys_count == 0 { + Vec::new() + } else { + let rows: &[IdentityPubkeyFFI] = + slice::from_raw_parts(add_public_keys, add_public_keys_count); + let mut keys: Vec = Vec::with_capacity(rows.len()); + for (i, row) in rows.iter().enumerate() { + let key_type = unwrap_result_or_return!(KeyType::try_from(row.key_type)); + let purpose = unwrap_result_or_return!(Purpose::try_from(row.purpose)); + let security_level = + unwrap_result_or_return!(SecurityLevel::try_from(row.security_level)); + if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorNullPointer, + format!("add_public_keys[{i}].pubkey_bytes is null or empty"), + ); + } + let pubkey_bytes: Vec = + slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); - let add_keys: Vec = if add_public_keys_count == 0 { - Vec::new() - } else { - let rows: &[IdentityPubkeyFFI] = - slice::from_raw_parts(add_public_keys, add_public_keys_count); - let mut keys: Vec = Vec::with_capacity(rows.len()); - for (i, row) in rows.iter().enumerate() { - let key_type = unwrap_result_or_return!(KeyType::try_from(row.key_type)); - let purpose = unwrap_result_or_return!(Purpose::try_from(row.purpose)); - let security_level = - unwrap_result_or_return!(SecurityLevel::try_from(row.security_level)); - if row.pubkey_bytes.is_null() || row.pubkey_len == 0 { - return PlatformWalletFFIResult::err( - PlatformWalletFFIResultCode::ErrorNullPointer, - format!("add_public_keys[{i}].pubkey_bytes is null or empty"), - ); - } - let pubkey_bytes: Vec = - slice::from_raw_parts(row.pubkey_bytes, row.pubkey_len).to_vec(); - - let contract_bounds = unwrap_result_or_return!(decode_contract_bounds( - row, - purpose, - i, - "add_public_keys" - )); - - keys.push(key_with_row_limits( - IdentityPublicKey::V0(IdentityPublicKeyV0 { - id: row.key_id, + let contract_bounds = unwrap_result_or_return!(decode_contract_bounds( + row, purpose, - security_level, - contract_bounds, - key_type, - read_only: row.read_only, - data: BinaryData::new(pubkey_bytes), - disabled_at: None, - }), - row, - )); - } - keys - }; + i, + "add_public_keys" + )); + + keys.push(key_with_row_limits( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: row.key_id, + purpose, + security_level, + contract_bounds, + key_type, + read_only: row.read_only, + data: BinaryData::new(pubkey_bytes), + disabled_at: None, + }), + row, + )); + } + keys + }; + + let disable_ids: Vec = + if disable_public_key_ids.is_null() || disable_public_key_ids_count == 0 { + Vec::new() + } else { + slice::from_raw_parts(disable_public_key_ids, disable_public_key_ids_count).to_vec() + }; let signer_addr = signer_handle as usize; @@ -589,7 +454,6 @@ unsafe fn update_identity_with_signer_unguarded( .update_identity_with_external_signer_at_revision( &id, add_keys, - &external_ids, disable_ids, revision, signer, @@ -625,31 +489,6 @@ mod tests { use crate::runtime::runtime; - /// A panic in an update body comes back as `ErrorUnknown` with the - /// ambiguous-outcome guidance, not as a definitive failure, and never - /// unwinds into the C frame. - #[test] - fn should_report_a_panicking_update_as_an_ambiguous_outcome() { - for (at_revision, guidance) in [ - (false, UPDATE_PANIC_GUIDANCE), - (true, UPDATE_AT_REVISION_PANIC_GUIDANCE), - ] { - let result = guard_update(at_revision, || panic!("worker gone")); - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorUnknown); - let message = unsafe { CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned(); - assert!( - message.contains("identity update panicked: worker gone"), - "{message}" - ); - assert!(message.ends_with(guidance), "{message}"); - } - - let ok = guard_update(true, PlatformWalletFFIResult::ok); - assert_eq!(ok.code, PlatformWalletFFIResultCode::Success); - } - /// Counts every call the update makes into a signer created through /// `dash_sdk_signer_create_with_ctx`; `ctx` points at the counter. unsafe extern "C" fn counting_sign_async( @@ -697,139 +536,6 @@ mod tests { .into_owned() } - /// Which array is null with a non-zero count, for - /// [`call_update_with_null_array`]. - #[derive(Clone, Copy, Debug)] - enum NullArray { - AddPublicKeys, - ExternalKeyIds, - DisablePublicKeyIds, - } - - /// Calls one update export with the other two arrays well-formed and - /// non-empty and `null` as the named one, over a handle that names no - /// wallet: a refusal that came after the wallet lookup would surface as - /// `NotFound` instead. - fn call_update_with_null_array( - null: NullArray, - revision: Option, - signer: *mut SignerHandle, - ) -> PlatformWalletFFIResult { - let identity_id = [0x11u8; 32]; - let pubkey = [0x02u8; 33]; - let row = IdentityPubkeyFFI { - key_id: 9, - key_type: KeyType::ECDSA_SECP256K1 as u8, - purpose: Purpose::AUTHENTICATION as u8, - security_level: SecurityLevel::HIGH as u8, - pubkey_bytes: pubkey.as_ptr(), - pubkey_len: pubkey.len(), - read_only: false, - contract_bounds_kind: 0, - contract_bounds_id: ptr::null(), - contract_bounds_document_type: ptr::null(), - has_total_budget: false, - total_budget: 0, - has_expires_at: false, - expires_at: 0, - }; - let external_ids = [9u32]; - let disable_ids = [4u32]; - let add_ptr = match null { - NullArray::AddPublicKeys => ptr::null(), - _ => &row as *const IdentityPubkeyFFI, - }; - let external_ptr = match null { - NullArray::ExternalKeyIds => ptr::null(), - _ => external_ids.as_ptr(), - }; - let disable_ptr = match null { - NullArray::DisablePublicKeyIds => ptr::null(), - _ => disable_ids.as_ptr(), - }; - let unknown_handle = Handle::MAX; - unsafe { - match revision { - None => platform_wallet_update_identity_with_signer( - unknown_handle, - identity_id.as_ptr(), - add_ptr, - 1, - external_ptr, - 1, - disable_ptr, - 1, - signer, - ), - Some(revision) => platform_wallet_update_identity_with_signer_at_revision( - unknown_handle, - identity_id.as_ptr(), - add_ptr, - 1, - external_ptr, - 1, - disable_ptr, - 1, - revision, - signer, - ), - } - } - } - - /// Read as empty, a null array with a count would broadcast part of the - /// update (an add without the revoke the caller asked for, say), so each - /// of the three is refused, in both exports, before the wallet is looked - /// up or the signer consulted. - #[test] - fn should_refuse_a_null_array_with_a_count_before_the_wallet_or_the_signer() { - let calls = AtomicUsize::new(0); - let signer = counting_signer(&calls); - for (null, name) in [ - (NullArray::AddPublicKeys, "add_public_keys"), - (NullArray::ExternalKeyIds, "external_key_ids"), - (NullArray::DisablePublicKeyIds, "disable_public_key_ids"), - ] { - for revision in [None, Some(5)] { - let result = call_update_with_null_array(null, revision, signer); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorNullPointer, - "{null:?} at {revision:?}: {}", - message_of(&result) - ); - assert_eq!( - message_of(&result), - format!("{name} is null with a non-zero count"), - "{null:?} at {revision:?}" - ); - } - } - assert_eq!(calls.load(Ordering::SeqCst), 0); - unsafe { dash_sdk_signer_destroy(signer) }; - } - - /// A zero count still reads as empty whatever the pointer, so the - /// refusal is only for a null array that claims rows. - #[test] - fn should_read_a_zero_count_as_empty_whatever_the_pointer() { - assert!(refuse_null_with_count(true, 0, "add_public_keys").is_ok()); - assert!(refuse_null_with_count(false, 3, "add_public_keys").is_ok()); - let ids = [7u32, 8]; - assert_eq!( - unsafe { read_key_ids(ptr::null(), 0, "disable_public_key_ids") }.ok(), - Some(vec![]) - ); - assert_eq!( - unsafe { read_key_ids(ids.as_ptr(), 0, "disable_public_key_ids") }.ok(), - Some(vec![]) - ); - assert_eq!( - unsafe { read_key_ids(ids.as_ptr(), 2, "disable_public_key_ids") }.ok(), - Some(vec![7, 8]) - ); - } - /// A wallet handle whose cached identity `identity_id` is at `revision` /// and holds a signable MASTER key (id 0), so an update that got past /// the revision check would consult the signer. The manager owns the @@ -883,8 +589,6 @@ mod tests { id_bytes.as_ptr(), ptr::null(), 0, - ptr::null(), - 0, disable_ids.as_ptr(), disable_ids.len(), revision, diff --git a/packages/rs-platform-wallet-ffi/src/lib.rs b/packages/rs-platform-wallet-ffi/src/lib.rs index 01f158fd611..6b7b6f5f258 100644 --- a/packages/rs-platform-wallet-ffi/src/lib.rs +++ b/packages/rs-platform-wallet-ffi/src/lib.rs @@ -64,7 +64,6 @@ pub mod masternode_update_service; pub mod masternode_withdrawal; pub mod memory_explorer; pub mod mnemonic_words; -pub(crate) mod panic_guard; pub mod parse_state_transition; pub mod persistence; pub mod platform_address_sync; diff --git a/packages/rs-platform-wallet-ffi/src/panic_guard.rs b/packages/rs-platform-wallet-ffi/src/panic_guard.rs deleted file mode 100644 index 3f062bdb759..00000000000 --- a/packages/rs-platform-wallet-ffi/src/panic_guard.rs +++ /dev/null @@ -1,53 +0,0 @@ -//! Keeping a panic in an FFI export body from unwinding into the C ABI. - -use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; - -/// Render a caught panic payload as a human-readable string. -fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { - if let Some(s) = payload.downcast_ref::<&'static str>() { - (*s).to_string() - } else if let Some(s) = payload.downcast_ref::() { - s.clone() - } else { - "".to_string() - } -} - -/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the -/// operation's contract-appropriate result `code` (with `guidance` appended to the message) -/// instead of letting it reach the `extern "C"` frame. -/// -/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer -/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but -/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the -/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it -/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, -/// note bookkeeping, the SDK) re-panics right here inside the export. -/// -/// The `code` must be chosen per operation to preserve that operation's result contract — a -/// panic can strike after side effects (note reservation, a broadcast) have happened, so the -/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See -/// `shielded_send`'s `catch_spend_panic` and the per-export call sites. -/// -/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and -/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles -/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size -/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the -/// process before this guard can see it. -pub(crate) fn catch_panic_to_code( - operation: &str, - code: PlatformWalletFFIResultCode, - guidance: &str, - body: impl FnOnce() -> PlatformWalletFFIResult, -) -> PlatformWalletFFIResult { - match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { - Ok(result) => result, - Err(payload) => PlatformWalletFFIResult::err( - code, - format!( - "{operation} panicked: {}. {guidance}", - panic_payload_message(payload.as_ref()) - ), - ), - } -} diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 9feb2cd8394..01f385d75b5 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -64,7 +64,6 @@ use crate::core_wallet_types::OutPointFFI; use crate::error::*; use crate::handle::*; use crate::identity_registration_with_signer::{decode_identity_pubkeys, IdentityPubkeyFFI}; -use crate::panic_guard::catch_panic_to_code; use crate::runtime::{block_on_worker, runtime}; use crate::shielded_types::{ShieldedIdentityDebitRecoveryRecordFFI, ShieldedShieldPreflightFFI}; use crate::types::read_identifier; @@ -682,6 +681,56 @@ fn is_preserved_signer_key_unavailable(error: &PlatformWalletError) -> bool { ) } +/// Render a caught panic payload as a human-readable string. +fn panic_payload_message(payload: &(dyn std::any::Any + Send)) -> String { + if let Some(s) = payload.downcast_ref::<&'static str>() { + (*s).to_string() + } else if let Some(s) = payload.downcast_ref::() { + s.clone() + } else { + "".to_string() + } +} + +/// Run an FFI export body under [`std::panic::catch_unwind`], converting a panic into the +/// operation's contract-appropriate result `code` (with `guidance` appended to the message) +/// instead of letting it reach the `extern "C"` frame. +/// +/// A Rust panic cannot unwind through a C ABI boundary: it aborts the process. The JNI layer +/// wraps its calls in `support::guard` (which catches panics and raises a Java exception), but +/// that guard sits on the FAR side of this `extern "C"` export, so it never sees the unwind — the +/// process is already gone. `block_on_worker` makes this reachable rather than theoretical: it +/// `.expect`s on the tokio `JoinError`, so any panic inside the proving future (Halo 2 synthesis, +/// note bookkeeping, the SDK) re-panics right here inside the export. +/// +/// The `code` must be chosen per operation to preserve that operation's result contract — a +/// panic can strike after side effects (note reservation, a broadcast) have happened, so the +/// outcome is genuinely ambiguous and the code must never promise a definitive failure. See +/// [`catch_spend_panic`] and the per-export call sites. +/// +/// NOTE: this guard is only effective where panics unwind. The Android (`*-android`) and +/// host/test profiles build with `panic = "unwind"`, so it works there; the iOS profiles +/// (`dev-ios` / `release-ios`) build with `panic = "abort"` as part of their staticlib size +/// tuning (see the workspace `Cargo.toml` profile comments), so on iOS a panic still aborts the +/// process before this guard can see it. +pub(crate) fn catch_panic_to_code( + operation: &str, + code: PlatformWalletFFIResultCode, + guidance: &str, + body: impl FnOnce() -> PlatformWalletFFIResult, +) -> PlatformWalletFFIResult { + match std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) { + Ok(result) => result, + Err(payload) => PlatformWalletFFIResult::err( + code, + format!( + "{operation} panicked: {}. {guidance}", + panic_payload_message(payload.as_ref()) + ), + ), + } +} + /// Post-panic guidance for the note-spending exports. Paired with /// `ErrorShieldedSpendUnconfirmed` in [`catch_spend_panic`]. const SPEND_PANIC_GUIDANCE: &str = "The spend may or may not have been broadcast — do NOT \ diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs index 3f4a8ce4aee..52463b4c307 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -16,9 +16,10 @@ use crate::check_ptr; use crate::core_wallet_types::OutPointFFI; use crate::error::{PlatformWalletFFIResult, PlatformWalletFFIResultCode}; use crate::handle::Handle; -use crate::panic_guard::catch_panic_to_code; use crate::runtime::block_on_worker; -use crate::shielded_send::{catch_funding_panic, resolve_wallet_and_coordinator}; +use crate::shielded_send::{ + catch_funding_panic, catch_panic_to_code, resolve_wallet_and_coordinator, +}; use std::sync::Arc; From 5b3840fead205e52716bee7931aac85e40c29967 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:30 +0300 Subject: [PATCH 47/72] refactor(platform-wallet-storage): keep a stored breadcrumb on the same row, drop the rejection rules An identity-key upsert without a breadcrumb keeps the one its row holds, as the Swift and Kotlin persisters already do; a row that does not decode or is over the blob limit is replaced. The rejection and same-public-key checks go. The restore-after-disk-full and refresh-found-key scenarios are kept as regression tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../src/sqlite/schema/identities.rs | 3 - .../src/sqlite/schema/identity_keys.rs | 148 +---- .../tests/integration/main.rs | 1 + .../sqlite_contacts_keys_rehydration.rs | 2 - ...qlite_delete_wallet_constraint_carveout.rs | 2 - .../sqlite_fk_changeset_ordering.rs | 2 - .../sqlite_identity_hard_delete.rs | 1 - .../sqlite_identity_key_restore.rs | 594 ++++++++++++++++++ .../sqlite_identity_keys_reader.rs | 412 +----------- .../integration/sqlite_persist_roundtrip.rs | 2 - .../tests/integration/sqlite_recovery_mode.rs | 2 - .../sqlite_structural_hardening.rs | 2 - .../integration/sqlite_unowned_identities.rs | 2 - .../tests/integration/sqlite_version_bump.rs | 2 - 14 files changed, 620 insertions(+), 555 deletions(-) create mode 100644 packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs index 8e5edf3388c..d29c622f5eb 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identities.rs @@ -722,7 +722,6 @@ mod tests { use dpp::prelude::Identifier; use platform_wallet::changeset::IdentityChangeSet; use platform_wallet::wallet::identity::IdentityStatus; - use std::collections::BTreeSet; fn migrated_conn() -> Connection { let mut conn = Connection::open_in_memory().unwrap(); @@ -904,7 +903,6 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts @@ -1113,7 +1111,6 @@ mod tests { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index 7dc831c300f..c8a47eced49 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -8,7 +8,6 @@ use rusqlite::{params, Connection, OptionalExtension, Transaction}; use serde::{Deserialize, Serialize}; -use std::collections::BTreeSet; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::IdentityPublicKey; @@ -69,67 +68,10 @@ impl IdentityKeyWire { public_key_hash: self.public_key_hash, wallet_id: self.wallet_id, derivation_indices: self.derivation_indices, - derivation_rejected: BTreeSet::new(), }) } } -/// The breadcrumb a stored row holds for `entry`'s key, to keep on an upsert -/// that carries none. Only a row that passes the checks `load_state` applies -/// (its blob agrees with its typed columns and its wallet scope) and stores -/// the same public key gives one: a breadcrumb belongs to one key's -/// derivation. A row that fails them, or does not decode, holds nothing to -/// keep; the write replaces it, as it did before rows kept breadcrumbs. -fn held_breadcrumb( - (blob_len, payload, typed_public_key_hash): (i64, Vec, Vec), - entry: &IdentityKeyEntry, - wallet_id: &WalletId, -) -> Option<(Option<[u8; 32]>, IdentityKeyDerivationIndices)> { - let held = blob::check_size(blob_len).and_then(|()| decode_entry(&payload)); - let held = match held { - Ok(held) => held, - Err(e) => { - tracing::warn!( - key_id = entry.key_id, - error = %e, - "identity_keys: stored row does not decode; replacing it", - ); - return None; - } - }; - let consistent = held.identity_id == entry.identity_id - && held.key_id == entry.key_id - && held.public_key.id() == entry.key_id - && held.public_key_hash[..] == typed_public_key_hash[..] - && held.public_key_hash == entry.public_key_hash - && held.public_key.data() == entry.public_key.data() - && held - .wallet_id - .is_none_or(|held_wallet_id| held_wallet_id == *wallet_id); - if !consistent { - tracing::warn!( - key_id = entry.key_id, - "identity_keys: stored row contradicts its columns or the key; replacing it", - ); - return None; - } - Some((held.wallet_id, held.derivation_indices?)) -} - -/// Read a stored row selected as `(length(public_key_blob), public_key_blob, -/// public_key_hash)`. The size guard runs before the payload is read, as in -/// `load_state`; an oversized row is read as empty and replaced -/// (`held_breadcrumb`). -fn read_stored_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(i64, Vec, Vec)> { - let blob_len: i64 = row.get(0)?; - let payload: Vec = if blob::check_size(blob_len).is_ok() { - row.get(1)? - } else { - Vec::new() - }; - Ok((blob_len, payload, row.get(2)?)) -} - /// SQLite's extended result code for a foreign-key constraint failure /// (`SQLITE_CONSTRAINT_FOREIGNKEY`). Discriminated numerically — never by /// matching the driver's message text. @@ -184,6 +126,12 @@ fn map_fk_violation( /// cross-checked against it so the typed columns and the blob stay aligned. /// A cross-wallet write is refused with /// [`WalletStorageError::IdentityKeyWalletMismatch`]. +/// +/// An upsert without a derivation breadcrumb keeps the one the same row +/// already holds, as the Swift and Kotlin persisters do: a write that only +/// knows the key's new state (a refresh, a raised limit, a disable) must not +/// turn a wallet-derived key watch-only. A stored row that does not decode, +/// or is over the blob size limit, holds nothing to keep and is replaced. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -219,17 +167,12 @@ pub fn apply( // 32 zero bytes instead would produce a row that matches no // wallet, no NULL-scoped reader, and no guard. let wallet_id_param = super::wallet_id_to_param(wallet_id); - // An entry without a derivation breadcrumb that leaves the key's - // origin unknown keeps the one the row already holds under this - // scope, as the Swift and Kotlin persisters do: an upsert that only - // knows the key's new state (a raised limit, a disable, a refresh) - // must not turn a wallet-derived key watch-only. One that refutes the - // coordinates the row holds (`derivation_rejected`) clears them. let mut stored = tx.prepare_cached( - "SELECT length(public_key_blob), public_key_blob, public_key_hash \ - FROM identity_keys \ - WHERE identity_id = ?1 AND key_id = ?2 AND wallet_id IS ?3", + "SELECT public_key_blob FROM identity_keys \ + WHERE identity_id = ?1 AND key_id = ?2 AND wallet_id IS ?3 \ + AND length(public_key_blob) <= ?4", )?; + let size_limit = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap_or(i64::MAX); for ((identity_id, key_id), entry) in &cs.upserts { // Typed columns and blob fields must agree so a row can never // diverge on disk. @@ -242,21 +185,23 @@ pub fn apply( } } let mut wire = IdentityKeyWire::from_entry(entry)?; - // An entry without a breadcrumb keeps the row's, unless it refutes - // exactly the coordinates the row holds (`derivation_rejected`). if wire.derivation_indices.is_none() { - let held: Option<(i64, Vec, Vec)> = stored + let held: Option> = stored .query_row( - params![identity_id.as_slice(), i64::from(*key_id), wallet_id_param], - read_stored_row, + params![ + identity_id.as_slice(), + i64::from(*key_id), + wallet_id_param, + size_limit + ], + |row| row.get(0), ) .optional()?; - if let Some((wallet_id_kept, derivation_indices)) = held - .and_then(|held| held_breadcrumb(held, entry, wallet_id)) - .filter(|(_, held)| !entry.derivation_rejected.contains(held)) - { - wire.wallet_id = wallet_id_kept; - wire.derivation_indices = Some(derivation_indices); + if let Some(held) = held.and_then(|payload| decode_entry(&payload).ok()) { + if held.derivation_indices.is_some() { + wire.wallet_id = held.wallet_id; + wire.derivation_indices = held.derivation_indices; + } } } let entry_blob = blob::encode(&wire)?; @@ -390,15 +335,6 @@ pub fn load_state( Ok(cs) } -/// Encode an [`IdentityKeyEntry`] to its on-disk `public_key_blob` form via -/// the wire type. Test-only seam so integration tests can forge a row whose -/// blob disagrees with its typed columns without naming the crate-internal -/// [`IdentityKeyWire`]. -#[cfg(any(test, feature = "__test-helpers"))] -pub fn encode_entry_for_test(entry: &IdentityKeyEntry) -> Result, WalletStorageError> { - blob::encode(&IdentityKeyWire::from_entry(entry)?) -} - /// Build an outer `public_key_blob` payload whose inner `public_key_bincode` /// field contains a crafted byte sequence that causes the inner /// `blob::bounded_config()` decode to fail. Used by the blob-gate integration @@ -691,43 +627,6 @@ mod tests { ); } - /// Run `read_stored_row` over a synthetic row whose stated blob length is - /// `blob_len` while the payload cell actually holds `payload`. - fn read_synthetic_row(blob_len: i64, payload: &[u8]) -> (i64, Vec, Vec) { - let conn = Connection::open_in_memory().unwrap(); - conn.query_row( - "SELECT ?1, ?2, ?3", - params![blob_len, payload, &[0x5Au8; 20][..]], - read_stored_row, - ) - .unwrap() - } - - /// A stored row whose length exceeds the blob size limit is not read: the - /// payload comes back empty, whatever the cell holds, so the writer never - /// allocates an oversized buffer to look for a breadcrumb. - #[test] - fn should_read_an_oversized_stored_row_as_an_empty_payload() { - let blob_len = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap() + 1; - let (len, payload, hash) = read_synthetic_row(blob_len, &[0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!(len, blob_len); - assert!( - payload.is_empty(), - "oversized payload was read: {payload:?}" - ); - assert_eq!(hash, vec![0x5Au8; 20]); - } - - /// A stored row within the limit is read in full. - #[test] - fn should_read_a_stored_row_within_the_size_limit() { - let blob_len = i64::try_from(blob::BLOB_SIZE_LIMIT_BYTES).unwrap(); - let (len, payload, hash) = read_synthetic_row(blob_len, &[0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!(len, blob_len); - assert_eq!(payload, vec![0xDE, 0xAD, 0xBE, 0xEF]); - assert_eq!(hash, vec![0x5Au8; 20]); - } - /// `IdentityKeyEntry` carries no key material by construction /// (derive-sign-destroy removed the carried scalar; the client derives it /// on demand from the keychain), so the "no key material at rest outside @@ -757,7 +656,6 @@ mod tests { identity_index: 1, key_index: 2, }), - derivation_rejected: BTreeSet::new(), }; let wire = IdentityKeyWire::from_entry(&entry).expect("encode wire"); diff --git a/packages/rs-platform-wallet-storage/tests/integration/main.rs b/packages/rs-platform-wallet-storage/tests/integration/main.rs index 918d0c6bac2..0f0a6c6f051 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/main.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/main.rs @@ -47,6 +47,7 @@ mod sqlite_foreign_keys; mod sqlite_identity_hard_delete; mod sqlite_identity_index_concurrency; mod sqlite_identity_index_uniqueness; +mod sqlite_identity_key_restore; mod sqlite_identity_keys_reader; mod sqlite_identity_scan_state; mod sqlite_legacy_state_migration; diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs index ae7a6207c0d..948d4a981c4 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_contacts_keys_rehydration.rs @@ -6,7 +6,6 @@ //! `ManagedIdentity` fields directly. use crate::common; -use std::collections::BTreeSet; use std::collections::{BTreeMap, HashSet}; @@ -87,7 +86,6 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8, security: SecurityLevel) -> public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs index 1ede76a46e7..c7a444c8676 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_delete_wallet_constraint_carveout.rs @@ -12,7 +12,6 @@ //! one moment an operator is removing state. use crate::common; -use std::collections::BTreeSet; use common::{ensure_identity, ensure_wallet_meta, fresh_persister_with_mode}; @@ -59,7 +58,6 @@ fn delete_wallet_aborts_on_a_constraint_failure_outside_the_carve_out() { public_key_hash: [9u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 1), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs index 3886754e8c1..8d5cac6a6e9 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_fk_changeset_ordering.rs @@ -12,7 +12,6 @@ //! dispatch order writes the parent first for every FK edge. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, fresh_persister_with_mode, wid}; @@ -46,7 +45,6 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs index c5bc4d47113..7a861e2fa21 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_hard_delete.rs @@ -81,7 +81,6 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs new file mode 100644 index 00000000000..355483acf2b --- /dev/null +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs @@ -0,0 +1,594 @@ +//! Identity keys across a restart: a restore whose key write the disk +//! refuses, and keys a refresh finds. +//! +//! Every test drives the real `PlatformWalletManager` + `IdentityWallet` +//! over a mock SDK and the real SQLite persister (Immediate mode, the +//! default and what dash-evo-tool runs). A disk-full failure is the genuine +//! rusqlite `SQLITE_FULL` error primed into the persister's own flush seam +//! (`force_next_flush_to_fail`), so classification, buffer restore and the +//! `FlushRetryable` surface are the production code paths; only the moment +//! the disk fills is chosen by the test. A "restart" closes the SQLite file, +//! reopens it and rebuilds a fresh manager through `load_from_persistor`. +//! + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; + +use dash_sdk::platform::types::identity::PublicKeyHash; +use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::v0::IdentityV0; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; +use dpp::platform_value::BinaryData; +use dpp::prelude::Identifier; +use key_wallet::bip32::ExtendedPrivKey; +use key_wallet::mnemonic::Mnemonic; +use key_wallet::wallet::initialization::WalletAccountCreationOptions; +use key_wallet::Network; +use platform_wallet::changeset::{ + ClientStartState, IdentityKeyEntry, IdentityScanStateEntry, PersistenceCapabilities, + PersistenceError, PersistenceErrorKind, PlatformWalletChangeSet, PlatformWalletPersistence, +}; +use platform_wallet::wallet::identity::network::{ + derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash_from_master, + IdentityDiscoveryOptions, +}; +use platform_wallet::wallet::platform_wallet::WalletId; +use platform_wallet::{PlatformEventHandler, PlatformWalletManager}; +use platform_wallet_storage::sqlite::schema::{identity_keys, identity_scan_states}; +use platform_wallet_storage::{ + LoadCtx, SqlitePersister, SqlitePersisterConfig, WalletStorageError, +}; + +const MNEMONIC_A: &str = "abandon abandon abandon abandon abandon abandon \ + abandon abandon abandon abandon abandon about"; +const NET: Network = Network::Testnet; + +// --------------------------------------------------------------------------- +// Persister: real SQLite, with a disk that can fill +// --------------------------------------------------------------------------- + +#[derive(Clone, Copy, PartialEq, Eq)] +enum DiskFull { + /// The disk never fills. + Never, + /// The disk fills on the first write that carries a key breadcrumb and + /// stays full until the process ends. + FromBreadcrumbWriteOn, +} + +struct Disk { + inner: Mutex>, + mode: DiskFull, + tripped: AtomicBool, + full: AtomicBool, + /// Every error `store`/`flush` returned, with its kind. + failures: Mutex>>, +} + +fn sqlite_full() -> WalletStorageError { + WalletStorageError::Sqlite(rusqlite::Error::SqliteFailure( + rusqlite::ffi::Error::new(rusqlite::ffi::SQLITE_FULL), + Some("database or disk is full".into()), + )) +} + +impl Disk { + fn open(path: &std::path::Path, mode: DiskFull) -> Arc { + let p = SqlitePersister::open(SqlitePersisterConfig::new(path)).expect("open persister"); + Arc::new(Self { + inner: Mutex::new(Some(p)), + mode, + tripped: AtomicBool::new(false), + full: AtomicBool::new(false), + failures: Mutex::new(Vec::new()), + }) + } + + /// Process death: the SQLite handle closes; anything only buffered is gone. + fn close(&self) { + self.inner.lock().unwrap().take(); + } + + fn with(&self, f: impl FnOnce(&SqlitePersister) -> R) -> R { + let guard = self.inner.lock().unwrap(); + f(guard.as_ref().expect("persister closed")) + } + + fn before_write(&self, p: &SqlitePersister) { + if self.full.load(Ordering::SeqCst) { + p.force_next_flush_to_fail(sqlite_full()); + } + } + + fn record(&self, r: Result) -> Result { + if let Err(e) = &r { + self.failures.lock().unwrap().push(e.kind()); + } + r + } + + fn key_rows(&self, w: &WalletId, id: &Identifier) -> BTreeMap { + self.with(|p| { + let conn = p.lock_conn_for_test(); + identity_keys::load_state(&conn, w, &LoadCtx::strict()) + .expect("read identity_keys") + .upserts + .into_iter() + .filter(|((owner, _), _)| owner == id) + .map(|((_, key_id), e)| (key_id, e)) + .collect() + }) + } + + fn scan_verdict(&self, w: &WalletId) -> Option { + self.with(|p| { + let conn = p.lock_conn_for_test(); + identity_scan_states::load_for_wallet(&conn, w, &LoadCtx::strict()) + .expect("read identity_scan_states") + }) + } +} + +fn closed() -> PersistenceError { + PersistenceError::backend("persister closed (process ended)") +} + +impl PlatformWalletPersistence for Disk { + fn store_commits_inline(&self) -> bool { + self.with(|p| p.store_commits_inline()) + } + fn persistence_capabilities(&self) -> PersistenceCapabilities { + self.with(|p| p.persistence_capabilities()) + } + fn store_transient_is_reissuable(&self) -> bool { + self.with(|p| p.store_transient_is_reissuable()) + } + fn stores_dashpay_backfill(&self) -> bool { + self.with(|p| p.stores_dashpay_backfill()) + } + fn store(&self, w: WalletId, cs: PlatformWalletChangeSet) -> Result<(), PersistenceError> { + let carries_breadcrumb = cs + .identity_keys + .as_ref() + .is_some_and(|k| k.upserts.values().any(|e| e.derivation_indices.is_some())); + if self.mode != DiskFull::Never + && carries_breadcrumb + && !self.tripped.swap(true, Ordering::SeqCst) + { + self.full.store(true, Ordering::SeqCst); + } + let guard = self.inner.lock().unwrap(); + let Some(p) = guard.as_ref() else { + return Err(closed()); + }; + self.before_write(p); + let r = p.store(w, cs); + drop(guard); + self.record(r) + } + fn flush(&self, w: WalletId) -> Result<(), PersistenceError> { + let guard = self.inner.lock().unwrap(); + let Some(p) = guard.as_ref() else { + return Err(closed()); + }; + self.before_write(p); + let r = p.flush(w); + drop(guard); + self.record(r) + } + fn load(&self) -> Result { + self.with(|p| p.load()) + } + fn persist_tracked_masternodes( + &self, + network: dashcore::Network, + records: &[platform_wallet::masternode::TrackedMasternode], + ) -> Result<(), PersistenceError> { + self.with(|p| p.persist_tracked_masternodes(network, records)) + } + fn load_tracked_masternodes( + &self, + network: dashcore::Network, + ) -> Result, PersistenceError> { + self.with(|p| p.load_tracked_masternodes(network)) + } +} + +// --------------------------------------------------------------------------- +// Wallet, identity and Platform fixtures +// --------------------------------------------------------------------------- + +struct Noop; +impl platform_wallet::events::EventHandler for Noop {} +impl PlatformEventHandler for Noop {} + +fn seed(phrase: &str) -> [u8; 64] { + Mnemonic::from_phrase(phrase).unwrap().to_seed("") +} + +fn master(phrase: &str) -> ExtendedPrivKey { + ExtendedPrivKey::new_master(NET, &seed(phrase)).unwrap() +} + +/// The DIP-9 ECDSA auth key a wallet derives at `(identity_index, key_index)`. +fn derived_key( + phrase: &str, + identity_index: u32, + key_index: u32, + id: KeyID, + level: SecurityLevel, +) -> IdentityPublicKey { + let kp = derive_ecdsa_identity_auth_keypair_from_master( + &master(phrase), + NET, + identity_index, + key_index, + ) + .unwrap(); + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level: level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(kp.public_key.to_vec()), + disabled_at: None, + }) +} + +/// Identity registered by wallet A at index 0 the way registration does it: +/// MASTER, HIGH and CRITICAL auth keys at key ids 0..=2, all from the seed. +fn registered_identity() -> Identity { + let levels = [ + SecurityLevel::MASTER, + SecurityLevel::HIGH, + SecurityLevel::CRITICAL, + ]; + let public_keys = levels + .iter() + .enumerate() + .map(|(i, level)| { + let i = i as u32; + (i, derived_key(MNEMONIC_A, 0, i, i, *level)) + }) + .collect(); + Identity::V0(IdentityV0 { + id: Identifier::from([0x5A; 32]), + public_keys, + balance: 1_000_000, + revision: 1, + }) +} + +fn probe(phrase: &str, index: u32) -> PublicKeyHash { + PublicKeyHash( + derive_identity_auth_key_hash_from_master(&master(phrase), NET, index, 0).unwrap(), + ) +} + +/// Platform: `phrase`'s index 0 answers `at_0`, index 1 holds nothing, and a +/// fetch by id answers `by_id`. +async fn platform( + phrase: &str, + at_0: Option, + by_id: Option, +) -> Arc { + let mut sdk = dash_sdk::SdkBuilder::new_mock() + .with_network(NET) + .build() + .unwrap(); + sdk.mock() + .expect_fetch(probe(phrase, 0), at_0) + .await + .unwrap(); + sdk.mock() + .expect_fetch(probe(phrase, 1), None::) + .await + .unwrap(); + if let Some(identity) = by_id { + sdk.mock() + .expect_fetch(identity.id(), Some(identity)) + .await + .unwrap(); + } + Arc::new(sdk) +} + +type Manager = Arc>; + +/// First launch: a fresh install restores the wallet from its mnemonic. +async fn first_launch( + sdk: Arc, + disk: Arc, + phrase: &str, +) -> (Manager, WalletId) { + let manager = Arc::new(PlatformWalletManager::new(sdk, disk, Arc::new(Noop))); + let wallet = manager + .create_wallet_from_seed_bytes( + NET, + &seed(phrase), + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .expect("create wallet"); + let id = wallet.wallet_id(); + (manager, id) +} + +/// A later launch: rebuild from what SQLite holds (watch-only wallet, the +/// iOS shape — signing keys come from the Keychain seed via the breadcrumb). +async fn relaunch(sdk: Arc, disk: Arc) -> Manager { + let manager = Arc::new(PlatformWalletManager::new(sdk, disk, Arc::new(Noop))); + manager + .load_from_persistor() + .await + .expect("load from SQLite"); + manager +} + +async fn end_process(manager: Manager, disk: &Disk) { + // No `shutdown()`: it needs a multi-thread runtime. Dropping the manager + // and closing the SQLite handle under it is the process dying. + drop(manager); + disk.close(); +} + +/// Startup's identity step (`start_wallet_subsystems`, startup.rs ~543): a +/// local identity on file and no recorded incomplete scan takes the warm +/// shortcut — no discovery runs this launch. +async fn startup_takes_warm_shortcut(manager: &Manager, w: &WalletId) -> bool { + let wm = manager.wallet_manager_arc(); + let wm = wm.read().await; + let info = wm.get_wallet_info(w).expect("wallet info"); + let known = !info.identity_manager.wallet_identity_ids(w).is_empty(); + known && !info.identity_manager.identity_scan_is_incomplete(w) +} + +async fn held_key_ids(manager: &Manager, w: &WalletId, id: &Identifier) -> Vec { + let wm = manager.wallet_manager_arc(); + let wm = wm.read().await; + wm.get_wallet_info(w) + .expect("wallet info") + .identity_manager + .identity(id) + .expect("identity in memory") + .identity + .public_keys() + .keys() + .copied() + .collect() +} + +fn breadcrumbed(rows: &BTreeMap) -> Vec { + rows.iter() + .filter(|(_, e)| e.derivation_indices.is_some()) + .map(|(k, _)| *k) + .collect() +} + +fn scan_from_zero() -> IdentityDiscoveryOptions { + // The options startup's discovery uses (startup.rs ~835). + IdentityDiscoveryOptions { + start_index: Some(0), + gap_limit: 1, + } +} + +// --------------------------------------------------------------------------- +// A key-row store fails during a restore +// --------------------------------------------------------------------------- + +/// A restore runs discovery, and the disk fills on the write that carries +/// the key breadcrumbs and stays full for the rest of the session (the scan +/// verdict cannot land either). The new identity and its keys go to the store +/// as one changeset, so nothing of it lands: after a restart no key row is +/// left without its breadcrumb, startup does not take the warm shortcut (no +/// identity is on file), and its discovery restores every breadcrumb. +#[tokio::test] +async fn should_not_leave_watch_only_key_rows_when_the_disk_fills_during_a_restore() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + let identity = registered_identity(); + let id = identity.id(); + + // Launch 1. + let disk = Disk::open(&path, DiskFull::FromBreadcrumbWriteOn); + let (manager, w) = first_launch( + platform(MNEMONIC_A, Some(identity.clone()), None).await, + disk.clone(), + MNEMONIC_A, + ) + .await; + let err = manager + .get_wallet(&w) + .await + .unwrap() + .identity() + .discover_from_master(scan_from_zero(), &master(MNEMONIC_A)) + .await + .expect_err("the key write fails"); + assert!( + err.to_string() + .contains("identity keys not persisted during discovery"), + "{err}" + ); + assert!( + disk.failures + .lock() + .unwrap() + .iter() + .all(|k| *k == Some(PersistenceErrorKind::Transient)), + "disk full reaches the wallet as Transient" + ); + // In memory the session is fine: all three keys are held. + assert_eq!(held_key_ids(&manager, &w, &id).await, vec![0, 1, 2]); + end_process(manager, &disk).await; + + // Launch 2, disk has space again. + let disk = Disk::open(&path, DiskFull::Never); + let rows = disk.key_rows(&w, &id); + assert!( + rows.values().all(|row| row.derivation_indices.is_some()), + "no key row is left watch-only: {rows:?}" + ); + assert!(disk.scan_verdict(&w).is_none(), "the verdict never landed"); + + let manager = relaunch( + platform(MNEMONIC_A, Some(identity.clone()), None).await, + disk.clone(), + ) + .await; + assert!( + !startup_takes_warm_shortcut(&manager, &w).await, + "no identity is on file, so startup runs discovery" + ); + manager + .get_wallet(&w) + .await + .unwrap() + .identity() + .discover_from_master(scan_from_zero(), &master(MNEMONIC_A)) + .await + .expect("startup's discovery"); + assert_eq!( + breadcrumbed(&disk.key_rows(&w, &id)), + vec![0, 1, 2], + "the startup discovery restores every breadcrumb" + ); + end_process(manager, &disk).await; +} + +// --------------------------------------------------------------------------- +// Keys a refresh finds (added from another device) +// --------------------------------------------------------------------------- + +/// The identity after the user's other device (same seed) added a MEDIUM key +/// at key id 3 — derived, as that device's add-key flow does, at (0, 3). +fn with_key_from_other_device(identity: &Identity) -> Identity { + let mut updated = identity.clone(); + updated.add_public_key(derived_key(MNEMONIC_A, 0, 3, 3, SecurityLevel::MEDIUM)); + updated.set_revision(2); + updated +} + +/// A refresh that finds a key the user's other device added stores its row +/// (without a breadcrumb, which leaves the stored ones as they are), so after +/// a restart the identity holds the key set its stored revision claims. +#[tokio::test] +async fn should_store_a_key_a_refresh_finds_so_it_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + let identity = registered_identity(); + let id = identity.id(); + let updated = with_key_from_other_device(&identity); + + let disk = Disk::open(&path, DiskFull::Never); + let (manager, w) = first_launch( + platform(MNEMONIC_A, Some(identity.clone()), Some(updated)).await, + disk.clone(), + MNEMONIC_A, + ) + .await; + let iw = manager.get_wallet(&w).await.unwrap(); + iw.identity() + .discover_from_master(scan_from_zero(), &master(MNEMONIC_A)) + .await + .expect("restore"); + iw.identity().refresh_identity(&id).await.expect("refresh"); + assert_eq!(held_key_ids(&manager, &w, &id).await, vec![0, 1, 2, 3]); + let rows = disk.key_rows(&w, &id); + assert!(rows.contains_key(&3), "the refreshed key has a row"); + assert_eq!( + breadcrumbed(&rows), + vec![0, 1, 2], + "the restore's breadcrumbs stay" + ); + drop(iw); + end_process(manager, &disk).await; + + let disk = Disk::open(&path, DiskFull::Never); + let manager = relaunch( + platform(MNEMONIC_A, Some(identity), None).await, + disk.clone(), + ) + .await; + assert_eq!( + held_key_ids(&manager, &w, &id).await, + vec![0, 1, 2, 3], + "key 3 is there after restart" + ); + let wm = manager.wallet_manager_arc(); + let revision = wm + .read() + .await + .get_wallet_info(&w) + .unwrap() + .identity_manager + .identity(&id) + .unwrap() + .identity + .revision(); + assert_eq!(revision, 2); + end_process(manager, &disk).await; +} + +/// A load at the identity's index (the host's "load identity at index") +/// re-fetches the identity and stores every on-chain key with a breadcrumb +/// when it derives from the seed, a key another device added included. +#[tokio::test] +async fn should_store_a_key_a_load_at_index_finds_with_its_breadcrumb() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + let identity = registered_identity(); + let id = identity.id(); + let updated = with_key_from_other_device(&identity); + + let disk = Disk::open(&path, DiskFull::Never); + let (manager, w) = first_launch( + platform(MNEMONIC_A, Some(identity.clone()), None).await, + disk.clone(), + MNEMONIC_A, + ) + .await; + manager + .get_wallet(&w) + .await + .unwrap() + .identity() + .discover_from_master(scan_from_zero(), &master(MNEMONIC_A)) + .await + .expect("restore"); + end_process(manager, &disk).await; + + // Next launch: Platform now holds the other device's key 3. + let disk = Disk::open(&path, DiskFull::Never); + let manager = relaunch( + platform(MNEMONIC_A, Some(updated.clone()), None).await, + disk.clone(), + ) + .await; + manager + .get_wallet(&w) + .await + .unwrap() + .identity() + .load_identity_by_index_from_master(0, &master(MNEMONIC_A)) + .await + .expect("load at index 0") + .expect("found"); + assert_eq!(breadcrumbed(&disk.key_rows(&w, &id)), vec![0, 1, 2, 3]); + end_process(manager, &disk).await; + + let disk = Disk::open(&path, DiskFull::Never); + let manager = relaunch( + platform(MNEMONIC_A, Some(updated), None).await, + disk.clone(), + ) + .await; + assert_eq!(held_key_ids(&manager, &w, &id).await, vec![0, 1, 2, 3]); + end_process(manager, &disk).await; +} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index f4c810cf285..9a6afdc07ee 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -5,7 +5,6 @@ //! corrupt blob. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; @@ -46,14 +45,14 @@ fn key_entry(identity: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { identity_index: 0, key_index: u32::from(byte), }), - derivation_rejected: BTreeSet::new(), } } /// An upsert without a derivation breadcrumb keeps the one the row holds /// and takes the rest of the entry: a key's derivation never changes, and a /// write that only knows the key's new state (a raised limit, a disable) -/// must not turn a wallet-derived key watch-only. A row without one stays +/// must not turn a wallet-derived key watch-only (a refresh stores the rows +/// of the keys it adds or changes without one). A row without one stays /// without one. #[test] fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { @@ -113,188 +112,6 @@ fn should_keep_a_stored_breadcrumb_when_an_upsert_carries_none() { assert_eq!(cs.upserts.get(&(id, 1)), Some(&watch_only_disabled)); } -/// An upsert that settles a key as not derived (`derivation_rejected`) -/// clears a stored breadcrumb, which can only be a stale inferred one, while -/// one that leaves the key's origin unknown keeps a valid same-key one. -#[test] -fn should_clear_a_stored_breadcrumb_a_load_rejects_and_keep_an_unknown_one() { - let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xD5); - ensure_wallet_meta(&persister, &w); - let id = Identifier::from([0x0F; 32]); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); - let store = |entries: Vec| { - let mut keys = IdentityKeysChangeSet::default(); - for entry in entries { - keys.upserts - .insert((entry.identity_id, entry.key_id), entry); - } - persister - .store( - w, - PlatformWalletChangeSet { - identity_keys: Some(keys), - ..Default::default() - }, - ) - .unwrap(); - }; - // Key 0: an external key carrying the legacy inferred breadcrumb; key 1: - // a wallet-derived key with its verified one. - store(vec![key_entry(id, 0, 0x11), key_entry(id, 1, 0x22)]); - - let mut rejected = key_entry(id, 0, 0x11); - rejected.derivation_indices = None; - rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 0x11, - }]); - let mut unknown = key_entry(id, 1, 0x22); - unknown.derivation_indices = None; - store(vec![rejected.clone(), unknown]); - drop(persister); - - let p2 = reopen(&path); - let conn = p2.lock_conn_for_test(); - let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) - .expect("load_state"); - drop(conn); - let mut cleared = rejected; - cleared.derivation_rejected = BTreeSet::new(); - assert_eq!(cs.upserts.get(&(id, 0)), Some(&cleared)); - assert_eq!(cs.upserts.get(&(id, 1)), Some(&key_entry(id, 1, 0x22))); -} - -/// A rejection refutes only the coordinates it was checked at: a breadcrumb -/// stored at other coordinates (supplied explicitly through `add_keys`) is -/// kept. -#[test] -fn should_keep_a_stored_breadcrumb_a_rejection_does_not_name() { - let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xD6); - ensure_wallet_meta(&persister, &w); - let id = Identifier::from([0x10; 32]); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); - let store = |entry: IdentityKeyEntry| { - let mut keys = IdentityKeysChangeSet::default(); - keys.upserts - .insert((entry.identity_id, entry.key_id), entry); - persister - .store( - w, - PlatformWalletChangeSet { - identity_keys: Some(keys), - ..Default::default() - }, - ) - .unwrap(); - }; - // Key id 3, stored with the breadcrumb `(0, 0x33)` its caller supplied. - let mut stored = key_entry(id, 3, 0x33); - stored.public_key = key_entry(id, 3, 0x33).public_key; - store(stored.clone()); - // A load checks `(0, 3)` and rejects it. - let mut rejected = stored.clone(); - rejected.derivation_indices = None; - rejected.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 3, - }]); - store(rejected); - drop(persister); - - let p2 = reopen(&path); - let conn = p2.lock_conn_for_test(); - let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) - .expect("load_state"); - drop(conn); - assert_eq!( - cs.upserts - .get(&(id, 3)) - .and_then(|row| row.derivation_indices), - stored.derivation_indices - ); -} - -/// Two rejections of one key at different coordinates give the same stored -/// row whether they are written one by one or combined in a buffered -/// segment: the legacy inferred breadcrumb either names is cleared. -#[test] -fn should_clear_a_breadcrumb_two_combined_rejections_name_as_one_by_one() { - for mode in [ - platform_wallet_storage::FlushMode::Immediate, - platform_wallet_storage::FlushMode::Manual, - ] { - let (persister, _tmp, path) = common::fresh_persister_with_mode(mode); - let w = wid(0xD7); - ensure_wallet_meta(&persister, &w); - let id = Identifier::from([0x11; 32]); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); - let store = |entry: IdentityKeyEntry| { - let mut keys = IdentityKeysChangeSet::default(); - keys.upserts - .insert((entry.identity_id, entry.key_id), entry); - persister - .store( - w, - PlatformWalletChangeSet { - identity_keys: Some(keys), - ..Default::default() - }, - ) - .unwrap(); - }; - // The legacy inferred breadcrumb at `(0, 3)`. - let mut inferred = key_entry(id, 3, 0x44); - inferred.derivation_indices = Some(IdentityKeyDerivationIndices { - identity_index: 0, - key_index: 3, - }); - store(inferred.clone()); - persister.flush(w).unwrap(); - - let rejecting = |identity_index| { - let mut entry = inferred.clone(); - entry.derivation_indices = None; - entry.derivation_rejected = BTreeSet::from([IdentityKeyDerivationIndices { - identity_index, - key_index: 3, - }]); - entry - }; - store(rejecting(0)); - store(rejecting(1)); - persister.flush(w).unwrap(); - drop(persister); - - let p2 = reopen(&path); - let conn = p2.lock_conn_for_test(); - let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) - .expect("load_state"); - drop(conn); - assert_eq!( - cs.upserts.get(&(id, 3)).map(|row| row.derivation_indices), - Some(None), - "{mode:?}" - ); - } -} - /// A stored row over the blob size limit is not read into memory to look for /// a breadcrumb; the upsert replaces it. #[test] @@ -348,48 +165,6 @@ fn should_replace_an_oversized_stored_row_without_reading_it() { assert_eq!(cs.upserts.get(&(id, 0)), Some(&upsert)); } -/// A breadcrumb belongs to one key: an upsert of another public key under a -/// stored row's key id does not take the row's breadcrumb. -#[test] -fn should_not_keep_a_breadcrumb_for_another_public_key() { - let (persister, _tmp, path) = fresh_persister(); - let w = wid(0xD3); - ensure_wallet_meta(&persister, &w); - let id = Identifier::from([0x0D; 32]); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); - let store = |entry: IdentityKeyEntry| { - let mut keys = IdentityKeysChangeSet::default(); - keys.upserts - .insert((entry.identity_id, entry.key_id), entry); - persister - .store( - w, - PlatformWalletChangeSet { - identity_keys: Some(keys), - ..Default::default() - }, - ) - .unwrap(); - }; - store(key_entry(id, 0, 0x11)); - let mut other = key_entry(id, 0, 0x44); - other.derivation_indices = None; - store(other.clone()); - drop(persister); - - let p2 = reopen(&path); - let conn = p2.lock_conn_for_test(); - let cs = identity_keys::load_state(&conn, &w, &platform_wallet_storage::LoadCtx::strict()) - .expect("load_state"); - drop(conn); - assert_eq!(cs.upserts.get(&(id, 0)), Some(&other)); -} - /// Identity-key rows round-trip bit-exact into the keyless /// `IdentityKeysChangeSet`. #[test] @@ -529,47 +304,6 @@ fn without_breadcrumb(mut entry: IdentityKeyEntry) -> IdentityKeyEntry { entry } -/// Insert the row `(id, upsert.key_id)` under `w` directly, bypassing the -/// writer, with `stored` as its blob and `hash_column` as its typed -/// `public_key_hash`, so the blob can disagree with the row's columns. Then -/// store `upsert` (which carries no breadcrumb) over it and return the row a -/// reload reads. -fn reload_after_upsert_over_crafted_row( - w: [u8; 32], - id: Identifier, - stored: &IdentityKeyEntry, - hash_column: [u8; 20], - upsert: &IdentityKeyEntry, -) -> Option { - assert!(upsert.derivation_indices.is_none()); - let (persister, _tmp, path) = fresh_persister(); - ensure_wallet_meta(&persister, &w); - platform_wallet_storage::sqlite::schema::identities::ensure_exists( - &persister.lock_conn_for_test(), - &w, - id.as_slice().try_into().unwrap(), - ) - .unwrap(); - persister - .lock_conn_for_test() - .execute( - "INSERT INTO identity_keys \ - (wallet_id, identity_id, key_id, public_key_blob, public_key_hash, derivation_blob) \ - VALUES (?1, ?2, ?3, ?4, ?5, NULL)", - rusqlite::params![ - w.as_slice(), - id.as_slice(), - i64::from(upsert.key_id), - identity_keys::encode_entry_for_test(stored).unwrap(), - &hash_column[..], - ], - ) - .unwrap(); - store_entries(&persister, w, vec![upsert.clone()]); - drop(persister); - reload(&path, &w).upserts.remove(&(id, upsert.key_id)) -} - /// A kept breadcrumb keeps the wallet scope the row's blob was stored with: /// an upsert that carries neither a breadcrumb nor a `wallet_id` takes both /// from the row, so the reloaded entry still names its wallet. @@ -641,145 +375,3 @@ fn should_replace_a_stored_row_that_does_not_decode() { assert_eq!(reload(&path, &w).upserts.get(&(id, 0)), Some(&upsert)); } - -/// Control for the crafted-row tests below: a row inserted directly whose -/// blob agrees with its columns keeps its breadcrumb, so each of those tests -/// loses the breadcrumb only to the one disagreement it introduces. -#[test] -fn should_keep_the_breadcrumb_of_a_crafted_row_that_agrees_with_its_columns() { - let id = Identifier::from([0x21; 32]); - let stored = key_entry(id, 0, 0x11); - let upsert = without_breadcrumb(stored.clone()); - let row = reload_after_upsert_over_crafted_row( - wid(0xE2), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(stored)); -} - -/// A stored blob naming another identity than its `identity_id` column -/// holds no breadcrumb to keep. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_identity() { - let id = Identifier::from([0x22; 32]); - let stored = key_entry(Identifier::from([0x23; 32]), 0, 0x11); - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE3), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} - -/// A stored blob naming another key id than its `key_id` column holds no -/// breadcrumb to keep, even when its public key's own id matches the column. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_key_id() { - let id = Identifier::from([0x24; 32]); - let mut stored = key_entry(id, 0, 0x11); - stored.key_id = 1; - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE4), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} - -/// A stored blob whose public key carries another id than the row's -/// `key_id` holds no breadcrumb to keep, even when the blob's own `key_id` -/// matches the column. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_whose_public_key_has_another_id() { - let id = Identifier::from([0x25; 32]); - let mut stored = key_entry(id, 0, 0x11); - if let IdentityPublicKey::V0(key) = &mut stored.public_key { - key.id = 5; - } - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE5), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} - -/// A row whose typed `public_key_hash` column disagrees with the hash in its -/// blob holds no breadcrumb to keep, even when the blob's hash matches the -/// upsert's. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_whose_hash_column_disagrees_with_its_blob() { - let id = Identifier::from([0x26; 32]); - let stored = key_entry(id, 0, 0x11); - let upsert = without_breadcrumb(stored.clone()); - let row = reload_after_upsert_over_crafted_row(wid(0xE6), id, &stored, [0x99; 20], &upsert); - assert_eq!(row, Some(upsert)); -} - -/// A row stored under another public-key hash than the upsert's holds no -/// breadcrumb to keep, even when the key bytes and the row's columns agree. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_stored_under_another_public_key_hash() { - let id = Identifier::from([0x27; 32]); - let mut stored = key_entry(id, 0, 0x11); - stored.public_key_hash = [0x99; 20]; - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE7), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} - -/// A stored blob whose `wallet_id` names another wallet than the scope it is -/// read under holds no breadcrumb to keep. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_whose_blob_names_another_wallet() { - let id = Identifier::from([0x28; 32]); - let mut stored = key_entry(id, 0, 0x11); - stored.wallet_id = Some(wid(0xEF)); - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE8), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} - -/// A row holding other public-key bytes than the upsert's holds no -/// breadcrumb to keep, even when it is stored under the upsert's hash. -#[test] -fn should_not_keep_the_breadcrumb_of_a_row_holding_other_public_key_bytes() { - let id = Identifier::from([0x29; 32]); - let mut stored = key_entry(id, 0, 0x11); - if let IdentityPublicKey::V0(key) = &mut stored.public_key { - key.data = BinaryData::new(vec![0x44; 33]); - } - let upsert = without_breadcrumb(key_entry(id, 0, 0x11)); - let row = reload_after_upsert_over_crafted_row( - wid(0xE9), - id, - &stored, - stored.public_key_hash, - &upsert, - ); - assert_eq!(row, Some(upsert)); -} diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs index f6ea9b8faff..d13e50be66f 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_persist_roundtrip.rs @@ -7,7 +7,6 @@ //! `sqlite_buffer_semantics.rs`. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use key_wallet::Network; @@ -226,7 +225,6 @@ fn tc007_identity_key_entry_roundtrip() { identity_index: 1, key_index: 2, }), - derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((identity_id, 7), entry.clone()); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs index 83ab911992f..a2f518dcf97 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_recovery_mode.rs @@ -9,7 +9,6 @@ //! projection is served instead. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, fresh_recovery_persister, wid}; use dashcore::hashes::Hash; @@ -1340,7 +1339,6 @@ fn identity_key_entry( public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs index a8477bbdd44..414f37074d4 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_structural_hardening.rs @@ -7,7 +7,6 @@ //! the truncation guards, and the compaction-marker-only load gate. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; @@ -273,7 +272,6 @@ fn identity_key_entry_mismatch_rejected() { public_key_hash: [3u8; 20], wallet_id: Some(w), derivation_indices: None, - derivation_rejected: BTreeSet::new(), }; let mut keys = IdentityKeysChangeSet::default(); keys.upserts.insert((key_identity, 0), entry); diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs index 54831d9261f..9e5a5bfe869 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_unowned_identities.rs @@ -7,7 +7,6 @@ //! round trip does NOT quietly hand them to a wallet. use crate::common; -use std::collections::BTreeSet; use common::{ensure_wallet_meta, fresh_persister, wid}; use dpp::identity::accessors::IdentityGettersV0; @@ -71,7 +70,6 @@ fn key_entry(id: Identifier, key_id: u32, byte: u8) -> IdentityKeyEntry { public_key_hash: [byte; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs index b9fe75bc1de..73d2437db0b 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_version_bump.rs @@ -5,7 +5,6 @@ //! bump with none silently excluded, and the seq saturates rather than wrapping. use crate::common; -use std::collections::BTreeSet; use std::collections::BTreeMap; @@ -322,7 +321,6 @@ fn identity_key_entry(id: Identifier) -> IdentityKeyEntry { public_key_hash: [3u8; 20], wallet_id: None, derivation_indices: None, - derivation_rejected: BTreeSet::new(), } } From 60b0dfa7f41e90830729fc0ec546ce20d16a76fa Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:30 +0300 Subject: [PATCH 48/72] refactor(kotlin-sdk): drop the key-origin repair and Room schema 16, keep the update revision The persister, the Room entities and the database go back to schema 15 as on base, and the JNI key-upsert descriptor with them. updateIdentity keeps only the revision pin (hasRevision / revision) and the typed IdentityRevisionMismatch error; externalKeyIds moves to its own PR. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../16.json | 4207 ----------------- .../persistence/DashDatabaseMigrationTest.kt | 54 +- .../dashsdk/ffi/NativePersistenceBridge.kt | 13 +- .../dashsdk/ffi/TransactionsNative.kt | 4 - .../dashsdk/identity/IdentityUpdates.kt | 17 +- .../dashsdk/persistence/DashDatabase.kt | 22 +- .../PlatformWalletPersistenceHandler.kt | 151 +- .../persistence/entities/PublicKeyEntity.kt | 12 - .../dashsdk/persistence/DashDatabaseTest.kt | 9 +- .../PlatformWalletPersistenceHandlerTest.kt | 662 --- .../rs-unified-sdk-jni/src/persistence.rs | 89 +- .../rs-unified-sdk-jni/src/transactions.rs | 167 +- 12 files changed, 104 insertions(+), 5303 deletions(-) delete mode 100644 packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json diff --git a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json b/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json deleted file mode 100644 index cf8bd68cf5e..00000000000 --- a/packages/kotlin-sdk/sdk/schemas/org.dashfoundation.dashsdk.persistence.DashDatabase/16.json +++ /dev/null @@ -1,4207 +0,0 @@ -{ - "formatVersion": 1, - "database": { - "version": 16, - "identityHash": "bf2c2e1447c26d26f643457c97fe5452", - "entities": [ - { - "tableName": "wallets", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `walletGroupId` BLOB NOT NULL, `networkRaw` INTEGER, `name` TEXT, `walletDescription` TEXT, `birthHeight` INTEGER NOT NULL, `syncedHeight` INTEGER NOT NULL, `lastSynced` INTEGER NOT NULL, `lastAppliedChainLockBytes` BLOB, `lastAppliedChainLockHeight` INTEGER, `dashPayBackfillFloor` INTEGER, `dashPayBackfillRewoundFrom` INTEGER, `dashPayBackfillCovered` BLOB, `isImported` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "walletGroupId", - "columnName": "walletGroupId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER" - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT" - }, - { - "fieldPath": "walletDescription", - "columnName": "walletDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "birthHeight", - "columnName": "birthHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "syncedHeight", - "columnName": "syncedHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSynced", - "columnName": "lastSynced", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastAppliedChainLockBytes", - "columnName": "lastAppliedChainLockBytes", - "affinity": "BLOB" - }, - { - "fieldPath": "lastAppliedChainLockHeight", - "columnName": "lastAppliedChainLockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "dashPayBackfillFloor", - "columnName": "dashPayBackfillFloor", - "affinity": "INTEGER" - }, - { - "fieldPath": "dashPayBackfillRewoundFrom", - "columnName": "dashPayBackfillRewoundFrom", - "affinity": "INTEGER" - }, - { - "fieldPath": "dashPayBackfillCovered", - "columnName": "dashPayBackfillCovered", - "affinity": "BLOB" - }, - { - "fieldPath": "isImported", - "columnName": "isImported", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId" - ] - }, - "indices": [ - { - "name": "index_wallets_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - }, - { - "name": "index_wallets_walletGroupId", - "unique": false, - "columnNames": [ - "walletGroupId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_wallets_walletGroupId` ON `${TABLE_NAME}` (`walletGroupId`)" - } - ] - }, - { - "tableName": "accounts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `walletId` BLOB NOT NULL, `accountType` INTEGER NOT NULL, `accountIndex` INTEGER NOT NULL, `accountTypeName` TEXT NOT NULL, `balanceConfirmed` INTEGER NOT NULL, `balanceUnconfirmed` INTEGER NOT NULL, `externalHighestUsed` INTEGER NOT NULL, `internalHighestUsed` INTEGER NOT NULL, `standardTag` INTEGER NOT NULL, `registrationIndex` INTEGER NOT NULL, `keyClass` INTEGER NOT NULL, `userIdentityId` BLOB NOT NULL, `friendIdentityId` BLOB NOT NULL, `accountExtendedPubKeyBytes` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountType", - "columnName": "accountType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountTypeName", - "columnName": "accountTypeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "balanceConfirmed", - "columnName": "balanceConfirmed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "balanceUnconfirmed", - "columnName": "balanceUnconfirmed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "externalHighestUsed", - "columnName": "externalHighestUsed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "internalHighestUsed", - "columnName": "internalHighestUsed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "standardTag", - "columnName": "standardTag", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "registrationIndex", - "columnName": "registrationIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keyClass", - "columnName": "keyClass", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "userIdentityId", - "columnName": "userIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "friendIdentityId", - "columnName": "friendIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountExtendedPubKeyBytes", - "columnName": "accountExtendedPubKeyBytes", - "affinity": "BLOB" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_accounts_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_accounts_walletId` ON `${TABLE_NAME}` (`walletId`)" - }, - { - "name": "index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId", - "unique": true, - "columnNames": [ - "walletId", - "accountType", - "accountIndex", - "standardTag", - "registrationIndex", - "keyClass", - "userIdentityId", - "friendIdentityId" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_walletId_accountType_accountIndex_standardTag_registrationIndex_keyClass_userIdentityId_friendIdentityId` ON `${TABLE_NAME}` (`walletId`, `accountType`, `accountIndex`, `standardTag`, `registrationIndex`, `keyClass`, `userIdentityId`, `friendIdentityId`)" - }, - { - "name": "index_accounts_accountExtendedPubKeyBytes", - "unique": true, - "columnNames": [ - "accountExtendedPubKeyBytes" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_accounts_accountExtendedPubKeyBytes` ON `${TABLE_NAME}` (`accountExtendedPubKeyBytes`)" - } - ], - "foreignKeys": [ - { - "table": "wallets", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "walletId" - ], - "referencedColumns": [ - "walletId" - ] - } - ] - }, - { - "tableName": "transactions", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`txid` BLOB NOT NULL, `transactionData` BLOB NOT NULL, `context` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `blockHash` BLOB, `blockTimestamp` INTEGER NOT NULL, `blockPosition` INTEGER NOT NULL, `hasBlockPosition` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `transactionType` TEXT NOT NULL, `transactionTypeKind` INTEGER NOT NULL, `netAmount` INTEGER NOT NULL, `fee` INTEGER, `label` TEXT NOT NULL, `firstSeen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`txid`))", - "fields": [ - { - "fieldPath": "txid", - "columnName": "txid", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "transactionData", - "columnName": "transactionData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "context", - "columnName": "context", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "blockHeight", - "columnName": "blockHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "blockHash", - "columnName": "blockHash", - "affinity": "BLOB" - }, - { - "fieldPath": "blockTimestamp", - "columnName": "blockTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "blockPosition", - "columnName": "blockPosition", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasBlockPosition", - "columnName": "hasBlockPosition", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "transactionType", - "columnName": "transactionType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "transactionTypeKind", - "columnName": "transactionTypeKind", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "netAmount", - "columnName": "netAmount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fee", - "columnName": "fee", - "affinity": "INTEGER" - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "firstSeen", - "columnName": "firstSeen", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "txid" - ] - }, - "indices": [ - { - "name": "index_transactions_firstSeen", - "unique": false, - "columnNames": [ - "firstSeen" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_transactions_firstSeen` ON `${TABLE_NAME}` (`firstSeen`)" - } - ] - }, - { - "tableName": "transaction_account_involvements", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`transactionTxid` BLOB NOT NULL, `accountId` INTEGER NOT NULL, PRIMARY KEY(`transactionTxid`, `accountId`), FOREIGN KEY(`transactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "transactionTxid", - "columnName": "transactionTxid", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountId", - "columnName": "accountId", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "transactionTxid", - "accountId" - ] - }, - "indices": [ - { - "name": "index_transaction_account_involvements_accountId", - "unique": false, - "columnNames": [ - "accountId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_transaction_account_involvements_accountId` ON `${TABLE_NAME}` (`accountId`)" - } - ], - "foreignKeys": [ - { - "table": "transactions", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "transactionTxid" - ], - "referencedColumns": [ - "txid" - ] - }, - { - "table": "accounts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "accountId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "txos", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outpoint` BLOB NOT NULL, `vout` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `address` TEXT NOT NULL, `scriptPubKey` BLOB NOT NULL, `height` INTEGER NOT NULL, `isCoinbase` INTEGER NOT NULL, `isConfirmed` INTEGER NOT NULL, `isInstantLocked` INTEGER NOT NULL, `isLocked` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `txid` BLOB, `spendingTxid` BLOB, `spendingInputIndex` INTEGER, `accountId` INTEGER, `coreAddressId` TEXT, `supersededByTxid` BLOB, PRIMARY KEY(`outpoint`), FOREIGN KEY(`txid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`spendingTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`coreAddressId`) REFERENCES `core_addresses`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "outpoint", - "columnName": "outpoint", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "vout", - "columnName": "vout", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "scriptPubKey", - "columnName": "scriptPubKey", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "height", - "columnName": "height", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isCoinbase", - "columnName": "isCoinbase", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isConfirmed", - "columnName": "isConfirmed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isInstantLocked", - "columnName": "isInstantLocked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLocked", - "columnName": "isLocked", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isSpent", - "columnName": "isSpent", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "txid", - "columnName": "txid", - "affinity": "BLOB" - }, - { - "fieldPath": "spendingTxid", - "columnName": "spendingTxid", - "affinity": "BLOB" - }, - { - "fieldPath": "spendingInputIndex", - "columnName": "spendingInputIndex", - "affinity": "INTEGER" - }, - { - "fieldPath": "accountId", - "columnName": "accountId", - "affinity": "INTEGER" - }, - { - "fieldPath": "coreAddressId", - "columnName": "coreAddressId", - "affinity": "TEXT" - }, - { - "fieldPath": "supersededByTxid", - "columnName": "supersededByTxid", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "outpoint" - ] - }, - "indices": [ - { - "name": "index_txos_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_walletId` ON `${TABLE_NAME}` (`walletId`)" - }, - { - "name": "index_txos_txid", - "unique": false, - "columnNames": [ - "txid" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_txid` ON `${TABLE_NAME}` (`txid`)" - }, - { - "name": "index_txos_spendingTxid", - "unique": false, - "columnNames": [ - "spendingTxid" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" - }, - { - "name": "index_txos_accountId", - "unique": false, - "columnNames": [ - "accountId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_accountId` ON `${TABLE_NAME}` (`accountId`)" - }, - { - "name": "index_txos_coreAddressId", - "unique": false, - "columnNames": [ - "coreAddressId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_txos_coreAddressId` ON `${TABLE_NAME}` (`coreAddressId`)" - } - ], - "foreignKeys": [ - { - "table": "transactions", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "txid" - ], - "referencedColumns": [ - "txid" - ] - }, - { - "table": "transactions", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "spendingTxid" - ], - "referencedColumns": [ - "txid" - ] - }, - { - "table": "accounts", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "accountId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "core_addresses", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "coreAddressId" - ], - "referencedColumns": [ - "address" - ] - } - ] - }, - { - "tableName": "core_addresses", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `publicKey` BLOB NOT NULL, `poolTypeTag` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "poolTypeTag", - "columnName": "poolTypeTag", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addressIndex", - "columnName": "addressIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "derivationPath", - "columnName": "derivationPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isUsed", - "columnName": "isUsed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "firstSeenHeight", - "columnName": "firstSeenHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSeenHeight", - "columnName": "lastSeenHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "balance", - "columnName": "balance", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountId", - "columnName": "accountId", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "address" - ] - }, - "indices": [ - { - "name": "index_core_addresses_accountId", - "unique": false, - "columnNames": [ - "accountId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_core_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" - } - ], - "foreignKeys": [ - { - "table": "accounts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "accountId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "asset_locks", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `walletId` BLOB NOT NULL, `transactionBytes` BLOB NOT NULL, `fundingTypeRaw` INTEGER NOT NULL, `identityIndexRaw` INTEGER NOT NULL, `accountIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `proofBytes` BLOB, `recipientPlatformAddressHash` BLOB, `recipientPlatformAddressType` INTEGER, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", - "fields": [ - { - "fieldPath": "outPointHex", - "columnName": "outPointHex", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "transactionBytes", - "columnName": "transactionBytes", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "fundingTypeRaw", - "columnName": "fundingTypeRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "identityIndexRaw", - "columnName": "identityIndexRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountIndexRaw", - "columnName": "accountIndexRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amountDuffs", - "columnName": "amountDuffs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "statusRaw", - "columnName": "statusRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "proofBytes", - "columnName": "proofBytes", - "affinity": "BLOB" - }, - { - "fieldPath": "recipientPlatformAddressHash", - "columnName": "recipientPlatformAddressHash", - "affinity": "BLOB" - }, - { - "fieldPath": "recipientPlatformAddressType", - "columnName": "recipientPlatformAddressType", - "affinity": "INTEGER" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "outPointHex" - ] - }, - "indices": [ - { - "name": "index_asset_locks_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_asset_locks_walletId` ON `${TABLE_NAME}` (`walletId`)" - } - ] - }, - { - "tableName": "invitations", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`outPointHex` TEXT NOT NULL, `rawOutPoint` BLOB NOT NULL, `walletId` BLOB NOT NULL, `fundingIndexRaw` INTEGER NOT NULL, `amountDuffs` INTEGER NOT NULL, `expiryUnix` INTEGER NOT NULL, `createdAtSecs` INTEGER NOT NULL, `hasInviter` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `reclaimInFlight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`outPointHex`))", - "fields": [ - { - "fieldPath": "outPointHex", - "columnName": "outPointHex", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "rawOutPoint", - "columnName": "rawOutPoint", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "fundingIndexRaw", - "columnName": "fundingIndexRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amountDuffs", - "columnName": "amountDuffs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "expiryUnix", - "columnName": "expiryUnix", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAtSecs", - "columnName": "createdAtSecs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasInviter", - "columnName": "hasInviter", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "statusRaw", - "columnName": "statusRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "reclaimInFlight", - "columnName": "reclaimInFlight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "outPointHex" - ] - }, - "indices": [ - { - "name": "index_invitations_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_invitations_walletId` ON `${TABLE_NAME}` (`walletId`)" - } - ] - }, - { - "tableName": "identities", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`identityId` BLOB NOT NULL, `balance` INTEGER NOT NULL, `revision` INTEGER NOT NULL, `isLocal` INTEGER NOT NULL, `alias` TEXT, `dpnsName` TEXT, `mainDpnsName` TEXT, `identityType` TEXT NOT NULL, `votingPrivateKeyIdentifier` TEXT, `ownerPrivateKeyIdentifier` TEXT, `payoutPrivateKeyIdentifier` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `networkRaw` INTEGER NOT NULL, `walletId` BLOB, `identityIndex` INTEGER NOT NULL, PRIMARY KEY(`identityId`), FOREIGN KEY(`walletId`) REFERENCES `wallets`(`walletId`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "balance", - "columnName": "balance", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "revision", - "columnName": "revision", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isLocal", - "columnName": "isLocal", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "alias", - "columnName": "alias", - "affinity": "TEXT" - }, - { - "fieldPath": "dpnsName", - "columnName": "dpnsName", - "affinity": "TEXT" - }, - { - "fieldPath": "mainDpnsName", - "columnName": "mainDpnsName", - "affinity": "TEXT" - }, - { - "fieldPath": "identityType", - "columnName": "identityType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "votingPrivateKeyIdentifier", - "columnName": "votingPrivateKeyIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "ownerPrivateKeyIdentifier", - "columnName": "ownerPrivateKeyIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "payoutPrivateKeyIdentifier", - "columnName": "payoutPrivateKeyIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncedAt", - "columnName": "lastSyncedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB" - }, - { - "fieldPath": "identityIndex", - "columnName": "identityIndex", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "identityId" - ] - }, - "indices": [ - { - "name": "index_identities_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - }, - { - "name": "index_identities_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_identities_walletId` ON `${TABLE_NAME}` (`walletId`)" - } - ], - "foreignKeys": [ - { - "table": "wallets", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "walletId" - ], - "referencedColumns": [ - "walletId" - ] - } - ] - }, - { - "tableName": "public_keys", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `keyId` INTEGER NOT NULL, `purpose` TEXT NOT NULL, `securityLevel` TEXT NOT NULL, `keyType` TEXT NOT NULL, `readOnly` INTEGER NOT NULL, `disabledAt` INTEGER, `totalBudget` INTEGER, `expiresAt` INTEGER, `publicKeyData` BLOB NOT NULL, `contractBoundsData` BLOB, `contractBoundsDocumentTypeName` TEXT, `contractBoundsKind` INTEGER, `privateKeyKeychainIdentifier` TEXT, `derivationIdentityIndex` INTEGER, `derivationKeyIndex` INTEGER, `identityId` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessed` INTEGER, `identityIdData` BLOB, `derivationWalletId` BLOB, FOREIGN KEY(`identityIdData`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keyId", - "columnName": "keyId", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "purpose", - "columnName": "purpose", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "securityLevel", - "columnName": "securityLevel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keyType", - "columnName": "keyType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "readOnly", - "columnName": "readOnly", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "disabledAt", - "columnName": "disabledAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "totalBudget", - "columnName": "totalBudget", - "affinity": "INTEGER" - }, - { - "fieldPath": "expiresAt", - "columnName": "expiresAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "publicKeyData", - "columnName": "publicKeyData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractBoundsData", - "columnName": "contractBoundsData", - "affinity": "BLOB" - }, - { - "fieldPath": "contractBoundsDocumentTypeName", - "columnName": "contractBoundsDocumentTypeName", - "affinity": "TEXT" - }, - { - "fieldPath": "contractBoundsKind", - "columnName": "contractBoundsKind", - "affinity": "INTEGER" - }, - { - "fieldPath": "privateKeyKeychainIdentifier", - "columnName": "privateKeyKeychainIdentifier", - "affinity": "TEXT" - }, - { - "fieldPath": "derivationIdentityIndex", - "columnName": "derivationIdentityIndex", - "affinity": "INTEGER" - }, - { - "fieldPath": "derivationKeyIndex", - "columnName": "derivationKeyIndex", - "affinity": "INTEGER" - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastAccessed", - "columnName": "lastAccessed", - "affinity": "INTEGER" - }, - { - "fieldPath": "identityIdData", - "columnName": "identityIdData", - "affinity": "BLOB" - }, - { - "fieldPath": "derivationWalletId", - "columnName": "derivationWalletId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_public_keys_identityId_keyId", - "unique": false, - "columnNames": [ - "identityId", - "keyId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityId_keyId` ON `${TABLE_NAME}` (`identityId`, `keyId`)" - }, - { - "name": "index_public_keys_identityIdData", - "unique": false, - "columnNames": [ - "identityIdData" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_identityIdData` ON `${TABLE_NAME}` (`identityIdData`)" - }, - { - "name": "index_public_keys_publicKeyData", - "unique": false, - "columnNames": [ - "publicKeyData" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_public_keys_publicKeyData` ON `${TABLE_NAME}` (`publicKeyData`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "identityIdData" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dpns_names", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `label` TEXT NOT NULL, `normalizedLabel` TEXT NOT NULL, `parentDomainName` TEXT NOT NULL, `normalizedParentDomainName` TEXT NOT NULL, `acquiredAt` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `documentId` BLOB, `isOwned` INTEGER NOT NULL, `priceCredits` INTEGER, `saleStatusRaw` INTEGER NOT NULL, `counterpartyIdentityId` BLOB, `documentCreatedAtMs` INTEGER NOT NULL, `documentUpdatedAtMs` INTEGER NOT NULL, `documentTransferredAtMs` INTEGER NOT NULL, `marketplaceUpdatedAt` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `normalizedParentDomainName`, `normalizedLabel`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "label", - "columnName": "label", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "normalizedLabel", - "columnName": "normalizedLabel", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "parentDomainName", - "columnName": "parentDomainName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "normalizedParentDomainName", - "columnName": "normalizedParentDomainName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "acquiredAt", - "columnName": "acquiredAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "documentId", - "columnName": "documentId", - "affinity": "BLOB" - }, - { - "fieldPath": "isOwned", - "columnName": "isOwned", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "priceCredits", - "columnName": "priceCredits", - "affinity": "INTEGER" - }, - { - "fieldPath": "saleStatusRaw", - "columnName": "saleStatusRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "counterpartyIdentityId", - "columnName": "counterpartyIdentityId", - "affinity": "BLOB" - }, - { - "fieldPath": "documentCreatedAtMs", - "columnName": "documentCreatedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentUpdatedAtMs", - "columnName": "documentUpdatedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentTransferredAtMs", - "columnName": "documentTransferredAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "marketplaceUpdatedAt", - "columnName": "marketplaceUpdatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "normalizedParentDomainName", - "normalizedLabel" - ] - }, - "indices": [ - { - "name": "index_dpns_names_identityId", - "unique": false, - "columnNames": [ - "identityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_identityId` ON `${TABLE_NAME}` (`identityId`)" - }, - { - "name": "index_dpns_names_documentId", - "unique": false, - "columnNames": [ - "documentId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dpns_names_documentId` ON `${TABLE_NAME}` (`documentId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "identityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dashpay_profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `identityId`), FOREIGN KEY(`identityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT" - }, - { - "fieldPath": "publicMessage", - "columnName": "publicMessage", - "affinity": "TEXT" - }, - { - "fieldPath": "bio", - "columnName": "bio", - "affinity": "TEXT" - }, - { - "fieldPath": "avatarUrl", - "columnName": "avatarUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "avatarHash", - "columnName": "avatarHash", - "affinity": "BLOB" - }, - { - "fieldPath": "avatarFingerprint", - "columnName": "avatarFingerprint", - "affinity": "BLOB" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "identityId" - ] - }, - "indices": [ - { - "name": "index_dashpay_profiles_identityId", - "unique": false, - "columnNames": [ - "identityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_profiles_identityId` ON `${TABLE_NAME}` (`identityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "identityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dashpay_contact_requests", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `isOutgoing` INTEGER NOT NULL, `senderKeyIndex` INTEGER NOT NULL, `recipientKeyIndex` INTEGER NOT NULL, `accountReference` INTEGER NOT NULL, `encryptedPublicKey` BLOB NOT NULL, `encryptedAccountLabel` BLOB, `autoAcceptProof` BLOB, `coreHeightCreatedAt` INTEGER NOT NULL, `createdAtMillis` INTEGER NOT NULL, `paymentChannelBroken` INTEGER NOT NULL DEFAULT 0, `contactAlias` TEXT, `contactNote` TEXT, `contactHidden` INTEGER NOT NULL DEFAULT 0, `contactAccountLabel` TEXT, `contactAcceptedAccounts` BLOB, `externalAccountReference` INTEGER, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`, `isOutgoing`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contactIdentityId", - "columnName": "contactIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "isOutgoing", - "columnName": "isOutgoing", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "senderKeyIndex", - "columnName": "senderKeyIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "recipientKeyIndex", - "columnName": "recipientKeyIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountReference", - "columnName": "accountReference", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "encryptedPublicKey", - "columnName": "encryptedPublicKey", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "encryptedAccountLabel", - "columnName": "encryptedAccountLabel", - "affinity": "BLOB" - }, - { - "fieldPath": "autoAcceptProof", - "columnName": "autoAcceptProof", - "affinity": "BLOB" - }, - { - "fieldPath": "coreHeightCreatedAt", - "columnName": "coreHeightCreatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAtMillis", - "columnName": "createdAtMillis", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "paymentChannelBroken", - "columnName": "paymentChannelBroken", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "contactAlias", - "columnName": "contactAlias", - "affinity": "TEXT" - }, - { - "fieldPath": "contactNote", - "columnName": "contactNote", - "affinity": "TEXT" - }, - { - "fieldPath": "contactHidden", - "columnName": "contactHidden", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "contactAccountLabel", - "columnName": "contactAccountLabel", - "affinity": "TEXT" - }, - { - "fieldPath": "contactAcceptedAccounts", - "columnName": "contactAcceptedAccounts", - "affinity": "BLOB" - }, - { - "fieldPath": "externalAccountReference", - "columnName": "externalAccountReference", - "affinity": "INTEGER" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "ownerIdentityId", - "contactIdentityId", - "isOutgoing" - ] - }, - "indices": [ - { - "name": "index_dashpay_contact_requests_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_requests_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dashpay_ignored_senders", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `ignoredSenderId` BLOB NOT NULL, `ignoredAt` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `ignoredSenderId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "ignoredSenderId", - "columnName": "ignoredSenderId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "ignoredAt", - "columnName": "ignoredAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "ownerIdentityId", - "ignoredSenderId" - ] - }, - "indices": [ - { - "name": "index_dashpay_ignored_senders_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_ignored_senders_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dashpay_contact_profiles", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `contactIdentityId` BLOB NOT NULL, `displayName` TEXT, `publicMessage` TEXT, `bio` TEXT, `avatarUrl` TEXT, `avatarHash` BLOB, `avatarFingerprint` BLOB, `checkedAtMs` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `contactIdentityId`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contactIdentityId", - "columnName": "contactIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "displayName", - "columnName": "displayName", - "affinity": "TEXT" - }, - { - "fieldPath": "publicMessage", - "columnName": "publicMessage", - "affinity": "TEXT" - }, - { - "fieldPath": "bio", - "columnName": "bio", - "affinity": "TEXT" - }, - { - "fieldPath": "avatarUrl", - "columnName": "avatarUrl", - "affinity": "TEXT" - }, - { - "fieldPath": "avatarHash", - "columnName": "avatarHash", - "affinity": "BLOB" - }, - { - "fieldPath": "avatarFingerprint", - "columnName": "avatarFingerprint", - "affinity": "BLOB" - }, - { - "fieldPath": "checkedAtMs", - "columnName": "checkedAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "ownerIdentityId", - "contactIdentityId" - ] - }, - "indices": [ - { - "name": "index_dashpay_contact_profiles_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_contact_profiles_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "dashpay_payments", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `ownerIdentityId` BLOB NOT NULL, `counterpartyIdentityId` BLOB NOT NULL, `amountDuffs` INTEGER NOT NULL, `directionRaw` INTEGER NOT NULL, `statusRaw` INTEGER NOT NULL, `txid` TEXT NOT NULL, `memo` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`, `ownerIdentityId`, `txid`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "counterpartyIdentityId", - "columnName": "counterpartyIdentityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "amountDuffs", - "columnName": "amountDuffs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "directionRaw", - "columnName": "directionRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "statusRaw", - "columnName": "statusRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "txid", - "columnName": "txid", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "memo", - "columnName": "memo", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw", - "ownerIdentityId", - "txid" - ] - }, - "indices": [ - { - "name": "index_dashpay_payments_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_dashpay_payments_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "data_contracts", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `name` TEXT NOT NULL, `serializedContract` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, `binarySerialization` BLOB, `version` INTEGER, `ownerId` BLOB, `contractDescription` TEXT, `schemaData` BLOB NOT NULL, `documentTypesData` BLOB NOT NULL, `groupsData` BLOB, `networkRaw` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `canBeDeleted` INTEGER NOT NULL, `readonly` INTEGER NOT NULL, `keepsHistory` INTEGER NOT NULL, `schemaDefs` INTEGER, `documentsKeepHistoryContractDefault` INTEGER NOT NULL, `documentsMutableContractDefault` INTEGER NOT NULL, `documentsCanBeDeletedContractDefault` INTEGER NOT NULL, `hasTokens` INTEGER NOT NULL, `tokensData` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "serializedContract", - "columnName": "serializedContract", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastAccessedAt", - "columnName": "lastAccessedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "binarySerialization", - "columnName": "binarySerialization", - "affinity": "BLOB" - }, - { - "fieldPath": "version", - "columnName": "version", - "affinity": "INTEGER" - }, - { - "fieldPath": "ownerId", - "columnName": "ownerId", - "affinity": "BLOB" - }, - { - "fieldPath": "contractDescription", - "columnName": "contractDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "schemaData", - "columnName": "schemaData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "documentTypesData", - "columnName": "documentTypesData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "groupsData", - "columnName": "groupsData", - "affinity": "BLOB" - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncedAt", - "columnName": "lastSyncedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "canBeDeleted", - "columnName": "canBeDeleted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "readonly", - "columnName": "readonly", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsHistory", - "columnName": "keepsHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "schemaDefs", - "columnName": "schemaDefs", - "affinity": "INTEGER" - }, - { - "fieldPath": "documentsKeepHistoryContractDefault", - "columnName": "documentsKeepHistoryContractDefault", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentsMutableContractDefault", - "columnName": "documentsMutableContractDefault", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentsCanBeDeletedContractDefault", - "columnName": "documentsCanBeDeletedContractDefault", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasTokens", - "columnName": "hasTokens", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "tokensData", - "columnName": "tokensData", - "affinity": "BLOB" - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_data_contracts_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - }, - { - "name": "index_data_contracts_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_data_contracts_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "document_types", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `name` TEXT NOT NULL, `schemaJSON` BLOB NOT NULL, `propertiesJSON` BLOB NOT NULL, `documentsKeepHistory` INTEGER NOT NULL, `documentsMutable` INTEGER NOT NULL, `documentsCanBeDeleted` INTEGER NOT NULL, `documentsTransferable` INTEGER NOT NULL, `requiredFieldsJSON` BLOB, `securityLevel` INTEGER NOT NULL, `tradeMode` INTEGER NOT NULL, `creationRestrictionMode` INTEGER NOT NULL, `requiresIdentityEncryptionBoundedKey` INTEGER NOT NULL, `requiresIdentityDecryptionBoundedKey` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastAccessedAt` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "schemaJSON", - "columnName": "schemaJSON", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "propertiesJSON", - "columnName": "propertiesJSON", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "documentsKeepHistory", - "columnName": "documentsKeepHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentsMutable", - "columnName": "documentsMutable", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentsCanBeDeleted", - "columnName": "documentsCanBeDeleted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentsTransferable", - "columnName": "documentsTransferable", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "requiredFieldsJSON", - "columnName": "requiredFieldsJSON", - "affinity": "BLOB" - }, - { - "fieldPath": "securityLevel", - "columnName": "securityLevel", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "tradeMode", - "columnName": "tradeMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "creationRestrictionMode", - "columnName": "creationRestrictionMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "requiresIdentityEncryptionBoundedKey", - "columnName": "requiresIdentityEncryptionBoundedKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "requiresIdentityDecryptionBoundedKey", - "columnName": "requiresIdentityDecryptionBoundedKey", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastAccessedAt", - "columnName": "lastAccessedAt", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_document_types_contractId", - "unique": false, - "columnNames": [ - "contractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_document_types_contractId` ON `${TABLE_NAME}` (`contractId`)" - } - ], - "foreignKeys": [ - { - "table": "data_contracts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "contractId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "documents", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`documentId` TEXT NOT NULL, `documentType` TEXT NOT NULL, `revision` INTEGER NOT NULL, `data` BLOB NOT NULL, `contractId` TEXT NOT NULL, `ownerId` TEXT NOT NULL, `contractIdData` BLOB NOT NULL, `ownerIdData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, `transferredAt` INTEGER, `createdAtBlockHeight` INTEGER, `updatedAtBlockHeight` INTEGER, `transferredAtBlockHeight` INTEGER, `createdAtCoreBlockHeight` INTEGER, `updatedAtCoreBlockHeight` INTEGER, `transferredAtCoreBlockHeight` INTEGER, `networkRaw` INTEGER NOT NULL, `isDeleted` INTEGER NOT NULL, `localCreatedAt` INTEGER NOT NULL, `localUpdatedAt` INTEGER NOT NULL, `documentTypeRelationId` BLOB, `dataContractId` BLOB, `ownerIdentityId` BLOB, PRIMARY KEY(`documentId`), FOREIGN KEY(`documentTypeRelationId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`ownerIdentityId`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "documentId", - "columnName": "documentId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "documentType", - "columnName": "documentType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "revision", - "columnName": "revision", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "data", - "columnName": "data", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "ownerId", - "columnName": "ownerId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "contractIdData", - "columnName": "contractIdData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "ownerIdData", - "columnName": "ownerIdData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "updatedAt", - "columnName": "updatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "transferredAt", - "columnName": "transferredAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "createdAtBlockHeight", - "columnName": "createdAtBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "updatedAtBlockHeight", - "columnName": "updatedAtBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "transferredAtBlockHeight", - "columnName": "transferredAtBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "createdAtCoreBlockHeight", - "columnName": "createdAtCoreBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "updatedAtCoreBlockHeight", - "columnName": "updatedAtCoreBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "transferredAtCoreBlockHeight", - "columnName": "transferredAtCoreBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isDeleted", - "columnName": "isDeleted", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "localCreatedAt", - "columnName": "localCreatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "localUpdatedAt", - "columnName": "localUpdatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentTypeRelationId", - "columnName": "documentTypeRelationId", - "affinity": "BLOB" - }, - { - "fieldPath": "dataContractId", - "columnName": "dataContractId", - "affinity": "BLOB" - }, - { - "fieldPath": "ownerIdentityId", - "columnName": "ownerIdentityId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "documentId" - ] - }, - "indices": [ - { - "name": "index_documents_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - }, - { - "name": "index_documents_contractId", - "unique": false, - "columnNames": [ - "contractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_contractId` ON `${TABLE_NAME}` (`contractId`)" - }, - { - "name": "index_documents_ownerId", - "unique": false, - "columnNames": [ - "ownerId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerId` ON `${TABLE_NAME}` (`ownerId`)" - }, - { - "name": "index_documents_documentTypeRelationId", - "unique": false, - "columnNames": [ - "documentTypeRelationId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_documentTypeRelationId` ON `${TABLE_NAME}` (`documentTypeRelationId`)" - }, - { - "name": "index_documents_dataContractId", - "unique": false, - "columnNames": [ - "dataContractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" - }, - { - "name": "index_documents_ownerIdentityId", - "unique": false, - "columnNames": [ - "ownerIdentityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_documents_ownerIdentityId` ON `${TABLE_NAME}` (`ownerIdentityId`)" - } - ], - "foreignKeys": [ - { - "table": "document_types", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "documentTypeRelationId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "data_contracts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "dataContractId" - ], - "referencedColumns": [ - "id" - ] - }, - { - "table": "identities", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "ownerIdentityId" - ], - "referencedColumns": [ - "identityId" - ] - } - ] - }, - { - "tableName": "indices", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `unique` INTEGER NOT NULL, `nullSearchable` INTEGER NOT NULL, `contested` INTEGER NOT NULL, `propertiesJSON` BLOB NOT NULL, `contestedDetailsJSON` BLOB, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "documentTypeName", - "columnName": "documentTypeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "unique", - "columnName": "unique", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nullSearchable", - "columnName": "nullSearchable", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "contested", - "columnName": "contested", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "propertiesJSON", - "columnName": "propertiesJSON", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contestedDetailsJSON", - "columnName": "contestedDetailsJSON", - "affinity": "BLOB" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentTypeId", - "columnName": "documentTypeId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_indices_documentTypeId", - "unique": false, - "columnNames": [ - "documentTypeId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_indices_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" - } - ], - "foreignKeys": [ - { - "table": "document_types", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "documentTypeId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "keywords", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `keyword` TEXT NOT NULL, `contractId` TEXT NOT NULL, `dataContractId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`dataContractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "keyword", - "columnName": "keyword", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "dataContractId", - "columnName": "dataContractId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_keywords_contractId", - "unique": false, - "columnNames": [ - "contractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_contractId` ON `${TABLE_NAME}` (`contractId`)" - }, - { - "name": "index_keywords_dataContractId", - "unique": false, - "columnNames": [ - "dataContractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_keywords_dataContractId` ON `${TABLE_NAME}` (`dataContractId`)" - } - ], - "foreignKeys": [ - { - "table": "data_contracts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "dataContractId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "properties", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `documentTypeName` TEXT NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `format` TEXT, `contentMediaType` TEXT, `byteArray` INTEGER NOT NULL, `minItems` INTEGER, `maxItems` INTEGER, `pattern` TEXT, `minLength` INTEGER, `maxLength` INTEGER, `minValue` INTEGER, `maxValue` INTEGER, `fieldDescription` TEXT, `transient` INTEGER NOT NULL, `isRequired` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `documentTypeId` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`documentTypeId`) REFERENCES `document_types`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "documentTypeName", - "columnName": "documentTypeName", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "type", - "columnName": "type", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "format", - "columnName": "format", - "affinity": "TEXT" - }, - { - "fieldPath": "contentMediaType", - "columnName": "contentMediaType", - "affinity": "TEXT" - }, - { - "fieldPath": "byteArray", - "columnName": "byteArray", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "minItems", - "columnName": "minItems", - "affinity": "INTEGER" - }, - { - "fieldPath": "maxItems", - "columnName": "maxItems", - "affinity": "INTEGER" - }, - { - "fieldPath": "pattern", - "columnName": "pattern", - "affinity": "TEXT" - }, - { - "fieldPath": "minLength", - "columnName": "minLength", - "affinity": "INTEGER" - }, - { - "fieldPath": "maxLength", - "columnName": "maxLength", - "affinity": "INTEGER" - }, - { - "fieldPath": "minValue", - "columnName": "minValue", - "affinity": "INTEGER" - }, - { - "fieldPath": "maxValue", - "columnName": "maxValue", - "affinity": "INTEGER" - }, - { - "fieldPath": "fieldDescription", - "columnName": "fieldDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "transient", - "columnName": "transient", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isRequired", - "columnName": "isRequired", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "documentTypeId", - "columnName": "documentTypeId", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_properties_documentTypeId", - "unique": false, - "columnNames": [ - "documentTypeId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_properties_documentTypeId` ON `${TABLE_NAME}` (`documentTypeId`)" - } - ], - "foreignKeys": [ - { - "table": "document_types", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "documentTypeId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "pending_inputs", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `outpoint` BLOB NOT NULL, `inputIndex` INTEGER NOT NULL, `spendingTxid` BLOB NOT NULL, `spendingTransactionTxid` BLOB, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `isSweptTombstone` INTEGER NOT NULL DEFAULT 0, `winnerMinedHeight` INTEGER, FOREIGN KEY(`spendingTransactionTxid`) REFERENCES `transactions`(`txid`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "outpoint", - "columnName": "outpoint", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "inputIndex", - "columnName": "inputIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "spendingTxid", - "columnName": "spendingTxid", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "spendingTransactionTxid", - "columnName": "spendingTransactionTxid", - "affinity": "BLOB" - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isSweptTombstone", - "columnName": "isSweptTombstone", - "affinity": "INTEGER", - "notNull": true, - "defaultValue": "0" - }, - { - "fieldPath": "winnerMinedHeight", - "columnName": "winnerMinedHeight", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_pending_inputs_outpoint", - "unique": false, - "columnNames": [ - "outpoint" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_outpoint` ON `${TABLE_NAME}` (`outpoint`)" - }, - { - "name": "index_pending_inputs_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId` ON `${TABLE_NAME}` (`walletId`)" - }, - { - "name": "index_pending_inputs_spendingTransactionTxid", - "unique": false, - "columnNames": [ - "spendingTransactionTxid" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTransactionTxid` ON `${TABLE_NAME}` (`spendingTransactionTxid`)" - }, - { - "name": "index_pending_inputs_spendingTxid", - "unique": false, - "columnNames": [ - "spendingTxid" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_spendingTxid` ON `${TABLE_NAME}` (`spendingTxid`)" - }, - { - "name": "index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight", - "unique": false, - "columnNames": [ - "walletId", - "isSweptTombstone", - "winnerMinedHeight" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_pending_inputs_walletId_isSweptTombstone_winnerMinedHeight` ON `${TABLE_NAME}` (`walletId`, `isSweptTombstone`, `winnerMinedHeight`)" - } - ], - "foreignKeys": [ - { - "table": "transactions", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "spendingTransactionTxid" - ], - "referencedColumns": [ - "txid" - ] - } - ] - }, - { - "tableName": "tokens", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` BLOB NOT NULL, `contractId` BLOB NOT NULL, `position` INTEGER NOT NULL, `name` TEXT NOT NULL, `baseSupply` TEXT NOT NULL, `maxSupply` TEXT, `decimals` INTEGER NOT NULL, `localizations` TEXT, `isPaused` INTEGER NOT NULL, `allowTransferToFrozenBalance` INTEGER NOT NULL, `keepsTransferHistory` INTEGER NOT NULL, `keepsFreezingHistory` INTEGER NOT NULL, `keepsMintingHistory` INTEGER NOT NULL, `keepsBurningHistory` INTEGER NOT NULL, `keepsDirectPricingHistory` INTEGER NOT NULL, `keepsDirectPurchaseHistory` INTEGER NOT NULL, `conventionsChangeRules` TEXT, `maxSupplyChangeRules` TEXT, `manualMintingRules` TEXT, `manualBurningRules` TEXT, `freezeRules` TEXT, `unfreezeRules` TEXT, `destroyFrozenFundsRules` TEXT, `emergencyActionRules` TEXT, `perpetualDistribution` TEXT, `preProgrammedDistribution` TEXT, `oncePerIdentityDistribution` TEXT, `newTokensDestinationIdentity` BLOB, `mintingAllowChoosingDestination` INTEGER NOT NULL, `distributionChangeRules` TEXT, `tradeMode` TEXT NOT NULL, `tradeModeChangeRules` TEXT, `mainControlGroupPosition` INTEGER, `mainControlGroupCanBeModified` TEXT, `tokenDescription` TEXT, `createdAt` INTEGER NOT NULL, `lastUpdatedAt` INTEGER NOT NULL, `canManuallyMint` INTEGER NOT NULL, `canManuallyBurn` INTEGER NOT NULL, `canFreeze` INTEGER NOT NULL, `canUnfreeze` INTEGER NOT NULL, `canDestroyFrozenFunds` INTEGER NOT NULL, `hasEmergencyActions` INTEGER NOT NULL, `canChangeMaxSupply` INTEGER NOT NULL, `canChangeConventions` INTEGER NOT NULL, `canChangeTradeMode` INTEGER NOT NULL, `hasDistribution` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`contractId`) REFERENCES `data_contracts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "contractId", - "columnName": "contractId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "name", - "columnName": "name", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "baseSupply", - "columnName": "baseSupply", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "maxSupply", - "columnName": "maxSupply", - "affinity": "TEXT" - }, - { - "fieldPath": "decimals", - "columnName": "decimals", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "localizations", - "columnName": "localizations", - "affinity": "TEXT" - }, - { - "fieldPath": "isPaused", - "columnName": "isPaused", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "allowTransferToFrozenBalance", - "columnName": "allowTransferToFrozenBalance", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsTransferHistory", - "columnName": "keepsTransferHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsFreezingHistory", - "columnName": "keepsFreezingHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsMintingHistory", - "columnName": "keepsMintingHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsBurningHistory", - "columnName": "keepsBurningHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsDirectPricingHistory", - "columnName": "keepsDirectPricingHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "keepsDirectPurchaseHistory", - "columnName": "keepsDirectPurchaseHistory", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "conventionsChangeRules", - "columnName": "conventionsChangeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "maxSupplyChangeRules", - "columnName": "maxSupplyChangeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "manualMintingRules", - "columnName": "manualMintingRules", - "affinity": "TEXT" - }, - { - "fieldPath": "manualBurningRules", - "columnName": "manualBurningRules", - "affinity": "TEXT" - }, - { - "fieldPath": "freezeRules", - "columnName": "freezeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "unfreezeRules", - "columnName": "unfreezeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "destroyFrozenFundsRules", - "columnName": "destroyFrozenFundsRules", - "affinity": "TEXT" - }, - { - "fieldPath": "emergencyActionRules", - "columnName": "emergencyActionRules", - "affinity": "TEXT" - }, - { - "fieldPath": "perpetualDistribution", - "columnName": "perpetualDistribution", - "affinity": "TEXT" - }, - { - "fieldPath": "preProgrammedDistribution", - "columnName": "preProgrammedDistribution", - "affinity": "TEXT" - }, - { - "fieldPath": "oncePerIdentityDistribution", - "columnName": "oncePerIdentityDistribution", - "affinity": "TEXT" - }, - { - "fieldPath": "newTokensDestinationIdentity", - "columnName": "newTokensDestinationIdentity", - "affinity": "BLOB" - }, - { - "fieldPath": "mintingAllowChoosingDestination", - "columnName": "mintingAllowChoosingDestination", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "distributionChangeRules", - "columnName": "distributionChangeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "tradeMode", - "columnName": "tradeMode", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "tradeModeChangeRules", - "columnName": "tradeModeChangeRules", - "affinity": "TEXT" - }, - { - "fieldPath": "mainControlGroupPosition", - "columnName": "mainControlGroupPosition", - "affinity": "INTEGER" - }, - { - "fieldPath": "mainControlGroupCanBeModified", - "columnName": "mainControlGroupCanBeModified", - "affinity": "TEXT" - }, - { - "fieldPath": "tokenDescription", - "columnName": "tokenDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdatedAt", - "columnName": "lastUpdatedAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canManuallyMint", - "columnName": "canManuallyMint", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canManuallyBurn", - "columnName": "canManuallyBurn", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canFreeze", - "columnName": "canFreeze", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canUnfreeze", - "columnName": "canUnfreeze", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canDestroyFrozenFunds", - "columnName": "canDestroyFrozenFunds", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasEmergencyActions", - "columnName": "hasEmergencyActions", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canChangeMaxSupply", - "columnName": "canChangeMaxSupply", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canChangeConventions", - "columnName": "canChangeConventions", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "canChangeTradeMode", - "columnName": "canChangeTradeMode", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasDistribution", - "columnName": "hasDistribution", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_tokens_contractId", - "unique": false, - "columnNames": [ - "contractId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_tokens_contractId` ON `${TABLE_NAME}` (`contractId`)" - } - ], - "foreignKeys": [ - { - "table": "data_contracts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "contractId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "token_balances", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `tokenId` TEXT NOT NULL, `identityId` BLOB NOT NULL, `balance` BLOB NOT NULL, `frozen` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `lastSyncedAt` INTEGER, `tokenName` TEXT, `tokenSymbol` TEXT, `tokenDecimals` INTEGER, `networkRaw` INTEGER NOT NULL, `identityRef` BLOB, `tokenRef` BLOB, FOREIGN KEY(`identityRef`) REFERENCES `identities`(`identityId`) ON UPDATE NO ACTION ON DELETE SET NULL , FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "tokenId", - "columnName": "tokenId", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "balance", - "columnName": "balance", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "frozen", - "columnName": "frozen", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncedAt", - "columnName": "lastSyncedAt", - "affinity": "INTEGER" - }, - { - "fieldPath": "tokenName", - "columnName": "tokenName", - "affinity": "TEXT" - }, - { - "fieldPath": "tokenSymbol", - "columnName": "tokenSymbol", - "affinity": "TEXT" - }, - { - "fieldPath": "tokenDecimals", - "columnName": "tokenDecimals", - "affinity": "INTEGER" - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "identityRef", - "columnName": "identityRef", - "affinity": "BLOB" - }, - { - "fieldPath": "tokenRef", - "columnName": "tokenRef", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": true, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_token_balances_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - }, - { - "name": "index_token_balances_tokenId_identityId", - "unique": false, - "columnNames": [ - "tokenId", - "identityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenId_identityId` ON `${TABLE_NAME}` (`tokenId`, `identityId`)" - }, - { - "name": "index_token_balances_identityId", - "unique": false, - "columnNames": [ - "identityId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityId` ON `${TABLE_NAME}` (`identityId`)" - }, - { - "name": "index_token_balances_identityRef", - "unique": false, - "columnNames": [ - "identityRef" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_identityRef` ON `${TABLE_NAME}` (`identityRef`)" - }, - { - "name": "index_token_balances_tokenRef", - "unique": false, - "columnNames": [ - "tokenRef" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_balances_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" - } - ], - "foreignKeys": [ - { - "table": "identities", - "onDelete": "SET NULL", - "onUpdate": "NO ACTION", - "columns": [ - "identityRef" - ], - "referencedColumns": [ - "identityId" - ] - }, - { - "table": "tokens", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "tokenRef" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "token_history_events", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `eventType` TEXT NOT NULL, `transactionId` BLOB, `blockHeight` INTEGER, `coreBlockHeight` INTEGER, `fromIdentity` BLOB, `toIdentity` BLOB, `performedByIdentity` BLOB NOT NULL, `amount` TEXT, `balanceBefore` TEXT, `balanceAfter` TEXT, `additionalDataJSON` BLOB, `eventDescription` TEXT, `createdAt` INTEGER NOT NULL, `eventTimestamp` INTEGER NOT NULL, `tokenRef` BLOB, PRIMARY KEY(`id`), FOREIGN KEY(`tokenRef`) REFERENCES `tokens`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "id", - "columnName": "id", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "eventType", - "columnName": "eventType", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "transactionId", - "columnName": "transactionId", - "affinity": "BLOB" - }, - { - "fieldPath": "blockHeight", - "columnName": "blockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "coreBlockHeight", - "columnName": "coreBlockHeight", - "affinity": "INTEGER" - }, - { - "fieldPath": "fromIdentity", - "columnName": "fromIdentity", - "affinity": "BLOB" - }, - { - "fieldPath": "toIdentity", - "columnName": "toIdentity", - "affinity": "BLOB" - }, - { - "fieldPath": "performedByIdentity", - "columnName": "performedByIdentity", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "TEXT" - }, - { - "fieldPath": "balanceBefore", - "columnName": "balanceBefore", - "affinity": "TEXT" - }, - { - "fieldPath": "balanceAfter", - "columnName": "balanceAfter", - "affinity": "TEXT" - }, - { - "fieldPath": "additionalDataJSON", - "columnName": "additionalDataJSON", - "affinity": "BLOB" - }, - { - "fieldPath": "eventDescription", - "columnName": "eventDescription", - "affinity": "TEXT" - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "eventTimestamp", - "columnName": "eventTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "tokenRef", - "columnName": "tokenRef", - "affinity": "BLOB" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "id" - ] - }, - "indices": [ - { - "name": "index_token_history_events_tokenRef", - "unique": false, - "columnNames": [ - "tokenRef" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_token_history_events_tokenRef` ON `${TABLE_NAME}` (`tokenRef`)" - } - ], - "foreignKeys": [ - { - "table": "tokens", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "tokenRef" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "platform_addresses", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `addressType` INTEGER NOT NULL, `addressHash` BLOB NOT NULL, `publicKey` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `addressIndex` INTEGER NOT NULL, `derivationPath` TEXT NOT NULL, `isUsed` INTEGER NOT NULL, `balance` INTEGER NOT NULL, `nonce` INTEGER NOT NULL, `firstSeenHeight` INTEGER NOT NULL, `lastSeenHeight` INTEGER NOT NULL, `walletId` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, `accountId` INTEGER, PRIMARY KEY(`walletId`, `address`), FOREIGN KEY(`accountId`) REFERENCES `accounts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", - "fields": [ - { - "fieldPath": "address", - "columnName": "address", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "addressType", - "columnName": "addressType", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addressHash", - "columnName": "addressHash", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "publicKey", - "columnName": "publicKey", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "addressIndex", - "columnName": "addressIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "derivationPath", - "columnName": "derivationPath", - "affinity": "TEXT", - "notNull": true - }, - { - "fieldPath": "isUsed", - "columnName": "isUsed", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "balance", - "columnName": "balance", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "nonce", - "columnName": "nonce", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "firstSeenHeight", - "columnName": "firstSeenHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSeenHeight", - "columnName": "lastSeenHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "accountId", - "columnName": "accountId", - "affinity": "INTEGER" - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "address" - ] - }, - "indices": [ - { - "name": "index_platform_addresses_walletId_addressHash", - "unique": true, - "columnNames": [ - "walletId", - "addressHash" - ], - "orders": [], - "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_platform_addresses_walletId_addressHash` ON `${TABLE_NAME}` (`walletId`, `addressHash`)" - }, - { - "name": "index_platform_addresses_accountId", - "unique": false, - "columnNames": [ - "accountId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_accountId` ON `${TABLE_NAME}` (`accountId`)" - } - ], - "foreignKeys": [ - { - "table": "accounts", - "onDelete": "CASCADE", - "onUpdate": "NO ACTION", - "columns": [ - "accountId" - ], - "referencedColumns": [ - "id" - ] - } - ] - }, - { - "tableName": "platform_addresses_sync_states", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `networkRaw` INTEGER NOT NULL, `syncHeight` INTEGER NOT NULL, `syncTimestamp` INTEGER NOT NULL, `lastKnownRecentBlock` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "syncHeight", - "columnName": "syncHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "syncTimestamp", - "columnName": "syncTimestamp", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastKnownRecentBlock", - "columnName": "lastKnownRecentBlock", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId" - ] - }, - "indices": [ - { - "name": "index_platform_addresses_sync_states_networkRaw", - "unique": false, - "columnNames": [ - "networkRaw" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_platform_addresses_sync_states_networkRaw` ON `${TABLE_NAME}` (`networkRaw`)" - } - ] - }, - { - "tableName": "shielded_notes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`nullifier` BLOB NOT NULL, `walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `position` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `isSpent` INTEGER NOT NULL, `value` INTEGER NOT NULL, `noteData` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`nullifier`))", - "fields": [ - { - "fieldPath": "nullifier", - "columnName": "nullifier", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "position", - "columnName": "position", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cmx", - "columnName": "cmx", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "blockHeight", - "columnName": "blockHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "isSpent", - "columnName": "isSpent", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "value", - "columnName": "value", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "noteData", - "columnName": "noteData", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "nullifier" - ] - }, - "indices": [ - { - "name": "index_shielded_notes_walletId_accountIndex", - "unique": false, - "columnNames": [ - "walletId", - "accountIndex" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" - } - ] - }, - { - "tableName": "shielded_outgoing_notes", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `cmx` BLOB NOT NULL, `recipient` BLOB NOT NULL, `value` INTEGER NOT NULL, `memo` BLOB NOT NULL, `blockHeight` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `cmx`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "cmx", - "columnName": "cmx", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "recipient", - "columnName": "recipient", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "value", - "columnName": "value", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "memo", - "columnName": "memo", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "blockHeight", - "columnName": "blockHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "accountIndex", - "cmx" - ] - }, - "indices": [ - { - "name": "index_shielded_outgoing_notes_walletId_accountIndex", - "unique": false, - "columnNames": [ - "walletId", - "accountIndex" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_outgoing_notes_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" - } - ] - }, - { - "tableName": "shielded_activities", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `entryId` BLOB NOT NULL, `kindTag` INTEGER NOT NULL, `direction` INTEGER NOT NULL, `status` INTEGER NOT NULL, `amount` INTEGER NOT NULL, `fee` INTEGER NOT NULL, `hasFee` INTEGER NOT NULL, `blockHeight` INTEGER NOT NULL, `hasBlockHeight` INTEGER NOT NULL, `createdAtMs` INTEGER NOT NULL, `identityId` BLOB NOT NULL, `counterparty` BLOB NOT NULL, `memo` BLOB NOT NULL, `noteCmxs` BLOB NOT NULL, `spentNullifiers` BLOB NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`, `entryId`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "entryId", - "columnName": "entryId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "kindTag", - "columnName": "kindTag", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "direction", - "columnName": "direction", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "status", - "columnName": "status", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "amount", - "columnName": "amount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fee", - "columnName": "fee", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasFee", - "columnName": "hasFee", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "blockHeight", - "columnName": "blockHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "hasBlockHeight", - "columnName": "hasBlockHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAtMs", - "columnName": "createdAtMs", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "identityId", - "columnName": "identityId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "counterparty", - "columnName": "counterparty", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "memo", - "columnName": "memo", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "noteCmxs", - "columnName": "noteCmxs", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "spentNullifiers", - "columnName": "spentNullifiers", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "accountIndex", - "entryId" - ] - }, - "indices": [ - { - "name": "index_shielded_activities_walletId_accountIndex", - "unique": false, - "columnNames": [ - "walletId", - "accountIndex" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_activities_walletId_accountIndex` ON `${TABLE_NAME}` (`walletId`, `accountIndex`)" - } - ] - }, - { - "tableName": "shielded_sync_states", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `lastSyncedIndex` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastSyncedIndex", - "columnName": "lastSyncedIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "accountIndex" - ] - }, - "indices": [ - { - "name": "index_shielded_sync_states_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_sync_states_walletId` ON `${TABLE_NAME}` (`walletId`)" - } - ] - }, - { - "tableName": "shielded_viewing_keys", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`walletId` BLOB NOT NULL, `accountIndex` INTEGER NOT NULL, `fvkBytes` BLOB NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`walletId`, `accountIndex`))", - "fields": [ - { - "fieldPath": "walletId", - "columnName": "walletId", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "accountIndex", - "columnName": "accountIndex", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "fvkBytes", - "columnName": "fvkBytes", - "affinity": "BLOB", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "walletId", - "accountIndex" - ] - }, - "indices": [ - { - "name": "index_shielded_viewing_keys_walletId", - "unique": false, - "columnNames": [ - "walletId" - ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_shielded_viewing_keys_walletId` ON `${TABLE_NAME}` (`walletId`)" - } - ] - }, - { - "tableName": "wallet_manager_metadata", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`networkRaw` INTEGER NOT NULL, `combinedSyncHeight` INTEGER NOT NULL, `combinedSyncBlockHash` BLOB, `walletCount` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `lastUpdated` INTEGER NOT NULL, PRIMARY KEY(`networkRaw`))", - "fields": [ - { - "fieldPath": "networkRaw", - "columnName": "networkRaw", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "combinedSyncHeight", - "columnName": "combinedSyncHeight", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "combinedSyncBlockHash", - "columnName": "combinedSyncBlockHash", - "affinity": "BLOB" - }, - { - "fieldPath": "walletCount", - "columnName": "walletCount", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "createdAt", - "columnName": "createdAt", - "affinity": "INTEGER", - "notNull": true - }, - { - "fieldPath": "lastUpdated", - "columnName": "lastUpdated", - "affinity": "INTEGER", - "notNull": true - } - ], - "primaryKey": { - "autoGenerate": false, - "columnNames": [ - "networkRaw" - ] - } - } - ], - "setupQueries": [ - "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'bf2c2e1447c26d26f643457c97fe5452')" - ] - } -} \ No newline at end of file diff --git a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt index 166fe745805..9a98201510b 100644 --- a/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt +++ b/packages/kotlin-sdk/sdk/src/androidTest/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseMigrationTest.kt @@ -690,59 +690,13 @@ class DashDatabaseMigrationTest { db.close() } - /** - * v15 → v16 adds `public_keys.derivationWalletId` without a backfill: a - * legacy breadcrumb's parent may already have been re-associated with - * another wallet, so its owner stays NULL, which never lets a rejection - * clear it. - */ - @Test - fun migrate15To16LeavesLegacyBreadcrumbOwnersUnknown() { - helper.createDatabase(dbName, 15).apply { - execSQL( - "INSERT INTO wallets (walletId, walletGroupId, networkRaw, name, birthHeight, " + - "syncedHeight, lastSynced, isImported, createdAt, lastUpdated) " + - "VALUES (x'01', x'02', 1, 'w', 0, 0, 0, 0, 0, 0)", - ) - execSQL( - "INSERT INTO identities (identityId, balance, revision, isLocal, identityType, " + - "createdAt, lastUpdated, networkRaw, identityIndex, walletId) " + - "VALUES (x'0A', 0, 0, 1, 'User', 0, 0, 1, 0, x'01')", - ) - execSQL( - "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + - "publicKeyData, derivationIdentityIndex, derivationKeyIndex, identityId, " + - "createdAt, identityIdData) " + - "VALUES (0, '0', '2', '0', 0, x'AA', 0, 0, 'a', 0, x'0A')", - ) - execSQL( - "INSERT INTO public_keys (keyId, purpose, securityLevel, keyType, readOnly, " + - "publicKeyData, identityId, createdAt, identityIdData) " + - "VALUES (1, '0', '2', '0', 0, x'BB', 'a', 0, x'0A')", - ) - close() - } - - val db = helper.runMigrationsAndValidate(dbName, 16, true, DashDatabase.MIGRATION_15_16) - - db.query("SELECT keyId, derivationWalletId FROM public_keys ORDER BY keyId").use { c -> - assertTrue(c.moveToFirst()) - assertEquals(0, c.getInt(0)) - assertTrue("a legacy breadcrumb's owner is unknown", c.isNull(1)) - assertTrue(c.moveToNext()) - assertEquals(1, c.getInt(0)) - assertTrue("a row without a breadcrumb stays NULL", c.isNull(1)) - } - db.close() - } - /** The requested contiguous path from the pre-u64 v4 schema to latest. */ @Test fun migrate4ToLatest() { helper.createDatabase(dbName, 4).close() helper.runMigrationsAndValidate( dbName, - 16, + 15, true, DashDatabase.MIGRATION_4_5, DashDatabase.MIGRATION_5_6, @@ -755,17 +709,16 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, - DashDatabase.MIGRATION_15_16, ).close() } - /** The full chain from v1 must also land on a valid v16 schema. */ + /** The full chain from v1 must also land on a valid v15 schema. */ @Test fun migrateAllTheWayFrom1() { helper.createDatabase(dbName, 1).close() helper.runMigrationsAndValidate( dbName, - 16, + 15, true, DashDatabase.MIGRATION_1_2, DashDatabase.MIGRATION_2_3, @@ -781,7 +734,6 @@ class DashDatabaseMigrationTest { DashDatabase.MIGRATION_12_13, DashDatabase.MIGRATION_13_14, DashDatabase.MIGRATION_14_15, - DashDatabase.MIGRATION_15_16, ).close() } } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt index 34f1dfe0f1f..f662cfae2fe 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/NativePersistenceBridge.kt @@ -538,19 +538,13 @@ abstract class NativePersistenceBridge { /** * One `IdentityKeyEntryFFI` upsert. Descriptor - * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ[I)I`. + * `([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I`. * `contractBoundsKind`: 0 none, 1 SingleContract, 2 SingleContractDocumentType, * 3 ContractGroup (`contractBoundsId` is then the contract group id and - * `contractBoundsDocumentType` is null). The four arguments before - * [rejectedDerivations] are the key's usage limits (protocol version 14): the credits it may spend over + * `contractBoundsDocumentType` is null). The last four arguments are the + * key's usage limits (protocol version 14): the credits it may spend over * its lifetime when [totalBudgetIsSome], and the block time in milliseconds * from which it can no longer sign when [expiresAtIsSome]. - * - * With no derivation indices, [rejectedDerivations] holds the - * coordinates Rust checked the key at under this wallet's seed and found - * it does not come from, as flat `[identityIndex, keyIndex]` pairs: a - * stored derivation breadcrumb of this wallet naming one of them is - * cleared. Empty leaves the key's origin unknown, which keeps it. */ @Suppress("LongParameterList") open fun onPersistIdentityKeyUpsert( @@ -577,7 +571,6 @@ abstract class NativePersistenceBridge { totalBudget: Long = 0L, expiresAtIsSome: Boolean = false, expiresAt: Long = 0L, - rejectedDerivations: IntArray = IntArray(0), ): Int = 0 /** One `(identityId, keyId)` removal. Descriptor `([B[BI)I`. */ diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 107e390a9b3..55b22c55dd5 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -46,9 +46,6 @@ internal object TransactionsNative { * `u64 totalBudget` (bit 0) and `u64 expiresAt` (bit 1). May be empty. * @param disablePublicKeyIds key ids to disable; may be empty. At least * one of add / disable must be non-empty. - * @param externalKeyIds ids of the added keys that do not come from the - * wallet's derivation; they are stored without derivation coordinates. - * May be empty. * @param hasRevision whether the update must carry [revision]: one the * cached identity would give another revision is refused before * anything is signed (bridges @@ -62,7 +59,6 @@ internal object TransactionsNative { addPubkeysBlob: ByteArray, disablePublicKeyIds: IntArray, signerHandle: Long, - externalKeyIds: IntArray, hasRevision: Boolean, revision: Long, ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index b8cc26eca9b..a37276ffeec 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -211,13 +211,6 @@ class IdentityUpdates internal constructor( * [identityId] in a single `IdentityUpdateTransition`, signed via * [signerHandle]. At least one of add / disable must be non-empty. * - * [externalKeyIds] names the added keys that do not come from the - * wallet's derivation (a key generated or received outside it, whose - * private key the wallet cannot derive): they are stored without - * derivation coordinates. Every other added key is stored as derived at - * the identity's index and the key's id. An id that names no added key - * is refused before anything is signed. - * * The transition carries the cached identity's revision plus one, read * when the update starts. With [revision] set, an update that would carry * another one throws [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionMismatch] @@ -233,7 +226,6 @@ class IdentityUpdates internal constructor( addPublicKeys: List = emptyList(), disablePublicKeyIds: List = emptyList(), signerHandle: Long, - externalKeyIds: List = emptyList(), revision: Long? = null, ) = gate.op { require(identityId.size == 32) { @@ -242,8 +234,12 @@ class IdentityUpdates internal constructor( require(addPublicKeys.isNotEmpty() || disablePublicKeyIds.isNotEmpty()) { "updateIdentity needs at least one key to add or disable" } - // Key ids and the revision are range-checked where they become - // the native u32 / u64 (the JNI binding), and refused there. + require(disablePublicKeyIds.all { it >= 0 }) { + "every disabled key id must be non-negative, got $disablePublicKeyIds" + } + require(revision == null || revision >= 0) { + "revision must be non-negative, got $revision" + } mapNativeErrors { TransactionsNative.updateIdentity( walletHandle, @@ -251,7 +247,6 @@ class IdentityUpdates internal constructor( IdentityPubkeyCodec.encode(addPublicKeys), disablePublicKeyIds.toIntArray(), signerHandle, - externalKeyIds.toIntArray(), revision != null, revision ?: 0L, ) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt index a2371ba3ca3..0283ac2904f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabase.kt @@ -179,18 +179,9 @@ import org.dashfoundation.dashsdk.persistence.entities.WalletManagerMetadataEnti * treated the account as rotated, tore it down and rebuilt it — and that * rebuild was the per-launch cursor rewind. NULL for pre-migration rows: * native rebuilds once, stamps it, and never again. - * - * Version 16 (key-origin provenance, dashpay/platform#5487): adds the - * nullable `public_keys.derivationWalletId` column, the wallet whose seed a - * row's derivation breadcrumb belongs to, so a rejection by another wallet's - * seed at the same coordinates does not clear it. Every existing row reads - * NULL: its parent identity may already have been re-associated with another - * wallet, so the owner of a legacy breadcrumb is unknown, and a NULL owner - * never lets a rejection clear it. A load or discovery that verifies the key - * again records the owner. */ @Database( - version = 16, + version = 15, exportSchema = true, entities = [ WalletEntity::class, @@ -725,16 +716,6 @@ abstract class DashDatabase : RoomDatabase() { } } - val MIGRATION_15_16: Migration = object : Migration(15, 16) { - override fun migrate(db: SupportSQLiteDatabase) { - // No backfill: a legacy row's parent may already have been - // re-associated with another wallet, so its breadcrumb's - // owner is unknown and stays NULL until a load or discovery - // verifies the key again and records it. - db.execSQL("ALTER TABLE `public_keys` ADD COLUMN `derivationWalletId` BLOB") - } - } - /** * Build the on-disk database. WAL is Room's default journal mode on * API 16+; writes go through the persistence handler inside @@ -765,7 +746,6 @@ abstract class DashDatabase : RoomDatabase() { MIGRATION_12_13, MIGRATION_13_14, MIGRATION_14_15, - MIGRATION_15_16, ) .build() diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt index ea33db7e750..dbaa458df17 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandler.kt @@ -364,11 +364,7 @@ class PlatformWalletPersistenceHandler( * (e.g. via `PlatformWalletManager.repairIdentityKey`). */ data class PendingIdentityKey( - /** - * Hex of the wallet whose seed the repair derives under. When - * [ownerKnown] is false this is only the identity's current wallet, - * a fallback for a breadcrumb with no recorded owner. - */ + /** Hex of the wallet the key belongs to. */ val walletIdHex: String, /** Base58 of the owning identity id. */ val identityIdBase58: String, @@ -384,14 +380,6 @@ class PlatformWalletPersistenceHandler( val reason: String, /** Epoch millis of the (latest) failure. */ val failedAtMs: Long, - /** - * Whether [walletIdHex] is the breadcrumb's recorded owner. False for - * an entry rebuilt from a row written before schema 16 - * ([PublicKeyEntity.derivationWalletId] is NULL): the entry is still - * offered for repair under [walletIdHex], but no wallet's rejection - * cancels it, because nothing has decided whose derivation it is. - */ - val ownerKnown: Boolean = true, ) private val _pendingIdentityKeys = @@ -410,9 +398,8 @@ class PlatformWalletPersistenceHandler( * [ChangesetBuffer] and published only after the Room transaction * commits — a rolled-back or aborted round leaves this map exactly as * it was before the round (see [ChangesetBuffer.pendingKeyDeltas]). - * Standalone (non-bracketed) upserts publish once their own write - * commits (a failed write publishes nothing); [markIdentityKeyRepaired] - * publishes immediately. + * Standalone (non-bracketed) upserts and [markIdentityKeyRepaired] + * publish immediately. */ val pendingIdentityKeys: StateFlow> = _pendingIdentityKeys.asStateFlow() @@ -1913,7 +1900,6 @@ class PlatformWalletPersistenceHandler( totalBudget: Long, expiresAtIsSome: Boolean, expiresAt: Long, - rejectedDerivations: IntArray, ): Int = guarded { // Item 1 — private-key persistence (the CLAUDE.md "one allowed // exception" shape). The `IdentityKeyEntryFFI` payload carries only @@ -1940,47 +1926,6 @@ class PlatformWalletPersistenceHandler( // alias whose row can never commit (the identity rows are gone), // stranding identity-key ciphertext behind a "successful" wipe. val roundKey = walletId.toHex() - // The slots a rejection refutes under this wallet's seed, as - // `(identityIndex, keyIndex)`; empty when the key's origin is unknown. - val refutedSlots: Set> = - if (derivationIndicesIsSome) { - emptySet() - } else { - (0 until rejectedDerivations.size / 2) - .map { rejectedDerivations[2 * it] to rejectedDerivations[2 * it + 1] } - .toSet() - } - // Rust's rejection cancels exactly the repair it refutes: the pending - // entry names the row being upserted (same identity and key id), its - // recorded owner is the rejecting wallet, and its slot is among the - // rejected ones. The map is keyed by public key, so the entry may - // name another row of the same key (another identity, key id, wallet, - // or slot); a rejection persisted for this row says nothing about - // it. An entry whose owner is unknown (rebuilt from a row with no - // recorded owner and filed under the identity's wallet) is never - // cancelled: that wallet's rejection does not show the breadcrumb - // was its own. - val pendingHex = publicKeyData.toHex() - val identityBase58 = identityId.toBase58String() - fun refutes(entry: PendingIdentityKey): Boolean = - entry.ownerKnown && entry.walletIdHex == roundKey && - entry.identityIdBase58 == identityBase58 && entry.keyId == keyId && - (entry.identityIndex to entry.keyIndex) in refutedSlots - val cancelRefutedPending: (Map) -> Map = - { map -> - val pending = map[pendingHex] - if (pending != null && refutes(pending)) map - pendingHex else map - } - val round = buffers[roundKey] - // Every change this upsert makes to [pendingIdentityKeys] takes - // effect only once its row commits: staged with the round when one - // is open, otherwise held here and applied after the standalone - // write (a failed write throws first, leaving the map as it was). - val afterCommit = mutableListOf<(Map) -> Map>() - fun stageWithRow(delta: (Map) -> Map) { - if (round != null) round.pendingKeyDeltas.add(delta) else afterCommit += delta - } - if (refutedSlots.isNotEmpty()) stageWithRow(cancelRefutedPending) val walletStillPersisted = buffers.containsKey(roundKey) || runBlockingResult { database.walletDao().getByWalletId(walletId) != null } val deriver = privateKeyDeriver @@ -2014,7 +1959,7 @@ class PlatformWalletPersistenceHandler( // round rolls back, alias cleanup deletes the newly // stored scalar, so the old watch-only row must keep // its repair signal (finding de3cf44a71fc). - stageWithRow(clearPendingKeyDelta(pendingHex)) + stagePendingKeyDelta(roundKey, clearPendingKeyDelta(publicKeyData.toHex())) } else { // NOT silent (dashpay/platform#4053): the key is being // persisted watch-only, so every signature with it will @@ -2026,7 +1971,7 @@ class PlatformWalletPersistenceHandler( Log.e( TAG, "identity private-key derive/store FAILED — key " + - "$pendingHex (identity $identityBase58, " + + "${publicKeyData.toHex()} (identity ${identityId.toBase58String()}, " + "keyId $keyId, slot $identityIndex/$keyIndex) is persisted " + "WATCH-ONLY and cannot sign until re-derived " + "(see PlatformWalletPersistenceHandler.pendingIdentityKeys): $reason", @@ -2036,13 +1981,14 @@ class PlatformWalletPersistenceHandler( // being persisted watch-only INSIDE the round's buffer, // so if the round aborts that row never commits and the // pending entry would be a phantom (finding de3cf44a71fc). - stageWithRow( + stagePendingKeyDelta( + roundKey, recordPendingKeyDelta( PendingIdentityKey( walletIdHex = keyOwnerWalletId.toHex(), - identityIdBase58 = identityBase58, + identityIdBase58 = identityId.toBase58String(), keyId = keyId, - publicKeyHex = pendingHex, + publicKeyHex = publicKeyData.toHex(), identityIndex = identityIndex, keyIndex = keyIndex, reason = reason, @@ -2061,26 +2007,12 @@ class PlatformWalletPersistenceHandler( // (or a wallet wipe racing the round) can still find and delete it. if (deriveResult != null && deriveResult.wasNewlyCreated && buffers.containsKey(roundKey)) { pendingRoundAliases.getOrPut(roundKey) { mutableSetOf() } - .add(pendingHex) + .add(publicKeyData.toHex()) } stage(walletId) { db -> - val stored = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) - // A row for another public key under this key id carries that - // key's derivation and private-key linkage, which must not pass to - // this one: only a row for the same public key lends them. - val existing = stored?.takeIf { it.publicKeyData.contentEquals(publicKeyData) } - // A rejection refutes only the coordinates it was checked at, under - // the seed of the wallet persisting it: the row's breadcrumb is - // cleared only when it is that wallet's derivation - // ([PublicKeyEntity.derivationWalletId]) at a refuted slot. The - // identity's wallet does not say whose the breadcrumb is: the same - // round may just have re-associated it. - val heldSlot = existing?.derivationIdentityIndex?.let { identity -> - existing.derivationKeyIndex?.let { key -> identity to key } - } - val refuted = heldSlot != null && heldSlot in refutedSlots && - existing.derivationWalletId?.contentEquals(walletId) == true + val identityBase58 = identityId.toBase58String() + val existing = db.publicKeyDao().getByIdentityAndKeyId(identityBase58, keyId) // ContractBounds projection → the legacy JSON blob column + // doc-type name (Swift stores `[base64(contractId)]` JSON), plus // the kind itself: the blob holds the contract group id for kind @@ -2091,7 +2023,7 @@ class PlatformWalletPersistenceHandler( val docTypeName = if (boundsKind == 2) contractBoundsDocumentType else null val row = PublicKeyEntity( - id = stored?.id ?: 0, + id = existing?.id ?: 0, keyId = keyId, purpose = (purpose.toInt() and 0xFF).toString(), securityLevel = (securityLevel.toInt() and 0xFF).toString(), @@ -2117,48 +2049,16 @@ class PlatformWalletPersistenceHandler( // failure marker; the null identifier is). They make the // pending-repair state reconstructible after restart // (dashpay/platform#4060 finding 5). - // Without indices, an upsert keeps the stored breadcrumb of - // the same public key unless it refutes it ([refuted]). - derivationIdentityIndex = when { - derivationIndicesIsSome -> identityIndex - refuted -> null - else -> existing?.derivationIdentityIndex - }, - derivationKeyIndex = when { - derivationIndicesIsSome -> keyIndex - refuted -> null - else -> existing?.derivationKeyIndex - }, - derivationWalletId = when { - derivationIndicesIsSome -> if (walletIdIsSome) keyWalletId else walletId - refuted -> null - else -> existing?.derivationWalletId - }, + derivationIdentityIndex = + if (derivationIndicesIsSome) identityIndex else existing?.derivationIdentityIndex, + derivationKeyIndex = + if (derivationIndicesIsSome) keyIndex else existing?.derivationKeyIndex, identityId = identityBase58, identityIdData = identityId, - createdAt = stored?.createdAt ?: java.util.Date(), + createdAt = existing?.createdAt ?: java.util.Date(), lastAccessed = now(), ) - if (stored == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) - // A new public key under this key id: a pending repair filed under - // the old key that names this row is for a key the row no longer - // carries. Dropped with the row, like every other change here. - if (stored != null && existing == null) { - val replacedHex = stored.publicKeyData.toHex() - stageWithRow { map -> - val pending = map[replacedHex] - if (pending != null && pending.identityIdBase58 == identityBase58 && pending.keyId == keyId) { - map - replacedHex - } else { - map - } - } - } - } - // Standalone path: the write above committed (a failed one threw - // before this line, so nothing is published). - if (afterCommit.isNotEmpty()) { - _pendingIdentityKeys.update { map -> afterCommit.fold(map) { acc, delta -> delta(acc) } } + if (existing == null) db.publicKeyDao().insert(row) else db.publicKeyDao().update(row) } 0 } @@ -4091,9 +3991,7 @@ class PlatformWalletPersistenceHandler( val rows = database.publicKeyDao().getWithDerivationBreadcrumbs() if (rows.isEmpty()) return val entries = mutableListOf() - // In row order, so the same store always seeds the same entry for a - // key carried by several rows (the first row of the key wins). - for (row in rows.sortedBy { it.id }) { + for (row in rows) { if (row.readOnly) continue val identityIndex = row.derivationIdentityIndex ?: continue val keyIndex = row.derivationKeyIndex ?: continue @@ -4101,13 +3999,7 @@ class PlatformWalletPersistenceHandler( val identity = database.identityDao().getByIdentityId(identityIdData) ?: continue val networkRaw = network?.ffiValue if (networkRaw != null && identity.networkRaw != networkRaw) continue - // The breadcrumb's own wallet: the identity may since have been - // re-associated with another wallet, whose seed is another - // derivation. A row with no recorded owner (written before schema - // 16) falls back to the identity's wallet, which may be the wrong - // one, so its entry keeps the owner unknown and no rejection - // cancels it. - val walletId = row.derivationWalletId ?: identity.walletId ?: continue + val walletId = identity.walletId ?: continue val pubkeyHex = row.publicKeyData.toHex() val usable = row.privateKeyKeychainIdentifier != null && try { @@ -4133,7 +4025,6 @@ class PlatformWalletPersistenceHandler( keyIndex = keyIndex, reason = reason, failedAtMs = nowMs, - ownerKnown = row.derivationWalletId != null, ) } if (entries.isEmpty()) return diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt index 194194b0059..9f8a20c35e0 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/persistence/entities/PublicKeyEntity.kt @@ -94,16 +94,4 @@ data class PublicKeyEntity( val lastAccessed: Date? = null, /** FK materialization of the Swift `identity` relationship (32 bytes). */ val identityIdData: ByteArray? = null, - /** - * The wallet whose seed the derivation breadcrumb - * ([derivationIdentityIndex] / [derivationKeyIndex]) belongs to, as Rust - * emitted it (the key's wallet, else the persisting one). A rejection - * clears the breadcrumb only when the rejecting wallet is this one: the - * same coordinates under another wallet's seed are another derivation. - * Null when the row carries no breadcrumb, or when its owner is unknown - * (a breadcrumb written before schema 16: its identity may have been - * re-associated since); a null owner never lets a rejection clear the - * breadcrumb. A load or discovery that verifies the key records it. - */ - val derivationWalletId: ByteArray? = null, ) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt index b149c573522..41326e84f92 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/DashDatabaseTest.kt @@ -233,17 +233,16 @@ class DashDatabaseTest { } @Test - fun schemaIsAtVersion16WithTheSweepHoldIndexes() = runTest { + fun schemaIsAtVersion15WithTheSweepHoldIndexes() = runTest { // The sweep-hold columns land in ONE migration (10 → 11), with the // two `pending_inputs` indexes the sweep's claimed-row lookup // (`spendingTxid`) and the end-of-round collector // (`walletId, isSweptTombstone, winnerMinedHeight`) rely on. // 11 → 12 adds the identity key usage limits columns on top, // 12 → 13 the contract bounds kind, 13 → 14 the token - // once-per-identity distribution block, 14 → 15 the DashPay - // backfill record columns on `wallets`, and 15 → 16 the - // breadcrumb wallet on `public_keys`. - assertEquals(16, db.openHelper.readableDatabase.version) + // once-per-identity distribution block, and 14 → 15 the DashPay + // backfill record columns on `wallets`. + assertEquals(15, db.openHelper.readableDatabase.version) val indexes = mutableSetOf() db.openHelper.readableDatabase.query("PRAGMA index_list('pending_inputs')").use { c -> val nameColumn = c.getColumnIndexOrThrow("name") diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt index ea496d06f04..cc6dede2141 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/persistence/PlatformWalletPersistenceHandlerTest.kt @@ -1188,143 +1188,6 @@ class PlatformWalletPersistenceHandlerTest { assertNull(row!!.privateKeyKeychainIdentifier) } - @Test - fun identityKeyUpsertKeepsAnUnknownOriginAndClearsARejectedOne() = runTest { - // Without indices, an upsert that leaves the key's origin unknown - // keeps the stored breadcrumb; one that refutes exactly its - // coordinates clears it, and one that refutes other coordinates - // keeps it. Another public key under the key id takes nothing. - val identityId = ByteArray(32) { 15 } - seedIdentity(identityId) - fun upsert( - indicesIsSome: Boolean, - rejected: Boolean, - rejectedKeyIndex: Int = 0, - key: ByteArray = ByteArray(33) { 10 }, - from: ByteArray = walletId, - ) { - handler.onChangesetBegin(from) - handler.onPersistIdentityKeyUpsert( - from, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), true, walletId, - indicesIsSome, 0, 7, 0, ByteArray(32), null, - rejectedDerivations = if (rejected) intArrayOf(0, rejectedKeyIndex) else IntArray(0), - ) - handler.onChangesetEnd(from, success = true) - } - suspend fun stored() = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - - upsert(indicesIsSome = true, rejected = false) - assertEquals(7, stored().derivationKeyIndex) - upsert(indicesIsSome = false, rejected = false) - assertEquals(7, stored().derivationKeyIndex) - upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 0) - assertEquals("another slot's rejection keeps it", 7, stored().derivationKeyIndex) - upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 7, from = ByteArray(32) { 2 }) - assertEquals("another wallet's seed refutes nothing here", 7, stored().derivationKeyIndex) - upsert(indicesIsSome = false, rejected = true, rejectedKeyIndex = 7) - assertNull(stored().derivationKeyIndex) - assertNull(stored().derivationIdentityIndex) - - upsert(indicesIsSome = true, rejected = false) - upsert(indicesIsSome = false, rejected = false, key = ByteArray(33) { 11 }) - assertNull("another public key takes no breadcrumb", stored().derivationKeyIndex) - } - - @Test - fun identityKeyRejectionByAnotherWalletKeepsTheBreadcrumbAcrossAFullRound() = runTest { - // Wallet A holds a verified breadcrumb for the key. A full load - // through wallet B re-associates the identity with B (its scalar - // upsert comes first in the round), then rejects the same numeric - // slot under B's seed: A's breadcrumb is another derivation and - // stays. - val walletA = walletId - val walletB = ByteArray(32) { 3 } - val identityId = ByteArray(32) { 16 } - seedIdentity(identityId) - val key = ByteArray(33) { 12 } - handler.onChangesetBegin(walletA) - handler.onPersistIdentityKeyUpsert( - walletA, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), true, walletA, - true, 0, 7, 0, ByteArray(32), null, - ) - handler.onChangesetEnd(walletA, success = true) - - handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) - handler.onChangesetBegin(walletB) - handler.onPersistIdentityUpsert( - walletB, identityId, 1, 0, true, 0, 2, true, walletB, - emptyArray(), longArrayOf(), false, null, null, null, - ByteArray(32), false, ByteArray(8), false, null, - ) - handler.onPersistIdentityKeyUpsert( - walletB, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(0, 7), - ) - handler.onChangesetEnd(walletB, success = true) - - val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertEquals("wallet B's rejection refutes nothing of wallet A", 7, row.derivationKeyIndex) - assertTrue(walletA.contentEquals(row.derivationWalletId)) - - // Wallet A's own rejection of that slot clears it. - handler.onChangesetBegin(walletA) - handler.onPersistIdentityKeyUpsert( - walletA, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(1, 7, 0, 7), - ) - handler.onChangesetEnd(walletA, success = true) - val cleared = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertNull(cleared.derivationKeyIndex) - assertNull(cleared.derivationWalletId) - } - - @Test - fun identityKeyRejectionKeepsALegacyBreadcrumbWhoseOwnerIsUnknown() = runTest { - // A breadcrumb written before schema 16 has no recorded owner (the - // migration leaves it NULL), and its identity may already belong to - // wallet B. B's rejection of the same numeric slot must not clear it. - val walletA = walletId - val walletB = ByteArray(32) { 4 } - val identityId = ByteArray(32) { 17 } - seedIdentity(identityId) - val key = ByteArray(33) { 13 } - handler.onChangesetBegin(walletA) - handler.onPersistIdentityKeyUpsert( - walletA, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), true, walletA, - true, 0, 7, 0, ByteArray(32), null, - ) - handler.onChangesetEnd(walletA, success = true) - val dao = db.publicKeyDao() - val legacy = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - dao.update(legacy.copy(derivationWalletId = null)) - - handler.onPersistWalletMetadata(walletB, testnet, groupId, 0) - handler.onChangesetBegin(walletB) - handler.onPersistIdentityUpsert( - walletB, identityId, 1, 0, true, 0, 2, true, walletB, - emptyArray(), longArrayOf(), false, null, null, null, - ByteArray(32), false, ByteArray(8), false, null, - ) - handler.onPersistIdentityKeyUpsert( - walletB, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(0, 7), - ) - handler.onChangesetEnd(walletB, success = true) - - val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertEquals("an unknown owner refutes nothing", 7, row.derivationKeyIndex) - assertNull(row.derivationWalletId) - } - @Test fun identityKeyUpsertSkipsDeriveForWatchOnlyKey() = runTest { val deriver = FakeDeriver() @@ -1517,531 +1380,6 @@ class PlatformWalletPersistenceHandlerTest { assertEquals("keystore unavailable", entry.reason) } - /** A rejection of `(3, 5)` for [pubkey], sent through a round of [from] or standalone. */ - private fun rejectIdentityKey( - pubkey: ByteArray, - identityId: ByteArray, - from: ByteArray = walletId, - slot: IntArray = intArrayOf(3, 5), - round: Boolean? = true, - ) { - if (round != null) handler.onChangesetBegin(from) - handler.onPersistIdentityKeyUpsert( - from, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = slot, - ) - if (round != null) handler.onChangesetEnd(from, success = round) - } - - @Test - fun aRejectionDropsThePendingRepairOnlyOnceItsRoundCommits() = runTest { - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 20 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 20 } - upsertIdentityKey(pubkey, identityId) - assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - - // Another wallet's rejection of the slot keeps it. - handler.onPersistWalletMetadata(ByteArray(32) { 5 }, testnet, groupId, 0) - rejectIdentityKey(pubkey, identityId, from = ByteArray(32) { 5 }) - assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - - // A rolled-back round keeps it; a committed one drops it, and only - // after the commit. - rejectIdentityKey(pubkey, identityId, round = false) - assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - handler.onChangesetBegin(walletId) - handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(3, 5), - ) - assertNotNull("not before the round commits", handler.pendingIdentityKeys.value[pubkey.toHex()]) - handler.onChangesetEnd(walletId, success = true) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - @Test - fun aStandaloneRejectionDropsThePendingRepairAfterItsWrite() = runTest { - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 21 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 21 } - upsertIdentityKey(pubkey, identityId) - assertNotNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - - rejectIdentityKey(pubkey, identityId, round = null) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) - } - - @Test - fun aRejectionOfALiveRepairLeavesTheRepairARestartRebuildsFromARowWithNoOwner() = runTest { - // The live entry was recorded from Rust's breadcrumb, so its owner is - // known; the stored row's owner has since been cleared (the shape a - // pre-schema-16 row has). That owner's rejection of the entry's own - // slot cancels the live entry, but the row with no recorded owner - // keeps its breadcrumb, so a restart rebuilds the repair with its - // owner unknown, and that entry no rejection cancels. - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 22 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 22 } - upsertIdentityKey(pubkey, identityId) - val dao = db.publicKeyDao() - val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - dao.update(row.copy(derivationWalletId = null)) - assertTrue(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) - - rejectIdentityKey(pubkey, identityId) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - assertEquals(5, dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) - - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertFalse(rebuilt.ownerKnown) - assertEquals(walletId.toHex(), rebuilt.walletIdHex) - - // Now unknown, the owner's rejection no longer cancels it. - rejectIdentityKey(pubkey, identityId) - assertEquals(rebuilt, handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - /** Seed the wallet + identity rows a public-key row FKs onto, under [wallet]. */ - private fun seedIdentityUnder(wallet: ByteArray, identityId: ByteArray) { - handler.onPersistWalletMetadata(wallet, testnet, groupId, 0) - handler.onChangesetBegin(wallet) - handler.onPersistIdentityUpsert( - wallet, identityId, 1, 0, true, 0, 2, true, wallet, - emptyArray(), longArrayOf(), false, null, null, null, - ByteArray(32), false, ByteArray(8), false, null, - ) - handler.onChangesetEnd(wallet, success = true) - } - - /** A breadcrumb upsert of [pubkey] at `(identityIndex, keyIndex)` under [wallet]'s seed. */ - private fun upsertKeyAt( - wallet: ByteArray, - identityId: ByteArray, - pubkey: ByteArray, - identityIndex: Int, - keyIndex: Int, - ) { - handler.onChangesetBegin(wallet) - handler.onPersistIdentityKeyUpsert( - wallet, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), true, wallet, - true, identityIndex, keyIndex, 0, ByteArray(32), null, - ) - handler.onChangesetEnd(wallet, success = true) - } - - @Test - fun aRepairRebuiltFromALegacyRowSurvivesTheReassociatedWalletsRejection() = runTest { - // Upgrade: the breadcrumb was written before schema 16, so its owner - // is unknown (NULL). Reassociation: a full load through wallet B moves - // the identity to B. Restart: the rebuilt repair is offered under B, - // the identity's wallet, but its owner stays unknown. B's rejection of - // the slot shows only that B's seed does not derive the key there, not - // whose breadcrumb it is, so the repair and the breadcrumb both stay. - handler = newHandler(ThrowingDeriver()) - val walletA = walletId - val walletB = ByteArray(32) { 6 } - val identityId = ByteArray(32) { 23 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 23 } - upsertIdentityKey(pubkey, identityId) - val dao = db.publicKeyDao() - val legacy = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - dao.update(legacy.copy(derivationWalletId = null)) - - seedIdentityUnder(walletB, identityId) - assertTrue(walletB.contentEquals(db.identityDao().getByIdentityId(identityId)!!.walletId)) - - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(walletB.toHex(), rebuilt.walletIdHex) - assertFalse(rebuilt.ownerKnown) - - // Neither a round nor a standalone rejection by B, nor one by A, cancels it. - rejectIdentityKey(pubkey, identityId, from = walletB) - rejectIdentityKey(pubkey, identityId, from = walletB, round = null) - rejectIdentityKey(pubkey, identityId, from = walletA) - assertEquals(rebuilt, handler.pendingIdentityKeys.value[pubkey.toHex()]) - val row = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertEquals(5, row.derivationKeyIndex) - assertNull(row.derivationWalletId) - - // The next restart rebuilds the same unresolved repair. - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - assertFalse(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) - } - - @Test - fun aRejectionByTheRecordedOwnerCancelsARepairRebuiltAtRestart() = runTest { - // The row records its owner, so the rebuilt repair's owner is known: - // that wallet's rejection of the repair's slot cancels it with the row. - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 24 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 24 } - upsertIdentityKey(pubkey, identityId) - - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - assertTrue(handler.pendingIdentityKeys.value[pubkey.toHex()]!!.ownerKnown) - - rejectIdentityKey(pubkey, identityId, slot = intArrayOf(3, 4)) - assertNotNull("another slot's rejection keeps it", handler.pendingIdentityKeys.value[pubkey.toHex()]) - rejectIdentityKey(pubkey, identityId) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex) - } - - @Test - fun aRejectionOfOneRowKeepsTheRepairOfAnotherRowOfTheSamePublicKey() = runTest { - // One public key, two rows with different repair scopes: identity X - // at (3, 5) under wallet A, identity Y at (2, 9) under wallet B. The - // pending map holds Y's repair. A's rejection of (3, 5) refutes X's - // row, which says nothing of Y's coordinates. - handler = newHandler(ThrowingDeriver()) - val walletA = walletId - val walletB = ByteArray(32) { 7 } - val identityX = ByteArray(32) { 25 } - val identityY = ByteArray(32) { 26 } - val pubkey = ByteArray(33) { 25 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(walletB.toHex(), held.walletIdHex) - - rejectIdentityKey(pubkey, identityX, from = walletA) - assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) - val dao = db.publicKeyDao() - assertNull("X's row is refuted", dao.getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) - assertEquals(9, dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.derivationKeyIndex) - } - - @Test - fun aCancelledRepairIsRemovedAndARestartRebuildsAnotherRowOfTheSameKey() = runTest { - // Same two scopes, but the pending map holds X's repair. A's rejection - // of (3, 5) cancels X's repair and nothing more (a rolled-back round - // not even that). Y's row still owes a derivation of the key; the - // live map does not pick it up, but the next restart rebuilds it. - handler = newHandler(ThrowingDeriver()) - val walletA = walletId - val walletB = ByteArray(32) { 8 } - val identityX = ByteArray(32) { 27 } - val identityY = ByteArray(32) { 28 } - val pubkey = ByteArray(33) { 27 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletB, identityY) - upsertKeyAt(walletB, identityY, pubkey, 2, 9) - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(identityX.toBase58String(), held.identityIdBase58) - - rejectIdentityKey(pubkey, identityX, from = walletA, round = false) - assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) - - rejectIdentityKey(pubkey, identityX, from = walletA) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - val rebuilt = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(identityY.toBase58String(), rebuilt.identityIdBase58) - assertEquals(walletB.toHex(), rebuilt.walletIdHex) - assertEquals(2, rebuilt.identityIndex) - assertEquals(9, rebuilt.keyIndex) - } - - /** - * A keystore stand-in: one scalar per public key (the shared - * `privkey.` alias), and persist-time stores that fail while - * [failStores] is set. - */ - private class ScriptedDeriver : PrivateKeyDeriver { - var failStores = true - val stored = mutableSetOf() - - override fun deriveAndStore( - walletId: ByteArray, - publicKeyData: ByteArray, - identityIndex: Int, - keyIndex: Int, - keyType: Int, - force: Boolean, - ): DerivedKeyStoreResult? { - val hex = publicKeyData.toHex() - if (failStores && !force) return null - return DerivedKeyStoreResult("privkey.$hex", wasNewlyCreated = stored.add(hex)) - } - - override fun deleteUnownedStored( - pubkeyHexes: Collection, - excludingWalletId: ByteArray, - ): Set = emptySet() - } - - /** What a restart over [deriver]'s store rebuilds for [pubkey]. */ - private suspend fun rebuiltAfterRestart( - deriver: ScriptedDeriver, - pubkey: ByteArray, - ): PlatformWalletPersistenceHandler.PendingIdentityKey? { - val restarted = newHandler(deriver) - restarted.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { it in deriver.stored }) - return restarted.pendingIdentityKeys.value[pubkey.toHex()] - } - - @Test - fun aRejectionOfAnotherIdentitysRowAtTheSameSlotKeepsTheRepair() = runTest { - // Identities X and Y both carry the key at (3, 5) under wallet A; row - // Z of identity Z carries it at (2, 9) under wallet B. The map holds - // Y's repair. A rejection persisted for X's row refutes X's row, not - // the repair Y owes at the same coordinates. - val deriver = ScriptedDeriver() - handler = newHandler(deriver) - val walletA = walletId - val walletB = ByteArray(32) { 10 } - val identityX = ByteArray(32) { 31 } - val identityY = ByteArray(32) { 32 } - val identityZ = ByteArray(32) { 33 } - val pubkey = ByteArray(33) { 31 } - seedIdentityUnder(walletA, identityX) - seedIdentityUnder(walletA, identityY) - seedIdentityUnder(walletB, identityZ) - upsertKeyAt(walletB, identityZ, pubkey, 2, 9) - upsertKeyAt(walletA, identityX, pubkey, 3, 5) - upsertKeyAt(walletA, identityY, pubkey, 3, 5) - val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - assertEquals(identityY.toBase58String(), held.identityIdBase58) - - rejectIdentityKey(pubkey, identityX, from = walletA) - assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) - val dao = db.publicKeyDao() - assertNull(dao.getByIdentityAndKeyId(identityX.toBase58String(), 0)!!.derivationKeyIndex) - assertEquals(5, dao.getByIdentityAndKeyId(identityY.toBase58String(), 0)!!.derivationKeyIndex) - - // Y's own rejection cancels it. - rejectIdentityKey(pubkey, identityY, from = walletA) - assertNull(handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - private suspend fun keyIndexOf(identityId: ByteArray): Int? = - db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.derivationKeyIndex - - @Test - fun aRejectionClearsTheBreadcrumbAndLeavesTheStoredScalar() = runTest { - // The rejection is applied as Rust sent it: the owner's breadcrumb at - // the refuted slot goes. Whatever scalar is stored for the key stays - // (a repair or a re-derive replaces it), and with it the identifier. - val deriver = ScriptedDeriver() - deriver.failStores = false - handler = newHandler(deriver) - val identityId = ByteArray(32) { 38 } - val pubkey = ByteArray(33) { 38 } - seedIdentityUnder(walletId, identityId) - upsertKeyAt(walletId, identityId, pubkey, 3, 5) - - rejectIdentityKey(pubkey, identityId, round = null) - val row = db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertNull(row.derivationKeyIndex) - assertNull(row.derivationWalletId) - assertEquals("privkey.${pubkey.toHex()}", row.privateKeyKeychainIdentifier) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - } - - @Test - fun aRepairRecordedAndRejectedInOneRoundIsCancelled() = runTest { - // One round records X's failed derive and then rejects X's slot: the - // cancel applies after the record it follows, so once the round - // commits no repair is left, as a restart rebuilds none. - val deriver = ScriptedDeriver() - handler = newHandler(deriver) - val identityId = ByteArray(32) { 50 } - val pubkey = ByteArray(33) { 50 } - seedIdentityUnder(walletId, identityId) - deriver.stored += pubkey.toHex() - - handler.onChangesetBegin(walletId) - handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), true, walletId, - true, 3, 5, 0, ByteArray(32), null, - ) - handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(3, 5), - ) - handler.onChangesetEnd(walletId, success = true) - assertNull(keyIndexOf(identityId)) - assertTrue(handler.pendingIdentityKeys.value.isEmpty()) - assertNull(rebuiltAfterRestart(deriver, pubkey)) - } - - @Test - fun aStandaloneUpsertWhoseWriteFailsLeavesThePendingMapUnchanged() = runTest { - // The identity row is missing, so the row's foreign key fails the - // standalone write. Neither the recorded failure nor the clear of an - // existing repair may be published for a row that never landed. - val deriver = ScriptedDeriver() - handler = newHandler(deriver) - handler.onPersistWalletMetadata(walletId, testnet, groupId, 0) - val missing = ByteArray(32) { 42 } - val pubkey = ByteArray(33) { 42 } - fun standaloneUpsert(identityId: ByteArray): Int = - handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), true, walletId, - true, 3, 5, 0, ByteArray(32), null, - ) - - assertEquals(1, standaloneUpsert(missing)) - assertTrue("no phantom repair", handler.pendingIdentityKeys.value.isEmpty()) - - val present = ByteArray(32) { 43 } - seedIdentityUnder(walletId, present) - assertEquals(0, standaloneUpsert(present)) - val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - deriver.failStores = false - assertEquals(1, standaloneUpsert(missing)) - assertEquals("the repair is not cleared", held, handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - @Test - fun aReplacementPublicKeyUnderTheSameKeyIdDropsTheOldKeysLinkage() = runTest { - // Mirror of the Swift handler's replacement test: the row's private-key - // identifier and derivation owner belonged to the old key. - val deriver = ScriptedDeriver() - deriver.failStores = false - handler = newHandler(deriver) - val identityId = ByteArray(32) { 44 } - seedIdentityUnder(walletId, identityId) - val oldKey = ByteArray(33) { 44 } - upsertKeyAt(walletId, identityId, oldKey, 3, 5) - val dao = db.publicKeyDao() - val before = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertEquals("privkey.${oldKey.toHex()}", before.privateKeyKeychainIdentifier) - assertTrue(walletId.contentEquals(before.derivationWalletId)) - - val newKey = ByteArray(33) { 45 } - handler.onChangesetBegin(walletId) - handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - newKey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - ) - handler.onChangesetEnd(walletId, success = true) - val after = dao.getByIdentityAndKeyId(identityId.toBase58String(), 0)!! - assertTrue(newKey.contentEquals(after.publicKeyData)) - assertNull(after.privateKeyKeychainIdentifier) - assertNull(after.derivationWalletId) - assertNull(after.derivationIdentityIndex) - assertNull(after.derivationKeyIndex) - } - - @Test - fun aRestartFilesARepairUnderTheBreadcrumbsRecordedOwner() = runTest { - // A's breadcrumb, then a full load through B re-associates the - // identity with B. The rebuilt repair is A's (the recorded owner), - // not the identity's current wallet's: B's rejection of the slot - // keeps it, A's cancels it. - handler = newHandler(ThrowingDeriver()) - val walletA = walletId - val walletB = ByteArray(32) { 19 } - val identityId = ByteArray(32) { 57 } - seedIdentity(identityId) - val key = ByteArray(33) { 57 } - handler.onChangesetBegin(walletA) - handler.onPersistIdentityKeyUpsert( - walletA, identityId, 0, 0, 0, 0, false, false, 0, - key, ByteArray(20), true, walletA, - true, 0, 7, 0, ByteArray(32), null, - ) - handler.onChangesetEnd(walletA, success = true) - seedIdentityUnder(walletB, identityId) - assertTrue(walletB.contentEquals(db.identityDao().getByIdentityId(identityId)!!.walletId)) - - handler = newHandler(ThrowingDeriver()) - handler.reconstructPendingIdentityKeysFromPersistence(isPrivateKeyDecryptable = { false }) - val rebuilt = handler.pendingIdentityKeys.value[key.toHex()]!! - assertEquals(walletA.toHex(), rebuilt.walletIdHex) - assertTrue(rebuilt.ownerKnown) - - rejectIdentityKey(key, identityId, from = walletB, slot = intArrayOf(0, 7)) - assertEquals(rebuilt, handler.pendingIdentityKeys.value[key.toHex()]) - rejectIdentityKey(key, identityId, from = walletA, slot = intArrayOf(0, 7)) - assertNull(handler.pendingIdentityKeys.value[key.toHex()]) - } - - @Test - fun aStandaloneRejectionWhoseWriteFailsKeepsTheRepair() = runTest { - // The identity row is gone (its key row with it), so the rejection's - // standalone insert fails the foreign key: the cancel it carries is - // never published. - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 58 } - seedIdentity(identityId) - val pubkey = ByteArray(33) { 58 } - upsertIdentityKey(pubkey, identityId) - val held = handler.pendingIdentityKeys.value[pubkey.toHex()]!! - db.identityDao().deleteByIdentityId(identityId) - assertNull(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)) - - val result = handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - pubkey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - rejectedDerivations = intArrayOf(3, 5), - ) - assertEquals(1, result) - assertEquals(held, handler.pendingIdentityKeys.value[pubkey.toHex()]) - } - - @Test - fun aReplacementPublicKeyDropsTheOldKeysRepairOfThatRowOnceItCommits() = runTest { - // The map holds the old key's repair for (identity, key 0). A new - // public key under the same key id leaves the old key no row to - // repair: its entry goes with the committed write, not before, and a - // rolled-back round keeps it. - handler = newHandler(ThrowingDeriver()) - val identityId = ByteArray(32) { 59 } - seedIdentity(identityId) - val oldKey = ByteArray(33) { 59 } - val newKey = ByteArray(33) { 60 } - upsertIdentityKey(oldKey, identityId) - val held = handler.pendingIdentityKeys.value[oldKey.toHex()]!! - fun upsertNewKey() = handler.onPersistIdentityKeyUpsert( - walletId, identityId, 0, 0, 0, 0, false, false, 0, - newKey, ByteArray(20), false, ByteArray(32), - false, 0, 0, 0, ByteArray(32), null, - ) - - handler.onChangesetBegin(walletId) - upsertNewKey() - handler.onChangesetEnd(walletId, success = false) - assertEquals(held, handler.pendingIdentityKeys.value[oldKey.toHex()]) - - handler.onChangesetBegin(walletId) - upsertNewKey() - assertEquals("not before the round commits", held, handler.pendingIdentityKeys.value[oldKey.toHex()]) - handler.onChangesetEnd(walletId, success = true) - assertNull(handler.pendingIdentityKeys.value[oldKey.toHex()]) - assertTrue(newKey.contentEquals(db.publicKeyDao().getByIdentityAndKeyId(identityId.toBase58String(), 0)!!.publicKeyData)) - } - @Test fun deriverReturningNullIsAlsoRecordedAsPending() = runTest { handler = PlatformWalletPersistenceHandler( diff --git a/packages/rs-unified-sdk-jni/src/persistence.rs b/packages/rs-unified-sdk-jni/src/persistence.rs index 439681b40fc..da5d40e8f04 100644 --- a/packages/rs-unified-sdk-jni/src/persistence.rs +++ b/packages/rs-unified-sdk-jni/src/persistence.rs @@ -1536,44 +1536,13 @@ unsafe extern "C" fn tramp_persist_identity_keys( }) } -/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The `ZJZJ` before the -/// final `[I` is the key usage limits pair (`hasTotalBudget`, -/// `totalBudget`, `hasExpiresAt`, `expiresAt`); the final `[I` is -/// `rejectedDerivations`, flat `[identityIndex, keyIndex]` pairs. Bound at the `call_method` site and in +/// `onPersistIdentityKeyUpsert`'s JNI descriptor. The trailing `ZJZJ` is the +/// key usage limits pair (`hasTotalBudget`, `totalBudget`, `hasExpiresAt`, +/// `expiresAt`). Bound at the `call_method` site and in /// [`BRIDGE_METHOD_TABLE`] so the two cannot drift: the smoke test resolves /// the table up front, while the call site only resolves when a key is /// first persisted. -const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ[I)I"; - -/// The `rejectedDerivations` payload of `onPersistIdentityKeyUpsert`: the -/// `pairs` rejected `[identity_index, key_index]` pairs at `ptr`, flat, as -/// `2 * pairs` ints. Null or zero pairs is empty. Each index crosses as its -/// bit pattern, the cast the same call applies to the entry's own -/// `identity_index` / `key_index`, so a rejected pair compares equal to the -/// coordinates Kotlin stored for the key. A payload whose length does not fit -/// a jint is refused rather than truncated. -/// -/// # Safety -/// When `ptr` is non-null and `pairs` non-zero, `ptr` must point at -/// `2 * pairs` `u32`s, valid for the callback window. -unsafe fn rejected_derivation_ints( - ptr: *const u32, - pairs: usize, -) -> Result, jni::errors::Error> { - if ptr.is_null() || pairs == 0 { - return Ok(Vec::new()); - } - let len = pairs - .checked_mul(2) - .filter(|len| i32::try_from(*len).is_ok()) - .ok_or(jni::errors::Error::JniCall( - jni::errors::JniError::InvalidArguments, - ))?; - Ok(std::slice::from_raw_parts(ptr, len) - .iter() - .map(|&v| v as i32) - .collect()) -} +const IDENTITY_KEY_UPSERT_DESCRIPTOR: &str = "([B[BIBBBZZJ[B[BZ[BZIIB[BLjava/lang/String;ZJZJ)I"; unsafe fn persist_identity_key_upsert( env: &mut JNIEnv, @@ -1587,11 +1556,6 @@ unsafe fn persist_identity_key_upsert( let key_wallet_id = env.byte_array_from_slice(&e.wallet_id)?; let cb_id = env.byte_array_from_slice(&e.contract_bounds_id)?; let cb_doctype = cstr_opt(env, e.contract_bounds_document_type)?; - let rejected_ints = - rejected_derivation_ints(e.rejected_derivations_ptr, e.rejected_derivations_len)?; - // Fits a jint: `rejected_derivation_ints` refuses a longer payload. - let rejected = env.new_int_array(rejected_ints.len() as i32)?; - env.set_int_array_region(&rejected, 0, &rejected_ints)?; env.call_method( bridge, "onPersistIdentityKeyUpsert", @@ -1620,7 +1584,6 @@ unsafe fn persist_identity_key_upsert( JValue::Long(e.total_budget as i64), JValue::Bool(e.expires_at_is_some as u8), JValue::Long(e.expires_at as i64), - (&rejected).into(), ], )? .i() @@ -5220,50 +5183,6 @@ mod tests { ); } - /// The rejected pairs cross flat, in order; null or zero pairs is - /// empty; an index past `i32::MAX` crosses as its bit pattern, as the - /// entry's own `identity_index` / `key_index` do. - #[test] - fn should_flatten_rejected_derivation_pairs_into_the_int_array_payload() { - let pairs = [3u32, 7, 0x8000_0000, u32::MAX]; - unsafe { - assert_eq!( - rejected_derivation_ints(ptr::null(), 0).unwrap(), - Vec::::new() - ); - assert_eq!( - rejected_derivation_ints(ptr::null(), 2).unwrap(), - Vec::::new() - ); - assert_eq!( - rejected_derivation_ints(pairs.as_ptr(), 0).unwrap(), - Vec::::new() - ); - assert_eq!( - rejected_derivation_ints(pairs.as_ptr(), 1).unwrap(), - vec![3, 7] - ); - assert_eq!( - rejected_derivation_ints(pairs.as_ptr(), 2).unwrap(), - vec![3, 7, i32::MIN, -1] - ); - } - } - - /// A pair count whose flat length overflows `usize` or a jint is refused - /// before anything is read, rather than wrapped into a short slice. - #[test] - fn should_refuse_a_rejected_derivation_payload_longer_than_a_jint() { - // Never dereferenced: the length check comes first. - let dangling = ptr::NonNull::::dangling().as_ptr() as *const u32; - for pairs in [usize::MAX, usize::MAX / 2 + 1, i32::MAX as usize / 2 + 1] { - assert!( - unsafe { rejected_derivation_ints(dangling, pairs) }.is_err(), - "{pairs}" - ); - } - } - #[test] fn a_height_past_i32_max_is_refused_rather_than_wrapped() { assert_eq!(jint_height(0).unwrap(), 0); diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index a5bf24b00a4..3dc253555a1 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -132,49 +132,6 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── -/// Read a JVM `int[]` of key ids into `Vec`. A null or empty array is -/// no ids; an array that cannot be read, or a negative entry (it would -/// bit-cast to a bogus huge key id), throws and returns `None`. jni 0.21 has no `convert_int_array`, so the -/// length is read first, then the region into an owned buffer. -fn read_key_ids(env: &mut JNIEnv, ids: &jni::objects::JIntArray) -> Option> { - if ids.is_null() { - return Some(Vec::new()); - } - match env.get_array_length(ids) { - Ok(0) => Some(Vec::new()), - Ok(len) => { - let mut buf = vec![0i32; len as usize]; - if env.get_int_array_region(ids, 0, &mut buf).is_err() { - let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "keyIds could not be read"); - return None; - } - match key_ids_from_ints(buf) { - Ok(ids) => Some(ids), - Err(message) => { - throw_sdk_exception(env, 1, message); - None - } - } - } - Err(_) => { - let _ = env.exception_clear(); - throw_sdk_exception(env, 1, "keyIds could not be read"); - None - } - } -} - -/// The key ids a JVM `int[]` carried, or the message to throw: a negative -/// entry would bit-cast to a bogus huge key id, so it is refused rather than -/// cast. -fn key_ids_from_ints(ints: Vec) -> Result, &'static str> { - if ints.iter().any(|&i| i < 0) { - return Err("keyIds must be non-negative"); - } - Ok(ints.into_iter().map(|i| i as u32).collect()) -} - /// The revision `updateIdentity` pins, or the message to throw: `None` /// without `hasRevision` (whatever `revision` holds), and a negative /// `revision` with it is refused rather than bit-cast to a huge one. @@ -226,13 +183,12 @@ fn update_revision_arg(has_revision: bool, revision: i64) -> Result, /// /// `disablePublicKeyIds` is a JVM `int[]` of key ids to disable (may be /// empty). At least one of add / disable must be non-empty for the FFI to -/// build a transition. `externalKeyIds` is a JVM `int[]` (may be empty) of -/// the added keys that do not come from the wallet's derivation; they are -/// stored without derivation coordinates. With `hasRevision`, the update -/// must carry `revision`, and one the cached identity would give another -/// revision is refused before anything is signed -/// (`platform_wallet_update_identity_with_signer_at_revision`). Returns nothing on success (throws on error) — Room -/// learns of the change through the persistence changeset. +/// build a transition. With `hasRevision`, the update must carry `revision`, +/// and one the cached identity would give another revision is refused +/// before anything is signed +/// (`platform_wallet_update_identity_with_signer_at_revision`). Returns +/// nothing on success (throws on error) — Room learns of the change through +/// the persistence changeset. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentity( mut env: JNIEnv, @@ -242,7 +198,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up add_pubkeys_blob: JByteArray, disable_public_key_ids: jni::objects::JIntArray, signer_handle: jlong, - external_key_ids: jni::objects::JIntArray, has_revision: jni::sys::jboolean, revision: jlong, ) { @@ -258,12 +213,37 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up return; }; - // Read the disable and external ids (JVM int[] → Vec). - let Some(disable_ids) = read_key_ids(env, &disable_public_key_ids) else { - return; - }; - let Some(external_ids) = read_key_ids(env, &external_key_ids) else { - return; + // Read the disable ids (a JVM int[] → Vec). A null int[] is a + // legitimate "no disables"; jni 0.21 has no `convert_int_array`, so + // read the length then the region into an owned buffer. + let disable_ids: Vec = if disable_public_key_ids.is_null() { + Vec::new() + } else { + match env.get_array_length(&disable_public_key_ids) { + Ok(len) if len > 0 => { + let mut buf = vec![0i32; len as usize]; + if env + .get_int_array_region(&disable_public_key_ids, 0, &mut buf) + .is_err() + { + let _ = env.exception_clear(); + Vec::new() + } else { + // Reject negative entries before the sign-losing + // cast — a negative int would otherwise bit-cast + // to a bogus huge u32 key id. + if buf.iter().any(|&i| i < 0) { + throw_sdk_exception(env, 1, "keyIds must be non-negative"); + return; + } + buf.into_iter().map(|i| i as u32).collect() + } + } + _ => { + let _ = env.exception_clear(); + Vec::new() + } + } }; if decoded.is_empty() && disable_ids.is_empty() { @@ -275,60 +255,53 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up return; } + let revision = match update_revision_arg(has_revision != 0, revision) { + Ok(revision) => revision, + Err(message) => { + throw_sdk_exception(env, 1, message); + return; + } + }; + // Build the FFI rows referencing the owned buffers in `decoded`. let ffi_rows: Vec = decoded.iter().map(|row| row.to_ffi()).collect(); - let rows = if ffi_rows.is_empty() { ptr::null() } else { ffi_rows.as_ptr() }; - let external = if external_ids.is_empty() { - ptr::null() - } else { - external_ids.as_ptr() - }; let disable = if disable_ids.is_empty() { ptr::null() } else { disable_ids.as_ptr() }; - let revision = match update_revision_arg(has_revision != 0, revision) { - Ok(revision) => revision, - Err(message) => { - throw_sdk_exception(env, 1, message); - return; - } - }; + let result = unsafe { - if let Some(revision) = revision { - platform_wallet_ffi::platform_wallet_update_identity_with_signer_at_revision( - wallet_handle as Handle, - id.as_ptr(), - rows, - ffi_rows.len(), - external, - external_ids.len(), - disable, - disable_ids.len(), - revision, - signer_handle as *mut SignerHandle, - ) - } else { - platform_wallet_ffi::platform_wallet_update_identity_with_signer( + match revision { + Some(revision) => { + platform_wallet_ffi::platform_wallet_update_identity_with_signer_at_revision( + wallet_handle as Handle, + id.as_ptr(), + rows, + ffi_rows.len(), + disable, + disable_ids.len(), + revision, + signer_handle as *mut SignerHandle, + ) + } + None => platform_wallet_ffi::platform_wallet_update_identity_with_signer( wallet_handle as Handle, id.as_ptr(), rows, ffi_rows.len(), - external, - external_ids.len(), disable, disable_ids.len(), signer_handle as *mut SignerHandle, - ) + ), } }; - // `decoded` / `ffi_rows` / `external_ids` / `disable_ids` own the buffers the pointers + // `decoded` / `ffi_rows` / `disable_ids` own the buffers the pointers // reference; they stay in scope through the FFI call above. take_pwffi_error(env, result); }) @@ -1029,22 +1002,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca mod tests { use super::*; - #[test] - fn should_take_non_negative_key_ids_and_refuse_a_negative_one() { - assert_eq!(key_ids_from_ints(vec![]), Ok(vec![])); - assert_eq!( - key_ids_from_ints(vec![0, 7, i32::MAX]), - Ok(vec![0, 7, i32::MAX as u32]) - ); - for ints in [vec![-1], vec![3, i32::MIN], vec![0, -7, 2]] { - assert_eq!( - key_ids_from_ints(ints.clone()), - Err("keyIds must be non-negative"), - "{ints:?}" - ); - } - } - #[test] fn should_pin_a_revision_only_with_has_revision_and_refuse_a_negative_one() { assert_eq!(update_revision_arg(false, 5), Ok(None)); From 54135e6e8bcc7f869f9dee40d883270ad9b6b4cc Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 15:09:30 +0300 Subject: [PATCH 49/72] refactor(swift-sdk): drop externalKeyIds and the persister's key-origin rules updateIdentity keeps the revision: argument and the signer's extended lifetime; the persistence handler, the example app's share-login-key view and their tests go back to base. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../ManagedPlatformWallet.swift | 52 ++--- .../PlatformWalletPersistenceHandler.swift | 53 +---- .../Views/ShareLoginKeyView.swift | 3 - ...ityKeyDerivationPathPersistenceTests.swift | 202 ------------------ 4 files changed, 22 insertions(+), 288 deletions(-) delete mode 100644 packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 474da1d718c..c541efdfc2f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3739,19 +3739,9 @@ extension ManagedPlatformWallet { /// revision this update can land at: Drive takes an identity update /// only at one above the identity's revision. `nil` takes whatever the /// cache gives. - /// - /// `externalKeyIds` names the added keys that do not come from the - /// wallet's derivation (a key generated or received outside it, such - /// as a browser session key, whose private key the wallet cannot - /// derive from its seed). They are persisted without a derivation - /// path, so the signer never takes them for keys it can derive. Every - /// other added key is persisted as derived at the identity's index and - /// the key's id. An id that names no added key throws before anything - /// is signed. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], - externalKeyIds: [UInt32] = [], disablePublicKeyIds: [UInt32] = [], revision: UInt64? = nil, signer: KeychainSigner @@ -3762,7 +3752,6 @@ extension ManagedPlatformWallet { Array(UnsafeBufferPointer(start: ptr, count: 32)) } let addPubkeys = addPublicKeys - let externalIds = externalKeyIds let disableIds = disablePublicKeyIds try await Task.detached(priority: .userInitiated) { // Mirror the registration FFI's pubkey-pinning pattern via @@ -3774,31 +3763,26 @@ extension ManagedPlatformWallet { let result = withExtendedLifetime(signer) { idBytes.withUnsafeBufferPointer { idBp -> PlatformWalletFFIResult in - externalIds.withUnsafeBufferPointer { externalBp -> PlatformWalletFFIResult in - disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in - let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in - let external = externalIds.isEmpty ? nil : externalBp.baseAddress - let disable = disableIds.isEmpty ? nil : disableBp.baseAddress - guard let revision else { - return platform_wallet_update_identity_with_signer( - handle, idBp.baseAddress!, rows, UInt(count), - external, UInt(externalIds.count), - disable, UInt(disableIds.count), signerHandle) - } - return platform_wallet_update_identity_with_signer_at_revision( + disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in + let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in + let disable = disableIds.isEmpty ? nil : disableBp.baseAddress + guard let revision else { + return platform_wallet_update_identity_with_signer( handle, idBp.baseAddress!, rows, UInt(count), - external, UInt(externalIds.count), - disable, UInt(disableIds.count), revision, signerHandle) - } - if addPubkeys.isEmpty { - return update(nil, 0) - } - return ManagedPlatformWallet.withPubkeyFFIArray( - addPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - update(ffiRowsPtr, ffiRowsCount) + disable, UInt(disableIds.count), signerHandle) } + return platform_wallet_update_identity_with_signer_at_revision( + handle, idBp.baseAddress!, rows, UInt(count), + disable, UInt(disableIds.count), revision, signerHandle) + } + if addPubkeys.isEmpty { + return update(nil, 0) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + addPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + update(ffiRowsPtr, ffiRowsCount) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift index 33f45ff1d85..17563d2b217 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletPersistenceHandler.swift @@ -4157,13 +4157,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { parent.addPublicKey(row) } } - // Another public key under this key id: the row's derivation - // path and private-key linkage belonged to the old key and must - // not pass to this one. - if row.publicKeyData != entry.publicKeyData { - row.identityDerivationPath = nil - row.privateKeyKeychainIdentifier = nil - } // Refresh mutable fields every upsert. row.publicKeyData = entry.publicKeyData row.readOnly = entry.readOnly @@ -4216,24 +4209,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { if let path = identityAuthPath(walletId: resolvedWalletId, indices: indices) { row.identityDerivationPath = path } - } else if !entry.derivationRejected.isEmpty, - // The rejection was checked under this wallet's seed: a path - // another wallet derived is not refuted by it, and neither is - // one whose owner was never recorded (a legacy row), as in - // the Kotlin persister. - row.walletId == walletId, - let heldPath = row.identityDerivationPath, - entry.derivationRejected.contains(where: { rejected in - identityAuthPath(walletId: walletId, indices: rejected) == heldPath - }) - { - // Rust checked the key at these coordinates and found it does - // not come from there (the candidate did not reproduce it, or - // the caller declared it external): the path is a stale - // inferred one, and the signer must not derive at it. A path - // at other coordinates, and every path when the origin is - // unknown, is kept. - row.identityDerivationPath = nil } row.lastAccessed = Date() @@ -5258,11 +5233,9 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { let publicKeyHash: Data /// Owning wallet if this key is derivable from one we control. let walletId: Data? - /// DIP-9 `(identity_index, key_index)` pair, present when Rust - /// verified the key as wallet-derivable; the client derives it on - /// demand from the Keychain seed at this path when it needs to sign - /// (no secret crosses the FFI). `nil` gives none, its meaning set by - /// `derivationRejected`. + /// DIP-9 `(identity_index, key_index)` pair. Present iff the key is + /// wallet-derivable; the client derives it on demand from the Keychain + /// seed at this path when it needs to sign (no secret crosses the FFI). let derivationIndices: (identityIndex: UInt32, keyIndex: UInt32)? /// Full ContractBounds projection mirrored from Rust: /// `nil` when the key has no bounds; `.singleContract` for @@ -5274,12 +5247,6 @@ public final class PlatformWalletPersistenceHandler: @unchecked Sendable { /// `identity.identityPublicKeys`) and so a group bound is not /// mistaken for a whole-contract one. let contractBounds: ManagedPlatformWallet.ContractBounds? - /// With no `derivationIndices`: the coordinates Rust checked the key - /// at under the persisting wallet's seed and found it does not come - /// from, so a stored derivation path of that wallet naming one of them - /// is cleared; empty when its origin is unknown, which keeps the - /// stored path. - var derivationRejected: [(identityIndex: UInt32, keyIndex: UInt32)] = [] } // MARK: - Watch-only Restore: Account Addresses @@ -10351,8 +10318,7 @@ private func persistIdentityKeysCallback( publicKeyHash: dataFromTuple20(e.public_key_hash), walletId: walletId, derivationIndices: indices, - contractBounds: bounds, - derivationRejected: rejectedPairs(e) + contractBounds: bounds )) } } @@ -10802,17 +10768,6 @@ private func persistContactsCallback( return 0 } -/// The rejected `[identity_index, key_index]` pairs a key entry carries. -private func rejectedPairs( - _ e: IdentityKeyEntryFFI -) -> [(identityIndex: UInt32, keyIndex: UInt32)] { - guard let ptr = e.rejected_derivations_ptr, e.rejected_derivations_len > 0 else { - return [] - } - let flat = UnsafeBufferPointer(start: ptr, count: Int(e.rejected_derivations_len) * 2) - return stride(from: 0, to: flat.count, by: 2).map { (flat[$0], flat[$0 + 1]) } -} - /// Copy a fixed 32-byte C tuple into an owned `Data`. Used by the /// identity-persistence callbacks where Rust hands over `[u8; 32]` /// fields as `(UInt8, UInt8, ...)` tuples. diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift index 7874e58a5e0..2205dd5da4f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/ShareLoginKeyView.swift @@ -311,12 +311,9 @@ struct ShareLoginKeyView: View { ) let signer = KeychainSigner(modelContainer: modelContext.container) - // The browser's key: the phone never holds its private key, - // so it is stored without a derivation path. try await wallet.updateIdentity( identityId: identity.identityId, addPublicKeys: [newKey], - externalKeyIds: [keyId], signer: signer ) _ = signer // keepalive: see KeychainSigner lifetime contract. diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift deleted file mode 100644 index 2e7685e419f..00000000000 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/IdentityKeyDerivationPathPersistenceTests.swift +++ /dev/null @@ -1,202 +0,0 @@ -import SwiftData -import XCTest - -@testable import SwiftDashSDK - -/// What a persister round does to a stored key's derivation breadcrumb -/// (`identityDerivationPath`) and private-key linkage -/// (`privateKeyKeychainIdentifier`) when the upsert carries no derivation -/// indices of its own. -/// -/// The rules `persistIdentityKeys` follows: -/// - no breadcrumb and no rejection: the key's origin is unknown, and the -/// stored path of the same public key is kept; -/// - `derivationRejected` clears the stored path only when the row belongs to -/// the persisting wallet (a legacy row without a wallet has an unknown owner -/// and keeps its path) and -/// the held path is the one at a rejected `(identityIndex, keyIndex)` pair; -/// - another public key under the same key id clears both the path and the -/// keychain linkage, which belonged to the old key. -@MainActor -final class IdentityKeyDerivationPathPersistenceTests: XCTestCase { - - private let walletId = Data(repeating: 0xD1, count: 32) - private let otherWalletId = Data(repeating: 0xD2, count: 32) - private let identityId = Data(repeating: 0xD3, count: 32) - private let publicKeyData = Data(repeating: 0x02, count: 33) - private let keychainId = "identity_privkey.stored" - private let keyId: UInt32 = 0 - - /// The DIP-9 identity-auth path the handler formats for the persisting - /// wallet, which lives on testnet. - private func authPath(identityIndex: UInt32, keyIndex: UInt32) throws -> String { - try KeyDerivation.getIdentityAuthenticationPath( - network: .testnet, identityIndex: identityIndex, keyIndex: keyIndex) - } - - /// An in-memory store holding the persisting wallet's row (the handler - /// needs it to format a rejected pair into a path) and one key row that - /// already carries a breadcrumb and a keychain linkage. - private func makeStore( - rowWalletId: Data?, - storedPath: String - ) throws -> (PlatformWalletPersistenceHandler, ModelContainer) { - let container = try DashModelContainer.createInMemory() - let context = ModelContext(container) - context.insert(PersistentWallet(walletId: walletId, network: .testnet)) - let row = PersistentPublicKey( - keyId: Int32(bitPattern: keyId), - purpose: .authentication, - securityLevel: .high, - keyType: .ecdsaSecp256k1, - publicKeyData: publicKeyData, - identityId: identityId.toBase58String()) - row.walletId = rowWalletId - row.identityDerivationPath = storedPath - row.privateKeyKeychainIdentifier = keychainId - context.insert(row) - try context.save() - let handler = PlatformWalletPersistenceHandler( - modelContainer: container, network: .testnet) - return (handler, container) - } - - /// An upsert of the stored key with no derivation indices, as Rust emits - /// it for a key whose origin it could not confirm. - private func snapshot( - publicKey: Data? = nil, - rejected: [(identityIndex: UInt32, keyIndex: UInt32)] = [] - ) -> PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot { - .init( - identityId: identityId, - keyId: keyId, - purpose: KeyPurpose.authentication.rawValue, - securityLevel: SecurityLevel.high.rawValue, - keyType: KeyType.ecdsaSecp256k1.rawValue, - readOnly: false, - disabledAt: nil, - totalBudget: nil, - expiresAt: nil, - publicKeyData: publicKey ?? publicKeyData, - publicKeyHash: Data(repeating: 0x03, count: 20), - walletId: nil, - derivationIndices: nil, - contractBounds: nil, - derivationRejected: rejected) - } - - /// One persist round, bracketed the way the Rust store() round is: - /// `persistIdentityKeys` stages rows and `endChangeset` saves them. - private func persist( - _ handler: PlatformWalletPersistenceHandler, - _ entry: PlatformWalletPersistenceHandler.IdentityKeyEntrySnapshot - ) { - handler.beginChangeset(walletId: walletId) - handler.persistIdentityKeys(walletId: walletId, upserts: [entry], removed: []) - XCTAssertTrue(handler.endChangeset(walletId: walletId, success: true)) - } - - private func storedKey(_ container: ModelContainer) throws -> PersistentPublicKey { - let rows = try ModelContext(container).fetch(FetchDescriptor()) - XCTAssertEqual(rows.count, 1, "one key row per (identity, key id)") - return try XCTUnwrap(rows.first) - } - - /// No breadcrumb and no rejection: Rust does not know where the key comes - /// from, which refutes nothing, so the stored path and linkage stay. - func testUnknownOriginKeepsTheStoredPath() throws { - let path = try authPath(identityIndex: 0, keyIndex: 0) - let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) - - persist(handler, snapshot()) - - let row = try storedKey(container) - XCTAssertEqual(row.identityDerivationPath, path) - XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) - XCTAssertEqual(row.walletId, walletId) - } - - /// The rejection was checked under the persisting wallet's seed. A path - /// another wallet derived is not refuted by it, even when it names the - /// same coordinates (the path string carries no wallet). - func testRejectionByAnotherWalletKeepsTheStoredPath() throws { - let path = try authPath(identityIndex: 0, keyIndex: 0) - let (handler, container) = try makeStore(rowWalletId: otherWalletId, storedPath: path) - - persist(handler, snapshot(rejected: [(identityIndex: 0, keyIndex: 0)])) - - let row = try storedKey(container) - XCTAssertEqual(row.identityDerivationPath, path, - "another wallet's path is not refuted by this wallet's check") - XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) - XCTAssertEqual(row.walletId, otherWalletId) - } - - /// The same wallet rejecting other coordinates says nothing about the - /// path the row holds: it stays. - func testRejectionAtOtherCoordinatesKeepsTheStoredPath() throws { - let path = try authPath(identityIndex: 0, keyIndex: 0) - let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) - - persist(handler, snapshot(rejected: [ - (identityIndex: 0, keyIndex: 1), - (identityIndex: 1, keyIndex: 0), - ])) - - let row = try storedKey(container) - XCTAssertEqual(row.identityDerivationPath, path, - "a rejection at other coordinates must not clear this path") - XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) - } - - /// The persisting wallet rejecting the coordinates the stored path names: - /// the path is a stale inferred one and is cleared, so the signer does not - /// derive at it. The public key is unchanged, so its keychain linkage - /// stays. - func testMatchingRejectionBySameWalletClearsTheStoredPath() throws { - let path = try authPath(identityIndex: 0, keyIndex: 0) - let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) - - persist(handler, snapshot(rejected: [ - (identityIndex: 0, keyIndex: 1), - (identityIndex: 0, keyIndex: 0), - ])) - - let row = try storedKey(container) - XCTAssertNil(row.identityDerivationPath, - "a path at rejected coordinates of this wallet must be cleared") - XCTAssertEqual(row.privateKeyKeychainIdentifier, keychainId) - } - - /// A legacy row with no wallet recorded has an unknown owner, which the - /// persisting wallet's rejection does not refute: its path is kept, as - /// the Kotlin persister keeps it. - func testMatchingRejectionKeepsTheStoredPathOfARowWithoutAWallet() throws { - let path = try authPath(identityIndex: 2, keyIndex: 3) - let (handler, container) = try makeStore(rowWalletId: nil, storedPath: path) - - persist(handler, snapshot(rejected: [(identityIndex: 2, keyIndex: 3)])) - - let row = try storedKey(container) - XCTAssertEqual(row.identityDerivationPath, path, - "a path whose owner is unknown must not be cleared by a rejection") - } - - /// Another public key under the same key id: the stored path and keychain - /// linkage belonged to the old key and must not pass to the new one, even - /// with no rejection on the upsert. - func testReplacementPublicKeyClearsPathAndKeychainLinkage() throws { - let path = try authPath(identityIndex: 0, keyIndex: 0) - let (handler, container) = try makeStore(rowWalletId: walletId, storedPath: path) - let replacement = Data(repeating: 0x03, count: 33) - - persist(handler, snapshot(publicKey: replacement)) - - let row = try storedKey(container) - XCTAssertEqual(row.publicKeyData, replacement) - XCTAssertNil(row.identityDerivationPath, - "the old key's derivation path must not sign for the new key") - XCTAssertNil(row.privateKeyKeychainIdentifier, - "the old key's private-key linkage must not pass to the new key") - } -} From d49fa6789c926cfa71ea433b8523f5fe9ca23216 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:07:32 +0300 Subject: [PATCH 50/72] test(platform-wallet): pin that a load by index stores a new identity with its keys in one changeset A load that finds an identity the wallet does not hold yet stores it and its keys, each with the breadcrumb verified against the seed, in a single changeset. On base the load wrote three: the identity with breadcrumb-less key rows, the status, then the breadcrumbs, so a store failing after the first left watch-only rows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UQmZQ3ci6WwfhbyqYkosKK --- .../src/wallet/identity/network/loading.rs | 129 +++++++++++++++++- .../wallet/identity/network/signing_key.rs | 5 + 2 files changed, 133 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 02f8a31d799..1646fd4a056 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -533,14 +533,26 @@ impl IdentityWallet { #[cfg(test)] mod tests { use super::super::identity_handle::{ - derive_identity_auth_key_hash, derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, + derive_ecdsa_identity_auth_keypair_from_master, derive_identity_auth_key_hash, + derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX, }; + use super::super::signing_key::tests::CapturingStore; use super::{derive_load_probe_hash, ResolvedLoadKeyHashSource}; + use crate::changeset::IdentityKeyDerivationIndices; + use crate::events::{EventHandler, PlatformEventHandler}; + use crate::PlatformWalletManager; + use dash_sdk::platform::types::identity::PublicKeyHash; + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::{Identity, IdentityPublicKey, KeyType, Purpose, SecurityLevel}; + use dpp::platform_value::BinaryData; + use dpp::prelude::Identifier; use key_wallet::bip32::ExtendedPrivKey; use key_wallet::mnemonic::Mnemonic; use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::wallet::Wallet; use key_wallet::Network; + use std::sync::Arc; /// English BIP-39 test vector (all-zero entropy). Same fixture the /// FFI-side derive tests and the `identity_handle` tests use, so the @@ -699,4 +711,119 @@ mod tests { } } } + + /// A load that finds an identity the wallet does not hold yet stores it + /// and its keys, each with the breadcrumb verified against the seed, as + /// one changeset. No snapshot of the identity or its keys reaches the + /// store ahead of the breadcrumbs, so a store that fails between two + /// writes cannot leave watch-only key rows behind. + #[tokio::test] + async fn should_store_a_newly_loaded_identity_with_its_keys_in_one_changeset() { + struct Events; + impl EventHandler for Events {} + impl PlatformEventHandler for Events {} + + const SEED: [u8; 64] = [7; 64]; + let network = Network::Testnet; + let master = ExtendedPrivKey::new_master(network, &SEED).unwrap(); + let identity_id = Identifier::from([0x5A; 32]); + // Registered at index 0 the way registration does it: MASTER, HIGH + // and CRITICAL auth keys at key ids 0..=2, all from the seed. + let levels = [ + SecurityLevel::MASTER, + SecurityLevel::HIGH, + SecurityLevel::CRITICAL, + ]; + let public_keys = (0u32..) + .zip(levels) + .map(|(key_id, security_level)| { + let derived = + derive_ecdsa_identity_auth_keypair_from_master(&master, network, 0, key_id) + .unwrap(); + let key: IdentityPublicKey = IdentityPublicKeyV0 { + id: key_id, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(derived.public_key.to_vec()), + disabled_at: None, + } + .into(); + (key_id, key) + }) + .collect(); + let on_chain = Identity::V0(IdentityV0 { + id: identity_id, + public_keys, + balance: 1_000_000, + revision: 1, + }); + + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + let probe = + derive_load_probe_hash(ResolvedLoadKeyHashSource::Master(&master), network, 0).unwrap(); + sdk.mock() + .expect_fetch(PublicKeyHash(probe), Some(on_chain)) + .await + .unwrap(); + let store = Arc::new(CapturingStore::default()); + let manager = PlatformWalletManager::new(Arc::new(sdk), store.clone(), Arc::new(Events)); + let wallet = manager + .create_wallet_from_seed_bytes( + network, + &SEED, + WalletAccountCreationOptions::Default, + Some(0), + ) + .await + .unwrap(); + let wallet_id = wallet.wallet_id(); + store.take(); + + wallet + .identity() + .load_identity_by_index_from_master(0, &master) + .await + .unwrap() + .expect("the identity at index 0 is found"); + + let stored = store.take(); + let identity_changesets: Vec<_> = stored + .iter() + .filter(|changeset| changeset.identities.is_some() || changeset.identity_keys.is_some()) + .collect(); + assert_eq!( + identity_changesets.len(), + 1, + "the identity and its keys go out together: {identity_changesets:?}" + ); + let changeset = identity_changesets[0]; + assert!( + changeset + .identities + .as_ref() + .is_some_and(|identities| identities.identities.contains_key(&identity_id)), + "the changeset carries the identity" + ); + let upserts = &changeset + .identity_keys + .as_ref() + .expect("the changeset carries the keys") + .upserts; + assert_eq!(upserts.len(), 3); + for key_id in 0..3 { + let row = &upserts[&(identity_id, key_id)]; + assert_eq!(row.wallet_id, Some(wallet_id)); + assert_eq!( + row.derivation_indices, + Some(IdentityKeyDerivationIndices { + identity_index: 0, + key_index: key_id, + }), + "key {key_id} carries its breadcrumb" + ); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs index 6aefc3fba65..4853014ffcf 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/signing_key.rs @@ -394,6 +394,11 @@ pub(super) mod tests { } impl CapturingStore { + /// Every changeset stored so far, removed from the record. + pub(crate) fn take(&self) -> Vec { + std::mem::take(&mut *self.0.lock().unwrap()) + } + /// The last key row stored for `key_id`. pub(crate) fn stored_key( &self, From d62a921e0834c14d04ddecbb39b3fd50ebf3aa98 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:15:22 +0300 Subject: [PATCH 51/72] docs(platform-wallet): state that a key entry without coordinates keeps a held breadcrumb IdentityKeyEntry::derivation_indices said None meant view-only, while the merge and every persister read it as no new coordinates for a row they already hold. Document both readings so a downstream persister keeps the breadcrumb a refresh's entry does not carry. Kotlin no longer checks the revision sign itself; the JNI marshaller already refuses a negative revision. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/identity/IdentityUpdates.kt | 3 --- .../rs-platform-wallet/src/changeset/changeset.rs | 12 ++++++++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index a37276ffeec..0dd8129e303 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -237,9 +237,6 @@ class IdentityUpdates internal constructor( require(disablePublicKeyIds.all { it >= 0 }) { "every disabled key id must be non-negative, got $disablePublicKeyIds" } - require(revision == null || revision >= 0) { - "revision must be non-negative, got $revision" - } mapNativeErrors { TransactionsNative.updateIdentity( walletHandle, diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 6096b48b0c8..73f4b9fde6d 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1050,8 +1050,16 @@ pub struct IdentityKeyEntry { /// watched-only identities with no wallet association. pub wallet_id: Option<[u8; 32]>, /// DIP-9 `(identity_index, key_index)` pair the client needs to - /// re-derive the private key. `None` means "view-only, no - /// private key recoverable". + /// re-derive the private key. + /// + /// `None` gives no coordinates: for a row the persister does not hold + /// yet it means "view-only, no private key recoverable", but for an + /// update to a held row it means "no new coordinates", and the + /// persister keeps the breadcrumb (and its `wallet_id`) the row already + /// holds. A refresh emits such entries for keys a wallet derived, so a + /// persister that replaced the held breadcrumb with `None` would lose + /// the key's signing recovery. The SQLite writer, the Swift and the + /// Kotlin persisters all keep it, and changeset merges do the same. pub derivation_indices: Option, } From 650fd457f852a89e9154a39e588310ce26d16aff Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:02:23 +0300 Subject: [PATCH 52/72] fix(platform-wallet): store a landed update's disables when the cache already holds it When the confirmation finds the cache at or past the transition's revision, only the added keys were stored, so a disable-only update stored nothing. If the concurrent fetch that put the cache there failed to persist (a failed mobile round leaves the fold in memory), the disable was never stored and a restart brought the key back active. That branch now stores the added keys, the disabled keys' cached copies (with no breadcrumb; the persisters keep the one a row holds) and the scalar snapshot as one fallible write, the snapshot alone when no key matched, and returns its failure. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 84 ++++++++++++++++--- .../state/managed_identity/identity_ops.rs | 11 +++ 2 files changed, 85 insertions(+), 10 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index ed70fcd7947..c452837fdad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -271,10 +271,14 @@ impl IdentityWallet { /// A cache already refreshed to `transition_revision` or past it (a /// fetch answered between the update's snapshot and this step) holds the /// update as Platform has it, on-chain `disabled_at` included, and - /// whatever later updates did: the payload is not applied over it. A - /// refresh stores the keys it adds without a breadcrumb, so the added - /// keys are stored here from the cache's own copies with the breadcrumb - /// the update gives them. + /// whatever later updates did: the payload is not applied over it. The + /// fetch that put it there may not have been stored (a failed mobile + /// round leaves the fold in memory), so the update's keys are stored + /// here from the cache's own copies, with the scalar snapshot, as one + /// fallible write: an added key with the breadcrumb the update gives it + /// (a refresh stores its rows without one), a disabled key without one + /// (the persisters keep what the row holds). The snapshot is stored even + /// when no key matched. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -294,7 +298,8 @@ impl IdentityWallet { }; if managed.identity.revision() >= transition_revision { - let keys = added_keys + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let mut keys: Vec = added_keys .into_iter() .map(|key| KeyWithBreadcrumb { breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), @@ -306,11 +311,23 @@ impl IdentityWallet { .unwrap_or(key), }) .collect(); - return managed.add_keys(keys, &self.persister).map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - }); + keys.extend( + disabled_ids + .iter() + .filter(|id| !added_ids.contains(id)) + .filter_map(|id| managed.identity.public_keys().get(id).cloned()) + .map(|key| KeyWithBreadcrumb { + key, + breadcrumb: None, + }), + ); + return managed + .store_keys_with_snapshot(keys, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }); } // The identity is at the transition's revision now, so a @@ -747,6 +764,53 @@ mod update_identity_at_revision_tests { let stored = store.stored_key(identity_id, 3).expect("key 3 stored"); assert!(stored.derivation_indices.is_some()); } + + /// A disable-only update at 5 whose confirmation finds the cache already + /// at 5 with the disable (a concurrent fetch's fold whose store failed): + /// the disabled key and the scalar snapshot are stored now, so a restart + /// does not bring the key back active. With no key matched, the snapshot + /// is still stored. + #[tokio::test] + async fn should_store_a_disable_the_cache_already_holds_when_the_confirmation_comes_back() { + let identity_id = Identifier::default(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(5, store.clone()).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap(); + let mut key = managed.identity.public_keys()[&1].clone(); + key.set_disabled_at(7); + managed.identity.add_public_key(key); + } + store.take(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap(); + let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); + assert_eq!(stored.public_key.disabled_at(), Some(7), "the cache's copy"); + assert_eq!(stored.derivation_indices, None); + let changesets = store.take(); + assert_eq!(changesets.len(), 1); + assert!( + changesets[0].identities.is_some(), + "with the scalar snapshot" + ); + + wallet + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![9]) + .await + .unwrap(); + let changesets = store.take(); + assert_eq!(changesets.len(), 1, "the snapshot alone"); + assert!(changesets[0].identities.is_some()); + } } #[cfg(test)] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 04a19d27f4a..4cbd1d08d3c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -567,6 +567,17 @@ impl ManagedIdentity { if keys.is_empty() { return Ok(()); } + self.store_keys_with_snapshot(keys, persister) + } + + /// Layer `keys` in memory and store them with the identity's scalar + /// snapshot as one changeset, even when `keys` is empty (the snapshot + /// alone, e.g. a landed revision). The store's failure is returned. + pub fn store_keys_with_snapshot( + &mut self, + keys: Vec, + persister: &WalletPersister, + ) -> Result<(), PersistenceError> { let keys_cs = self.layer_keys(keys); let cs = crate::changeset::PlatformWalletChangeSet { identities: Some(self.snapshot_changeset()), From 8e5af24ccba89a285b0c2e033ab7fd38d245429f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:41:55 +0300 Subject: [PATCH 53/72] fix(platform-wallet): take back a revision a base build put past the chain A base build's post-broadcast bump (`cached + 1`) can persist revision 3 while Platform stays at 2, when a fetch took the update first. The ordered fold kept that revision against every revision-2 answer, so the next update carried 4 and a caller pinning 3 was refused locally. A fetch at a lower revision whose keys are exactly the cache's (`disabled_at` included, limits aside) is now taken whole. Only an identity update moves the revision, and Drive takes one only when it adds a key or disables one at its block's time, so two key sets at different revisions always differ: such a fetch misses no update the cache holds. A delayed answer after an add or a disable, including a second disable of a key, differs and is still folded as older. A disable the bump stamped with the local clock is first replaced with Platform's time, and the next answer takes the revision. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/lagging_fetch_tests.rs | 118 ++++++++++++++++- .../state/managed_identity/identity_ops.rs | 121 +++++++++++++++++- 2 files changed, 232 insertions(+), 7 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs index f1a82c31ece..fe043a8bc66 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs @@ -37,9 +37,14 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use super::super::identity_handle::{derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX}; +use super::update_revision; use super::IdentityWallet; +use crate::changeset::{ + ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, +}; use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::persister::NoPlatformPersistence; +use crate::wallet::platform_wallet::WalletId; use crate::{BlockTime, PlatformWallet, PlatformWalletManager}; const SEED: [u8; 64] = [7; 64]; @@ -104,14 +109,18 @@ fn probe() -> PublicKeyHash { /// A wallet from `SEED` holding the identity at revision 1 at index 0, as a /// registration or an earlier load leaves it. async fn wallet_on(sdk: dash_sdk::Sdk) -> Arc { + wallet_storing_to(sdk, Arc::new(NoPlatformPersistence)).await +} + +/// [`wallet_on`], storing to `persister`. +async fn wallet_storing_to( + sdk: dash_sdk::Sdk, + persister: Arc

, +) -> Arc { struct Events; impl EventHandler for Events {} impl PlatformEventHandler for Events {} - let manager = PlatformWalletManager::new( - Arc::new(sdk), - Arc::new(NoPlatformPersistence), - Arc::new(Events), - ); + let manager = PlatformWalletManager::new(Arc::new(sdk), persister, Arc::new(Events)); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, @@ -372,3 +381,102 @@ async fn should_not_put_the_cache_past_the_chain_when_a_fetch_lands_before_the_a assert!(held.public_keys().contains_key(&3), "{fetch:?}"); } } + +/// The revision of the identity snapshot each store carried, in order. +#[derive(Default)] +struct RevisionLog(std::sync::Mutex>); + +impl PlatformWalletPersistence for RevisionLog { + fn store( + &self, + _wallet_id: WalletId, + changeset: PlatformWalletChangeSet, + ) -> Result<(), PersistenceError> { + if let Some(entry) = changeset + .identities + .and_then(|ids| ids.identities.get(&Identifier::default()).cloned()) + { + self.0.lock().unwrap().push(entry.revision); + } + Ok(()) + } + fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { + Ok(()) + } + fn load(&self) -> Result { + Ok(ClientStartState::default()) + } +} + +/// A base build hit the A3 race and persisted its result: a fetch took the +/// update at the chain's revision 2, then the post-broadcast bump stored +/// `cached + 1` = 3 over the chain's keys (and stamped the disable it +/// carried with the local clock). The app is then updated, and the restart +/// restores that state. +/// +/// A load or refresh answered at the chain's revision 2 takes the cache back +/// to it, so the next update carries 3 and a caller pinning 3 passes the +/// revision check; the snapshot stored last carries 2. After an update that +/// only added keys the first answer does it. After one that also disabled a +/// key, the first answer replaces the local stamp with Platform's time and +/// the next one takes the revision. +#[tokio::test] +async fn should_take_back_a_revision_a_base_build_put_past_the_chain() { + // The update added key 3 only. + let mut added = identity_at_revision_1(); + added.add_public_key(key(3, SecurityLevel::HIGH)); + added.set_revision(2); + added.set_balance(BALANCE_AFTER); + // The scenarios' update: add key 3, disable key 1. + let added_and_disabled = identity_at_revision_2(); + + for (chain, answers_to_heal) in [(added, 1), (added_and_disabled, 2)] { + for fetch in [Fetch::Load, Fetch::Refresh] { + let log = Arc::new(RevisionLog::default()); + let wallet = + wallet_storing_to(sdk_answering(fetch, chain.clone()).await, log.clone()).await; + let wallet = wallet.identity(); + let mut restored = chain.clone(); + restored.set_revision(3); + if let Some(key_1) = restored.public_keys().get(&1).cloned() { + if key_1.is_disabled() { + let mut stamped = key_1; + stamped.set_disabled_at(1_800_000_000_000); + restored.add_public_key(stamped); + } + } + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap() + .identity = restored; + } + let id = Identifier::default(); + assert!( + update_revision(&id, cached(wallet).await.revision(), Some(3)).is_err(), + "{fetch:?}: the restored state refuses a pinned 3" + ); + + for answer in 1..=answers_to_heal { + run_fetch(wallet, fetch).await; + let held = cached(wallet).await; + if answer < answers_to_heal { + assert_eq!(held.revision(), 3, "{fetch:?}: stamps differ"); + assert_eq!(held.public_keys(), chain.public_keys(), "{fetch:?}"); + } + } + let held = cached(wallet).await; + assert_eq!(held.revision(), 2, "{fetch:?}: cache back at the chain"); + assert_eq!(held.public_keys(), chain.public_keys(), "{fetch:?}"); + assert_eq!(update_revision(&id, held.revision(), Some(3)).unwrap(), 3); + assert_eq!( + log.0.lock().unwrap().last(), + Some(&2), + "{fetch:?}: the store carries the chain's revision" + ); + } + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 4cbd1d08d3c..f5ec9c6a39b 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -411,11 +411,17 @@ impl ManagedIdentity { /// Platform's own) and a raised limit. Returns whether it was taken /// whole. /// + /// A fetch at a lower revision whose keys are the cache's is taken whole + /// too (see `misses_no_update`): it lacks no update the cache holds, so + /// the cache's revision is the wrong one. A base build's post-broadcast + /// bump (`cached + 1`) left it one past the chain when a fetch had + /// already taken the update. + /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { let balance = self.identity.balance(); let cached = self.identity.public_keys().clone(); - let taken = fetched.revision() >= self.identity.revision(); + let taken = takes_fetched_whole(&self.identity, fetched); let mut keys = if taken { self.identity = fetched.clone(); self.identity.set_balance(balance); @@ -506,7 +512,9 @@ impl ManagedIdentity { /// fetch shows a disable; either way each limit is the higher of the two. /// A key the cache lacks is left out of an older fetch's decisions, since /// an older answer cannot show whether a later update disabled it, and a - /// fetch at least as new will bring it. + /// fetch at least as new will bring it. A lower-revision fetch with the + /// cache's keys, which the fold takes whole, gets the same decisions + /// either way. fn key_decisions_for_fetch( &self, fetched: &Identity, @@ -781,6 +789,46 @@ impl ManagedIdentity { } } +/// Whether `fetched` is folded into `cached` as an answer at least as new +/// (see [`ManagedIdentity::take_fetched_identity`]): at the cache's revision +/// or above, or below it while missing no update the cache holds. +fn takes_fetched_whole(cached: &Identity, fetched: &Identity) -> bool { + fetched.revision() >= cached.revision() + || misses_no_update(cached.public_keys(), fetched.public_keys()) +} + +/// Whether a fetch with `fetched` keys, at a lower revision than a cache +/// with `cached` keys, lacks no update the cache holds: the two key sets are +/// the same, key for key and field for field, `disabled_at` included, the +/// limits aside. +/// +/// Only an identity update moves an identity's revision, and Drive takes +/// one only when it adds or disables at least one key. An added key has a +/// new id (keys are never removed); a disable sets `disabled_at` to the +/// block's time, later than any earlier disable of that key. A limits +/// update claims no revision, and a masternode identity's keys change with +/// Core, at revision 0 (it has no MASTER key to sign an update with). So +/// two key sets at different revisions always differ, and a lower revision +/// with the cache's keys means the cache's revision is wrong. +/// +/// A disable the cache stamped with the local clock (a landed update's +/// placeholder) differs from Platform's time, so such a cache is not taken +/// back on the first fetch: that fetch replaces the stamp with Platform's +/// time (see `with_disable_from`), and the next one takes its revision. +fn misses_no_update( + cached: &BTreeMap, + fetched: &BTreeMap, +) -> bool { + let without_limits = |key: &IdentityPublicKey| key.clone().with_limits(None, None); + cached.len() == fetched.len() + && cached + .iter() + .zip(fetched) + .all(|((cached_id, cached), (fetched_id, fetched))| { + cached_id == fetched_id && without_limits(cached) == without_limits(fetched) + }) +} + /// How a fetched key lands in the cache, the one rule the identity fold and /// its key decisions share. From a fetch taken whole (`taken`) it is the /// fetched key, disabled when the cache's copy is (a disable is permanent @@ -1067,6 +1115,75 @@ mod tests { ); } + /// A cache one past the chain (a base build's post-broadcast bump after a + /// fetch had taken the update) holds the chain's keys at a higher + /// revision. A fetch at the chain's revision with exactly those keys is + /// taken whole, so the next update carries the revision Drive takes; each + /// limit stays the higher of the two. A fetch that lacks a key the cache + /// holds, shows a live key the cache holds disabled, or shows an earlier + /// disable time (a later update disabled the key again) is an answer from + /// before an update the cache holds and is folded as older. + #[test] + fn should_take_a_lower_revision_back_only_when_it_misses_no_update() { + fn at(revision: u64, keys: Vec) -> Identity { + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), + balance: 0, + revision, + }) + } + let disabled = |id, disabled_at| { + let mut key = key(id); + key.set_disabled_at(disabled_at); + key + }; + let block = BlockTime::new(1, 1, 1); + let chain = vec![key(0), disabled(1, 7), key(3)]; + + // Inflated: the chain's keys at revision 3; the cache also holds a + // limit raise the fetch's node has not seen. + let mut raised = chain.clone(); + raised[2] = key(3).with_limits(Some(500), None); + let mut inflated = ManagedIdentity::new(at(3, raised.clone()), 0); + assert!(inflated.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(inflated.identity, at(2, raised)); + // Two past the chain is the same state and heals the same way. + let mut far = ManagedIdentity::new(at(4, chain.clone()), 0); + assert!(far.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(far.identity.revision(), 2); + + // Delayed answers (the chain's keys at 2), from before an update at 3 + // the cache holds. + let delayed = [ + // The update added key 4. + vec![key(0), disabled(1, 7), key(3), key(4)], + // The update disabled key 0. + vec![disabled(0, 9), disabled(1, 7), key(3)], + // The update disabled key 1 again, at its own block's time. + vec![key(0), disabled(1, 9), key(3)], + ]; + for cache in delayed { + let mut managed = ManagedIdentity::new(at(3, cache.clone()), 0); + assert!(!managed.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(managed.identity.revision(), 3, "{cache:?}"); + assert_eq!(managed.identity.public_keys().len(), cache.len()); + } + // A key only the fetch has: folded as older, the key left out. + let mut lacking = ManagedIdentity::new(at(3, vec![key(0), disabled(1, 7)]), 0); + assert!(!lacking.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(lacking.identity, at(3, vec![key(0), disabled(1, 7)])); + + // The bump stamped key 1's disable with the local clock: the first + // fetch replaces the stamp with Platform's time, the next one takes + // the revision. + let mut stamped = ManagedIdentity::new(at(3, vec![key(0), disabled(1, 99), key(3)]), 0); + assert!(!stamped.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(stamped.identity, at(3, chain.clone())); + assert!(stamped.take_fetched_identity(&at(2, chain.clone()), block)); + assert_eq!(stamped.identity, at(2, chain.clone())); + } + /// A key decision keeps the higher of each limit at any revision, so a /// load answered before a limits update the cache holds does not store /// the lower one. From 2028be0671e6a88dece79ad0347dabeee33e794f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:41:55 +0300 Subject: [PATCH 54/72] fix(platform-wallet): lend a buffered breadcrumb only to the same key in the same scope `IdentityKeysChangeSet::merge` gave a later entry without coordinates the earlier entry's breadcrumb whenever the row id matched. It now does so only when the later entry carries the same public key (data and hash) and the same wallet scope or none. The `IdentityKeyEntry` summary no longer says that an entry without coordinates is watch-only; it supplies no new ones, and an update keeps the breadcrumb a persister holds. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 69 +++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 73f4b9fde6d..377b020d52f 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -40,6 +40,7 @@ use crate::wallet::platform_wallet::WalletId; use dpp::balances::credits::Credits; use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::{IdentityPublicKey, KeyID}; use dpp::prelude::{Identifier, Revision}; @@ -1031,8 +1032,9 @@ pub struct KeyWithBreadcrumb { /// derivation that produced it. No private material crosses here: the client /// derives the key on demand from the Keychain seed at the breadcrumb path /// (`m/9'/coin'/5'/0'/ECDSA'/identity_index'/key_index'`). When -/// `derivation_indices` is `None` the key is watch-only from this wallet's -/// point of view. +/// `derivation_indices` is `None`, the entry supplies no new coordinates; +/// an update preserves any breadcrumb the persister already holds (see the +/// field). #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct IdentityKeyEntry { @@ -1088,11 +1090,17 @@ impl Merge for IdentityKeysChangeSet { // entry without a derivation breadcrumb (a refresh, a limits // update) keeps the earlier one's, as every persister keeps a // stored one: a buffered write that only knows the key's new state - // must not turn a wallet-derived key watch-only. + // must not turn a wallet-derived key watch-only. Only for the same + // public key in the same wallet scope (or none): the coordinates + // derive that key, not another one under the same row id. for (id, mut entry) in other.upserts { if entry.derivation_indices.is_none() { if let Some(earlier) = self.upserts.get(&id) { - if earlier.derivation_indices.is_some() { + let same_key = earlier.public_key_hash == entry.public_key_hash + && earlier.public_key.data() == entry.public_key.data(); + let same_scope = + entry.wallet_id.is_none() || entry.wallet_id == earlier.wallet_id; + if earlier.derivation_indices.is_some() && same_key && same_scope { entry.wallet_id = earlier.wallet_id; entry.derivation_indices = earlier.derivation_indices; } @@ -2589,6 +2597,59 @@ mod tests { assert_eq!(merged.upserts[&(id, 3)], entry(Some(9), None)); } + /// A later entry without a breadcrumb takes the earlier one's only for + /// the same public key (data and hash) in the same wallet scope or none: + /// a replacement key under the same row id, or an entry scoped to + /// another wallet, stands as it is. + #[test] + fn should_lend_a_breadcrumb_only_to_the_same_public_key_in_the_same_scope() { + use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + let id = Identifier::from([0x55; 32]); + let entry = |byte: u8, hash: u8, wallet_id: Option<[u8; 32]>, key_index: Option| { + IdentityKeyEntry { + identity_id: id, + key_id: 3, + public_key: IdentityPublicKeyV0 { + id: 3, + data: vec![byte; 33].into(), + ..Default::default() + } + .into(), + public_key_hash: [hash; 20], + wallet_id, + derivation_indices: key_index.map(|key_index| IdentityKeyDerivationIndices { + identity_index: 0, + key_index, + }), + } + }; + let merged = |later: IdentityKeyEntry| { + let mut cs = IdentityKeysChangeSet::default(); + cs.upserts + .insert((id, 3), entry(1, 1, Some([9; 32]), Some(3))); + let mut next = IdentityKeysChangeSet::default(); + next.upserts.insert((id, 3), later); + cs.merge(next); + cs.upserts[&(id, 3)].clone() + }; + + for later in [entry(1, 1, None, None), entry(1, 1, Some([9; 32]), None)] { + assert_eq!(merged(later), entry(1, 1, Some([9; 32]), Some(3))); + } + for later in [ + // A replacement key: other data, other hash. + entry(2, 2, None, None), + // The same hash over other data. + entry(2, 1, None, None), + // The same data with another hash. + entry(1, 2, None, None), + // Another wallet's scope. + entry(1, 1, Some([8; 32]), None), + ] { + assert_eq!(merged(later.clone()), later); + } + } + #[test] fn should_merge_balance_and_watermark_under_the_same_revision_gate() { let id = Identifier::from([0x53; 32]); From 0198486b69a4998bc9228170dafcb14f683d9916 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:52:26 +0300 Subject: [PATCH 55/72] docs(platform-wallet): mention the equal-keys exception in the update's fold note Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index c452837fdad..9b4da07f3da 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -88,8 +88,10 @@ impl IdentityWallet { /// has it and keeps it. A refresh that fetched the identity before the /// update landed and finishes after this step does not take the cache /// back: a fetched identity at a lower revision than the cache's keeps - /// the cache's revision, key set and every disable (see - /// `ManagedIdentity::take_fetched_identity`). When the call fails + /// the cache's revision, key set and every disable, unless its keys + /// equal the cache's exactly, which shows the cached revision itself is + /// the one that is wrong (see `ManagedIdentity::take_fetched_identity`). + /// When the call fails /// after the broadcast (the proof wait timed out, say), the update may /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( From 0fbfba3f015898efa7b238831e221da2da548deb Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:20:07 +0300 Subject: [PATCH 56/72] revert(platform-wallet): drop the equal-keys revision recovery Reverts 8e5af24ccb and 0198486b69. Taking a lower-revision fetch whole when its keys equal the cache's lets two stale answers roll the revision back: the first older answer rewrote the cache's disable timestamp to its own, so the second one matched. A cache a base build left past the chain is back to the documented limitation; recovering it needs an authoritative boundary and is tracked in #5542. Co-Authored-By: Claude Opus 5.5 --- .../identity/network/lagging_fetch_tests.rs | 118 +---------------- .../src/wallet/identity/network/update.rs | 6 +- .../state/managed_identity/identity_ops.rs | 121 +----------------- 3 files changed, 9 insertions(+), 236 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs index fe043a8bc66..f1a82c31ece 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs @@ -37,14 +37,9 @@ use key_wallet::wallet::initialization::WalletAccountCreationOptions; use key_wallet::Network; use super::super::identity_handle::{derive_identity_auth_key_hash_from_master, MASTER_KEY_INDEX}; -use super::update_revision; use super::IdentityWallet; -use crate::changeset::{ - ClientStartState, PersistenceError, PlatformWalletChangeSet, PlatformWalletPersistence, -}; use crate::events::{EventHandler, PlatformEventHandler}; use crate::wallet::persister::NoPlatformPersistence; -use crate::wallet::platform_wallet::WalletId; use crate::{BlockTime, PlatformWallet, PlatformWalletManager}; const SEED: [u8; 64] = [7; 64]; @@ -109,18 +104,14 @@ fn probe() -> PublicKeyHash { /// A wallet from `SEED` holding the identity at revision 1 at index 0, as a /// registration or an earlier load leaves it. async fn wallet_on(sdk: dash_sdk::Sdk) -> Arc { - wallet_storing_to(sdk, Arc::new(NoPlatformPersistence)).await -} - -/// [`wallet_on`], storing to `persister`. -async fn wallet_storing_to( - sdk: dash_sdk::Sdk, - persister: Arc

, -) -> Arc { struct Events; impl EventHandler for Events {} impl PlatformEventHandler for Events {} - let manager = PlatformWalletManager::new(Arc::new(sdk), persister, Arc::new(Events)); + let manager = PlatformWalletManager::new( + Arc::new(sdk), + Arc::new(NoPlatformPersistence), + Arc::new(Events), + ); let wallet = manager .create_wallet_from_seed_bytes( Network::Testnet, @@ -381,102 +372,3 @@ async fn should_not_put_the_cache_past_the_chain_when_a_fetch_lands_before_the_a assert!(held.public_keys().contains_key(&3), "{fetch:?}"); } } - -/// The revision of the identity snapshot each store carried, in order. -#[derive(Default)] -struct RevisionLog(std::sync::Mutex>); - -impl PlatformWalletPersistence for RevisionLog { - fn store( - &self, - _wallet_id: WalletId, - changeset: PlatformWalletChangeSet, - ) -> Result<(), PersistenceError> { - if let Some(entry) = changeset - .identities - .and_then(|ids| ids.identities.get(&Identifier::default()).cloned()) - { - self.0.lock().unwrap().push(entry.revision); - } - Ok(()) - } - fn flush(&self, _wallet_id: WalletId) -> Result<(), PersistenceError> { - Ok(()) - } - fn load(&self) -> Result { - Ok(ClientStartState::default()) - } -} - -/// A base build hit the A3 race and persisted its result: a fetch took the -/// update at the chain's revision 2, then the post-broadcast bump stored -/// `cached + 1` = 3 over the chain's keys (and stamped the disable it -/// carried with the local clock). The app is then updated, and the restart -/// restores that state. -/// -/// A load or refresh answered at the chain's revision 2 takes the cache back -/// to it, so the next update carries 3 and a caller pinning 3 passes the -/// revision check; the snapshot stored last carries 2. After an update that -/// only added keys the first answer does it. After one that also disabled a -/// key, the first answer replaces the local stamp with Platform's time and -/// the next one takes the revision. -#[tokio::test] -async fn should_take_back_a_revision_a_base_build_put_past_the_chain() { - // The update added key 3 only. - let mut added = identity_at_revision_1(); - added.add_public_key(key(3, SecurityLevel::HIGH)); - added.set_revision(2); - added.set_balance(BALANCE_AFTER); - // The scenarios' update: add key 3, disable key 1. - let added_and_disabled = identity_at_revision_2(); - - for (chain, answers_to_heal) in [(added, 1), (added_and_disabled, 2)] { - for fetch in [Fetch::Load, Fetch::Refresh] { - let log = Arc::new(RevisionLog::default()); - let wallet = - wallet_storing_to(sdk_answering(fetch, chain.clone()).await, log.clone()).await; - let wallet = wallet.identity(); - let mut restored = chain.clone(); - restored.set_revision(3); - if let Some(key_1) = restored.public_keys().get(&1).cloned() { - if key_1.is_disabled() { - let mut stamped = key_1; - stamped.set_disabled_at(1_800_000_000_000); - restored.add_public_key(stamped); - } - } - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&Identifier::default()) - .unwrap() - .identity = restored; - } - let id = Identifier::default(); - assert!( - update_revision(&id, cached(wallet).await.revision(), Some(3)).is_err(), - "{fetch:?}: the restored state refuses a pinned 3" - ); - - for answer in 1..=answers_to_heal { - run_fetch(wallet, fetch).await; - let held = cached(wallet).await; - if answer < answers_to_heal { - assert_eq!(held.revision(), 3, "{fetch:?}: stamps differ"); - assert_eq!(held.public_keys(), chain.public_keys(), "{fetch:?}"); - } - } - let held = cached(wallet).await; - assert_eq!(held.revision(), 2, "{fetch:?}: cache back at the chain"); - assert_eq!(held.public_keys(), chain.public_keys(), "{fetch:?}"); - assert_eq!(update_revision(&id, held.revision(), Some(3)).unwrap(), 3); - assert_eq!( - log.0.lock().unwrap().last(), - Some(&2), - "{fetch:?}: the store carries the chain's revision" - ); - } - } -} diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9b4da07f3da..c452837fdad 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -88,10 +88,8 @@ impl IdentityWallet { /// has it and keeps it. A refresh that fetched the identity before the /// update landed and finishes after this step does not take the cache /// back: a fetched identity at a lower revision than the cache's keeps - /// the cache's revision, key set and every disable, unless its keys - /// equal the cache's exactly, which shows the cached revision itself is - /// the one that is wrong (see `ManagedIdentity::take_fetched_identity`). - /// When the call fails + /// the cache's revision, key set and every disable (see + /// `ManagedIdentity::take_fetched_identity`). When the call fails /// after the broadcast (the proof wait timed out, say), the update may /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index f5ec9c6a39b..4cbd1d08d3c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -411,17 +411,11 @@ impl ManagedIdentity { /// Platform's own) and a raised limit. Returns whether it was taken /// whole. /// - /// A fetch at a lower revision whose keys are the cache's is taken whole - /// too (see `misses_no_update`): it lacks no update the cache holds, so - /// the cache's revision is the wrong one. A base build's post-broadcast - /// bump (`cached + 1`) left it one past the chain when a fetch had - /// already taken the update. - /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { let balance = self.identity.balance(); let cached = self.identity.public_keys().clone(); - let taken = takes_fetched_whole(&self.identity, fetched); + let taken = fetched.revision() >= self.identity.revision(); let mut keys = if taken { self.identity = fetched.clone(); self.identity.set_balance(balance); @@ -512,9 +506,7 @@ impl ManagedIdentity { /// fetch shows a disable; either way each limit is the higher of the two. /// A key the cache lacks is left out of an older fetch's decisions, since /// an older answer cannot show whether a later update disabled it, and a - /// fetch at least as new will bring it. A lower-revision fetch with the - /// cache's keys, which the fold takes whole, gets the same decisions - /// either way. + /// fetch at least as new will bring it. fn key_decisions_for_fetch( &self, fetched: &Identity, @@ -789,46 +781,6 @@ impl ManagedIdentity { } } -/// Whether `fetched` is folded into `cached` as an answer at least as new -/// (see [`ManagedIdentity::take_fetched_identity`]): at the cache's revision -/// or above, or below it while missing no update the cache holds. -fn takes_fetched_whole(cached: &Identity, fetched: &Identity) -> bool { - fetched.revision() >= cached.revision() - || misses_no_update(cached.public_keys(), fetched.public_keys()) -} - -/// Whether a fetch with `fetched` keys, at a lower revision than a cache -/// with `cached` keys, lacks no update the cache holds: the two key sets are -/// the same, key for key and field for field, `disabled_at` included, the -/// limits aside. -/// -/// Only an identity update moves an identity's revision, and Drive takes -/// one only when it adds or disables at least one key. An added key has a -/// new id (keys are never removed); a disable sets `disabled_at` to the -/// block's time, later than any earlier disable of that key. A limits -/// update claims no revision, and a masternode identity's keys change with -/// Core, at revision 0 (it has no MASTER key to sign an update with). So -/// two key sets at different revisions always differ, and a lower revision -/// with the cache's keys means the cache's revision is wrong. -/// -/// A disable the cache stamped with the local clock (a landed update's -/// placeholder) differs from Platform's time, so such a cache is not taken -/// back on the first fetch: that fetch replaces the stamp with Platform's -/// time (see `with_disable_from`), and the next one takes its revision. -fn misses_no_update( - cached: &BTreeMap, - fetched: &BTreeMap, -) -> bool { - let without_limits = |key: &IdentityPublicKey| key.clone().with_limits(None, None); - cached.len() == fetched.len() - && cached - .iter() - .zip(fetched) - .all(|((cached_id, cached), (fetched_id, fetched))| { - cached_id == fetched_id && without_limits(cached) == without_limits(fetched) - }) -} - /// How a fetched key lands in the cache, the one rule the identity fold and /// its key decisions share. From a fetch taken whole (`taken`) it is the /// fetched key, disabled when the cache's copy is (a disable is permanent @@ -1115,75 +1067,6 @@ mod tests { ); } - /// A cache one past the chain (a base build's post-broadcast bump after a - /// fetch had taken the update) holds the chain's keys at a higher - /// revision. A fetch at the chain's revision with exactly those keys is - /// taken whole, so the next update carries the revision Drive takes; each - /// limit stays the higher of the two. A fetch that lacks a key the cache - /// holds, shows a live key the cache holds disabled, or shows an earlier - /// disable time (a later update disabled the key again) is an answer from - /// before an update the cache holds and is folded as older. - #[test] - fn should_take_a_lower_revision_back_only_when_it_misses_no_update() { - fn at(revision: u64, keys: Vec) -> Identity { - Identity::V0(IdentityV0 { - id: Identifier::from([1u8; 32]), - public_keys: keys.into_iter().map(|key| (key.id(), key)).collect(), - balance: 0, - revision, - }) - } - let disabled = |id, disabled_at| { - let mut key = key(id); - key.set_disabled_at(disabled_at); - key - }; - let block = BlockTime::new(1, 1, 1); - let chain = vec![key(0), disabled(1, 7), key(3)]; - - // Inflated: the chain's keys at revision 3; the cache also holds a - // limit raise the fetch's node has not seen. - let mut raised = chain.clone(); - raised[2] = key(3).with_limits(Some(500), None); - let mut inflated = ManagedIdentity::new(at(3, raised.clone()), 0); - assert!(inflated.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(inflated.identity, at(2, raised)); - // Two past the chain is the same state and heals the same way. - let mut far = ManagedIdentity::new(at(4, chain.clone()), 0); - assert!(far.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(far.identity.revision(), 2); - - // Delayed answers (the chain's keys at 2), from before an update at 3 - // the cache holds. - let delayed = [ - // The update added key 4. - vec![key(0), disabled(1, 7), key(3), key(4)], - // The update disabled key 0. - vec![disabled(0, 9), disabled(1, 7), key(3)], - // The update disabled key 1 again, at its own block's time. - vec![key(0), disabled(1, 9), key(3)], - ]; - for cache in delayed { - let mut managed = ManagedIdentity::new(at(3, cache.clone()), 0); - assert!(!managed.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(managed.identity.revision(), 3, "{cache:?}"); - assert_eq!(managed.identity.public_keys().len(), cache.len()); - } - // A key only the fetch has: folded as older, the key left out. - let mut lacking = ManagedIdentity::new(at(3, vec![key(0), disabled(1, 7)]), 0); - assert!(!lacking.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(lacking.identity, at(3, vec![key(0), disabled(1, 7)])); - - // The bump stamped key 1's disable with the local clock: the first - // fetch replaces the stamp with Platform's time, the next one takes - // the revision. - let mut stamped = ManagedIdentity::new(at(3, vec![key(0), disabled(1, 99), key(3)]), 0); - assert!(!stamped.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(stamped.identity, at(3, chain.clone())); - assert!(stamped.take_fetched_identity(&at(2, chain.clone()), block)); - assert_eq!(stamped.identity, at(2, chain.clone())); - } - /// A key decision keeps the higher of each limit at any revision, so a /// load answered before a limits update the cache holds does not store /// the lower one. From 6a9f9099e6fae9d3b0d3775e9323172e87bb06a1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:20:07 +0300 Subject: [PATCH 57/72] fix(platform-wallet): take a fetch's key states at its revision and keep a held disable from an older one A fetch at the cache's revision or above is now taken whole, key states included. Core disables and re-enables a masternode identity's keys at revision 0 (ReEnableIdentityKeys in the masternode identity updates), so a cached disable that stuck kept a re-enabled key disabled in memory and in the store. Limits still keep the higher of the two copies. An older fetch no longer rewrites a disable the cache holds: the cache's timestamp is at least as new (a later update may have disabled the key again). It still takes a disable for a key the cache holds enabled. Co-Authored-By: Claude Opus 5.5 --- .../sqlite_identity_key_restore.rs | 65 ++++++++++++++ .../identity/network/lagging_fetch_tests.rs | 39 ++++++++ .../src/wallet/identity/network/loading.rs | 9 +- .../src/wallet/identity/network/update.rs | 4 +- .../state/managed_identity/identity_ops.rs | 88 ++++++++++--------- 5 files changed, 156 insertions(+), 49 deletions(-) diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs index 355483acf2b..cdf8a10cbfb 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_key_restore.rs @@ -17,6 +17,9 @@ use std::sync::{Arc, Mutex}; use dash_sdk::platform::types::identity::PublicKeyHash; use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use dpp::identity::v0::IdentityV0; use dpp::identity::{Identity, IdentityPublicKey, KeyID, KeyType, Purpose, SecurityLevel}; @@ -536,6 +539,68 @@ async fn should_store_a_key_a_refresh_finds_so_it_survives_a_restart() { end_process(manager, &disk).await; } +/// A refresh at the cache's revision is taken whole, key states included: +/// Core disables and re-enables a masternode identity's keys at revision 0 +/// (`ReEnableIdentityKeys` in the masternode identity updates), so a cached +/// disable must not stick. The identity here has that shape: revision 0, key +/// 2 disabled when restored, active again in the refresh. The re-enabled row +/// is stored with its breadcrumb kept and survives a restart. +#[tokio::test] +async fn should_store_a_key_core_re_enabled_so_it_survives_a_restart() { + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("wallet.db"); + let mut active = registered_identity(); + active.set_revision(0); + let id = active.id(); + let mut disabled = active.clone(); + let mut key_2 = disabled.public_keys()[&2].clone(); + key_2.set_disabled_at(1_700_000_000_000); + disabled.add_public_key(key_2); + + let disk = Disk::open(&path, DiskFull::Never); + let (manager, w) = first_launch( + platform(MNEMONIC_A, Some(disabled), Some(active.clone())).await, + disk.clone(), + MNEMONIC_A, + ) + .await; + let iw = manager.get_wallet(&w).await.unwrap(); + iw.identity() + .discover_from_master(scan_from_zero(), &master(MNEMONIC_A)) + .await + .expect("restore"); + assert!(disk.key_rows(&w, &id)[&2].public_key.is_disabled()); + iw.identity().refresh_identity(&id).await.expect("refresh"); + let rows = disk.key_rows(&w, &id); + assert!( + !rows[&2].public_key.is_disabled(), + "the re-enable is stored" + ); + assert_eq!(breadcrumbed(&rows), vec![0, 1, 2], "breadcrumbs stay"); + drop(iw); + end_process(manager, &disk).await; + + let disk = Disk::open(&path, DiskFull::Never); + let manager = relaunch(platform(MNEMONIC_A, None, None).await, disk.clone()).await; + let wm = manager.wallet_manager_arc(); + let held = wm + .read() + .await + .get_wallet_info(&w) + .unwrap() + .identity_manager + .identity(&id) + .unwrap() + .identity + .clone(); + assert!( + !held.public_keys()[&2].is_disabled(), + "key 2 is active after restart" + ); + drop(wm); + end_process(manager, &disk).await; +} + /// A load at the identity's index (the host's "load identity at index") /// re-fetches the identity and stores every on-chain key with a breadcrumb /// when it derives from the seed, a key another device added included. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs index f1a82c31ece..91e447983e3 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/lagging_fetch_tests.rs @@ -288,6 +288,45 @@ async fn should_keep_a_landed_update_when_a_lagging_fetch_answers_older() { } } +/// The identity is at revision 2 (key 1 disabled), and an update at 3 +/// disables key 1 again, which only moves its `disabled_at`. Two loads or +/// refreshes in a row answered by a node still at revision 2 keep the cache +/// at the update: an older answer never rewrites a disable the cache holds, +/// so the second one finds nothing the first one changed. +#[tokio::test] +async fn should_keep_a_second_disable_when_two_lagging_fetches_answer_older() { + for fetch in [Fetch::Load, Fetch::Refresh] { + let wallet = wallet_on(sdk_answering(fetch, identity_at_revision_2()).await).await; + let wallet = wallet.identity(); + { + // The cache as a fetch at revision 2 left it. + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&Identifier::default()) + .unwrap() + .identity = identity_at_revision_2(); + } + let transition_revision = update_snapshot(wallet).await; + assert_eq!(transition_revision, 3); + land_update(wallet, transition_revision, vec![], vec![1]).await; + let after_update = cached(wallet).await; + assert_eq!(after_update.revision(), 3, "{fetch:?}"); + + for answer in 1..=2 { + run_fetch(wallet, fetch).await; + let held = cached(wallet).await; + assert_eq!(held.revision(), 3, "{fetch:?}: answer {answer}"); + assert_eq!( + held.public_keys()[&1].disabled_at(), + after_update.public_keys()[&1].disabled_at(), + "{fetch:?}: answer {answer} keeps the second disable" + ); + } + } +} + /// A key-limits update raises key 3's budget 1_000 -> 5_000 and expiry /// T1 -> T2 (no revision is claimed); a load or refresh answered by a node /// one block behind, with the old limits at the same revision, keeps the diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs index 1646fd4a056..1e032ed2690 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/loading.rs @@ -319,14 +319,15 @@ impl IdentityWallet { /// /// The identity must already exist in the [`IdentityManager`]. Its /// on-chain state (keys, revision) is replaced with the latest version - /// from Platform and the status is set to `Active`; a disable the cache - /// holds stays, and so does a key limit the cache holds higher. The + /// from Platform and the status is set to `Active`, key states included + /// (Core re-enables a masternode identity's keys at revision 0); a key + /// limit the cache holds higher stays. The /// balance, at any revision, is taken only when the fetch's proof is at a /// block above the cache's balance watermark. A fetch at a lower revision /// than the cache's was answered before an update the cache already /// holds (a node behind the block it landed in): the cache keeps its - /// revision, key set and every disable, and takes only a disable or a - /// raised limit the fetch shows + /// revision, key set and every disable, and takes only a disable the + /// fetch shows on a key it holds enabled, or a raised limit /// (`ManagedIdentity::take_fetched_identity`). The row of every key the /// fold adds or changes is stored with the scalar snapshot, without a /// derivation breadcrumb (the persisters keep the one they hold); a diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index c452837fdad..d14a3323757 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -351,8 +351,8 @@ impl IdentityWallet { // keys and fire the persister so the Swift // `PersistentPublicKey.disabledAt` rows flip without // a network re-fetch. The local wall-clock timestamp - // is a placeholder — the next Platform refresh - // reconciles it to the authoritative on-chain block + // is a placeholder — the next fetch at this revision or + // above reconciles it to the authoritative on-chain block // time. `disable_keys` rebuilds the derivation breadcrumb // from the managed identity's own wallet slot, so the // disabled key keeps its private-key linkage. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 4cbd1d08d3c..6dd0e7a0110 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -402,14 +402,15 @@ impl ManagedIdentity { /// higher of the two (see `with_raised_limits`) at any revision. /// /// A fetch at the cache's revision or above is otherwise taken whole, - /// except that a key the cache holds disabled stays disabled (a disable - /// is permanent on Platform). Revisions only grow on Platform, so a fetch + /// key states included: a masternode identity's keys are disabled and + /// re-enabled by Core at revision 0, so such a fetch may re-enable a key + /// the cache holds disabled. Revisions only grow on Platform, so a fetch /// at a lower revision was answered before an update the cache already /// holds (one applied locally after it landed, or a newer refresh, from a /// node that lags behind): the cache keeps its revision and key set; it - /// takes only a disable the fetch shows (the fetch's timestamp is - /// Platform's own) and a raised limit. Returns whether it was taken - /// whole. + /// takes only a disable the fetch shows on a key the cache holds enabled + /// (an identity update's disable is permanent) and a raised limit. + /// Returns whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { @@ -499,11 +500,12 @@ impl ManagedIdentity { /// The key decisions to store for `decisions` built from `fetched`, each /// key folded as [`Self::take_fetched_identity`] folds it, so storing its - /// breadcrumb (a key's derivation never changes) never takes back a - /// disable or a raised limit the cache holds. For a fetch at the cache's - /// revision or above that is the fetched key, disabled when the cache's - /// copy is. For an older fetch it is the cache's copy, disabled when the - /// fetch shows a disable; either way each limit is the higher of the two. + /// breadcrumb (a key's derivation never changes) stores what the fold + /// holds: never a raised limit taken back, nor a disable an older fetch + /// lacks. For a fetch at the cache's + /// revision or above that is the fetched key. For an older fetch it is + /// the cache's copy, disabled when only the fetch's copy is; either way + /// each limit is the higher of the two. /// A key the cache lacks is left out of an older fetch's decisions, since /// an older answer cannot show whether a later update disabled it, and a /// fetch at least as new will bring it. @@ -698,8 +700,9 @@ impl ManagedIdentity { /// `key_ids` not present on the identity are skipped (logged) and /// no changeset is emitted when nothing matched. `disabled_at` is /// the timestamp stamped on every matching key — callers pass the - /// local broadcast time; the next Platform refresh reconciles it to - /// the authoritative on-chain block time. + /// local broadcast time; the next fetch at the update's revision or + /// above reconciles it to the authoritative on-chain block time (an + /// older one keeps it). /// /// Does **not** touch the identity revision — the caller bumps it /// alongside the update transition. @@ -783,10 +786,9 @@ impl ManagedIdentity { /// How a fetched key lands in the cache, the one rule the identity fold and /// its key decisions share. From a fetch taken whole (`taken`) it is the -/// fetched key, disabled when the cache's copy is (a disable is permanent -/// on Platform; Platform's timestamp first). From an older fetch it is the -/// cache's copy with the fetch's disable, or nothing for a key the cache -/// lacks. Either way each limit is the higher of the two copies'. +/// fetched key. From an older fetch it is the cache's copy, disabled when +/// only the fetch's copy is, or nothing for a key the cache lacks. Either +/// way each limit is the higher of the two copies'. fn fold_fetched_key( cached: Option<&IdentityPublicKey>, fetched: &IdentityPublicKey, @@ -796,15 +798,10 @@ fn fold_fetched_key( return cached .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); } - let mut key = fetched.clone(); + let key = fetched.clone(); let Some(cached) = cached else { return Some(key); }; - if key.disabled_at().is_none() { - if let Some(disabled_at) = cached.disabled_at() { - key.set_disabled_at(disabled_at); - } - } Some(with_raised_limits(key, cached)) } @@ -830,12 +827,13 @@ fn with_raised_limits(key: IdentityPublicKey, other: &IdentityPublicKey) -> Iden key.with_limits(total_budget, expires_at) } -/// The cache's copy of a key, disabled when an older fetch's copy is: a -/// disable is permanent on Platform. The fetch's timestamp is Platform's -/// own, so it wins over the cache's, which may be a local placeholder. +/// The cache's copy of a key, disabled when an older fetch's copy is: an +/// identity update's disable is permanent on Platform. A disable the cache +/// already holds is kept as it is: it is at least as new as the fetch's (a +/// later update may have disabled the key again at a later block). fn with_disable_from(cached: &IdentityPublicKey, fetched: &IdentityPublicKey) -> IdentityPublicKey { let mut key = cached.clone(); - if let Some(disabled_at) = fetched.disabled_at().or(cached.disabled_at()) { + if let Some(disabled_at) = cached.disabled_at().or(fetched.disabled_at()) { key.set_disabled_at(disabled_at); } key @@ -914,10 +912,10 @@ mod tests { /// A fetch at a lower revision than the cache's was answered before an /// update the cache holds: the cache keeps its revision and key set, and - /// takes only a disable the fetch shows (Platform's timestamp first) and - /// a raised limit. A fetch at the same revision or higher replaces the - /// cache but never takes back a disable or a raise. Either way the - /// balance follows the proof's block, not the revision. + /// takes only a disable the fetch shows on a key it holds enabled and a + /// raised limit. A fetch at the same revision or higher replaces the + /// cache, key states included, but never takes back a raise. Either way + /// the balance follows the proof's block, not the revision. #[test] fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { @@ -963,21 +961,24 @@ mod tests { assert_eq!(managed.identity, at(6, 20, vec![limited(3, 100, Some(9))])); assert_eq!(managed.last_updated_balance_block_time, Some(block(10))); - // A disable is permanent: one the older fetch shows sticks even - // when the cache's copy is live. A higher budget it shows is a raise - // the cache missed (a limits update claims no revision). + // An identity update's disable is permanent: one the older fetch + // shows sticks even when the cache's copy is live. A higher budget + // it shows is a raise the cache missed (a limits update claims no + // revision). let mut live = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, None)]), 0); live.last_updated_balance_block_time = Some(block(9)); let disabling = at(5, 20, vec![limited(3, 500, Some(4))]); assert!(!live.take_fetched_identity(&disabling, block(9))); assert_eq!(live.identity, at(6, 10, vec![limited(3, 500, Some(4))])); - // Both disabled: the fetch's timestamp is Platform's, the cache's - // may be a local placeholder. + // Both disabled: the cache's timestamp is at least as new (a later + // update may have disabled the key again), so an older answer, or a + // second one like it, never rewrites it. let mut stamped = ManagedIdentity::new(at(6, 10, vec![limited(3, 100, Some(99))]), 0); - assert!( - !stamped.take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]), block(9)) - ); - assert_eq!(stamped.identity.public_keys()[&3].disabled_at(), Some(4)); + for _ in 0..2 { + assert!(!stamped + .take_fetched_identity(&at(5, 10, vec![limited(3, 100, Some(4))]), block(9))); + assert_eq!(stamped.identity, at(6, 10, vec![limited(3, 100, Some(99))])); + } // An answer at the cache's revision or above is taken whole... let same = at(6, 30, vec![limited(3, 600, Some(7)), limited(4, 1, None)]); @@ -988,13 +989,14 @@ mod tests { assert!(managed.take_fetched_identity(&newer, block(12))); assert_eq!(managed.identity, newer); - // ...except for a balance its proof does not order after the cache's, - // a disable the cache holds and the answer lacks, and a budget the - // cache holds higher: a delayed answer cannot take a raise back. + // ...key states included (Core re-enables a masternode identity's + // key at revision 0), except for a balance its proof does not order + // after the cache's and a budget the cache holds higher: a delayed + // answer cannot take a raise back. let delayed = at(7, 35, vec![limited(3, 550, None)]); for height in [11, 12] { assert!(managed.take_fetched_identity(&delayed, block(height))); - assert_eq!(managed.identity, at(7, 40, vec![limited(3, 600, Some(7))])); + assert_eq!(managed.identity, at(7, 40, vec![limited(3, 600, None)])); assert_eq!(managed.last_updated_balance_block_time, Some(block(12))); } } From 693fd170748b77f0f65ee206c27cb805a75e3f69 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:20:07 +0300 Subject: [PATCH 58/72] fix(platform-wallet-storage): lend a held breadcrumb only from a valid row with the same key The SQLite writer kept a stored breadcrumb for an upsert without one from any row that decoded. It now lends it only from a row that passes the reader's column checks (one `contradicts_columns` helper shared with `load_state`) and holds the same public key (type, data and hash). The changeset merge's same-key check also compares the key type. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/schema/identity_keys.rs | 68 ++++++++++++++----- .../sqlite_identity_keys_reader.rs | 40 +++++++++++ .../src/changeset/changeset.rs | 1 + 3 files changed, 93 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs index c8a47eced49..b8046091d04 100644 --- a/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs +++ b/packages/rs-platform-wallet-storage/src/sqlite/schema/identity_keys.rs @@ -128,10 +128,14 @@ fn map_fk_violation( /// [`WalletStorageError::IdentityKeyWalletMismatch`]. /// /// An upsert without a derivation breadcrumb keeps the one the same row -/// already holds, as the Swift and Kotlin persisters do: a write that only +/// already holds, as the Swift and Kotlin persisters do (they keep it on any +/// upsert of the row; this writer also checks the row first): a write that only /// knows the key's new state (a refresh, a raised limit, a disable) must not -/// turn a wallet-derived key watch-only. A stored row that does not decode, -/// or is over the blob size limit, holds nothing to keep and is replaced. +/// turn a wallet-derived key watch-only. Only a row that holds the same +/// public key (type, data and hash) and passes the reader's checks +/// (`contradicts_columns`) lends its breadcrumb; any other stored row (one +/// that does not decode, is over the blob size limit, contradicts its +/// columns or holds another key) holds nothing to keep and is replaced. pub fn apply( tx: &Transaction<'_>, wallet_id: &WalletId, @@ -168,7 +172,7 @@ pub fn apply( // wallet, no NULL-scoped reader, and no guard. let wallet_id_param = super::wallet_id_to_param(wallet_id); let mut stored = tx.prepare_cached( - "SELECT public_key_blob FROM identity_keys \ + "SELECT public_key_blob, public_key_hash FROM identity_keys \ WHERE identity_id = ?1 AND key_id = ?2 AND wallet_id IS ?3 \ AND length(public_key_blob) <= ?4", )?; @@ -186,7 +190,7 @@ pub fn apply( } let mut wire = IdentityKeyWire::from_entry(entry)?; if wire.derivation_indices.is_none() { - let held: Option> = stored + let held: Option<(Vec, Vec)> = stored .query_row( params![ identity_id.as_slice(), @@ -194,11 +198,25 @@ pub fn apply( wallet_id_param, size_limit ], - |row| row.get(0), + |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; - if let Some(held) = held.and_then(|payload| decode_entry(&payload).ok()) { - if held.derivation_indices.is_some() { + let held = held.and_then(|(payload, typed_public_key_hash)| { + decode_entry(&payload).ok().filter(|held| { + !contradicts_columns( + held, + identity_id, + *key_id, + &typed_public_key_hash, + wallet_id, + ) + }) + }); + if let Some(held) = held { + let same_key = held.public_key.key_type() == entry.public_key.key_type() + && held.public_key.data() == entry.public_key.data() + && held.public_key_hash == entry.public_key_hash; + if same_key && held.derivation_indices.is_some() { wire.wallet_id = held.wallet_id; wire.derivation_indices = held.derivation_indices; } @@ -248,6 +266,25 @@ pub fn apply( Ok(()) } +/// Whether a decoded row names a different identity, key, public-key hash +/// or wallet than the typed columns it was selected by: corruption, which +/// the reader skips and the writer never borrows a breadcrumb from. +fn contradicts_columns( + entry: &IdentityKeyEntry, + identity_id: &Identifier, + key_id: KeyID, + typed_public_key_hash: &[u8], + wallet_id: &WalletId, +) -> bool { + entry.identity_id != *identity_id + || entry.key_id != key_id + || entry.public_key.id() != key_id + || entry.public_key_hash[..] != typed_public_key_hash[..] + || entry + .wallet_id + .is_some_and(|entry_wallet_id| entry_wallet_id != *wallet_id) +} + /// Decode an `identity_keys.public_key_blob` cell back to the entry. pub fn decode_entry(payload: &[u8]) -> Result { let wire: IdentityKeyWire = blob::decode(payload)?; @@ -316,14 +353,13 @@ pub fn load_state( // `public_key_hash` is the indexed lookup column while the blob is // what callers receive, so a divergence makes a key findable under a // hash it does not carry. - let contradicts_columns = entry.identity_id != identity_id - || entry.key_id != key_id - || entry.public_key.id() != key_id - || entry.public_key_hash[..] != typed_public_key_hash[..] - || entry - .wallet_id - .is_some_and(|entry_wallet_id| entry_wallet_id != *wallet_id); - if contradicts_columns { + if contradicts_columns( + &entry, + &identity_id, + key_id, + &typed_public_key_hash, + wallet_id, + ) { ctx.tolerate( LoadSite::IdentityKeyRow, WalletStorageError::IdentityKeyEntryMismatch, diff --git a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs index 9a6afdc07ee..6736ec108eb 100644 --- a/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs +++ b/packages/rs-platform-wallet-storage/tests/integration/sqlite_identity_keys_reader.rs @@ -337,6 +337,46 @@ fn should_keep_a_stored_wallet_id_with_the_breadcrumb_when_an_upsert_carries_nei assert_eq!(row.public_key, upsert.public_key); } +/// A stored breadcrumb is lent only to the same public key from a row that +/// passes the reader's checks: an upsert of another key at the same id, or +/// over a row whose blob contradicts its typed hash column, replaces the row +/// without its coordinates. +#[test] +fn should_lend_a_stored_breadcrumb_only_to_the_same_key_from_a_valid_row() { + let (persister, _tmp, path) = fresh_persister(); + let w = wid(0xE2); + ensure_wallet_meta(&persister, &w); + let id = Identifier::from([0x21; 32]); + platform_wallet_storage::sqlite::schema::identities::ensure_exists( + &persister.lock_conn_for_test(), + &w, + id.as_slice().try_into().unwrap(), + ) + .unwrap(); + store_entries( + &persister, + w, + vec![key_entry(id, 0, 0x11), key_entry(id, 1, 0x22)], + ); + // Row 1's typed hash no longer matches its blob. + persister + .lock_conn_for_test() + .execute( + "UPDATE identity_keys SET public_key_hash = ?1 WHERE key_id = 1", + rusqlite::params![&[0x44u8; 20][..]], + ) + .unwrap(); + + let other_key = without_breadcrumb(key_entry(id, 0, 0x33)); + let same_key = without_breadcrumb(key_entry(id, 1, 0x22)); + store_entries(&persister, w, vec![other_key.clone(), same_key.clone()]); + drop(persister); + + let rows = reload(&path, &w).upserts; + assert_eq!(rows.get(&(id, 0)), Some(&other_key)); + assert_eq!(rows.get(&(id, 1)), Some(&same_key)); +} + /// A stored row within the size limit that does not decode holds no /// breadcrumb to keep: the upsert replaces it and the store succeeds. #[test] diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 377b020d52f..0c1737925fa 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1097,6 +1097,7 @@ impl Merge for IdentityKeysChangeSet { if entry.derivation_indices.is_none() { if let Some(earlier) = self.upserts.get(&id) { let same_key = earlier.public_key_hash == entry.public_key_hash + && earlier.public_key.key_type() == entry.public_key.key_type() && earlier.public_key.data() == entry.public_key.data(); let same_scope = entry.wallet_id.is_none() || entry.wallet_id == earlier.wallet_id; From 2b8ba90889749934cf903548efbe2cbaa2992c6e Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 13:32:26 +0300 Subject: [PATCH 59/72] fix(platform-wallet): take Drive's revision when it refuses an update for its revision A cache one past the chain, which a build before this branch could leave (a fetch answered between an update's snapshot and its bump), no longer heals from a fetch: every fetch comes back older and keeps the cache's revision, so every update of that identity would be refused for good. Drive refuses such an update with InvalidIdentityRevisionError, which carries the revision it holds. On that refusal the cached revision moves down to Drive's and is stored with the scalar snapshot, so the next update carries the right one. It is lowered only while the cache is still at the revision the refused transition was built from, and never raised: the error comes from one node without a proof, so a wrong value can get later updates refused until a fetch, but cannot lock the cache ahead. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 16 ++ .../src/wallet/identity/network/update.rs | 211 +++++++++++++++++- 2 files changed, 216 insertions(+), 11 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 3fb62570a6e..5ccfbac2b4d 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -2,6 +2,7 @@ use dash_sdk::error::ContextProviderError; use dashcore::sml::llmq_entry_verification::LLMQEntryVerificationStatus; use dpp::address_funds::PlatformAddress; use dpp::consensus::state::address_funds::AddressInvalidNonceError; +use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; use dpp::consensus::ConsensusError; use dpp::fee::Credits; use dpp::identifier::Identifier; @@ -1283,6 +1284,21 @@ pub fn as_address_invalid_nonce(error: &dash_sdk::Error) -> Option<&AddressInval } } +/// Extract the `InvalidIdentityRevisionError` (DPP consensus code 40203) +/// when Platform rejected an identity update whose revision is not one above +/// the identity's, exposing `identity_id()` and Drive's `current_revision()`; +/// `None` otherwise. +pub fn as_identity_revision_invalid( + error: &dash_sdk::Error, +) -> Option<&InvalidIdentityRevisionError> { + use dpp::consensus::state::state_error::StateError; + + match consensus_error_of(error) { + Some(ConsensusError::StateError(StateError::InvalidIdentityRevisionError(e))) => Some(e), + _ => None, + } +} + /// Promote a nonce-rejection SDK error to the typed /// [`PlatformWalletError::AddressNonceMismatch`] so callers can recover /// `expected_nonce` and retry, instead of receiving the rejection flattened diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index d14a3323757..242e5701c1f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -21,7 +21,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::changeset::KeyWithBreadcrumb; -use crate::error::PlatformWalletError; +use crate::error::{as_identity_revision_invalid, PlatformWalletError}; use super::signing_key::AvailableSigningKey; use super::*; @@ -89,9 +89,12 @@ impl IdentityWallet { /// update landed and finishes after this step does not take the cache /// back: a fetched identity at a lower revision than the cache's keeps /// the cache's revision, key set and every disable (see - /// `ManagedIdentity::take_fetched_identity`). When the call fails - /// after the broadcast (the proof wait timed out, say), the update may - /// have landed without this step: refresh before the next update. + /// `ManagedIdentity::take_fetched_identity`). When Drive refuses the + /// update for its revision and holds a lower one than the cache, the + /// cache takes Drive's (see `take_rejected_revision`), so the next + /// update carries the right one. When the call fails after the + /// broadcast (the proof wait timed out, say), the update may have + /// landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -226,15 +229,17 @@ impl IdentityWallet { )) })?; - state_transition + if let Err(e) = state_transition .broadcast_and_wait::(&self.sdk, settings) .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to broadcast identity update: {}", - e - )) - })?; + { + self.take_rejected_revision(identity_id, transition_revision, &e) + .await; + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to broadcast identity update: {}", + e + ))); + } self.apply_landed_update( identity_id, @@ -366,6 +371,66 @@ impl IdentityWallet { Ok(()) } + /// Take Drive's revision from an update it refused for its revision. + /// + /// Drive takes an identity update only at one above the identity's + /// revision and otherwise refuses it in the block with + /// `InvalidIdentityRevisionError`, which carries the revision it holds. + /// A cache ahead of that value cannot heal from a fetch, which comes back + /// older and keeps the cache's revision, so every update would be + /// refused again. A build before this one could leave a cache one past + /// the chain (a fetch answered between an update's snapshot and its + /// bump, which then counted twice). The cached revision moves down to + /// Drive's and is stored with the scalar snapshot, so the next update + /// built from the cache carries the right one; the key set is left as + /// it is. + /// + /// The cache is lowered only while it is still at the revision the + /// refused transition was built from (`transition_revision - 1`): a + /// fetch or another update that moved it since knows more than this + /// refusal. A cache at Drive's revision or below is left alone: raising + /// it would take no keys with it. The error comes from one node without + /// a proof, so a node that lies low gets later updates refused until a + /// fetch, which is taken at or above the lowered revision, brings the + /// cache back; it can never lock the cache ahead. A snapshot at a higher + /// revision still buffered in the store wins the merge over this one, + /// so a restart before it is written can bring the old revision back, + /// to be lowered again by the next refusal. A failed store is logged; + /// the caller gets the refusal either way. + async fn take_rejected_revision( + &self, + identity_id: &Identifier, + transition_revision: u64, + error: &dash_sdk::Error, + ) { + let Some(rejection) = as_identity_revision_invalid(error) else { + return; + }; + if rejection.identity_id() != identity_id { + return; + } + let current = *rejection.current_revision(); + let mut wm = self.wallet_manager.write().await; + let Some(managed) = wm + .get_wallet_info_mut(&self.wallet_id) + .and_then(|info| info.identity_manager.managed_identity_mut(identity_id)) + else { + return; + }; + let built_from = transition_revision.saturating_sub(1); + if managed.identity.revision() != built_from || built_from <= current { + return; + } + managed.identity.set_revision(current); + if let Err(e) = managed.store_keys_with_snapshot(vec![], &self.persister) { + tracing::error!( + identity = %identity_id, + revision = current, + "identity revision from Drive not persisted: {e}" + ); + } + } + /// Update an identity using an externally-provided identity and signer. /// /// Unlike [`Self::update_identity_with_external_signer`], this method does @@ -811,6 +876,130 @@ mod update_identity_at_revision_tests { assert_eq!(changesets.len(), 1, "the snapshot alone"); assert!(changesets[0].identities.is_some()); } + + /// Drive's refusal of an update for an identity it holds at `current`, + /// in the shape `shape` picks: the wait-stream envelope Drive answers + /// with, or a bare consensus error, matched as well. + fn revision_refusal(identity_id: Identifier, current: u64, shape: usize) -> dash_sdk::Error { + use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; + use dpp::consensus::ConsensusError; + + let cause: ConsensusError = InvalidIdentityRevisionError::new(identity_id, current).into(); + match shape { + 0 => dash_sdk::Error::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 40203, + message: cause.to_string(), + cause: Some(cause), + }, + ), + _ => dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(cause))), + } + } + + /// A cache one past the chain (6 over Drive's 5, which a build before + /// this one could leave) is refused on every update, and no fetch heals + /// it: each comes back older. Drive's refusal carries 5, the cache takes + /// it and stores it with the scalar snapshot, keys untouched, and the + /// next update carries 6 and gets past the revision check. + #[tokio::test] + async fn should_take_drives_revision_from_an_update_refused_for_its_revision() { + let identity_id = Identifier::default(); + let no_retries = Some(PutSettings { + request_settings: dash_sdk::RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }); + for shape in [0, 1] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + let stuck = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(6), + &RecordingSigner::default(), + no_retries, + ) + .await + .unwrap_err(); + assert!( + matches!(stuck, PlatformWalletError::IdentityRevisionMismatch { .. }), + "{stuck:?}" + ); + store.take(); + + wallet + .take_rejected_revision(&identity_id, 7, &revision_refusal(identity_id, 5, shape)) + .await; + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5, "shape {shape}"); + assert_eq!(identity.public_keys(), before.public_keys()); + let changesets = store.take(); + assert_eq!(changesets.len(), 1); + let stored = changesets[0] + .identities + .as_ref() + .and_then(|cs| cs.identities.get(&identity_id)) + .expect("the scalar snapshot"); + assert_eq!(stored.revision, 5); + + let signer = RecordingSigner::default(); + let error = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(6), + &signer, + no_retries, + ) + .await + .unwrap_err(); + assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); + assert!(signer.0.load(Ordering::SeqCst) > 0); + } + } + + /// The cache is left alone when the refusal does not show it ahead of + /// Drive: Drive at the cache's revision or above it (raising the cache + /// would take no keys, and a lying node could lock it ahead), a cache + /// that moved after the refused transition was built (a later update + /// landed, or a fetch took a newer identity), a refusal for another + /// identity, or another error. + #[tokio::test] + async fn should_keep_the_cached_revision_unless_drive_holds_a_lower_one() { + let identity_id = Identifier::default(); + let other = Identifier::from([7; 32]); + for (cached, transition, error) in [ + (5, 6, revision_refusal(identity_id, 5, 0)), + (5, 6, revision_refusal(identity_id, 9, 1)), + (7, 6, revision_refusal(identity_id, 5, 0)), + (6, 6, revision_refusal(identity_id, 3, 1)), + (5, 6, revision_refusal(other, 2, 0)), + (5, 6, dash_sdk::Error::Generic("timeout".to_string())), + ] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(cached, store.clone()).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + store.take(); + wallet + .take_rejected_revision(&identity_id, transition, &error) + .await; + assert_eq!( + cached_identity(wallet, &identity_id).await, + before, + "cached at {cached}: {error:?}" + ); + assert!(store.take().is_empty(), "cached at {cached}: {error:?}"); + } + } } #[cfg(test)] From 2be18622c261218b12adaa3cccaa684609325673 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:41:07 +0300 Subject: [PATCH 60/72] fix(platform-wallet): take a refused update's revision only from a proved identity that holds every cached key Drive's InvalidIdentityRevisionError reaches the wallet from one node without a proof, so lowering the cache to the revision it carries let a node roll a confirmed cache back while it kept newer keys, and a later older fetch then passed the lowered gate. The refusal now only prompts a proved fetch of the identity. The cache takes the fetched identity whole, and stores it as a refresh does, when the fetch shows the refusal's revision, below the cache's, the cache is still at the revision the refused transition was built from, and the fetch holds every key the cache holds with the same data and disabled state. An update Drive takes adds or disables a key, so a cache holding an update the proof lacks fails that check. The fetch uses the caller's request settings. The revision-error matcher uses the SDK's Error::consensus_error(), which replaced the wallet-local helper on v5.1-dev. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet/src/error.rs | 2 +- .../src/wallet/identity/network/update.rs | 273 ++++++++++++++---- 2 files changed, 213 insertions(+), 62 deletions(-) diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 0e8233fc836..7a9a371bdf8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -1243,7 +1243,7 @@ pub fn as_identity_revision_invalid( ) -> Option<&InvalidIdentityRevisionError> { use dpp::consensus::state::state_error::StateError; - match consensus_error_of(error) { + match error.consensus_error() { Some(ConsensusError::StateError(StateError::InvalidIdentityRevisionError(e))) => Some(e), _ => None, } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 242e5701c1f..9a13a6d0aef 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -22,6 +22,7 @@ use dash_sdk::platform::transition::put_settings::PutSettings; use crate::changeset::KeyWithBreadcrumb; use crate::error::{as_identity_revision_invalid, PlatformWalletError}; +use crate::BlockTime; use super::signing_key::AvailableSigningKey; use super::*; @@ -90,9 +91,10 @@ impl IdentityWallet { /// back: a fetched identity at a lower revision than the cache's keeps /// the cache's revision, key set and every disable (see /// `ManagedIdentity::take_fetched_identity`). When Drive refuses the - /// update for its revision and holds a lower one than the cache, the - /// cache takes Drive's (see `take_rejected_revision`), so the next - /// update carries the right one. When the call fails after the + /// update for its revision and a proved fetch shows that lower revision + /// with every key and disable the cache holds, the cache takes the + /// fetched identity (see `take_rejected_revision`), so the next update + /// carries the right one. When the call fails after the /// broadcast (the proof wait timed out, say), the update may have /// landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( @@ -233,7 +235,7 @@ impl IdentityWallet { .broadcast_and_wait::(&self.sdk, settings) .await { - self.take_rejected_revision(identity_id, transition_revision, &e) + self.take_rejected_revision(identity_id, transition_revision, &e, settings) .await; return Err(PlatformWalletError::InvalidIdentityData(format!( "Failed to broadcast identity update: {}", @@ -371,7 +373,7 @@ impl IdentityWallet { Ok(()) } - /// Take Drive's revision from an update it refused for its revision. + /// Take a proved identity at the revision Drive refused an update for. /// /// Drive takes an identity update only at one above the identity's /// revision and otherwise refuses it in the block with @@ -380,29 +382,41 @@ impl IdentityWallet { /// older and keeps the cache's revision, so every update would be /// refused again. A build before this one could leave a cache one past /// the chain (a fetch answered between an update's snapshot and its - /// bump, which then counted twice). The cached revision moves down to - /// Drive's and is stored with the scalar snapshot, so the next update - /// built from the cache carries the right one; the key set is left as - /// it is. + /// bump, which then counted twice). /// - /// The cache is lowered only while it is still at the revision the - /// refused transition was built from (`transition_revision - 1`): a - /// fetch or another update that moved it since knows more than this - /// refusal. A cache at Drive's revision or below is left alone: raising - /// it would take no keys with it. The error comes from one node without - /// a proof, so a node that lies low gets later updates refused until a - /// fetch, which is taken at or above the lowered revision, brings the - /// cache back; it can never lock the cache ahead. A snapshot at a higher - /// revision still buffered in the store wins the merge over this one, - /// so a restart before it is written can bring the old revision back, - /// to be lowered again by the next refusal. A failed store is logged; - /// the caller gets the refusal either way. + /// The refusal comes from one node without a proof, so it only prompts a + /// proved fetch of the identity. The cache takes the fetched identity + /// whole, keys included, and stores it as a refresh does, when the fetch + /// shows the refusal's revision, below the cache's, and holds every key + /// the cache holds, with the same data and disabled state. An update + /// Drive takes adds a key or disables one (it refuses an empty one), so a + /// cache that holds such an update the fetch lacks fails that check; one + /// that is only ahead by its revision number holds the fetched keys. An + /// update that only disables a key already disabled re-stamps its + /// `disabled_at` and passes it: a node that proves the identity from + /// before it can take the cache back one revision with the older + /// timestamps, no key or disable lost, until the next fetch at the + /// chain's revision. The timestamps are not compared, because a disable + /// applied locally carries a wall-clock placeholder. + /// Anything else leaves the cache as it is: the refusal alone, a fetch + /// that fails, shows another revision or lacks a cached key or disable, + /// a cache at the refusal's revision or below (raising it would take no + /// keys), or a cache that moved after the refused transition was built + /// (`transition_revision - 1`), since a fetch or another update that + /// moved it knows more. A snapshot at a higher revision still buffered + /// in the store wins the merge over this one, so a restart before it is + /// written can bring the old revision back, to be healed by the next + /// refusal. A failed store is logged; the caller gets the refusal either + /// way. async fn take_rejected_revision( &self, identity_id: &Identifier, transition_revision: u64, error: &dash_sdk::Error, + settings: Option, ) { + use dash_sdk::platform::Fetch; + let Some(rejection) = as_identity_revision_invalid(error) else { return; }; @@ -410,6 +424,38 @@ impl IdentityWallet { return; } let current = *rejection.current_revision(); + let built_from = transition_revision.saturating_sub(1); + if built_from <= current { + return; + } + let (fetched, block_time) = match Identity::fetch_with_metadata( + &self.sdk, + *identity_id, + settings.map(|s| s.request_settings), + ) + .await + { + Ok((Some(fetched), metadata)) if fetched.revision() == current => { + (fetched, BlockTime::from(metadata)) + } + Ok((fetched, _)) => { + tracing::debug!( + identity = %identity_id, + revision = current, + proved = ?fetched.map(|identity| identity.revision()), + "proof does not back Drive's refusal of an update for its revision" + ); + return; + } + Err(e) => { + tracing::warn!( + identity = %identity_id, + revision = current, + "identity not fetched after Drive refused an update for its revision: {e}" + ); + return; + } + }; let mut wm = self.wallet_manager.write().await; let Some(managed) = wm .get_wallet_info_mut(&self.wallet_id) @@ -417,16 +463,22 @@ impl IdentityWallet { else { return; }; - let built_from = transition_revision.saturating_sub(1); - if managed.identity.revision() != built_from || built_from <= current { + if managed.identity.revision() != built_from + || !holds_every_key(&fetched, &managed.identity) + { + tracing::debug!( + identity = %identity_id, + revision = current, + "cache moved or holds a key the proof lacks; Drive's refusal not taken" + ); return; } managed.identity.set_revision(current); - if let Err(e) = managed.store_keys_with_snapshot(vec![], &self.persister) { + if let Err(e) = managed.store_fetched_identity(&fetched, block_time, &self.persister) { tracing::error!( identity = %identity_id, revision = current, - "identity revision from Drive not persisted: {e}" + "identity proved at Drive's revision not persisted: {e}" ); } } @@ -498,6 +550,18 @@ impl IdentityWallet { } } +/// Whether `fetched` holds every key `cached` holds, with the same data and +/// disabled state. +fn holds_every_key(fetched: &Identity, cached: &Identity) -> bool { + cached.public_keys().iter().all(|(key_id, cached_key)| { + fetched.public_keys().get(key_id).is_some_and(|key| { + key.key_type() == cached_key.key_type() + && key.data() == cached_key.data() + && key.is_disabled() == cached_key.is_disabled() + }) + }) +} + /// The revision an update of an identity cached at `cached` carries: one /// above it. With `expected` set, any other value is refused. fn update_revision( @@ -897,26 +961,50 @@ mod update_identity_at_revision_tests { } } - /// A cache one past the chain (6 over Drive's 5, which a build before - /// this one could leave) is refused on every update, and no fetch heals - /// it: each comes back older. Drive's refusal carries 5, the cache takes - /// it and stores it with the scalar snapshot, keys untouched, and the - /// next update carries 6 and gets past the revision check. - #[tokio::test] - async fn should_take_drives_revision_from_an_update_refused_for_its_revision() { - let identity_id = Identifier::default(); - let no_retries = Some(PutSettings { + /// The mock node answers a fetch of the identity with `answer`, proved. + async fn answer_fetch(wallet: &IdentityWallet, answer: Identity) { + let mut sdk = (*wallet.sdk).clone(); + sdk.mock() + .expect_fetch(Identifier::default(), Some(answer)) + .await + .unwrap(); + } + + /// The cache's identity at `revision`, as a node proves it. + async fn proved_at(wallet: &IdentityWallet, revision: u64) -> Identity { + let mut identity = cached_identity(wallet, &Identifier::default()).await; + identity.set_revision(revision); + identity + } + + fn no_retries() -> Option { + Some(PutSettings { request_settings: dash_sdk::RequestSettings { retries: Some(0), ..Default::default() }, ..Default::default() - }); + }) + } + + /// A cache one past the chain (6 over Drive's 5, which a build before + /// this one could leave) is refused on every update, and no fetch heals + /// it: each comes back older. Drive's refusal carries 5, a proved fetch + /// shows the identity at 5 with every key the cache holds and one it + /// lacks, so the cache takes it whole and stores it, and the next update carries 6 and gets past + /// the revision check. + #[tokio::test] + async fn should_take_a_proved_identity_at_the_revision_drive_refused_an_update_for() { + let identity_id = Identifier::default(); for shape in [0, 1] { let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(6, store.clone()).await; let wallet = wallet.identity(); - let before = cached_identity(wallet, &identity_id).await; + // The proof also holds a key the cache lacks (added on another + // device), so taking it whole shows in the key set. + let mut chain = proved_at(wallet, 5).await; + chain.add_public_key(key_3()); + answer_fetch(wallet, chain.clone()).await; let stuck = wallet .update_identity_with_external_signer_at_revision( &identity_id, @@ -924,7 +1012,7 @@ mod update_identity_at_revision_tests { vec![1], Some(6), &RecordingSigner::default(), - no_retries, + no_retries(), ) .await .unwrap_err(); @@ -935,17 +1023,21 @@ mod update_identity_at_revision_tests { store.take(); wallet - .take_rejected_revision(&identity_id, 7, &revision_refusal(identity_id, 5, shape)) + .take_rejected_revision( + &identity_id, + 7, + &revision_refusal(identity_id, 5, shape), + no_retries(), + ) .await; let identity = cached_identity(wallet, &identity_id).await; assert_eq!(identity.revision(), 5, "shape {shape}"); - assert_eq!(identity.public_keys(), before.public_keys()); - let changesets = store.take(); - assert_eq!(changesets.len(), 1); - let stored = changesets[0] - .identities - .as_ref() - .and_then(|cs| cs.identities.get(&identity_id)) + assert_eq!(identity.public_keys(), chain.public_keys()); + let stored = store + .take() + .iter() + .filter_map(|cs| cs.identities.as_ref()) + .find_map(|cs| cs.identities.get(&identity_id).cloned()) .expect("the scalar snapshot"); assert_eq!(stored.revision, 5); @@ -957,7 +1049,7 @@ mod update_identity_at_revision_tests { vec![1], Some(6), &signer, - no_retries, + no_retries(), ) .await .unwrap_err(); @@ -966,31 +1058,90 @@ mod update_identity_at_revision_tests { } } - /// The cache is left alone when the refusal does not show it ahead of - /// Drive: Drive at the cache's revision or above it (raising the cache - /// would take no keys, and a lying node could lock it ahead), a cache - /// that moved after the refused transition was built (a later update - /// landed, or a fetch took a newer identity), a refusal for another - /// identity, or another error. + /// A cache confirmed at 6, holding the update that landed there (key 3 + /// added, or key 1 disabled), and a node that refuses the next update + /// claiming 5 and proves the identity at 5, from before that update: the + /// proof lacks a key or a disable the cache holds, so the cache keeps 6 + /// and stores nothing. A lagging refresh answering that same identity at + /// 5 afterwards still keeps the update. + #[tokio::test] + async fn should_keep_a_confirmed_update_when_a_refusal_claims_an_older_revision() { + let identity_id = Identifier::default(); + for landed in ["added key 3", "disabled key 1"] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(5, store.clone()).await; + let wallet = wallet.identity(); + let before_update = proved_at(wallet, 5).await; + let (added, disabled) = match landed { + "added key 3" => (vec![key_3()], vec![]), + _ => (vec![], vec![1]), + }; + wallet + .apply_landed_update(&identity_id, Some(0), 6, added, disabled) + .await + .unwrap(); + answer_fetch(wallet, before_update).await; + let confirmed = cached_identity(wallet, &identity_id).await; + assert_eq!(confirmed.revision(), 6); + store.take(); + + wallet + .take_rejected_revision( + &identity_id, + 7, + &revision_refusal(identity_id, 5, 0), + no_retries(), + ) + .await; + assert_eq!( + cached_identity(wallet, &identity_id).await, + confirmed, + "{landed}" + ); + assert!(store.take().is_empty(), "{landed}"); + + let refreshed = wallet.refresh_identity(&identity_id).await.unwrap(); + assert_eq!(refreshed.revision(), 6, "{landed}"); + assert_eq!(refreshed.public_keys(), confirmed.public_keys(), "{landed}"); + } + } + + /// The cache is left alone when the refusal does not show it ahead of a + /// proved identity: Drive at the cache's revision or above it (raising + /// the cache would take no keys), a refusal for another identity, + /// another error, a cache that moved after the refused transition was + /// built (a later update landed, or a fetch took a newer identity), a + /// proved identity at another revision than the refusal's, and a fetch + /// that fails (the mock answers nothing it was not told to). #[tokio::test] - async fn should_keep_the_cached_revision_unless_drive_holds_a_lower_one() { + async fn should_keep_the_cached_revision_unless_a_proof_backs_the_refusal() { let identity_id = Identifier::default(); let other = Identifier::from([7; 32]); - for (cached, transition, error) in [ - (5, 6, revision_refusal(identity_id, 5, 0)), - (5, 6, revision_refusal(identity_id, 9, 1)), - (7, 6, revision_refusal(identity_id, 5, 0)), - (6, 6, revision_refusal(identity_id, 3, 1)), - (5, 6, revision_refusal(other, 2, 0)), - (5, 6, dash_sdk::Error::Generic("timeout".to_string())), + for (cached, transition, error, proved) in [ + (5, 6, revision_refusal(identity_id, 5, 0), Some(5)), + (5, 6, revision_refusal(identity_id, 9, 1), Some(9)), + (5, 6, revision_refusal(other, 2, 0), Some(2)), + ( + 5, + 6, + dash_sdk::Error::Generic("timeout".to_string()), + Some(4), + ), + (7, 7, revision_refusal(identity_id, 5, 0), Some(5)), + (6, 7, revision_refusal(identity_id, 4, 1), Some(3)), + (6, 7, revision_refusal(identity_id, 4, 0), None), ] { let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(cached, store.clone()).await; let wallet = wallet.identity(); + if let Some(revision) = proved { + let answer = proved_at(wallet, revision).await; + answer_fetch(wallet, answer).await; + } let before = cached_identity(wallet, &identity_id).await; store.take(); wallet - .take_rejected_revision(&identity_id, transition, &error) + .take_rejected_revision(&identity_id, transition, &error, no_retries()) .await; assert_eq!( cached_identity(wallet, &identity_id).await, From f8b1bb3ce71f077d7d5c6a219eff36ca5407a311 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:36:59 +0300 Subject: [PATCH 61/72] fix(platform-wallet): take a refused update's revision only from a proof above the cache's watermark A proved identity is authenticated but not necessarily fresh. An update that only re-disables an already-disabled key changes no key, so a proof from before it passed `holds_every_key` and could take the cache back a revision. The heal now also requires the proof to come from a block above the cache's verified-height watermark (`last_updated_balance_block_time`), which is at or above every fetch the cache took, so a revision learned from a fetch cannot be undone by an older proof. A revision learned from an update this wallet landed moves no watermark, so the wallet now refuses, before signing, an update that disables a key the cache holds disabled: every update it lands then adds a key or disables an enabled one, which a proof from before it lacks. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 149 ++++++++++++++++-- 1 file changed, 134 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9a13a6d0aef..ee130a68c5c 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -94,9 +94,10 @@ impl IdentityWallet { /// update for its revision and a proved fetch shows that lower revision /// with every key and disable the cache holds, the cache takes the /// fetched identity (see `take_rejected_revision`), so the next update - /// carries the right one. When the call fails after the - /// broadcast (the proof wait timed out, say), the update may have - /// landed without this step: refresh before the next update. + /// carries the right one. An update that disables a key the cache holds + /// disabled is refused before anything is signed. When the call fails + /// after the broadcast (the proof wait timed out, say), the update may + /// have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -179,6 +180,7 @@ impl IdentityWallet { // Increment revision for the update transition. let transition_revision = update_revision(identity_id, identity.revision(), revision)?; identity.set_revision(transition_revision); + refuse_disabled_again(identity_id, &identity, &disable_public_keys)?; // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. @@ -387,17 +389,24 @@ impl IdentityWallet { /// The refusal comes from one node without a proof, so it only prompts a /// proved fetch of the identity. The cache takes the fetched identity /// whole, keys included, and stores it as a refresh does, when the fetch - /// shows the refusal's revision, below the cache's, and holds every key - /// the cache holds, with the same data and disabled state. An update - /// Drive takes adds a key or disables one (it refuses an empty one), so a - /// cache that holds such an update the fetch lacks fails that check; one - /// that is only ahead by its revision number holds the fetched keys. An - /// update that only disables a key already disabled re-stamps its - /// `disabled_at` and passes it: a node that proves the identity from - /// before it can take the cache back one revision with the older - /// timestamps, no key or disable lost, until the next fetch at the - /// chain's revision. The timestamps are not compared, because a disable - /// applied locally carries a wall-clock placeholder. + /// shows the refusal's revision, below the cache's, its proof is from a + /// block above the cache's verified-height watermark + /// (`last_updated_balance_block_time`, at or above the height of every + /// fetch the cache took), and it holds every key the cache holds, with + /// the same data and disabled state. A revision the cache learned from a fetch therefore + /// cannot be undone by a proof from before that fetch. A revision it + /// learned from an update it landed (`apply_landed_update`, which moves no + /// watermark) cannot either: this wallet sends no update that only + /// disables a key already disabled (`refuse_disabled_again`), so each one + /// adds a key or disables an enabled one, which a proof from before it + /// lacks. A cache only ahead by its revision number holds the fetched + /// keys, and the refused update's own block puts a fresh proof above the + /// watermark; a proof at the watermark's height is refused as well, which + /// costs at most one more refusal. Left open, until the next fetch at the + /// chain's revision: a revision an older build confirmed by a local apply + /// of a re-disable; a landed revision stored while its key rows were not + /// (a failed store); and a cache restored without a watermark (iOS rows + /// stored before the balance sidecar). /// Anything else leaves the cache as it is: the refusal alone, a fetch /// that fails, shows another revision or lacks a cached key or disable, /// a cache at the refusal's revision or below (raising it would take no @@ -463,13 +472,17 @@ impl IdentityWallet { else { return; }; + let (_, watermark) = managed.balance_snapshot_for_persistence(); if managed.identity.revision() != built_from + || watermark.is_some_and(|watermark| block_time.height <= watermark.height) || !holds_every_key(&fetched, &managed.identity) { tracing::debug!( identity = %identity_id, revision = current, - "cache moved or holds a key the proof lacks; Drive's refusal not taken" + proved_at = block_time.height, + "cache moved, the proof is not above the cache's, or it lacks a key the cache \ + holds; Drive's refusal not taken" ); return; } @@ -550,6 +563,30 @@ impl IdentityWallet { } } +/// Refuse an update that disables a key the identity holds disabled. +/// +/// Drive takes one, but it changes no key: it only re-stamps `disabled_at` +/// and moves the revision. Every update this wallet lands then adds a key or +/// disables an enabled one, which `take_rejected_revision` relies on to tell +/// a proof from before a landed update. +fn refuse_disabled_again( + identity_id: &Identifier, + identity: &Identity, + disable_public_keys: &[u32], +) -> Result<(), PlatformWalletError> { + match disable_public_keys.iter().find(|id| { + identity + .public_keys() + .get(id) + .is_some_and(|key| key.is_disabled()) + }) { + Some(key_id) => Err(PlatformWalletError::InvalidIdentityData(format!( + "identity {identity_id}: key {key_id} is already disabled; nothing was signed" + ))), + None => Ok(()), + } +} + /// Whether `fetched` holds every key `cached` holds, with the same data and /// disabled state. fn holds_every_key(fetched: &Identity, cached: &Identity) -> bool { @@ -1151,6 +1188,88 @@ mod update_identity_at_revision_tests { assert!(store.take().is_empty(), "cached at {cached}: {error:?}"); } } + + /// The proof backs the refusal in every other way, but is from a block + /// below the cache's verified-height watermark, where the cache may have + /// learned its revision, or at it, which the floor refuses as well. The + /// mock proves every answer at height 0. + #[tokio::test] + async fn should_keep_the_cache_when_the_proof_is_not_above_its_watermark() { + let identity_id = Identifier::default(); + for watermark in [0, 100] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + let answer = proved_at(wallet, 5).await; + answer_fetch(wallet, answer).await; + { + let mut wm = wallet.wallet_manager.write().await; + wm.get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap() + .last_updated_balance_block_time = Some(BlockTime::new(watermark, 0, 0)); + } + let before = cached_identity(wallet, &identity_id).await; + store.take(); + wallet + .take_rejected_revision( + &identity_id, + 7, + &revision_refusal(identity_id, 5, 0), + no_retries(), + ) + .await; + assert_eq!( + cached_identity(wallet, &identity_id).await, + before, + "watermark at {watermark}" + ); + assert!(store.take().is_empty(), "watermark at {watermark}"); + } + } + + /// Drive takes an update that disables a key already disabled, but it + /// changes no key; the wallet refuses it before signing or reaching + /// Platform, and the cache is unchanged. + #[tokio::test] + async fn should_refuse_to_disable_a_key_already_disabled_before_signing() { + let identity_id = Identifier::default(); + let wallet = wallet_at_revision(4).await; + let wallet = wallet.identity(); + { + let mut wm = wallet.wallet_manager.write().await; + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id) + .unwrap() + .identity_manager + .managed_identity_mut(&identity_id) + .unwrap(); + let mut key = managed.identity.public_keys()[&1].clone(); + key.set_disabled_at(7); + managed.identity.add_public_key(key); + } + let before = cached_identity(wallet, &identity_id).await; + let signer = RecordingSigner::default(); + let error = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![key_3()], + vec![1], + Some(5), + &signer, + None, + ) + .await + .unwrap_err(); + assert!( + matches!(&error, PlatformWalletError::InvalidIdentityData(m) if m.contains("key 1 is already disabled")), + "{error:?}" + ); + assert_eq!(signer.0.load(Ordering::SeqCst), 0); + assert_eq!(cached_identity(wallet, &identity_id).await, before); + } } #[cfg(test)] From 2aa05cd68d6c951aef0c2bf02811591964435f9f Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 18:41:36 +0300 Subject: [PATCH 62/72] fix(platform-wallet): re-send an update Drive refused below the cache instead of lowering the cache Lowering the cached revision on Drive's refusal, even from a proved fetch, could take a confirmed cache back: a proof is authenticated but not fresh, and some cache states (an older build's local re-disable, a key-limits confirmation over a newer disable) cannot be told from an older snapshot. The cache is now never lowered. When Drive refuses an update for its revision and reports one below the cache's (a cache an older build left one past the chain, which no fetch heals), an update asked for no revision is sent once more at the revision Drive takes, as a new transition with a fresh nonce. It lands at the cache's own revision, so cache and chain agree again; a node that lies can only cost paid refusals. A pinned update is not re-sent at another revision: it fails with IdentityRevisionRefused (FFI code 63), the refusal is noted in memory, and next_update_revision (FFI platform_wallet_identity_next_update_revision, Swift nextIdentityUpdateRevision) gives Drive's revision plus one, which a pin may then ask for while the cache has not moved. A landed update, a fetch taken whole, or Drive at the cache's revision clears the note. The landed update's cache-ahead branch also disables a key the inflated cache holds enabled. This drops the proved-fetch heal, the watermark check and the refusal of re-disables from the previous commits. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 14 + .../dashsdk/identity/IdentityUpdates.kt | 8 +- .../dashsdk/errors/DashSdkErrorTest.kt | 10 + .../ERROR_CODE_REGISTRY.md | 7 +- packages/rs-platform-wallet-ffi/src/error.rs | 31 + .../src/identity_update.rs | 74 +- packages/rs-platform-wallet/src/error.rs | 16 + .../src/wallet/identity/network/update.rs | 924 ++++++++---------- .../state/managed_identity/identity_ops.rs | 3 + .../identity/state/managed_identity/mod.rs | 7 + .../ManagedPlatformWallet.swift | 30 +- .../PlatformWallet/PlatformWalletResult.swift | 17 + .../ErrorHandlingTests.swift | 14 + 13 files changed, 649 insertions(+), 506 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 4f4eb0fb4ce..e77a82d78e3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -249,6 +249,19 @@ sealed class DashSdkError( class IdentityRevisionMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) + /** + * `ErrorIdentityRevisionRefused` (native code 63). Drive refused an + * identity update asked for a revision, for its revision, and + * reported one below the cached identity's (a cache ahead of the + * chain). Drive charged a processing fee and took the nonce; nothing + * else changed, and the cache + * was left as it is; the revisions are in [message]. Not retryable + * as is: the next update may ask for the revision Drive reported plus + * one. Port of Swift `PlatformWalletError.identityRevisionRefused`. + */ + class IdentityRevisionRefused(message: String, cause: Throwable? = null) : + PlatformWallet(message, cause) + /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -778,6 +791,7 @@ sealed class DashSdkError( 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds 60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs 62 -> PlatformWallet.IdentityRevisionMismatch(message, cause) // ErrorIdentityRevisionMismatch + 63 -> PlatformWallet.IdentityRevisionRefused(message, cause) // ErrorIdentityRevisionRefused // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 0dd8129e303..6b779bc8c6f 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -217,8 +217,12 @@ class IdentityUpdates internal constructor( * before anything is signed or broadcast, so a caller that recorded * [revision] beforehand knows the only revision this update can land at * (Drive takes an identity update only at one above the identity's - * revision). `null` takes whatever the cache gives. Port of the - * `revision:` argument of Swift `ManagedPlatformWallet.updateIdentity`. + * revision). When Drive refuses the update for its revision and reports + * one below the cached identity's, it throws + * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionRefused]. + * `null` takes whatever the cache gives, and on such a refusal is sent + * once more at the revision Drive takes. Port of the `revision:` argument + * of Swift `ManagedPlatformWallet.updateIdentity`. */ suspend fun update( walletHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index d3ccbc34b28..3ba68214c8d 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -29,6 +29,16 @@ class DashSdkErrorTest { assertFalse(mapped.isRetryable) } + @Test + fun shouldPreserveIdentityRevisionRefusedAsItsOwnError() { + val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 63, "refused 7, Drive holds 5") + val mapped = DashSdkError.fromNative(native) + assertTrue(mapped is DashSdkError.PlatformWallet.IdentityRevisionRefused) + assertEquals(native.message, mapped.message) + assertEquals(native, mapped.cause) + assertFalse(mapped.isRetryable) + } + @Test fun mapsNativeCodesToHierarchy() { // Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 3f33d9e6bdf..717202f207b 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 63** — 27–62 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 64** — 27–63 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -124,8 +124,8 @@ identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed -by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 proposed by #5487 for -`ErrorIdentityRevisionMismatch`; 28, 30, +by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 and 63 proposed by #5487 for +`ErrorIdentityRevisionMismatch` and `ErrorIdentityRevisionRefused`; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -180,6 +180,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | | 62 | `ErrorIdentityRevisionMismatch` | #5487 | Proposed — 61 is held by open #5395 (`ErrorCoreFundsAwaitingNetwork`), so this takes 62 (#5395 records its own row). An identity update asked for a revision (`update_identity_with_external_signer_at_revision`) the cached identity would not give it; refused before signing, nothing broadcast, the expected and actual revisions in the message. Not retryable as is: the host refreshes and decides again. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `identityRevisionMismatch` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.IdentityRevisionMismatch` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin | +| 63 | `ErrorIdentityRevisionRefused` | #5487 | Proposed — Drive refused an identity update asked for a revision (`update_identity_with_external_signer_at_revision`) for its revision and reported one below the cached identity's: the cache is ahead of the chain. Drive charged a processing fee and took the nonce; nothing else changed, and the cache was left as it is; the revisions are in the message. Not retryable as is: read the revision Drive takes from `platform_wallet_identity_next_update_revision` and update again asking for it. Mirrored in Swift (`errorIdentityRevisionRefused = 63`, `init(ffi:)` arm, typed `PlatformWalletError.identityRevisionRefused`, pinned by `ErrorHandlingTests.testIdentityRevisionRefusedFFIResultMapping`) and Kotlin (`63 ->` arm to `DashSdkError.PlatformWallet.IdentityRevisionRefused`, pinned by `DashSdkErrorTest.shouldPreserveIdentityRevisionRefusedAsItsOwnError`); Rust pins it in `should_preserve_identity_revision_refused_code_and_message` | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 9ba8b38384d..58d20af726c 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -622,6 +622,16 @@ pub enum PlatformWalletFFIResultCode { /// are in the message. ErrorIdentityRevisionMismatch = 62, + /// Maps `PlatformWalletError::IdentityRevisionRefused`: Drive refused an + /// identity update asked to carry a revision, for its revision, and + /// reported one below the cached identity's (a cache ahead of the + /// chain). Drive charged a processing fee and took the nonce; nothing else + /// changed, and the cache was + /// left as it is. Read the revision Drive takes from + /// `platform_wallet_identity_next_update_revision`, record it, and + /// update again asking for it. The revisions are in the message. + ErrorIdentityRevisionRefused = 63, + /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1039,6 +1049,9 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::IdentityRevisionMismatch { .. } => { PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch } + PlatformWalletError::IdentityRevisionRefused { .. } => { + PlatformWalletFFIResultCode::ErrorIdentityRevisionRefused + } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. @@ -1700,6 +1713,24 @@ mod tests { assert_eq!(message_of(&result), rendered); } + #[test] + fn should_preserve_identity_revision_refused_code_and_message() { + let error = PlatformWalletError::IdentityRevisionRefused { + identity_id: Identifier::from([7; 32]), + refused: 7, + drive_revision: 5, + }; + let rendered = error.to_string(); + let result: PlatformWalletFFIResult = error.into(); + + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorIdentityRevisionRefused + ); + assert_eq!(result.code as i32, 63, "the host ABI code must stay stable"); + assert_eq!(message_of(&result), rendered); + } + #[test] fn should_preserve_shielded_identity_debit_pending_code_and_message() { let error = PlatformWalletError::ShieldedIdentityDebitPending { diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index e22e7591ac4..cd92c7355aa 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -348,7 +348,11 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( /// update must carry. When the cached identity would give the transition /// another revision (it moved since the caller read it), the call fails /// before anything is signed or broadcast, so a caller that recorded -/// `revision` knows the only revision this update can land at. +/// `revision` knows the only revision this update can land at. Read the +/// revision to record from [`platform_wallet_identity_next_update_revision`]. +/// When Drive refuses the update for its revision and reports one below the +/// cached identity's, the call fails with `ErrorIdentityRevisionRefused` +/// (63) and the next call to that reader gives the revision Drive takes. /// /// # Safety /// Same pointer, length and lifetime rules as @@ -377,6 +381,36 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision ) } +/// The revision the next update of `identity_id` carries, into +/// `out_revision`: one above the cached identity's, or, after +/// Drive refused the last update for its revision below the cache's +/// (`ErrorIdentityRevisionRefused` for a pinned update), one above the +/// revision Drive reported while the cache is still where it was then. Record it before +/// submitting and pass it to +/// [`platform_wallet_update_identity_with_signer_at_revision`]. +/// +/// # Safety +/// `identity_id` must point to 32 readable bytes and `out_revision` to a +/// writable `u64`, both for the duration of the call. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_identity_next_update_revision( + wallet_handle: Handle, + identity_id: *const u8, + out_revision: *mut u64, +) -> PlatformWalletFFIResult { + check_ptr!(out_revision); + *out_revision = 0; + let id = unwrap_result_or_return!(read_identifier(identity_id)); + + let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { identity_wallet.next_update_revision(&id).await }) + }); + let result = unwrap_option_or_return!(option); + *out_revision = unwrap_result_or_return!(result); + PlatformWalletFFIResult::ok() +} + #[allow(clippy::too_many_arguments)] unsafe fn update_identity_with_signer( wallet_handle: Handle, @@ -572,6 +606,44 @@ mod tests { }) } + /// The reader gives one above the cached revision for an identity the + /// wallet holds, and refuses a null out-pointer and an unknown identity. + #[test] + fn should_read_the_next_update_revision_through_the_export() { + let identity_id = Identifier::from([0x22; 32]); + let id_bytes = identity_id.to_buffer(); + let (_manager, handle) = wallet_handle_at_revision(identity_id, 5); + + let mut revision = u64::MAX; + let result = unsafe { + platform_wallet_identity_next_update_revision(handle, id_bytes.as_ptr(), &mut revision) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::Success, + "{}", + message_of(&result) + ); + assert_eq!(revision, 6); + + let result = unsafe { + platform_wallet_identity_next_update_revision( + handle, + id_bytes.as_ptr(), + ptr::null_mut(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + + let unknown = Identifier::from([0x23; 32]).to_buffer(); + let mut revision = u64::MAX; + let result = unsafe { + platform_wallet_identity_next_update_revision(handle, unknown.as_ptr(), &mut revision) + }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(revision, 0); + } + /// The `_at_revision` export hands its revision to the wallet's guard: a /// revision the cached identity would not give the transition comes back /// as `ErrorIdentityRevisionMismatch` (62) before the signer is diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7a9a371bdf8..fd31281cc64 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -100,6 +100,22 @@ pub enum PlatformWalletError { actual: u64, }, + /// Drive refused an identity update asked to carry `refused` for its + /// revision, and reported `drive_revision`, below the cached identity's: + /// the cache is ahead of the chain. Drive charged a processing fee and + /// took the nonce; nothing else changed. The cache is left as it is; the next update may be asked to + /// carry `drive_revision + 1` + /// (`IdentityWallet::next_update_revision` gives it). + #[error( + "identity {identity_id}: Drive refused the update at revision {refused} and holds \ + revision {drive_revision}, below the cached identity's" + )] + IdentityRevisionRefused { + identity_id: Identifier, + refused: u64, + drive_revision: u64, + }, + /// A DashPay invitation link's voucher key is for the other network (a /// testnet link opened in a mainnet wallet, or the reverse). Definitive: /// the claim refuses the link before any network work. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index ee130a68c5c..e4c477bc5bd 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -5,7 +5,9 @@ use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; @@ -22,7 +24,6 @@ use dash_sdk::platform::transition::put_settings::PutSettings; use crate::changeset::KeyWithBreadcrumb; use crate::error::{as_identity_revision_invalid, PlatformWalletError}; -use crate::BlockTime; use super::signing_key::AvailableSigningKey; use super::*; @@ -91,13 +92,15 @@ impl IdentityWallet { /// back: a fetched identity at a lower revision than the cache's keeps /// the cache's revision, key set and every disable (see /// `ManagedIdentity::take_fetched_identity`). When Drive refuses the - /// update for its revision and a proved fetch shows that lower revision - /// with every key and disable the cache holds, the cache takes the - /// fetched identity (see `take_rejected_revision`), so the next update - /// carries the right one. An update that disables a key the cache holds - /// disabled is refused before anything is signed. When the call fails - /// after the broadcast (the proof wait timed out, say), the update may - /// have landed without this step: refresh before the next update. + /// update for its revision and reports one below the cache's (a cache an + /// older build left ahead of the chain, which no fetch heals since each + /// comes back older), the update is sent once more at the revision Drive + /// takes; the cache is not lowered on that unproven word, and the + /// landed update brings the chain to the cache's revision (shipped builds + /// leave a cache at most one past the chain, since every later update is + /// refused). Drive charges for each refused update. When the call + /// fails after the broadcast (the proof wait timed out, say), the update + /// may have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -124,15 +127,23 @@ impl IdentityWallet { /// the transition must carry. /// /// The transition takes the cached identity's revision plus one, read - /// when the update starts. A caller that records the revision before + /// when the update starts (or, asked for it, one above the revision + /// Drive reported when it refused the last update below the cache). A caller that records the revision before /// submitting (to tell later whether the update can still land: Drive /// takes an identity update only at one above the identity's revision) /// cannot see that read, and a refresh in between moves it. With /// `revision` set, an update that would carry another one fails with /// [`PlatformWalletError::IdentityRevisionMismatch`] before anything is /// signed or broadcast, so the revision the caller recorded is the only - /// one this update can land at. `None` takes whatever the cache gives, - /// as [`Self::update_identity_with_external_signer`] does. + /// one this update can land at. Read the revision to record from + /// [`Self::next_update_revision`]. When Drive refuses the update for its + /// revision and reports one below the cache's, the update is not sent + /// again at another revision: it fails with + /// [`PlatformWalletError::IdentityRevisionRefused`], and + /// [`Self::next_update_revision`] then gives the revision Drive takes, + /// which the next update may be asked to carry. `None` takes whatever + /// the cache gives, as [`Self::update_identity_with_external_signer`] + /// does, and is sent once more on such a refusal. pub async fn update_identity_with_external_signer_at_revision( &self, identity_id: &Identifier, @@ -154,7 +165,7 @@ impl IdentityWallet { // for the derivation breadcrumb on the post-broadcast apply // pass below). Read lock only — the broadcast itself doesn't // touch local state. - let (mut identity, identity_index) = { + let (mut identity, identity_index, refused_at) = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -174,13 +185,15 @@ impl IdentityWallet { // identity_index, key_index)` derivation metadata for // the iOS Keychain re-derivation path. let index = manager.identity_index(identity_id); - (identity, index) + let refused_at = manager + .identity(identity_id) + .and_then(|m| m.revision_refused_at); + (identity, index, refused_at) }; - // Increment revision for the update transition. - let transition_revision = update_revision(identity_id, identity.revision(), revision)?; - identity.set_revision(transition_revision); - refuse_disabled_again(identity_id, &identity, &disable_public_keys)?; + let built_from = identity.revision(); + let mut transition_revision = + update_revision(identity_id, built_from, revision, refused_at)?; // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. @@ -199,58 +212,98 @@ impl IdentityWallet { ) })?; - let identity_nonce = self - .sdk - .get_identity_nonce(identity.id(), true, settings) - .await?; - let user_fee_increase = settings .and_then(|s| s.user_fee_increase) .unwrap_or_default(); - // `try_from_identity_with_signer` consumes the keys vec, so - // clone before handing it off — we need the originals to - // apply locally after the broadcast succeeds. - let added_keys_for_local_apply = add_public_keys.clone(); - let disabled_ids_for_local_apply = disable_public_keys.clone(); - - let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( - &identity, - &master_key_id, - add_public_keys, - disable_public_keys, - identity_nonce, - user_fee_increase, - &SignerRef(signer), - self.sdk.version(), - None, - ) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to create identity update transition: {}", - e - )) - })?; - - if let Err(e) = state_transition - .broadcast_and_wait::(&self.sdk, settings) + // Drive refuses an update whose revision is not one above the + // identity's, and says which revision it holds. When that is below + // the cache (a cache an older build left ahead of the chain), an + // update asked to carry no revision is sent once more at the one + // Drive takes; the cache itself is never lowered on that unproven + // word, so a node that lies costs one more paid refusal. One that + // hides a disable-only update still in the mempool behind a false + // refusal can get both to land (Drive takes a disable of a key + // already disabled and only re-stamps it): one more fee, and a cache + // one revision behind until the next fetch. An update that adds a + // key cannot land twice: Drive refuses its key id the second time. + let mut retried = false; + loop { + identity.set_revision(transition_revision); + let identity_nonce = self + .sdk + .get_identity_nonce(identity.id(), true, settings) + .await?; + + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + add_public_keys.clone(), + disable_public_keys.clone(), + identity_nonce, + user_fee_increase, + &SignerRef(signer), + self.sdk.version(), + None, + ) .await - { - self.take_rejected_revision(identity_id, transition_revision, &e, settings) + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to create identity update transition: {}", + e + )) + })?; + + let Err(e) = state_transition + .broadcast_and_wait::(&self.sdk, settings) + .await + else { + break; + }; + let refused = revision_refusal(identity_id, &e); + if let Some(drive) = refused { + // Drive's word on the revision, or that the cache is not + // ahead of it after all, replaces the last one. + self.note_refused_revision( + identity_id, + built_from, + Some(drive).filter(|drive| *drive < built_from), + ) .await; - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to broadcast identity update: {}", - e - ))); + } + match after_refusal( + refused, + built_from, + transition_revision, + revision.is_some(), + retried, + ) { + AfterRefusal::Retry(next) => { + retried = true; + transition_revision = next; + } + AfterRefusal::Refused(drive_revision) => { + return Err(PlatformWalletError::IdentityRevisionRefused { + identity_id: *identity_id, + refused: transition_revision, + drive_revision, + }); + } + AfterRefusal::Fail => { + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to broadcast identity update: {}", + e + ))); + } + } } self.apply_landed_update( identity_id, identity_index, transition_revision, - added_keys_for_local_apply, - disabled_ids_for_local_apply, + add_public_keys, + disable_public_keys, ) .await } @@ -287,7 +340,12 @@ impl IdentityWallet { /// fallible write: an added key with the breadcrumb the update gives it /// (a refresh stores its rows without one), a disabled key without one /// (the persisters keep what the row holds). The snapshot is stored even - /// when no key matched. + /// when no key matched. A cache an older build left ahead of the chain + /// is past the transition without holding it (the update was sent at + /// the revision Drive takes): an added key it lacks takes the update's + /// copy, and a disabled key it holds enabled is disabled with the local + /// placeholder time. A cache that holds the update already shows both. + /// Any note of a refused revision goes: Drive took this one. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -305,6 +363,9 @@ impl IdentityWallet { let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { return Ok(()); }; + // Landed: Drive took this revision, so a refusal noted before it + // says nothing any more. + managed.revision_refused_at = None; if managed.identity.revision() >= transition_revision { let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); @@ -325,9 +386,16 @@ impl IdentityWallet { .iter() .filter(|id| !added_ids.contains(id)) .filter_map(|id| managed.identity.public_keys().get(id).cloned()) - .map(|key| KeyWithBreadcrumb { - key, - breadcrumb: None, + .map(|mut key| { + // A cache an older build left ahead of the chain + // does not hold the update it just landed. + if !key.is_disabled() { + key.set_disabled_at(local_disable_time()); + } + KeyWithBreadcrumb { + key, + breadcrumb: None, + } }), ); return managed @@ -365,106 +433,21 @@ impl IdentityWallet { // time. `disable_keys` rebuilds the derivation breadcrumb // from the managed identity's own wallet slot, so the // disabled key keeps its private-key linkage. - let disabled_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); - managed.disable_keys(&disabled_ids, disabled_at, &self.persister); + managed.disable_keys(&disabled_ids, local_disable_time(), &self.persister); } Ok(()) } - /// Take a proved identity at the revision Drive refused an update for. - /// - /// Drive takes an identity update only at one above the identity's - /// revision and otherwise refuses it in the block with - /// `InvalidIdentityRevisionError`, which carries the revision it holds. - /// A cache ahead of that value cannot heal from a fetch, which comes back - /// older and keeps the cache's revision, so every update would be - /// refused again. A build before this one could leave a cache one past - /// the chain (a fetch answered between an update's snapshot and its - /// bump, which then counted twice). - /// - /// The refusal comes from one node without a proof, so it only prompts a - /// proved fetch of the identity. The cache takes the fetched identity - /// whole, keys included, and stores it as a refresh does, when the fetch - /// shows the refusal's revision, below the cache's, its proof is from a - /// block above the cache's verified-height watermark - /// (`last_updated_balance_block_time`, at or above the height of every - /// fetch the cache took), and it holds every key the cache holds, with - /// the same data and disabled state. A revision the cache learned from a fetch therefore - /// cannot be undone by a proof from before that fetch. A revision it - /// learned from an update it landed (`apply_landed_update`, which moves no - /// watermark) cannot either: this wallet sends no update that only - /// disables a key already disabled (`refuse_disabled_again`), so each one - /// adds a key or disables an enabled one, which a proof from before it - /// lacks. A cache only ahead by its revision number holds the fetched - /// keys, and the refused update's own block puts a fresh proof above the - /// watermark; a proof at the watermark's height is refused as well, which - /// costs at most one more refusal. Left open, until the next fetch at the - /// chain's revision: a revision an older build confirmed by a local apply - /// of a re-disable; a landed revision stored while its key rows were not - /// (a failed store); and a cache restored without a watermark (iOS rows - /// stored before the balance sidecar). - /// Anything else leaves the cache as it is: the refusal alone, a fetch - /// that fails, shows another revision or lacks a cached key or disable, - /// a cache at the refusal's revision or below (raising it would take no - /// keys), or a cache that moved after the refused transition was built - /// (`transition_revision - 1`), since a fetch or another update that - /// moved it knows more. A snapshot at a higher revision still buffered - /// in the store wins the merge over this one, so a restart before it is - /// written can bring the old revision back, to be healed by the next - /// refusal. A failed store is logged; the caller gets the refusal either - /// way. - async fn take_rejected_revision( + /// Record Drive's `drive_revision` from a refusal of an update built on + /// the cached revision `built_from`, or clear it (`None`: Drive holds + /// the cache's revision or above), while the cache is still there. + async fn note_refused_revision( &self, identity_id: &Identifier, - transition_revision: u64, - error: &dash_sdk::Error, - settings: Option, + built_from: u64, + drive_revision: Option, ) { - use dash_sdk::platform::Fetch; - - let Some(rejection) = as_identity_revision_invalid(error) else { - return; - }; - if rejection.identity_id() != identity_id { - return; - } - let current = *rejection.current_revision(); - let built_from = transition_revision.saturating_sub(1); - if built_from <= current { - return; - } - let (fetched, block_time) = match Identity::fetch_with_metadata( - &self.sdk, - *identity_id, - settings.map(|s| s.request_settings), - ) - .await - { - Ok((Some(fetched), metadata)) if fetched.revision() == current => { - (fetched, BlockTime::from(metadata)) - } - Ok((fetched, _)) => { - tracing::debug!( - identity = %identity_id, - revision = current, - proved = ?fetched.map(|identity| identity.revision()), - "proof does not back Drive's refusal of an update for its revision" - ); - return; - } - Err(e) => { - tracing::warn!( - identity = %identity_id, - revision = current, - "identity not fetched after Drive refused an update for its revision: {e}" - ); - return; - } - }; let mut wm = self.wallet_manager.write().await; let Some(managed) = wm .get_wallet_info_mut(&self.wallet_id) @@ -472,27 +455,40 @@ impl IdentityWallet { else { return; }; - let (_, watermark) = managed.balance_snapshot_for_persistence(); - if managed.identity.revision() != built_from - || watermark.is_some_and(|watermark| block_time.height <= watermark.height) - || !holds_every_key(&fetched, &managed.identity) - { - tracing::debug!( - identity = %identity_id, - revision = current, - proved_at = block_time.height, - "cache moved, the proof is not above the cache's, or it lacks a key the cache \ - holds; Drive's refusal not taken" - ); - return; + if managed.identity.revision() == built_from { + managed.revision_refused_at = drive_revision.map(|drive| (built_from, drive)); } - managed.identity.set_revision(current); - if let Err(e) = managed.store_fetched_identity(&fetched, block_time, &self.persister) { - tracing::error!( - identity = %identity_id, - revision = current, - "identity proved at Drive's revision not persisted: {e}" - ); + } + + /// The revision the next update of `identity_id` carries: one above the + /// cached identity's, or one above the revision Drive reported when it + /// refused the last update for its revision while the cache is still + /// where it was then ([`PlatformWalletError::IdentityRevisionRefused`]). + /// A caller that records the revision before submitting reads it here + /// and passes it to [`Self::update_identity_with_external_signer_at_revision`]. + /// After such a refusal the cached revision is at or above the one + /// returned while the chain is below it, so settle a record of an update + /// sent at it against a proved revision (a fetch whose answer is at or + /// above the record), not against the cache. + pub async fn next_update_revision( + &self, + identity_id: &Identifier, + ) -> Result { + let wm = self.wallet_manager.read().await; + let managed = wm + .get_wallet_info(&self.wallet_id) + .ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })? + .identity_manager + .identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + let cached = managed.identity.revision(); + match managed.revision_refused_at { + Some((at, drive)) if at == cached => Ok(drive + 1), + _ => update_revision(identity_id, cached, None, None), } } @@ -563,48 +559,15 @@ impl IdentityWallet { } } -/// Refuse an update that disables a key the identity holds disabled. -/// -/// Drive takes one, but it changes no key: it only re-stamps `disabled_at` -/// and moves the revision. Every update this wallet lands then adds a key or -/// disables an enabled one, which `take_rejected_revision` relies on to tell -/// a proof from before a landed update. -fn refuse_disabled_again( - identity_id: &Identifier, - identity: &Identity, - disable_public_keys: &[u32], -) -> Result<(), PlatformWalletError> { - match disable_public_keys.iter().find(|id| { - identity - .public_keys() - .get(id) - .is_some_and(|key| key.is_disabled()) - }) { - Some(key_id) => Err(PlatformWalletError::InvalidIdentityData(format!( - "identity {identity_id}: key {key_id} is already disabled; nothing was signed" - ))), - None => Ok(()), - } -} - -/// Whether `fetched` holds every key `cached` holds, with the same data and -/// disabled state. -fn holds_every_key(fetched: &Identity, cached: &Identity) -> bool { - cached.public_keys().iter().all(|(key_id, cached_key)| { - fetched.public_keys().get(key_id).is_some_and(|key| { - key.key_type() == cached_key.key_type() - && key.data() == cached_key.data() - && key.is_disabled() == cached_key.is_disabled() - }) - }) -} - /// The revision an update of an identity cached at `cached` carries: one -/// above it. With `expected` set, any other value is refused. +/// above it. With `expected` set, any other value is refused, except one +/// above the revision Drive reported when it refused the last update below +/// the cache, while the cache is still at `cached` (`refused_at`). fn update_revision( identity_id: &Identifier, cached: u64, expected: Option, + refused_at: Option<(u64, u64)>, ) -> Result { let next = cached.checked_add(1).ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( @@ -612,15 +575,80 @@ fn update_revision( )) })?; match expected { - Some(expected) if expected != next => Err(PlatformWalletError::IdentityRevisionMismatch { + Some(expected) if expected == next => Ok(next), + // Drive refused the last update and reported a revision below the + // cache's, which is still where it was: the caller asks for the one + // Drive takes (`next_update_revision`). + Some(expected) + if refused_at.is_some_and(|(at, drive)| at == cached && drive + 1 == expected) => + { + Ok(expected) + } + Some(expected) => Err(PlatformWalletError::IdentityRevisionMismatch { identity_id: *identity_id, expected, actual: next, }), - _ => Ok(next), + None => Ok(next), } } +/// What an update does after a failed broadcast. +#[derive(Debug, PartialEq, Eq)] +enum AfterRefusal { + /// Send the update once more, at this revision. + Retry(u64), + /// Fail with `IdentityRevisionRefused`, Drive at this revision. + Refused(u64), + /// Fail with the broadcast's error. + Fail, +} + +/// The step after a broadcast of an update built on the cached revision +/// `built_from` at `transition` failed, `refused` being Drive's revision +/// when it refused the update for its revision. Only a refusal below the +/// cache (the cache is ahead of the chain) and not already at the revision +/// Drive takes leads anywhere: a pinned update fails with it, an update +/// asked for no revision is sent once more at the one Drive takes. +fn after_refusal( + refused: Option, + built_from: u64, + transition: u64, + pinned: bool, + retried: bool, +) -> AfterRefusal { + let Some(drive) = refused + .filter(|drive| *drive < built_from) + .filter(|drive| drive + 1 != transition) + else { + return AfterRefusal::Fail; + }; + if pinned { + AfterRefusal::Refused(drive) + } else if retried { + AfterRefusal::Fail + } else { + AfterRefusal::Retry(drive + 1) + } +} + +/// Drive's revision from its refusal of an update of `identity_id` for its +/// revision (`InvalidIdentityRevisionError`, unproven). +fn revision_refusal(identity_id: &Identifier, error: &dash_sdk::Error) -> Option { + as_identity_revision_invalid(error) + .filter(|rejection| rejection.identity_id() == identity_id) + .map(|rejection| *rejection.current_revision()) +} + +/// The local placeholder for a disable's `disabled_at`; the next fetch at +/// the update's revision or above takes the on-chain block time. +fn local_disable_time() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default() +} + #[cfg(test)] mod update_revision_tests { use super::*; @@ -628,31 +656,123 @@ mod update_revision_tests { #[test] fn should_carry_one_above_the_cached_revision() { let id = Identifier::from([7u8; 32]); - assert_eq!(update_revision(&id, 4, None).unwrap(), 5); - assert_eq!(update_revision(&id, 4, Some(5)).unwrap(), 5); + assert_eq!(update_revision(&id, 4, None, None).unwrap(), 5); + assert_eq!(update_revision(&id, 4, Some(5), None).unwrap(), 5); } #[test] fn should_refuse_an_update_asked_for_another_revision() { let id = Identifier::from([7u8; 32]); // The cache moved past the revision the caller read. - match update_revision(&id, 5, Some(5)) { + match update_revision(&id, 5, Some(5), None) { Err(PlatformWalletError::IdentityRevisionMismatch { expected, actual, .. }) => assert_eq!((expected, actual), (5, 6)), other => panic!("unexpected: {other:?}"), } assert!(matches!( - update_revision(&id, 3, Some(5)), + update_revision(&id, 3, Some(5), None), Err(PlatformWalletError::IdentityRevisionMismatch { .. }) )); } + #[test] + fn should_take_drives_revision_asked_for_only_while_the_cache_is_where_it_was_refused() { + let id = Identifier::default(); + // Cache at 6, Drive refused and holds 4: the caller may ask for 5. + assert_eq!(update_revision(&id, 6, Some(5), Some((6, 4))).unwrap(), 5); + // One above the cache still goes. + assert_eq!(update_revision(&id, 6, Some(7), Some((6, 4))).unwrap(), 7); + // The cache moved since the refusal, or the caller asks for another. + for (cached, expected, refused_at) in [(7, 5, (6, 4)), (6, 4, (6, 4)), (6, 5, (5, 4))] { + assert!( + matches!( + update_revision(&id, cached, Some(expected), Some(refused_at)), + Err(PlatformWalletError::IdentityRevisionMismatch { .. }) + ), + "cached {cached}, asked {expected}, refused at {refused_at:?}" + ); + } + // An update asked for no revision carries one above the cache. + assert_eq!(update_revision(&id, 6, None, Some((6, 4))).unwrap(), 7); + } + + #[test] + fn should_send_once_more_at_drives_revision_only_when_the_cache_is_ahead() { + // Cache at 6, update at 7, Drive holds 5: sent once more at 6, or, + // pinned, refused with 5. + assert_eq!( + after_refusal(Some(5), 6, 7, false, false), + AfterRefusal::Retry(6) + ); + assert_eq!( + after_refusal(Some(5), 6, 7, true, false), + AfterRefusal::Refused(5) + ); + // The second send is refused too: no third. + assert_eq!( + after_refusal(Some(4), 6, 6, false, true), + AfterRefusal::Fail + ); + // Refused at the revision Drive reported: nothing more to try. + assert_eq!( + after_refusal(Some(5), 6, 6, false, true), + AfterRefusal::Fail + ); + assert_eq!( + after_refusal(Some(5), 6, 6, true, false), + AfterRefusal::Fail + ); + // Drive at the cache's revision or above (the cache is behind; a + // refresh heals it), or another failure. + for refused in [Some(6), Some(9), None] { + assert_eq!( + after_refusal(refused, 6, 7, false, false), + AfterRefusal::Fail + ); + assert_eq!( + after_refusal(refused, 6, 7, true, false), + AfterRefusal::Fail + ); + } + } + + #[test] + fn should_read_drives_revision_only_from_a_refusal_of_this_identity() { + use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; + use dpp::consensus::ConsensusError; + + let id = Identifier::default(); + let refusal = |identity_id, current| -> ConsensusError { + InvalidIdentityRevisionError::new(identity_id, current).into() + }; + let wait_stream = dash_sdk::Error::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 40203, + message: String::new(), + cause: Some(refusal(id, 4)), + }, + ); + let bare = + dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal(id, 4)))); + assert_eq!(revision_refusal(&id, &wait_stream), Some(4)); + assert_eq!(revision_refusal(&id, &bare), Some(4)); + let other = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal( + Identifier::from([7; 32]), + 4, + )))); + assert_eq!(revision_refusal(&id, &other), None); + assert_eq!( + revision_refusal(&id, &dash_sdk::Error::Generic("timeout".to_string())), + None + ); + } + #[test] fn should_refuse_an_identity_at_the_last_revision() { let id = Identifier::from([7u8; 32]); assert!(matches!( - update_revision(&id, u64::MAX, None), + update_revision(&id, u64::MAX, None, None), Err(PlatformWalletError::InvalidIdentityData(_)) )); } @@ -664,7 +784,7 @@ mod update_identity_at_revision_tests { use super::*; use crate::changeset::PlatformWalletPersistence; use crate::wallet::persister::NoPlatformPersistence; - use crate::PlatformWallet; + use crate::{BlockTime, PlatformWallet}; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -848,21 +968,24 @@ mod update_identity_at_revision_tests { assert_eq!(identity.revision(), 5); assert!(identity.public_keys()[&1].disabled_at().is_some()); - // A cache refreshed to 5 or past it already holds the update and - // what came after, and is left as it is: one past the chain would - // get the next update refused. + // A cache refreshed to 5 or past it already holds the update (key 3 + // added, key 1 disabled for good) and what came after, and is left + // as it is: one past the chain would get the next update refused. for cached in [5, 6] { let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); { let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) + let managed = wm + .get_wallet_info_mut(&wallet.wallet_id) .unwrap() .identity_manager .managed_identity_mut(&identity_id) - .unwrap() - .identity - .add_public_key(added.clone()); + .unwrap(); + managed.identity.add_public_key(added.clone()); + let mut key_1 = managed.identity.public_keys()[&1].clone(); + key_1.set_disabled_at(42); + managed.identity.add_public_key(key_1); } let before = cached_identity(wallet, &identity_id).await; wallet @@ -978,266 +1101,64 @@ mod update_identity_at_revision_tests { assert!(changesets[0].identities.is_some()); } - /// Drive's refusal of an update for an identity it holds at `current`, - /// in the shape `shape` picks: the wait-stream envelope Drive answers - /// with, or a bare consensus error, matched as well. - fn revision_refusal(identity_id: Identifier, current: u64, shape: usize) -> dash_sdk::Error { - use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; - use dpp::consensus::ConsensusError; - - let cause: ConsensusError = InvalidIdentityRevisionError::new(identity_id, current).into(); - match shape { - 0 => dash_sdk::Error::StateTransitionBroadcastError( - dash_sdk::error::StateTransitionBroadcastError { - code: 40203, - message: cause.to_string(), - cause: Some(cause), - }, - ), - _ => dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(cause))), - } - } - - /// The mock node answers a fetch of the identity with `answer`, proved. - async fn answer_fetch(wallet: &IdentityWallet, answer: Identity) { - let mut sdk = (*wallet.sdk).clone(); - sdk.mock() - .expect_fetch(Identifier::default(), Some(answer)) - .await - .unwrap(); - } - - /// The cache's identity at `revision`, as a node proves it. - async fn proved_at(wallet: &IdentityWallet, revision: u64) -> Identity { - let mut identity = cached_identity(wallet, &Identifier::default()).await; - identity.set_revision(revision); - identity - } - - fn no_retries() -> Option { - Some(PutSettings { - request_settings: dash_sdk::RequestSettings { - retries: Some(0), - ..Default::default() - }, - ..Default::default() - }) - } - - /// A cache one past the chain (6 over Drive's 5, which a build before - /// this one could leave) is refused on every update, and no fetch heals - /// it: each comes back older. Drive's refusal carries 5, a proved fetch - /// shows the identity at 5 with every key the cache holds and one it - /// lacks, so the cache takes it whole and stores it, and the next update carries 6 and gets past - /// the revision check. - #[tokio::test] - async fn should_take_a_proved_identity_at_the_revision_drive_refused_an_update_for() { - let identity_id = Identifier::default(); - for shape in [0, 1] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(6, store.clone()).await; - let wallet = wallet.identity(); - // The proof also holds a key the cache lacks (added on another - // device), so taking it whole shows in the key set. - let mut chain = proved_at(wallet, 5).await; - chain.add_public_key(key_3()); - answer_fetch(wallet, chain.clone()).await; - let stuck = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(6), - &RecordingSigner::default(), - no_retries(), - ) - .await - .unwrap_err(); - assert!( - matches!(stuck, PlatformWalletError::IdentityRevisionMismatch { .. }), - "{stuck:?}" - ); - store.take(); - - wallet - .take_rejected_revision( - &identity_id, - 7, - &revision_refusal(identity_id, 5, shape), - no_retries(), - ) - .await; - let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), 5, "shape {shape}"); - assert_eq!(identity.public_keys(), chain.public_keys()); - let stored = store - .take() - .iter() - .filter_map(|cs| cs.identities.as_ref()) - .find_map(|cs| cs.identities.get(&identity_id).cloned()) - .expect("the scalar snapshot"); - assert_eq!(stored.revision, 5); - - let signer = RecordingSigner::default(); - let error = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(6), - &signer, - no_retries(), - ) - .await - .unwrap_err(); - assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); - assert!(signer.0.load(Ordering::SeqCst) > 0); - } + async fn refused_at(wallet: &IdentityWallet) -> Option<(u64, u64)> { + let wm = wallet.wallet_manager.read().await; + wm.get_wallet_info(&wallet.wallet_id) + .unwrap() + .identity_manager + .identity(&Identifier::default()) + .unwrap() + .revision_refused_at } - /// A cache confirmed at 6, holding the update that landed there (key 3 - /// added, or key 1 disabled), and a node that refuses the next update - /// claiming 5 and proves the identity at 5, from before that update: the - /// proof lacks a key or a disable the cache holds, so the cache keeps 6 - /// and stores nothing. A lagging refresh answering that same identity at - /// 5 afterwards still keeps the update. + /// A cache at 6 that an older build left one past the chain at 5. The + /// update asked for 7 is refused and Drive says 5; that is noted, the + /// cache is not lowered, and the next update may be asked for 6, which + /// gets past the revision check. Drive saying the cache is not ahead, or + /// a fetch the cache takes whole (one at its revision or above), clears + /// the note. #[tokio::test] - async fn should_keep_a_confirmed_update_when_a_refusal_claims_an_older_revision() { + async fn should_let_the_next_update_carry_drives_revision_without_lowering_the_cache() { let identity_id = Identifier::default(); - for landed in ["added key 3", "disabled key 1"] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(5, store.clone()).await; - let wallet = wallet.identity(); - let before_update = proved_at(wallet, 5).await; - let (added, disabled) = match landed { - "added key 3" => (vec![key_3()], vec![]), - _ => (vec![], vec![1]), - }; - wallet - .apply_landed_update(&identity_id, Some(0), 6, added, disabled) - .await - .unwrap(); - answer_fetch(wallet, before_update).await; - let confirmed = cached_identity(wallet, &identity_id).await; - assert_eq!(confirmed.revision(), 6); - store.take(); + let wallet = wallet_at_revision(6).await; + let wallet = wallet.identity(); + assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 7); - wallet - .take_rejected_revision( - &identity_id, - 7, - &revision_refusal(identity_id, 5, 0), - no_retries(), - ) - .await; - assert_eq!( - cached_identity(wallet, &identity_id).await, - confirmed, - "{landed}" - ); - assert!(store.take().is_empty(), "{landed}"); + // A refusal noted against another revision than the cache's is not. + wallet.note_refused_revision(&identity_id, 5, Some(4)).await; + assert_eq!(refused_at(wallet).await, None); - let refreshed = wallet.refresh_identity(&identity_id).await.unwrap(); - assert_eq!(refreshed.revision(), 6, "{landed}"); - assert_eq!(refreshed.public_keys(), confirmed.public_keys(), "{landed}"); - } - } + wallet.note_refused_revision(&identity_id, 6, Some(5)).await; + assert_eq!(cached_identity(wallet, &identity_id).await.revision(), 6); + assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 6); - /// The cache is left alone when the refusal does not show it ahead of a - /// proved identity: Drive at the cache's revision or above it (raising - /// the cache would take no keys), a refusal for another identity, - /// another error, a cache that moved after the refused transition was - /// built (a later update landed, or a fetch took a newer identity), a - /// proved identity at another revision than the refusal's, and a fetch - /// that fails (the mock answers nothing it was not told to). - #[tokio::test] - async fn should_keep_the_cached_revision_unless_a_proof_backs_the_refusal() { - let identity_id = Identifier::default(); - let other = Identifier::from([7; 32]); - for (cached, transition, error, proved) in [ - (5, 6, revision_refusal(identity_id, 5, 0), Some(5)), - (5, 6, revision_refusal(identity_id, 9, 1), Some(9)), - (5, 6, revision_refusal(other, 2, 0), Some(2)), - ( - 5, - 6, - dash_sdk::Error::Generic("timeout".to_string()), - Some(4), - ), - (7, 7, revision_refusal(identity_id, 5, 0), Some(5)), - (6, 7, revision_refusal(identity_id, 4, 1), Some(3)), - (6, 7, revision_refusal(identity_id, 4, 0), None), - ] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(cached, store.clone()).await; - let wallet = wallet.identity(); - if let Some(revision) = proved { - let answer = proved_at(wallet, revision).await; - answer_fetch(wallet, answer).await; - } - let before = cached_identity(wallet, &identity_id).await; - store.take(); - wallet - .take_rejected_revision(&identity_id, transition, &error, no_retries()) - .await; - assert_eq!( - cached_identity(wallet, &identity_id).await, - before, - "cached at {cached}: {error:?}" - ); - assert!(store.take().is_empty(), "cached at {cached}: {error:?}"); - } - } + let signer = RecordingSigner::default(); + let error = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(6), + &signer, + Some(PutSettings { + request_settings: dash_sdk::RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }), + ) + .await + .unwrap_err(); + assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); + assert!(signer.0.load(Ordering::SeqCst) > 0); - /// The proof backs the refusal in every other way, but is from a block - /// below the cache's verified-height watermark, where the cache may have - /// learned its revision, or at it, which the floor refuses as well. The - /// mock proves every answer at height 0. - #[tokio::test] - async fn should_keep_the_cache_when_the_proof_is_not_above_its_watermark() { - let identity_id = Identifier::default(); - for watermark in [0, 100] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(6, store.clone()).await; - let wallet = wallet.identity(); - let answer = proved_at(wallet, 5).await; - answer_fetch(wallet, answer).await; - { - let mut wm = wallet.wallet_manager.write().await; - wm.get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap() - .last_updated_balance_block_time = Some(BlockTime::new(watermark, 0, 0)); - } - let before = cached_identity(wallet, &identity_id).await; - store.take(); - wallet - .take_rejected_revision( - &identity_id, - 7, - &revision_refusal(identity_id, 5, 0), - no_retries(), - ) - .await; - assert_eq!( - cached_identity(wallet, &identity_id).await, - before, - "watermark at {watermark}" - ); - assert!(store.take().is_empty(), "watermark at {watermark}"); - } - } + // Drive says the cache is not ahead after all: the note goes. + wallet.note_refused_revision(&identity_id, 6, None).await; + assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 7); - /// Drive takes an update that disables a key already disabled, but it - /// changes no key; the wallet refuses it before signing or reaching - /// Platform, and the cache is unchanged. - #[tokio::test] - async fn should_refuse_to_disable_a_key_already_disabled_before_signing() { - let identity_id = Identifier::default(); - let wallet = wallet_at_revision(4).await; - let wallet = wallet.identity(); + // A fetch taken at the cache's revision or above clears it. + wallet.note_refused_revision(&identity_id, 6, Some(5)).await; { let mut wm = wallet.wallet_manager.write().await; let managed = wm @@ -1246,29 +1167,36 @@ mod update_identity_at_revision_tests { .identity_manager .managed_identity_mut(&identity_id) .unwrap(); - let mut key = managed.identity.public_keys()[&1].clone(); - key.set_disabled_at(7); - managed.identity.add_public_key(key); + let fetched = managed.identity.clone(); + assert!(managed.take_fetched_identity(&fetched, BlockTime::new(1, 0, 0))); } - let before = cached_identity(wallet, &identity_id).await; - let signer = RecordingSigner::default(); - let error = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![key_3()], - vec![1], - Some(5), - &signer, - None, - ) + assert_eq!(refused_at(wallet).await, None); + } + + /// The update asked for Drive's revision lands at 6 on a cache an older + /// build left at 6 without it: the cache is at the chain's revision now + /// and takes the update it lacks, key 3 added and key 1 disabled, and + /// the note of the refusal goes. + #[tokio::test] + async fn should_take_an_update_landed_at_drives_revision_into_a_cache_ahead_without_it() { + let identity_id = Identifier::default(); + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + wallet.note_refused_revision(&identity_id, 6, Some(5)).await; + store.take(); + wallet + .apply_landed_update(&identity_id, Some(0), 6, vec![key_3()], vec![1]) .await - .unwrap_err(); - assert!( - matches!(&error, PlatformWalletError::InvalidIdentityData(m) if m.contains("key 1 is already disabled")), - "{error:?}" - ); - assert_eq!(signer.0.load(Ordering::SeqCst), 0); - assert_eq!(cached_identity(wallet, &identity_id).await, before); + .unwrap(); + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 6); + assert_eq!(identity.public_keys().get(&3), Some(&key_3())); + assert!(identity.public_keys()[&1].is_disabled()); + assert_eq!(refused_at(wallet).await, None); + let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); + assert!(stored.public_key.is_disabled()); + assert!(store.stored_key(identity_id, 3).is_some()); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 6dd0e7a0110..650de105cd2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -88,6 +88,7 @@ impl ManagedIdentity { identity_index: Some(identity_index), last_updated_balance_block_time: None, pending_balance_snapshot: None, + revision_refused_at: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -109,6 +110,7 @@ impl ManagedIdentity { identity_index: None, last_updated_balance_block_time: None, pending_balance_snapshot: None, + revision_refused_at: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -419,6 +421,7 @@ impl ManagedIdentity { let taken = fetched.revision() >= self.identity.revision(); let mut keys = if taken { self.identity = fetched.clone(); + self.revision_refused_at = None; self.identity.set_balance(balance); fetched.public_keys().clone() } else { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 436dbaaabf6..12f840f48cb 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -58,6 +58,13 @@ pub struct ManagedIdentity { /// retry. Query results are published only after that retry commits. pending_balance_snapshot: Option<(u64, BlockTime)>, + /// The revision Drive reported when it refused an update of this + /// identity for its revision, below the cached one, with the cached + /// revision it was refused against: `(cached, drive)`. Memory only and + /// unproven: it lets the next update be asked to carry `drive + 1` + /// while the cache is still at `cached`, and changes nothing else. + pub(crate) revision_refused_at: Option<(u64, u64)>, + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index c541efdfc2f..a22021eaf4c 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3737,8 +3737,14 @@ extension ManagedPlatformWallet { /// whose message carries the expected and actual revisions), /// so a caller that recorded `revision` beforehand knows the only /// revision this update can land at: Drive takes an identity update - /// only at one above the identity's revision. `nil` takes whatever the - /// cache gives. + /// only at one above the identity's revision. Read the revision to + /// record from `nextIdentityUpdateRevision(identityId:)`. When Drive + /// refuses the update for its revision and reports one below the + /// cached identity's, the call throws + /// `PlatformWalletError.identityRevisionRefused` and the reader then + /// gives the revision Drive takes. `nil` takes whatever the cache + /// gives, and on such a refusal is sent once more at the revision Drive + /// takes. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], @@ -3791,6 +3797,26 @@ extension ManagedPlatformWallet { }.value } + /// The revision the next `updateIdentity` of `identityId` carries: one + /// above the cached identity's, or, after + /// `PlatformWalletError.identityRevisionRefused`, one above the revision + /// Drive reported while the cache is still where it was then. Record it + /// before submitting and pass it as `updateIdentity(…, revision:)`. + public func nextIdentityUpdateRevision(identityId: Identifier) async throws -> UInt64 { + let handle = self.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + return try await Task.detached(priority: .userInitiated) { + var revision: UInt64 = 0 + let result = idBytes.withUnsafeBufferPointer { idBp in + platform_wallet_identity_next_update_revision(handle, idBp.baseAddress!, &revision) + } + try result.check() + return revision + }.value + } + /// Raise the usage limits of one of an identity's keys (protocol /// version 14), signing the resulting `IdentityKeyLimitsUpdate` with /// the identity's MASTER key (or a CRITICAL authentication key that diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2ec67466028..b7d105a8785 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -267,6 +267,11 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// An identity update asked for a revision the cached identity would not /// give it; refused before signing, nothing was broadcast. case errorIdentityRevisionMismatch = 62 + /// Drive refused an identity update asked for a revision, for its + /// revision, and reported one below the cached identity's. Nothing + /// changed but a processing fee and the nonce; + /// `nextIdentityUpdateRevision` gives the one Drive takes. + case errorIdentityRevisionRefused = 63 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -400,6 +405,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockTooManyInputs case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH: self = .errorIdentityRevisionMismatch + case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_REFUSED: + self = .errorIdentityRevisionRefused case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -566,6 +573,14 @@ public enum PlatformWalletError: LocalizedError { /// the expected and actual revisions are in the message. Refresh the /// identity and decide again rather than retrying the same call. case identityRevisionMismatch(String) + /// `updateIdentity(…, revision:)` was refused by Drive for its revision, + /// and Drive reported one below the cached identity's (a cache ahead of + /// the chain). Drive charged a processing fee and took the nonce; nothing + /// else changed, and the cache was left as it is; the revisions are in + /// the message. Read the revision Drive takes from + /// `nextIdentityUpdateRevision(identityId:)`, record it, and update + /// again asking for it. + case identityRevisionRefused(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -810,6 +825,7 @@ public enum PlatformWalletError: LocalizedError { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), .identityNotFound(let m), .identityBalanceUnavailable(let m), .identityRevisionMismatch(let m), + .identityRevisionRefused(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), @@ -923,6 +939,7 @@ public enum PlatformWalletError: LocalizedError { case .errorIdentityNotFound: self = .identityNotFound(detail) case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) case .errorIdentityRevisionMismatch: self = .identityRevisionMismatch(detail) + case .errorIdentityRevisionRefused: self = .identityRevisionRefused(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 2b42a2fac11..f154b571c18 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -47,6 +47,20 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(error.errorDescription, "asked 5, would give 6") } + func testIdentityRevisionRefusedFFIResultMapping() { + XCTAssertEqual( + PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_REFUSED), + .errorIdentityRevisionRefused + ) + XCTAssertEqual(PlatformWalletResultCode.errorIdentityRevisionRefused.rawValue, 63) + let error = PlatformWalletError(code: .errorIdentityRevisionRefused, message: "refused 7, Drive holds 5") + guard case .identityRevisionRefused(let detail) = error else { + return XCTFail("the Drive revision refusal lost its typed case") + } + XCTAssertEqual(detail, "refused 7, Drive holds 5") + XCTAssertEqual(error.errorDescription, "refused 7, Drive holds 5") + } + func testAssetLockInsufficientFundsFFIResultMapping() { // The asset-lock coin-selection shortfall (dashpay/platform#4073). // Swift could not decode code 29 at all before this mirror existed — From d67e5a51bb8eda3b6ccf73e4d87c4409e11c9898 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Fri, 23 Oct 2026 21:33:04 +0300 Subject: [PATCH 63/72] fix(platform-wallet): re-send a refused update only once proofs show its nonce spent Drive's InvalidIdentityRevisionError comes without a proof, so its current_revision no longer decides anything. It only prompts two proved reads: the identity nonce and the identity revision. The update is sent once more (unpinned), or fails with IdentityRevisionRefused and is noted (pinned), only when the proved nonce word shows the refused transition's nonce spent by Drive's own merge rule (validate_identity_nonce_update), so that transition can never execute, and the proved revision is below the transition's by more than one. Had the refused transition executed after all, the chain is at its revision or above and Drive refuses the re-send, whose revision is not above it, so the update cannot land twice. A lagging proof costs one more paid refusal. The note keeps the highest proved revision and goes once a proof shows Drive at or above the cache's revision. A fetch at the cache's revision takes its key states only from a block at or above the key watermark (last_synced_keys_block_time), which a fetch taken whole now moves. A lagging proof at the same revision can no longer bring back a disable Core lifted on a masternode identity, or undo one it applied. apply_landed_update stores the landed revision and the update's keys as one snapshot write in both branches and returns a failed store, so a disable of a key the cache lacks no longer leaves the stored revision behind. The Swift nextIdentityUpdateRevision and updateIdentity keep the wallet alive through their detached FFI calls. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 15 +- .../dashsdk/identity/IdentityUpdates.kt | 7 +- .../ERROR_CODE_REGISTRY.md | 2 +- packages/rs-platform-wallet-ffi/src/error.rs | 9 +- .../src/identity_update.rs | 13 +- packages/rs-platform-wallet/src/error.rs | 8 +- .../src/wallet/identity/network/update.rs | 605 ++++++++++++------ .../state/managed_identity/identity_ops.rs | 204 ++++-- .../identity/state/managed_identity/mod.rs | 15 +- .../identity/state/managed_identity/sync.rs | 8 +- .../ManagedPlatformWallet.swift | 42 +- .../PlatformWallet/PlatformWalletResult.swift | 13 +- 12 files changed, 636 insertions(+), 305 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index e77a82d78e3..719caa2a256 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -251,13 +251,14 @@ sealed class DashSdkError( /** * `ErrorIdentityRevisionRefused` (native code 63). Drive refused an - * identity update asked for a revision, for its revision, and - * reported one below the cached identity's (a cache ahead of the - * chain). Drive charged a processing fee and took the nonce; nothing - * else changed, and the cache - * was left as it is; the revisions are in [message]. Not retryable - * as is: the next update may ask for the revision Drive reported plus - * one. Port of Swift `PlatformWalletError.identityRevisionRefused`. + * identity update asked for a revision, for its revision, and proofs + * show its nonce spent and the identity at a revision below the one + * it would take (a cache ahead of the chain). Drive charged a + * processing fee and took the nonce, so the refused update can never + * execute; nothing else changed, and the cache was left as it is; + * the revisions are in [message]. Not retryable as is: the next + * update may ask for the proved revision plus one. Port of Swift + * `PlatformWalletError.identityRevisionRefused`. */ class IdentityRevisionRefused(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 6b779bc8c6f..4d07ad0e339 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -217,11 +217,12 @@ class IdentityUpdates internal constructor( * before anything is signed or broadcast, so a caller that recorded * [revision] beforehand knows the only revision this update can land at * (Drive takes an identity update only at one above the identity's - * revision). When Drive refuses the update for its revision and reports - * one below the cached identity's, it throws + * revision). When Drive refuses the update for its revision and proofs + * show the refused transition's nonce spent and the identity below that + * revision, it throws * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionRefused]. * `null` takes whatever the cache gives, and on such a refusal is sent - * once more at the revision Drive takes. Port of the `revision:` argument + * once more at one above the proved revision. Port of the `revision:` argument * of Swift `ManagedPlatformWallet.updateIdentity`. */ suspend fun update( diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 717202f207b..0180c4941d6 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -180,7 +180,7 @@ Fork-era numbers remain in the collision history, which is immutable record. | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | | 62 | `ErrorIdentityRevisionMismatch` | #5487 | Proposed — 61 is held by open #5395 (`ErrorCoreFundsAwaitingNetwork`), so this takes 62 (#5395 records its own row). An identity update asked for a revision (`update_identity_with_external_signer_at_revision`) the cached identity would not give it; refused before signing, nothing broadcast, the expected and actual revisions in the message. Not retryable as is: the host refreshes and decides again. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `identityRevisionMismatch` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.IdentityRevisionMismatch` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin | -| 63 | `ErrorIdentityRevisionRefused` | #5487 | Proposed — Drive refused an identity update asked for a revision (`update_identity_with_external_signer_at_revision`) for its revision and reported one below the cached identity's: the cache is ahead of the chain. Drive charged a processing fee and took the nonce; nothing else changed, and the cache was left as it is; the revisions are in the message. Not retryable as is: read the revision Drive takes from `platform_wallet_identity_next_update_revision` and update again asking for it. Mirrored in Swift (`errorIdentityRevisionRefused = 63`, `init(ffi:)` arm, typed `PlatformWalletError.identityRevisionRefused`, pinned by `ErrorHandlingTests.testIdentityRevisionRefusedFFIResultMapping`) and Kotlin (`63 ->` arm to `DashSdkError.PlatformWallet.IdentityRevisionRefused`, pinned by `DashSdkErrorTest.shouldPreserveIdentityRevisionRefusedAsItsOwnError`); Rust pins it in `should_preserve_identity_revision_refused_code_and_message` | +| 63 | `ErrorIdentityRevisionRefused` | #5487 | Proposed — Drive refused an identity update asked for a revision (`update_identity_with_external_signer_at_revision`) for its revision, and proofs show its nonce spent and the identity at a revision below the one it would take: the cache is ahead of the chain. Drive charged a processing fee and took the nonce, so the refused update can never execute; nothing else changed, and the cache was left as it is; the revisions are in the message. Not retryable as is: read the revision Drive takes from `platform_wallet_identity_next_update_revision` and update again asking for it. Mirrored in Swift (`errorIdentityRevisionRefused = 63`, `init(ffi:)` arm, typed `PlatformWalletError.identityRevisionRefused`, pinned by `ErrorHandlingTests.testIdentityRevisionRefusedFFIResultMapping`) and Kotlin (`63 ->` arm to `DashSdkError.PlatformWallet.IdentityRevisionRefused`, pinned by `DashSdkErrorTest.shouldPreserveIdentityRevisionRefusedAsItsOwnError`); Rust pins it in `should_preserve_identity_revision_refused_code_and_message` | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 58d20af726c..72d6133738a 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -624,10 +624,11 @@ pub enum PlatformWalletFFIResultCode { /// Maps `PlatformWalletError::IdentityRevisionRefused`: Drive refused an /// identity update asked to carry a revision, for its revision, and - /// reported one below the cached identity's (a cache ahead of the - /// chain). Drive charged a processing fee and took the nonce; nothing else - /// changed, and the cache was - /// left as it is. Read the revision Drive takes from + /// proofs show its nonce spent and the identity at a revision below the + /// one it would take (a cache ahead of the chain). Drive charged a + /// processing fee and took the nonce, so the refused update can never + /// execute; nothing else changed, and the cache was left as it is. Read + /// the revision Drive takes from /// `platform_wallet_identity_next_update_revision`, record it, and /// update again asking for it. The revisions are in the message. ErrorIdentityRevisionRefused = 63, diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index cd92c7355aa..d8bae2451a2 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -350,9 +350,11 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( /// before anything is signed or broadcast, so a caller that recorded /// `revision` knows the only revision this update can land at. Read the /// revision to record from [`platform_wallet_identity_next_update_revision`]. -/// When Drive refuses the update for its revision and reports one below the -/// cached identity's, the call fails with `ErrorIdentityRevisionRefused` -/// (63) and the next call to that reader gives the revision Drive takes. +/// When Drive refuses the update for its revision and proofs show the +/// refused transition's nonce spent and the identity below that revision, +/// the call fails with `ErrorIdentityRevisionRefused` (63) and the next +/// call to that reader gives the revision Drive takes. Any other failure +/// keeps its own code. /// /// # Safety /// Same pointer, length and lifetime rules as @@ -385,8 +387,9 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision /// `out_revision`: one above the cached identity's, or, after /// Drive refused the last update for its revision below the cache's /// (`ErrorIdentityRevisionRefused` for a pinned update), one above the -/// revision Drive reported while the cache is still where it was then. Record it before -/// submitting and pass it to +/// proved revision while the cache is still where it was then. The note +/// lives in memory: after a restart this gives one above the cache again. +/// Record it before submitting and pass it to /// [`platform_wallet_update_identity_with_signer_at_revision`]. /// /// # Safety diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index fd31281cc64..7a951e7376a 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -101,10 +101,12 @@ pub enum PlatformWalletError { }, /// Drive refused an identity update asked to carry `refused` for its - /// revision, and reported `drive_revision`, below the cached identity's: + /// revision, and proofs show the refused transition's nonce spent and + /// the identity at `drive_revision`, below the revision it would take: /// the cache is ahead of the chain. Drive charged a processing fee and - /// took the nonce; nothing else changed. The cache is left as it is; the next update may be asked to - /// carry `drive_revision + 1` + /// took the nonce, so the refused transition can never execute; nothing + /// else changed. The cache is left as it is; the next update may be + /// asked to carry `drive_revision + 1` while the cache has not moved /// (`IdentityWallet::next_update_revision` gives it). #[error( "identity {identity_id}: Drive refused the update at revision {refused} and holds \ diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index e4c477bc5bd..c1d7431c184 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -1,10 +1,15 @@ //! Mutate an identity's public-key set. use async_trait::async_trait; +use dash_sdk::platform::Fetch; +use dash_sdk::query_types::{IdentityBalanceAndRevision, IdentityNonceFetcher}; use dpp::address_funds::AddressWitness; +use dpp::consensus::state::state_error::StateError; +use dpp::consensus::ConsensusError; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; +use dpp::identity::identity_nonce::{validate_identity_nonce_update, MergeIdentityNonceResult}; use dpp::identity::identity_public_key::accessors::v0::{ IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, }; @@ -14,7 +19,7 @@ use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; use dpp::platform_value::BinaryData; -use dpp::prelude::Identifier; +use dpp::prelude::{Identifier, IdentityNonce}; use dpp::ProtocolError; use std::time::{SystemTime, UNIX_EPOCH}; @@ -92,13 +97,15 @@ impl IdentityWallet { /// back: a fetched identity at a lower revision than the cache's keeps /// the cache's revision, key set and every disable (see /// `ManagedIdentity::take_fetched_identity`). When Drive refuses the - /// update for its revision and reports one below the cache's (a cache an - /// older build left ahead of the chain, which no fetch heals since each - /// comes back older), the update is sent once more at the revision Drive - /// takes; the cache is not lowered on that unproven word, and the - /// landed update brings the chain to the cache's revision (shipped builds - /// leave a cache at most one past the chain, since every later update is - /// refused). Drive charges for each refused update. When the call + /// update for its revision (a cache an older build left ahead of the + /// chain, which no fetch heals since each comes back older), and proofs + /// show the refused transition's nonce spent and the identity below its + /// revision, the update is sent once more at one above the proved + /// revision; the cache is not lowered, and the landed update brings the + /// chain to the cache's revision (shipped builds leave a cache at most + /// one past the chain, since every later update is refused). Without + /// those proofs the refusal fails the call as it is. Drive charges for + /// each refused update. When the call /// fails after the broadcast (the proof wait timed out, say), the update /// may have landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( @@ -127,8 +134,9 @@ impl IdentityWallet { /// the transition must carry. /// /// The transition takes the cached identity's revision plus one, read - /// when the update starts (or, asked for it, one above the revision - /// Drive reported when it refused the last update below the cache). A caller that records the revision before + /// when the update starts (or, asked for it, one above the proved + /// revision after Drive refused the last update below the cache). A + /// caller that records the revision before /// submitting (to tell later whether the update can still land: Drive /// takes an identity update only at one above the identity's revision) /// cannot see that read, and a refresh in between moves it. With @@ -137,13 +145,14 @@ impl IdentityWallet { /// signed or broadcast, so the revision the caller recorded is the only /// one this update can land at. Read the revision to record from /// [`Self::next_update_revision`]. When Drive refuses the update for its - /// revision and reports one below the cache's, the update is not sent - /// again at another revision: it fails with - /// [`PlatformWalletError::IdentityRevisionRefused`], and - /// [`Self::next_update_revision`] then gives the revision Drive takes, - /// which the next update may be asked to carry. `None` takes whatever - /// the cache gives, as [`Self::update_identity_with_external_signer`] - /// does, and is sent once more on such a refusal. + /// revision and proofs show the refused transition's nonce spent and the + /// identity below its revision, the update is not sent again at another + /// revision: it fails with [`PlatformWalletError::IdentityRevisionRefused`], + /// and [`Self::next_update_revision`] then gives the revision Drive + /// takes, which the next update may be asked to carry. `None` takes + /// whatever the cache gives, as + /// [`Self::update_identity_with_external_signer`] does, and is sent once + /// more on such a refusal. pub async fn update_identity_with_external_signer_at_revision( &self, identity_id: &Identifier, @@ -217,16 +226,21 @@ impl IdentityWallet { .unwrap_or_default(); // Drive refuses an update whose revision is not one above the - // identity's, and says which revision it holds. When that is below - // the cache (a cache an older build left ahead of the chain), an - // update asked to carry no revision is sent once more at the one - // Drive takes; the cache itself is never lowered on that unproven - // word, so a node that lies costs one more paid refusal. One that - // hides a disable-only update still in the mempool behind a false - // refusal can get both to land (Drive takes a disable of a key - // already disabled and only re-stamps it): one more fee, and a cache - // one revision behind until the next fetch. An update that adds a - // key cannot land twice: Drive refuses its key id the second time. + // identity's, and says which revision it holds. That word comes + // without a proof, so it only prompts two proved reads (see + // `prove_refusal`): the identity's nonce, which must show the + // refused transition's nonce spent so that transition can never + // execute, and the identity's revision. When that is below the + // refused transition's by more than one, an update asked to + // carry no revision is sent once more at one above the proved + // revision; the cache itself is never lowered. That re-send cannot + // land the update twice: the refused transition can no longer + // execute, and had it executed after all (a node that lied about + // the refusal), the chain is at its revision or above, and Drive + // refuses the re-send, whose revision is not above it. A proof that + // lags costs one more paid refusal, which is proved again; the note a + // pinned update reads keeps the highest revision proved, and goes + // once a proof shows Drive at or above the cache's. let mut retried = false; loop { identity.set_revision(transition_revision); @@ -260,24 +274,17 @@ impl IdentityWallet { else { break; }; - let refused = revision_refusal(identity_id, &e); - if let Some(drive) = refused { - // Drive's word on the revision, or that the cache is not - // ahead of it after all, replaces the last one. - self.note_refused_revision( - identity_id, - built_from, - Some(drive).filter(|drive| *drive < built_from), - ) - .await; + let proved = if is_revision_refusal(identity_id, &e) { + self.prove_refusal(identity_id, identity_nonce, settings) + .await + } else { + None + }; + if let Some(proved) = proved { + self.note_refused_revision(identity_id, built_from, proved) + .await; } - match after_refusal( - refused, - built_from, - transition_revision, - revision.is_some(), - retried, - ) { + match after_refusal(proved, transition_revision, revision.is_some(), retried) { AfterRefusal::Retry(next) => { retried = true; transition_revision = next; @@ -319,16 +326,18 @@ impl IdentityWallet { /// duplicate. /// - The Swift `PersistentPublicKey` rows never receive /// the new keys (rows arrive via the - /// `IdentityKeysChangeSet` persister callback that - /// `add_key` fires). + /// `IdentityKeysChangeSet` persister callback). /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// - /// `ManagedIdentity::add_key` handles both the in-memory - /// mutation AND the `IdentityKeysChangeSet` upsert. Run it - /// on each new key under a write lock; the breadcrumb lets - /// the iOS Keychain re-derive the matching private bytes - /// from the wallet seed + DIP-9 path. + /// A cache behind `transition_revision` moves to it. The update's keys + /// are then stored with the scalar snapshot, revision included, as one + /// fallible write under the write lock, so the stored revision never + /// runs ahead of or behind the stored keys, and the snapshot is stored + /// even when no key matched: an added key with the breadcrumb the update + /// gives it, which lets the iOS Keychain re-derive the matching private + /// bytes from the wallet seed + DIP-9 path, and a disabled key without + /// one (the persisters keep what the row holds). /// /// A cache already refreshed to `transition_revision` or past it (a /// fetch answered between the update's snapshot and this step) holds the @@ -336,16 +345,15 @@ impl IdentityWallet { /// whatever later updates did: the payload is not applied over it. The /// fetch that put it there may not have been stored (a failed mobile /// round leaves the fold in memory), so the update's keys are stored - /// here from the cache's own copies, with the scalar snapshot, as one - /// fallible write: an added key with the breadcrumb the update gives it - /// (a refresh stores its rows without one), a disabled key without one - /// (the persisters keep what the row holds). The snapshot is stored even - /// when no key matched. A cache an older build left ahead of the chain - /// is past the transition without holding it (the update was sent at - /// the revision Drive takes): an added key it lacks takes the update's - /// copy, and a disabled key it holds enabled is disabled with the local - /// placeholder time. A cache that holds the update already shows both. - /// Any note of a refused revision goes: Drive took this one. + /// from the cache's own copies (a refresh stores its rows without a + /// breadcrumb). A cache an older build left ahead of the chain is past + /// the transition without holding it (the update was sent at the + /// revision Drive takes): an added key it lacks takes the update's copy. + /// Either way a disabled key the cache holds enabled is disabled with + /// the local placeholder time, which the next fetch at the update's + /// revision or above reconciles to the on-chain block time; a cache + /// that holds the update already shows both. Any note of a refused + /// revision goes: Drive took this one. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -366,88 +374,53 @@ impl IdentityWallet { // Landed: Drive took this revision, so a refusal noted before it // says nothing any more. managed.revision_refused_at = None; - - if managed.identity.revision() >= transition_revision { - let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let mut keys: Vec = added_keys - .into_iter() - .map(|key| KeyWithBreadcrumb { - breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), - key: managed - .identity - .public_keys() - .get(&key.id()) - .cloned() - .unwrap_or(key), - }) - .collect(); - keys.extend( - disabled_ids - .iter() - .filter(|id| !added_ids.contains(id)) - .filter_map(|id| managed.identity.public_keys().get(id).cloned()) - .map(|mut key| { - // A cache an older build left ahead of the chain - // does not hold the update it just landed. - if !key.is_disabled() { - key.set_disabled_at(local_disable_time()); - } - KeyWithBreadcrumb { - key, - breadcrumb: None, - } - }), - ); - return managed - .store_keys_with_snapshot(keys, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - }); - } - - // The identity is at the transition's revision now, so a - // subsequent update must not reuse the pre-broadcast value. - managed.identity.set_revision(transition_revision); - - for key in added_keys { - let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); - managed - .add_key(key, breadcrumb, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - })?; - } - - if !disabled_ids.is_empty() { - // Disable-side counterpart to the `add_key` loop - // above: stamp `disabled_at` on the matching cached - // keys and fire the persister so the Swift - // `PersistentPublicKey.disabledAt` rows flip without - // a network re-fetch. The local wall-clock timestamp - // is a placeholder — the next fetch at this revision or - // above reconciles it to the authoritative on-chain block - // time. `disable_keys` rebuilds the derivation breadcrumb - // from the managed identity's own wallet slot, so the - // disabled key keeps its private-key linkage. - managed.disable_keys(&disabled_ids, local_disable_time(), &self.persister); + if managed.identity.revision() < transition_revision { + // The identity is at the transition's revision now, so a + // subsequent update must not reuse the pre-broadcast value. + managed.identity.set_revision(transition_revision); } - Ok(()) + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let mut keys: Vec = added_keys + .into_iter() + .map(|key| KeyWithBreadcrumb { + breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), + key: managed + .identity + .public_keys() + .get(&key.id()) + .cloned() + .unwrap_or(key), + }) + .collect(); + keys.extend( + disabled_ids + .iter() + .filter(|id| !added_ids.contains(id)) + .filter_map(|id| managed.identity.public_keys().get(id).cloned()) + .map(|mut key| { + if !key.is_disabled() { + key.set_disabled_at(local_disable_time()); + } + KeyWithBreadcrumb { + key, + breadcrumb: None, + } + }), + ); + managed + .store_keys_with_snapshot(keys, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }) } - /// Record Drive's `drive_revision` from a refusal of an update built on - /// the cached revision `built_from`, or clear it (`None`: Drive holds - /// the cache's revision or above), while the cache is still there. - async fn note_refused_revision( - &self, - identity_id: &Identifier, - built_from: u64, - drive_revision: Option, - ) { + /// Note the identity's `proved` revision after a refusal of an update + /// built on the cached revision `built_from`, while the cache is still + /// there (see [`noted_refusal`]). + async fn note_refused_revision(&self, identity_id: &Identifier, built_from: u64, proved: u64) { let mut wm = self.wallet_manager.write().await; let Some(managed) = wm .get_wallet_info_mut(&self.wallet_id) @@ -456,14 +429,46 @@ impl IdentityWallet { return; }; if managed.identity.revision() == built_from { - managed.revision_refused_at = drive_revision.map(|drive| (built_from, drive)); + managed.revision_refused_at = + noted_refusal(managed.revision_refused_at, built_from, proved); } } + /// The identity's proved revision after Drive refused an update of it + /// with `nonce` for its revision, or `None` unless a proof shows `nonce` + /// spent (see [`spent_refusal`]). A failed read counts as no proof. + async fn prove_refusal( + &self, + identity_id: &Identifier, + nonce: IdentityNonce, + settings: Option, + ) -> Option { + let request_settings = settings.map(|s| s.request_settings).unwrap_or_default(); + let stored_nonce = + IdentityNonceFetcher::fetch_with_settings(&self.sdk, *identity_id, request_settings) + .await + .ok()? + .map(|fetched| fetched.0); + let revision = IdentityBalanceAndRevision::fetch_with_settings( + &self.sdk, + *identity_id, + request_settings, + ) + .await + .ok()? + .map(|(_, revision)| revision); + spent_refusal(identity_id, nonce, stored_nonce, revision) + } + /// The revision the next update of `identity_id` carries: one above the - /// cached identity's, or one above the revision Drive reported when it - /// refused the last update for its revision while the cache is still + /// cached identity's, or one above the proved revision after Drive + /// refused the last update for its revision, while the cache is still /// where it was then ([`PlatformWalletError::IdentityRevisionRefused`]). + /// That note lives in memory: after a restart this is one above the + /// cache again, and an update asked for the noted revision fails before + /// signing with [`PlatformWalletError::IdentityRevisionMismatch`]; one + /// asked for this revision is refused once more, which notes the proved + /// revision again. /// A caller that records the revision before submitting reads it here /// and passes it to [`Self::update_identity_with_external_signer_at_revision`]. /// After such a refusal the cached revision is at or above the one @@ -561,8 +566,8 @@ impl IdentityWallet { /// The revision an update of an identity cached at `cached` carries: one /// above it. With `expected` set, any other value is refused, except one -/// above the revision Drive reported when it refused the last update below -/// the cache, while the cache is still at `cached` (`refused_at`). +/// above the proved revision after Drive refused the last update below the +/// cache, while the cache is still at `cached` (`refused_at`). fn update_revision( identity_id: &Identifier, cached: u64, @@ -576,9 +581,9 @@ fn update_revision( })?; match expected { Some(expected) if expected == next => Ok(next), - // Drive refused the last update and reported a revision below the - // cache's, which is still where it was: the caller asks for the one - // Drive takes (`next_update_revision`). + // Drive refused the last update and a proof showed a revision below + // the cache's, which is still where it was: the caller asks for the + // one Drive takes (`next_update_revision`). Some(expected) if refused_at.is_some_and(|(at, drive)| at == cached && drive + 1 == expected) => { @@ -604,23 +609,20 @@ enum AfterRefusal { Fail, } -/// The step after a broadcast of an update built on the cached revision -/// `built_from` at `transition` failed, `refused` being Drive's revision -/// when it refused the update for its revision. Only a refusal below the -/// cache (the cache is ahead of the chain) and not already at the revision -/// Drive takes leads anywhere: a pinned update fails with it, an update -/// asked for no revision is sent once more at the one Drive takes. +/// The step after a failed broadcast of an update at `transition`, `proved` +/// being the identity's proved revision when a proof shows Drive refused the +/// update for its revision (see [`spent_refusal`]). Only a revision below +/// `transition` by more than one leads anywhere, since only then can +/// another transition at a revision no higher than `transition`'s land: a +/// pinned update fails with it, an update asked for no revision is sent +/// once more at one above it. fn after_refusal( - refused: Option, - built_from: u64, + proved: Option, transition: u64, pinned: bool, retried: bool, ) -> AfterRefusal { - let Some(drive) = refused - .filter(|drive| *drive < built_from) - .filter(|drive| drive + 1 != transition) - else { + let Some(drive) = proved.filter(|proved| proved + 1 < transition) else { return AfterRefusal::Fail; }; if pinned { @@ -632,12 +634,50 @@ fn after_refusal( } } -/// Drive's revision from its refusal of an update of `identity_id` for its -/// revision (`InvalidIdentityRevisionError`, unproven). -fn revision_refusal(identity_id: &Identifier, error: &dash_sdk::Error) -> Option { +/// Whether `error` is Drive's refusal of an update of `identity_id` for its +/// revision (`InvalidIdentityRevisionError`). It comes without a proof, so +/// the revision it carries is not read. +fn is_revision_refusal(identity_id: &Identifier, error: &dash_sdk::Error) -> bool { as_identity_revision_invalid(error) - .filter(|rejection| rejection.identity_id() == identity_id) - .map(|rejection| *rejection.current_revision()) + .is_some_and(|rejection| rejection.identity_id() == identity_id) +} + +/// The proved revision `revision` of an identity whose update with `nonce` +/// Drive refused, or `None` unless its proved nonce word `stored_nonce` +/// shows `nonce` spent by Drive's own merge rule +/// (`validate_identity_nonce_update` finds it already present or too far in +/// the past): then the refused transition can never execute, even one a +/// node kept back while it reported the refusal. +fn spent_refusal( + identity_id: &Identifier, + nonce: IdentityNonce, + stored_nonce: Option, + revision: Option, +) -> Option { + let spent = stored_nonce.is_some_and(|stored| { + matches!( + validate_identity_nonce_update(stored, nonce, *identity_id).first_error(), + Some(ConsensusError::StateError(StateError::InvalidIdentityNonceError(error))) + if !matches!(error.error(), MergeIdentityNonceResult::NonceTooFarInFuture) + ) + }); + revision.filter(|_| spent) +} + +/// The refusal note after a proof showed the identity at `proved` once an +/// update built on the cached revision `built_from` was refused: none when +/// `proved` is at or above the cache's (the cache is not ahead after all), +/// otherwise `(built_from, drive)` with `drive` the higher of `proved` and +/// what `noted` holds for `built_from` (revisions only grow, so a lower +/// proof lags). +fn noted_refusal(noted: Option<(u64, u64)>, built_from: u64, proved: u64) -> Option<(u64, u64)> { + if proved >= built_from { + return None; + } + let drive = noted + .filter(|(at, _)| *at == built_from) + .map_or(proved, |(_, drive)| drive.max(proved)); + Some((built_from, drive)) } /// The local placeholder for a disable's `disabled_at`; the next fetch at @@ -698,49 +738,85 @@ mod update_revision_tests { } #[test] - fn should_send_once_more_at_drives_revision_only_when_the_cache_is_ahead() { - // Cache at 6, update at 7, Drive holds 5: sent once more at 6, or, + fn should_send_once_more_at_the_proved_revision_only_once() { + // Update at 7, proofs show Drive at 5: sent once more at 6, or, // pinned, refused with 5. assert_eq!( - after_refusal(Some(5), 6, 7, false, false), + after_refusal(Some(5), 7, false, false), AfterRefusal::Retry(6) ); assert_eq!( - after_refusal(Some(5), 6, 7, true, false), + after_refusal(Some(5), 7, true, false), AfterRefusal::Refused(5) ); // The second send is refused too: no third. - assert_eq!( - after_refusal(Some(4), 6, 6, false, true), - AfterRefusal::Fail - ); - // Refused at the revision Drive reported: nothing more to try. - assert_eq!( - after_refusal(Some(5), 6, 6, false, true), - AfterRefusal::Fail - ); - assert_eq!( - after_refusal(Some(5), 6, 6, true, false), - AfterRefusal::Fail - ); - // Drive at the cache's revision or above (the cache is behind; a - // refresh heals it), or another failure. - for refused in [Some(6), Some(9), None] { + assert_eq!(after_refusal(Some(4), 6, false, true), AfterRefusal::Fail); + // Drive would take the transition (6), is at it or above, or no + // proof shows the refusal. + for proved in [Some(6), Some(7), Some(9), None] { assert_eq!( - after_refusal(refused, 6, 7, false, false), - AfterRefusal::Fail + after_refusal(proved, 7, false, false), + AfterRefusal::Fail, + "proved {proved:?}" ); assert_eq!( - after_refusal(refused, 6, 7, true, false), - AfterRefusal::Fail + after_refusal(proved, 7, true, false), + AfterRefusal::Fail, + "proved {proved:?}" ); } } #[test] - fn should_read_drives_revision_only_from_a_refusal_of_this_identity() { + fn should_take_a_refused_identity_s_revision_only_from_a_spent_nonce() { + let id = Identifier::default(); + let missing_bit = |position: u64| { + 1u64 << (position - 1 + + dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES) + }; + // Nonce 10 is spent: at the tip, merged below it, or past the window. + for stored in [10, 12, 40] { + assert_eq!( + spent_refusal(&id, 10, Some(stored), Some(5)), + Some(5), + "stored {stored}" + ); + } + // The nonce can still execute: above the tip, missing below it, too + // far ahead, or no nonce stored. + for (nonce, stored) in [ + (10, Some(9)), + (10, Some(12 | missing_bit(2))), + (30, Some(1)), + (10, None), + ] { + assert_eq!( + spent_refusal(&id, nonce, stored, Some(5)), + None, + "stored {stored:?}, nonce {nonce}" + ); + } + assert_eq!(spent_refusal(&id, 10, Some(10), None), None); + } + + #[test] + fn should_keep_the_highest_proved_revision_until_drive_reaches_the_cache() { + // Cache at 6, proved at 5: noted. + assert_eq!(noted_refusal(None, 6, 5), Some((6, 5))); + // A lagging proof does not take the note lower, a newer one raises it. + assert_eq!(noted_refusal(Some((6, 5)), 6, 4), Some((6, 5))); + assert_eq!(noted_refusal(Some((6, 4)), 6, 5), Some((6, 5))); + // A note against another cached revision says nothing. + assert_eq!(noted_refusal(Some((7, 5)), 6, 4), Some((6, 4))); + // Drive at the cache's revision or above: the cache is not ahead. + for proved in [6, 8] { + assert_eq!(noted_refusal(Some((6, 5)), 6, proved), None); + } + } + + #[test] + fn should_tell_a_revision_refusal_only_of_this_identity() { use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; - use dpp::consensus::ConsensusError; let id = Identifier::default(); let refusal = |identity_id, current| -> ConsensusError { @@ -755,17 +831,17 @@ mod update_revision_tests { ); let bare = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal(id, 4)))); - assert_eq!(revision_refusal(&id, &wait_stream), Some(4)); - assert_eq!(revision_refusal(&id, &bare), Some(4)); + assert!(is_revision_refusal(&id, &wait_stream)); + assert!(is_revision_refusal(&id, &bare)); let other = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal( Identifier::from([7; 32]), 4, )))); - assert_eq!(revision_refusal(&id, &other), None); - assert_eq!( - revision_refusal(&id, &dash_sdk::Error::Generic("timeout".to_string())), - None - ); + assert!(!is_revision_refusal(&id, &other)); + assert!(!is_revision_refusal( + &id, + &dash_sdk::Error::Generic("timeout".to_string()) + )); } #[test] @@ -1101,6 +1177,85 @@ mod update_identity_at_revision_tests { assert!(changesets[0].identities.is_some()); } + /// A cache at 4 takes an update landed at 5 and stores the revision with + /// the update's keys as one write, also when the disabled key is not in + /// the cache; a store that fails fails the call. + #[tokio::test] + async fn should_store_a_landed_revision_with_its_keys_in_one_write() { + let identity_id = Identifier::default(); + let stored_revision = |changesets: &[crate::changeset::PlatformWalletChangeSet]| { + changesets[0] + .identities + .as_ref() + .and_then(|identities| identities.identities.get(&identity_id)) + .map(|entry| entry.revision) + }; + for (added, disabled) in [(vec![key_3()], vec![1]), (vec![], vec![9])] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + let wallet = wallet.identity(); + store.take(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, added.clone(), disabled.clone()) + .await + .unwrap(); + let changesets = store.take(); + assert_eq!( + changesets.len(), + 1, + "added {added:?}, disabled {disabled:?}" + ); + assert_eq!(stored_revision(&changesets), Some(5)); + let rows = changesets[0] + .identity_keys + .as_ref() + .map_or(0, |keys| keys.upserts.len()); + assert_eq!(rows, if added.is_empty() { 0 } else { 2 }); + } + + let store = Arc::new(FailingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + store.0.store(true, Ordering::SeqCst); + let error = wallet + .identity() + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::Persistence(_)), + "{error:?}" + ); + } + + /// Refuses every store once set. + #[derive(Default)] + struct FailingStore(std::sync::atomic::AtomicBool); + + impl PlatformWalletPersistence for FailingStore { + fn store( + &self, + _: crate::wallet::platform_wallet::WalletId, + _: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + if self.0.load(Ordering::SeqCst) { + return Err(crate::changeset::PersistenceError::backend("store refused")); + } + Ok(()) + } + fn flush( + &self, + _: crate::wallet::platform_wallet::WalletId, + ) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + fn load( + &self, + ) -> Result + { + Ok(Default::default()) + } + } + async fn refused_at(wallet: &IdentityWallet) -> Option<(u64, u64)> { let wm = wallet.wallet_manager.read().await; wm.get_wallet_info(&wallet.wallet_id) @@ -1125,10 +1280,10 @@ mod update_identity_at_revision_tests { assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 7); // A refusal noted against another revision than the cache's is not. - wallet.note_refused_revision(&identity_id, 5, Some(4)).await; + wallet.note_refused_revision(&identity_id, 5, 4).await; assert_eq!(refused_at(wallet).await, None); - wallet.note_refused_revision(&identity_id, 6, Some(5)).await; + wallet.note_refused_revision(&identity_id, 6, 5).await; assert_eq!(cached_identity(wallet, &identity_id).await.revision(), 6); assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 6); @@ -1153,12 +1308,7 @@ mod update_identity_at_revision_tests { assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); assert!(signer.0.load(Ordering::SeqCst) > 0); - // Drive says the cache is not ahead after all: the note goes. - wallet.note_refused_revision(&identity_id, 6, None).await; - assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 7); - // A fetch taken at the cache's revision or above clears it. - wallet.note_refused_revision(&identity_id, 6, Some(5)).await; { let mut wm = wallet.wallet_manager.write().await; let managed = wm @@ -1173,6 +1323,51 @@ mod update_identity_at_revision_tests { assert_eq!(refused_at(wallet).await, None); } + /// Drive's refusal of an update with nonce 10 gives the proved revision + /// only when both proved reads answer: the nonce word shows 10 spent and + /// the revision is 5. A read that fails counts as no proof. + #[tokio::test] + async fn should_prove_a_refusal_only_when_both_reads_answer() { + let identity_id = Identifier::default(); + let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); + sdk.mock() + .expect_fetch::( + identity_id, + Some(IdentityNonceFetcher(10)), + ) + .await + .unwrap(); + let settings = Some(PutSettings { + request_settings: dash_sdk::RequestSettings { + retries: Some(0), + ..Default::default() + }, + ..Default::default() + }); + let wallet = + wallet_with_signing_keys_on(sdk.clone(), Arc::new(NoPlatformPersistence)).await; + assert_eq!( + wallet + .identity() + .prove_refusal(&identity_id, 10, settings) + .await, + None + ); + + sdk.mock() + .expect_fetch::(identity_id, Some((0, 5))) + .await + .unwrap(); + let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; + assert_eq!( + wallet + .identity() + .prove_refusal(&identity_id, 10, settings) + .await, + Some(5) + ); + } + /// The update asked for Drive's revision lands at 6 on a cache an older /// build left at 6 without it: the cache is at the chain's revision now /// and takes the update it lacks, key 3 added and key 1 disabled, and @@ -1183,7 +1378,7 @@ mod update_identity_at_revision_tests { let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(6, store.clone()).await; let wallet = wallet.identity(); - wallet.note_refused_revision(&identity_id, 6, Some(5)).await; + wallet.note_refused_revision(&identity_id, 6, 5).await; store.take(); wallet .apply_landed_update(&identity_id, Some(0), 6, vec![key_3()], vec![1]) diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 650de105cd2..87a11003146 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -17,6 +17,7 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; use dpp::util::hash::ripemd160_sha256; +use std::cmp::Ordering; use std::collections::BTreeMap; /// Compute the 20-byte RIPEMD160(SHA256) hash of the DPP public-key @@ -403,33 +404,51 @@ impl ManagedIdentity { /// claims none), but they only ever rise, so each shared key keeps the /// higher of the two (see `with_raised_limits`) at any revision. /// - /// A fetch at the cache's revision or above is otherwise taken whole, - /// key states included: a masternode identity's keys are disabled and - /// re-enabled by Core at revision 0, so such a fetch may re-enable a key - /// the cache holds disabled. Revisions only grow on Platform, so a fetch - /// at a lower revision was answered before an update the cache already - /// holds (one applied locally after it landed, or a newer refresh, from a - /// node that lags behind): the cache keeps its revision and key set; it - /// takes only a disable the fetch shows on a key the cache holds enabled - /// (an identity update's disable is permanent) and a raised limit. - /// Returns whether it was taken whole. + /// A fetch above the cache's revision is otherwise taken whole, key + /// states included. So is one at the cache's revision from a block at or + /// above the key watermark (`last_synced_keys_block_time`, the block of + /// the last fetch taken whole, or none; a proof from that same block + /// shows the same key states): a masternode identity's keys are disabled + /// and re-enabled by Core at revision 0, so a newer fetch at the same + /// revision may re-enable a key the cache holds disabled, or disable one + /// it holds enabled. A fetch at the cache's revision from a block below + /// the watermark is older than the key states the cache took, so it + /// changes no key state; only a raised limit is taken. The watermark is + /// stored with the scalar snapshot, but the mobile persisters do not + /// restore it, so there the first fetch after a restart is taken whole. + /// + /// Revisions only grow on Platform, so a fetch at a lower revision was + /// answered before an update the cache already holds (one applied + /// locally after it landed, or a newer refresh, from a node that lags + /// behind): the cache keeps its revision and key set; it takes only a + /// disable the fetch shows on a key the cache holds enabled (an identity + /// update's disable is permanent) and a raised limit. A fetch taken + /// whole moves the watermark to its block. Returns whether it was taken + /// whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { let balance = self.identity.balance(); let cached = self.identity.public_keys().clone(); - let taken = fetched.revision() >= self.identity.revision(); + let fold = self.key_fold(fetched, block_time); + let taken = fold == KeyFold::Whole; let mut keys = if taken { self.identity = fetched.clone(); self.revision_refused_at = None; self.identity.set_balance(balance); + if self + .last_synced_keys_block_time + .is_none_or(|synced| synced.height < block_time.height) + { + self.last_synced_keys_block_time = Some(block_time); + } fetched.public_keys().clone() } else { cached.clone() }; for (key_id, key) in keys.iter_mut() { if let Some(fetched_key) = fetched.public_keys().get(key_id) { - if let Some(folded) = fold_fetched_key(cached.get(key_id), fetched_key, taken) { + if let Some(folded) = fold_fetched_key(cached.get(key_id), fetched_key, fold) { *key = folded; } } @@ -439,6 +458,23 @@ impl ManagedIdentity { taken } + /// How the keys of `fetched`, proved at `block_time`, fold into the + /// cache's (see [`Self::take_fetched_identity`]). + fn key_fold(&self, fetched: &Identity, block_time: BlockTime) -> KeyFold { + match fetched.revision().cmp(&self.identity.revision()) { + Ordering::Greater => KeyFold::Whole, + Ordering::Equal + if self + .last_synced_keys_block_time + .is_none_or(|synced| block_time.height >= synced.height) => + { + KeyFold::Whole + } + Ordering::Equal => KeyFold::OlderBlock, + Ordering::Less => KeyFold::Older, + } + } + /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row /// of every key the fold adds or changes (a disable, a raised limit, a /// key added at a newer revision, on this device or another) with the @@ -495,7 +531,7 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PersistenceError> { - let decisions = self.key_decisions_for_fetch(fetched, decisions); + let decisions = self.key_decisions_for_fetch(fetched, decisions, block_time); self.take_fetched_identity(fetched, block_time); let keys_cs = self.layer_keys(decisions); self.store_keys_and_balance(keys_cs, persister) @@ -505,24 +541,24 @@ impl ManagedIdentity { /// key folded as [`Self::take_fetched_identity`] folds it, so storing its /// breadcrumb (a key's derivation never changes) stores what the fold /// holds: never a raised limit taken back, nor a disable an older fetch - /// lacks. For a fetch at the cache's - /// revision or above that is the fetched key. For an older fetch it is - /// the cache's copy, disabled when only the fetch's copy is; either way - /// each limit is the higher of the two. - /// A key the cache lacks is left out of an older fetch's decisions, since - /// an older answer cannot show whether a later update disabled it, and a - /// fetch at least as new will bring it. + /// lacks. For a fetch taken whole that is the fetched key. For any other + /// it is the cache's copy, from an older fetch disabled when only the + /// fetch's copy is; either way each limit is the higher of the two. A + /// key the cache lacks is left out of the decisions of a fetch not taken + /// whole, since such an answer cannot show whether a later update + /// disabled it, and a newer fetch will bring it. fn key_decisions_for_fetch( &self, fetched: &Identity, decisions: Vec, + block_time: BlockTime, ) -> Vec { - let taken = fetched.revision() >= self.identity.revision(); + let fold = self.key_fold(fetched, block_time); decisions .into_iter() .filter_map(|mut decision| { let cached = self.identity.public_keys().get(&decision.key.id()); - decision.key = fold_fetched_key(cached, &decision.key, taken)?; + decision.key = fold_fetched_key(cached, &decision.key, fold)?; Some(decision) }) .collect() @@ -787,19 +823,39 @@ impl ManagedIdentity { } } +/// How a fetched identity's keys fold into the cache's (see +/// [`ManagedIdentity::take_fetched_identity`]). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum KeyFold { + /// Above the cache's revision, or at it from a block at or above the key + /// watermark: its key states are taken. + Whole, + /// At the cache's revision from a block below the key watermark: no key + /// state is taken. + OlderBlock, + /// Below the cache's revision: only a disable is taken. + Older, +} + /// How a fetched key lands in the cache, the one rule the identity fold and -/// its key decisions share. From a fetch taken whole (`taken`) it is the -/// fetched key. From an older fetch it is the cache's copy, disabled when -/// only the fetch's copy is, or nothing for a key the cache lacks. Either -/// way each limit is the higher of the two copies'. +/// its key decisions share. From a fetch taken whole it is the fetched key. +/// From any other it is the cache's copy, or nothing for a key the cache +/// lacks; from an older fetch, disabled when only the fetch's copy is. +/// Either way each limit is the higher of the two copies'. fn fold_fetched_key( cached: Option<&IdentityPublicKey>, fetched: &IdentityPublicKey, - taken: bool, + fold: KeyFold, ) -> Option { - if !taken { - return cached - .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); + match fold { + KeyFold::Older => { + return cached + .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); + } + KeyFold::OlderBlock => { + return cached.map(|cached| with_raised_limits(cached.clone(), fetched)); + } + KeyFold::Whole => {} } let key = fetched.clone(); let Some(cached) = cached else { @@ -916,11 +972,12 @@ mod tests { /// A fetch at a lower revision than the cache's was answered before an /// update the cache holds: the cache keeps its revision and key set, and /// takes only a disable the fetch shows on a key it holds enabled and a - /// raised limit. A fetch at the same revision or higher replaces the - /// cache, key states included, but never takes back a raise. Either way - /// the balance follows the proof's block, not the revision. + /// raised limit. A fetch at a higher revision, or at the same one from a + /// block at or above the key watermark, replaces the cache, key states + /// included, but never takes back a raise. Either way the balance + /// follows the proof's block, not the revision. #[test] - fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { + fn should_take_a_fetched_identity_whole_only_from_a_newer_revision_or_block() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { IdentityPublicKey::V1(IdentityPublicKeyV1 { id, @@ -992,16 +1049,68 @@ mod tests { assert!(managed.take_fetched_identity(&newer, block(12))); assert_eq!(managed.identity, newer); - // ...key states included (Core re-enables a masternode identity's - // key at revision 0), except for a balance its proof does not order - // after the cache's and a budget the cache holds higher: a delayed - // answer cannot take a raise back. - let delayed = at(7, 35, vec![limited(3, 550, None)]); - for height in [11, 12] { - assert!(managed.take_fetched_identity(&delayed, block(height))); - assert_eq!(managed.identity, at(7, 40, vec![limited(3, 600, None)])); + // ...key states included, at the cache's revision only from a block + // at or above the key watermark the last fetch taken whole set (Core + // re-enables a masternode identity's key at revision 0). One from a + // block below it is older: it changes no key state, nor a balance + // its proof does not order after the cache's. + assert_eq!(managed.last_synced_keys_block_time, Some(block(12))); + let reenabled = at(7, 35, vec![limited(3, 550, None)]); + for height in [10, 11] { + assert!(!managed.take_fetched_identity(&reenabled, block(height))); + assert_eq!(managed.identity, newer); assert_eq!(managed.last_updated_balance_block_time, Some(block(12))); } + // Taken whole from above it, but a budget the cache holds higher + // stays: a delayed answer cannot take a raise back. + assert!(managed.take_fetched_identity(&reenabled, block(13))); + assert_eq!(managed.identity, at(7, 35, vec![limited(3, 600, None)])); + assert_eq!(managed.last_synced_keys_block_time, Some(block(13))); + } + + /// Core disables and re-enables a masternode identity's keys at revision + /// 0, so key states at one revision are ordered by proof height: a + /// lagging answer at the cache's revision brings back neither a disable + /// Core lifted nor a key state Core changed since, and a cache with no + /// watermark takes its first answer whole. + #[test] + fn should_order_key_states_at_the_cache_s_revision_by_proof_height() { + let identity = |disabled_at: Option| { + let mut key = key(3); + if let Some(disabled_at) = disabled_at { + key.set_disabled_at(disabled_at); + } + Identity::V0(IdentityV0 { + id: Identifier::from([1u8; 32]), + public_keys: BTreeMap::from([(3, key)]), + balance: 0, + revision: 0, + }) + }; + let block = |height| BlockTime::new(height, 1, height); + let mut managed = ManagedIdentity::new(identity(Some(5)), 0); + // No watermark: the answer at block 20, the key re-enabled, is taken. + assert!(managed.take_fetched_identity(&identity(None), block(20))); + assert_eq!(managed.identity, identity(None)); + assert_eq!(managed.last_synced_keys_block_time, Some(block(20))); + // A lagging answer from before the re-enable does not disable it again. + for height in [15, 19] { + assert!(!managed.take_fetched_identity(&identity(Some(5)), block(height))); + assert_eq!(managed.identity, identity(None), "block {height}"); + } + // Core disables it again at a later block: taken. + assert!(managed.take_fetched_identity(&identity(Some(25)), block(25))); + assert_eq!(managed.identity, identity(Some(25))); + // The decisions stored with a fetch follow the same order. + let decisions = managed.key_decisions_for_fetch( + &identity(None), + vec![KeyWithBreadcrumb { + key: key(3), + breadcrumb: None, + }], + block(22), + ); + assert_eq!(decisions[0].key, identity(Some(25)).public_keys()[&3]); } /// Each limit is the higher of the two copies', since consensus only @@ -1061,12 +1170,13 @@ mod tests { }, ] }; - let folded = managed.key_decisions_for_fetch(&fetched(5), decisions()); + let folded = + managed.key_decisions_for_fetch(&fetched(5), decisions(), BlockTime::new(1, 0, 0)); assert_eq!(folded.len(), 1); assert_eq!(folded[0].key, disabled); assert_eq!( managed - .key_decisions_for_fetch(&fetched(6), decisions()) + .key_decisions_for_fetch(&fetched(6), decisions(), BlockTime::new(1, 0, 0)) .len(), 2 ); @@ -1091,7 +1201,11 @@ mod tests { key: key(3).with_limits(Some(100), None), breadcrumb: None, }]; - let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); + let folded = managed.key_decisions_for_fetch( + &identity(revision, 100), + decisions, + BlockTime::new(1, 0, 0), + ); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 12f840f48cb..6b5e3da685d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -58,14 +58,17 @@ pub struct ManagedIdentity { /// retry. Query results are published only after that retry commits. pending_balance_snapshot: Option<(u64, BlockTime)>, - /// The revision Drive reported when it refused an update of this - /// identity for its revision, below the cached one, with the cached - /// revision it was refused against: `(cached, drive)`. Memory only and - /// unproven: it lets the next update be asked to carry `drive + 1` - /// while the cache is still at `cached`, and changes nothing else. + /// The identity's proved revision after Drive refused an update of it + /// for its revision, below the cached one, with the cached revision it + /// was refused against: `(cached, drive)`. Noted only once proofs show + /// the refused transition's nonce spent (it can never execute). Memory + /// only: it lets the next update be asked to carry `drive + 1` while the + /// cache is still at `cached`, and changes nothing else. pub(crate) revision_refused_at: Option<(u64, u64)>, - /// Last block time when keys were synced for this identity + /// Block of the last fetch whose key states the cache took whole: a + /// fetch at the cached revision takes its key states only from a block + /// at or above it (see `take_fetched_identity`). pub last_synced_keys_block_time: Option, /// Identity lifecycle status on Platform. diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 8bf238c99f7..02b4508d988 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -178,9 +178,11 @@ impl ManagedIdentity { } } - /// Update the last keys sync block time. - /// - /// Persists the resulting changeset via `persister` and returns `()`. + /// Set the key watermark (`last_synced_keys_block_time`) to + /// `block_time`, as is, and persist the scalar snapshot. A fetch at the + /// cached revision then takes its key states only from that block or a + /// later one (see `take_fetched_identity`), so set it only from a block + /// whose key states the cache holds. pub fn update_keys_sync_block_time( &mut self, block_time: BlockTime, diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index a22021eaf4c..6f46d6d3014 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3739,12 +3739,12 @@ extension ManagedPlatformWallet { /// revision this update can land at: Drive takes an identity update /// only at one above the identity's revision. Read the revision to /// record from `nextIdentityUpdateRevision(identityId:)`. When Drive - /// refuses the update for its revision and reports one below the - /// cached identity's, the call throws - /// `PlatformWalletError.identityRevisionRefused` and the reader then - /// gives the revision Drive takes. `nil` takes whatever the cache - /// gives, and on such a refusal is sent once more at the revision Drive - /// takes. + /// refuses the update for its revision and proofs show the refused + /// transition's nonce spent and the identity below that revision, the + /// call throws `PlatformWalletError.identityRevisionRefused` and the + /// reader then gives the revision Drive takes. `nil` takes whatever the + /// cache gives, and on such a refusal is sent once more at one above the + /// proved revision. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], @@ -3759,14 +3759,16 @@ extension ManagedPlatformWallet { } let addPubkeys = addPublicKeys let disableIds = disablePublicKeyIds - try await Task.detached(priority: .userInitiated) { + // `self` stays alive for the whole call: a deinit mid-call would + // destroy the handle the FFI is still using. + try await Task.detached(priority: .userInitiated) { [self] in // Mirror the registration FFI's pubkey-pinning pattern via // `withPubkeyFFIArray` so each `pubkey_bytes` pointer the // FFI sees stays valid for the call duration. let pubkeyBuffers: [Data] = addPubkeys.map { $0.pubkeyBytes } // The Rust worker borrows the signer's vtable for the whole // synchronous call, so the signer must outlive it. - let result = withExtendedLifetime(signer) { + let result = withExtendedLifetime((self, signer)) { idBytes.withUnsafeBufferPointer { idBp -> PlatformWalletFFIResult in disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in @@ -3800,20 +3802,26 @@ extension ManagedPlatformWallet { /// The revision the next `updateIdentity` of `identityId` carries: one /// above the cached identity's, or, after /// `PlatformWalletError.identityRevisionRefused`, one above the revision - /// Drive reported while the cache is still where it was then. Record it - /// before submitting and pass it as `updateIdentity(…, revision:)`. + /// proved while the cache is still where it was then. Record it before + /// submitting and pass it as `updateIdentity(…, revision:)`. The note of + /// a refusal lives in memory: after a restart this gives one above the + /// cached identity's again. public func nextIdentityUpdateRevision(identityId: Identifier) async throws -> UInt64 { - let handle = self.handle let idBytes: [UInt8] = identityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } - return try await Task.detached(priority: .userInitiated) { - var revision: UInt64 = 0 - let result = idBytes.withUnsafeBufferPointer { idBp in - platform_wallet_identity_next_update_revision(handle, idBp.baseAddress!, &revision) + // `self` stays alive for the whole call: a deinit mid-call would + // destroy the handle the FFI is still using. + return try await Task.detached(priority: .userInitiated) { [self] () -> UInt64 in + try withExtendedLifetime(self) { + var revision: UInt64 = 0 + let result = idBytes.withUnsafeBufferPointer { idBp in + platform_wallet_identity_next_update_revision( + handle, idBp.baseAddress!, &revision) + } + try result.check() + return revision } - try result.check() - return revision }.value } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index b7d105a8785..0191bd4127d 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -268,8 +268,8 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// give it; refused before signing, nothing was broadcast. case errorIdentityRevisionMismatch = 62 /// Drive refused an identity update asked for a revision, for its - /// revision, and reported one below the cached identity's. Nothing - /// changed but a processing fee and the nonce; + /// revision, and proofs show the identity below it. Nothing changed but + /// a processing fee and the nonce; /// `nextIdentityUpdateRevision` gives the one Drive takes. case errorIdentityRevisionRefused = 63 /// The named thing does not exist. Besides the handle/lookup failures this @@ -574,10 +574,11 @@ public enum PlatformWalletError: LocalizedError { /// identity and decide again rather than retrying the same call. case identityRevisionMismatch(String) /// `updateIdentity(…, revision:)` was refused by Drive for its revision, - /// and Drive reported one below the cached identity's (a cache ahead of - /// the chain). Drive charged a processing fee and took the nonce; nothing - /// else changed, and the cache was left as it is; the revisions are in - /// the message. Read the revision Drive takes from + /// and proofs show its nonce spent and the identity at a revision below + /// the one it would take (a cache ahead of the chain). Drive charged a + /// processing fee and took the nonce, so the refused update can never + /// execute; nothing else changed, and the cache was left as it is; the + /// revisions are in the message. Read the revision Drive takes from /// `nextIdentityUpdateRevision(identityId:)`, record it, and update /// again asking for it. case identityRevisionRefused(String) From a6e23a6bce77c446ffcc7c0cc5a6bf2508dd56db Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 24 Oct 2026 15:18:43 +0300 Subject: [PATCH 64/72] fix(platform-wallet): fail a refusal proved at u64::MAX instead of overflowing after_refusal computed the proved revision plus one before deciding, which panics in debug builds and wraps in release when a proof reports u64::MAX. It now uses checked_add and fails the update when there is no next revision. The breadcrumb docs on KeyDerivationBreadcrumb and KeyWithBreadcrumb no longer say None means watch-only; they point to IdentityKeyEntry::derivation_indices, where None means no coordinates and a persister keeps the breadcrumb it holds. Co-Authored-By: Claude Opus 5.5 --- .../rs-platform-wallet/src/changeset/changeset.rs | 13 ++++++++----- .../src/wallet/identity/network/update.rs | 9 ++++++--- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index e7250bd966f..918c69d5a82 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1007,8 +1007,10 @@ pub struct IdentityKeyDerivationIndices { /// A derivation breadcrumb as the raw `(wallet_id, identity_index, /// key_index)` triple passed to `ManagedIdentity::add_key` / `add_keys`. -/// `Some` lets the client re-derive the private key from the wallet seed; -/// `None` marks a watch-only key. +/// `Some` lets the client re-derive the private key from the wallet seed. +/// `None` gives no coordinates: a key the persister does not hold yet is +/// watch-only, and one it holds keeps its breadcrumb (see +/// [`IdentityKeyEntry::derivation_indices`]). pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// One public key paired with its derivation breadcrumb — the unit @@ -1018,12 +1020,13 @@ pub type KeyDerivationBreadcrumb = ([u8; 32], u32, u32); /// Discovery derives a candidate scalar, validates it against the on-chain /// key, and emits a breadcrumb (the DIP-9 coordinates) only when it matches. /// The scalar itself is never carried out — the client derives the key on -/// demand from the Keychain seed at the breadcrumb path. `breadcrumb` is -/// `None` for a watch-only key. +/// demand from the Keychain seed at the breadcrumb path. A `None` +/// `breadcrumb` gives no coordinates (see +/// [`IdentityKeyEntry::derivation_indices`]). pub struct KeyWithBreadcrumb { /// The DPP public-key record. pub key: dpp::identity::IdentityPublicKey, - /// Derivation coordinates for re-derivable keys; `None` for watch-only. + /// Derivation coordinates for re-derivable keys; `None` gives none. pub breadcrumb: Option, } diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index c1d7431c184..58acd7effc2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -622,7 +622,10 @@ fn after_refusal( pinned: bool, retried: bool, ) -> AfterRefusal { - let Some(drive) = proved.filter(|proved| proved + 1 < transition) else { + let Some((drive, next)) = proved + .and_then(|drive| Some((drive, drive.checked_add(1)?))) + .filter(|&(_, next)| next < transition) + else { return AfterRefusal::Fail; }; if pinned { @@ -630,7 +633,7 @@ fn after_refusal( } else if retried { AfterRefusal::Fail } else { - AfterRefusal::Retry(drive + 1) + AfterRefusal::Retry(next) } } @@ -753,7 +756,7 @@ mod update_revision_tests { assert_eq!(after_refusal(Some(4), 6, false, true), AfterRefusal::Fail); // Drive would take the transition (6), is at it or above, or no // proof shows the refusal. - for proved in [Some(6), Some(7), Some(9), None] { + for proved in [Some(6), Some(7), Some(9), Some(u64::MAX), None] { assert_eq!( after_refusal(proved, 7, false, false), AfterRefusal::Fail, From d415e70a621407c356f1cd8ad119de544929d6a6 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 24 Oct 2026 16:33:33 +0300 Subject: [PATCH 65/72] fix(kotlin-sdk): pass the update revision through addKey and disableKeys The single-key convenience methods now take an optional revision and hand it to update, so Android callers on these entry points can pin the revision they recorded. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/identity/IdentityUpdates.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 4d07ad0e339..b09cd70b7c3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -187,24 +187,28 @@ class IdentityUpdates internal constructor( /** * Add a single public key to [identityId], signed via [signerHandle]. - * Convenience over [update] for the single-key `AddIdentityKeyView` flow. + * Convenience over [update] for the single-key `AddIdentityKeyView` flow; + * [revision] is passed to [update]. */ suspend fun addKey( walletHandle: Long, identityId: ByteArray, key: IdentityPubkey, signerHandle: Long, - ) = update(walletHandle, identityId, listOf(key), emptyList(), signerHandle) + revision: Long? = null, + ) = update(walletHandle, identityId, listOf(key), emptyList(), signerHandle, revision) /** - * Disable existing key ids on [identityId], signed via [signerHandle]. + * Disable existing key ids on [identityId], signed via [signerHandle]; + * [revision] is passed to [update]. */ suspend fun disableKeys( walletHandle: Long, identityId: ByteArray, keyIds: List, signerHandle: Long, - ) = update(walletHandle, identityId, emptyList(), keyIds, signerHandle) + revision: Long? = null, + ) = update(walletHandle, identityId, emptyList(), keyIds, signerHandle, revision) /** * Add [addPublicKeys] and/or disable [disablePublicKeyIds] on From 36c02c81f79b4cdf40c14980efa02a3ae64d7eca Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:27:06 +0300 Subject: [PATCH 66/72] fix(platform-wallet): order key states after a restore by the restored balance block The FFI restore does not carry the key watermark, so after a restart a fetch at the cached revision took its key states whole and a lagging node could roll back a key state Core changed. When the host restores the identity's balance block (the Swift persister does, for identities the wallet owns), the restore now sets the key watermark to it: every fetch folded into the stored key states moved the balance block to its own or above, and the sidecar is written in the same transaction as the key rows, so a fetch below it is older than what the cache holds. The in-memory refusal note now goes with any fetch at or above the cached revision, not only one taken whole: such a proof shows Drive at the cache's revision. Co-Authored-By: Claude Opus 5.5 --- packages/rs-platform-wallet-ffi/src/persistence.rs | 12 ++++++++++++ .../identity/state/managed_identity/identity_ops.rs | 10 +++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 09e06b1fe14..322d1240349 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3501,6 +3501,13 @@ impl PlatformWalletPersistence for FFIPersister { )); } managed.last_updated_balance_block_time = found.then(|| stamp.into()); + // Every fetch folded into the stored key states moved + // the balance's block to its own or above, so it bounds + // their age when the key watermark is not restored. + if managed.last_synced_keys_block_time.is_none() { + managed.last_synced_keys_block_time = + managed.last_updated_balance_block_time; + } } } } @@ -7643,6 +7650,11 @@ mod tests { .identity_manager .wallet_identities[&[42; 32]][&0]; assert_eq!(managed.last_updated_balance_block_time.is_some(), mode == 1); + // The restored balance's block bounds the stored key states' age. + assert_eq!( + managed.last_synced_keys_block_time, + managed.last_updated_balance_block_time + ); assert_eq!( dpp::identity::accessors::IdentityGettersV0::balance(&managed.identity), 123 diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 87a11003146..94eaa6d5ca1 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -414,8 +414,10 @@ impl ManagedIdentity { /// it holds enabled. A fetch at the cache's revision from a block below /// the watermark is older than the key states the cache took, so it /// changes no key state; only a raised limit is taken. The watermark is - /// stored with the scalar snapshot, but the mobile persisters do not - /// restore it, so there the first fetch after a restart is taken whole. + /// stored with the scalar snapshot. A store that restores only the + /// balance's block gives that as the watermark (every fetch folded in + /// reached it); one that restores neither leaves none, so there the + /// first fetch after a restart is taken whole. /// /// Revisions only grow on Platform, so a fetch at a lower revision was /// answered before an update the cache already holds (one applied @@ -432,9 +434,11 @@ impl ManagedIdentity { let cached = self.identity.public_keys().clone(); let fold = self.key_fold(fetched, block_time); let taken = fold == KeyFold::Whole; + if fetched.revision() >= self.identity.revision() { + self.revision_refused_at = None; + } let mut keys = if taken { self.identity = fetched.clone(); - self.revision_refused_at = None; self.identity.set_balance(balance); if self .last_synced_keys_block_time From f4d318fd3e13fef14c746460a8a09542a886e16d Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sat, 24 Oct 2026 22:13:47 +0300 Subject: [PATCH 67/72] fix(platform-wallet): keep the key watermark monotonic The key watermark decides whether a same-revision fetch may change key states, so a lower or absent watermark must never replace a higher one. The setter, the fold, the changeset merge and the replay now all keep the later block by Platform height, at any revision. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 42 +++++++++++++++- .../state/managed_identity/identity_ops.rs | 8 +--- .../identity/state/managed_identity/mod.rs | 3 ++ .../identity/state/managed_identity/sync.rs | 14 +++--- .../wallet/identity/state/manager/apply.rs | 48 ++++++++++++++++++- .../src/wallet/identity/types/block_time.rs | 11 +++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 918c69d5a82..c0a2ae0e9ad 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1148,7 +1148,8 @@ impl Merge for IdentityChangeSet { // IdentityEntry is a scalar-only snapshot via // `IdentityEntry::from_managed`; public keys / private keys // live in the sibling `IdentityKeysChangeSet`. "Later wins" - // for scalar fields, revision-gated for balance/revision. + // for scalar fields, revision-gated for balance/revision; the + // key watermark keeps the later block. for (id, entry) in other.identities { self.identities .entry(id) @@ -1163,7 +1164,11 @@ impl Merge for IdentityChangeSet { existing.last_updated_balance_block_time = entry.last_updated_balance_block_time; } - existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; + // The key watermark only moves up, at any revision. + existing.last_synced_keys_block_time = BlockTime::later_watermark( + existing.last_synced_keys_block_time, + entry.last_synced_keys_block_time, + ); existing.status = entry.status; // `wallet_id` is immutable per identity (SHA256 of // root public key + chain code), so LWW and FWW are @@ -2687,6 +2692,39 @@ mod tests { } } + #[test] + fn should_merge_the_later_key_watermark() { + let id = Identifier::from([0x54; 32]); + let newer = Some(BlockTime::new(20, 50, 2000)); + for (incoming, expected) in [ + (None, newer), + (Some(BlockTime::new(10, 42, 1000)), newer), + (newer, newer), + ( + Some(BlockTime::new(30, 60, 3000)), + Some(BlockTime::new(30, 60, 3000)), + ), + ] { + for revision in [6, 7, 8] { + let mut old = identity_entry_with_contested(id, &[]); + old.revision = 7; + old.last_synced_keys_block_time = newer; + let mut later_entry = old.clone(); + later_entry.revision = revision; + later_entry.last_synced_keys_block_time = incoming; + let mut changes = IdentityChangeSet::default(); + changes.identities.insert(id, old); + let mut later = IdentityChangeSet::default(); + later.identities.insert(id, later_entry); + changes.merge(later); + assert_eq!( + changes.identities[&id].last_synced_keys_block_time, + expected + ); + } + } + } + #[test] fn test_empty_changeset() { let cs = PlatformWalletChangeSet::default(); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index 94eaa6d5ca1..c0fc004bacc 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -440,12 +440,8 @@ impl ManagedIdentity { let mut keys = if taken { self.identity = fetched.clone(); self.identity.set_balance(balance); - if self - .last_synced_keys_block_time - .is_none_or(|synced| synced.height < block_time.height) - { - self.last_synced_keys_block_time = Some(block_time); - } + self.last_synced_keys_block_time = + BlockTime::later_watermark(self.last_synced_keys_block_time, Some(block_time)); fetched.public_keys().clone() } else { cached.clone() diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 6b5e3da685d..8473135f85f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -266,6 +266,9 @@ mod tests { managed.last_synced_keys_block_time.unwrap().timestamp, 9876543210 ); + + managed.update_keys_sync_block_time(BlockTime::new(40000, 440000, 9876500000), &p); + assert_eq!(managed.last_synced_keys_block_time, Some(block_time)); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 02b4508d988..9ce4792d307 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -178,17 +178,19 @@ impl ManagedIdentity { } } - /// Set the key watermark (`last_synced_keys_block_time`) to - /// `block_time`, as is, and persist the scalar snapshot. A fetch at the - /// cached revision then takes its key states only from that block or a - /// later one (see `take_fetched_identity`), so set it only from a block - /// whose key states the cache holds. + /// Raise the key watermark (`last_synced_keys_block_time`) to + /// `block_time`, unless it is already at or above it, and persist the + /// scalar snapshot. A fetch at the cached revision then takes its key + /// states only from that block or a later one (see + /// `take_fetched_identity`), so raise it only from a block whose key + /// states the cache holds. pub fn update_keys_sync_block_time( &mut self, block_time: BlockTime, persister: &WalletPersister, ) { - self.last_synced_keys_block_time = Some(block_time); + self.last_synced_keys_block_time = + BlockTime::later_watermark(self.last_synced_keys_block_time, Some(block_time)); let cs = self.snapshot_changeset(); if let Err(e) = persister.store(cs.into()) { tracing::error!("Failed to persist changeset: {}", e); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index 03f8ed10004..c1221065754 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -17,6 +17,7 @@ use super::{IdentityLocation, IdentityManager}; use crate::changeset::{IdentityEntry, IdentityKeyEntry}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; +use crate::BlockTime; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::v0::IdentityV0; @@ -38,7 +39,8 @@ impl IdentityManager { /// as applying it once. If the identity already exists in either /// bucket, the scalar fields are updated in place; balance/revision /// are gated on `entry.revision >= existing.identity.revision()` - /// matching the merge policy on `IdentityChangeSet`. DPNS labels and + /// matching the merge policy on `IdentityChangeSet`, and the key + /// watermark keeps the later of the two blocks. DPNS labels and /// contested DPNS labels are complete canonical snapshots and are /// assigned wholesale. pub(crate) fn apply_identity_entry(&mut self, entry: IdentityEntry) { @@ -55,7 +57,10 @@ impl IdentityManager { ); existing.identity.set_revision(entry.revision); } - existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; + existing.last_synced_keys_block_time = BlockTime::later_watermark( + existing.last_synced_keys_block_time, + entry.last_synced_keys_block_time, + ); existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; // DPNS names: wholesale assign, matching the changeset's @@ -252,4 +257,43 @@ mod tests { .contested_dpns_names .is_empty()); } + + #[test] + fn should_keep_the_later_key_watermark_on_replay() { + let id = Identifier::from([0x54; 32]); + let mut manager = IdentityManager::default(); + let mut newer = entry(id, &[]); + newer.revision = 7; + newer.last_synced_keys_block_time = Some(BlockTime::new(20, 50, 2000)); + manager.apply_identity_entry(newer); + for (revision, older) in [ + (6, None), + (8, None), + (6, Some(BlockTime::new(10, 42, 1000))), + (8, Some(BlockTime::new(10, 42, 1000))), + ] { + let mut stale = entry(id, &[]); + stale.revision = revision; + stale.last_synced_keys_block_time = older; + manager.apply_identity_entry(stale); + assert_eq!( + manager + .locate_mut(&id) + .expect("identity must exist") + .last_synced_keys_block_time, + Some(BlockTime::new(20, 50, 2000)) + ); + } + let mut later = entry(id, &[]); + later.revision = 6; + later.last_synced_keys_block_time = Some(BlockTime::new(30, 60, 3000)); + manager.apply_identity_entry(later); + assert_eq!( + manager + .locate_mut(&id) + .expect("identity must exist") + .last_synced_keys_block_time, + Some(BlockTime::new(30, 60, 3000)) + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs index 073ad187b9d..1de424316a9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs @@ -37,6 +37,17 @@ impl BlockTime { pub fn is_older_than(&self, current_timestamp: TimestampMillis, max_age_millis: u64) -> bool { (current_timestamp - self.timestamp) > max_age_millis } + + /// The later of two key watermarks, by Platform height. A watermark only + /// moves up: an older or absent one in a later snapshot must not reopen + /// the cache to key states from below the block it already took. + pub(crate) fn later_watermark(kept: Option, incoming: Option) -> Option { + match (kept, incoming) { + (Some(kept), Some(incoming)) if incoming.height <= kept.height => Some(kept), + (kept, None) => kept, + (_, incoming) => incoming, + } + } } impl From for BlockTime { From 58d0fc9bac80f612f3b63f134abb9bcf65c81545 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:02:46 +0300 Subject: [PATCH 68/72] docs(kotlin-sdk): note that Kotlin has no next-revision reader for an update Co-Authored-By: Claude Opus 5.5 --- .../org/dashfoundation/dashsdk/identity/IdentityUpdates.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index b09cd70b7c3..cab5efd19d8 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -225,6 +225,10 @@ class IdentityUpdates internal constructor( * show the refused transition's nonce spent and the identity below that * revision, it throws * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionRefused]. + * Kotlin has no counterpart of Swift + * `ManagedPlatformWallet.nextIdentityUpdateRevision`: the revisions of + * such a refusal are only in its message, so the revision to pin next is + * not available as a value. * `null` takes whatever the cache gives, and on such a refusal is sent * once more at one above the proved revision. Port of the `revision:` argument * of Swift `ManagedPlatformWallet.updateIdentity`. From 9080abf83243aa1fc441c25b49e04966660b6b39 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:07:12 +0200 Subject: [PATCH 69/72] fix(platform-wallet)!: go back to lowering the cache on Drive's revision refusal Returns to the refusal handling of 2b8ba90889. When Drive refuses an update with InvalidIdentityRevisionError, the cached revision moves down to the one Drive holds and is stored with the snapshot, but only while the cache is still at the revision the refused transition was built from. The error is then returned to the caller. An update that was not pinned to a revision is no longer signed and broadcast a second time without the caller knowing. Removed: - the re-send after a proved nonce check; - the in-memory refusal note (revision_refused_at); - IdentityWallet::next_update_revision, with its FFI export and the Swift reader; - IdentityRevisionRefused, which was FFI code 63. Also removed is the ordering of key states at the cache's own revision by the key watermark, with the monotonic watermark and its restore seeding. A fetch at the cache's revision is again taken whole, as on base. Kept: - the Kotlin addKey/disableKeys revision pass-through; - the checked_add guard in update_revision; - the breadcrumb doc fixes; - Swift updateIdentity keeping self alive for the detached FFI call. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 15 - .../dashsdk/identity/IdentityUpdates.kt | 13 +- .../dashsdk/errors/DashSdkErrorTest.kt | 10 - .../ERROR_CODE_REGISTRY.md | 7 +- packages/rs-platform-wallet-ffi/src/error.rs | 32 - .../src/identity_update.rs | 77 +- .../rs-platform-wallet-ffi/src/persistence.rs | 12 - .../src/changeset/changeset.rs | 42 +- packages/rs-platform-wallet/src/error.rs | 18 - .../src/wallet/identity/network/update.rs | 1002 +++++------------ .../state/managed_identity/identity_ops.rs | 207 +--- .../identity/state/managed_identity/mod.rs | 15 +- .../identity/state/managed_identity/sync.rs | 12 +- .../wallet/identity/state/manager/apply.rs | 48 +- .../src/wallet/identity/types/block_time.rs | 11 - .../ManagedPlatformWallet.swift | 36 +- .../PlatformWallet/PlatformWalletResult.swift | 18 - .../ErrorHandlingTests.swift | 14 - 18 files changed, 365 insertions(+), 1224 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 719caa2a256..4f4eb0fb4ce 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -249,20 +249,6 @@ sealed class DashSdkError( class IdentityRevisionMismatch(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) - /** - * `ErrorIdentityRevisionRefused` (native code 63). Drive refused an - * identity update asked for a revision, for its revision, and proofs - * show its nonce spent and the identity at a revision below the one - * it would take (a cache ahead of the chain). Drive charged a - * processing fee and took the nonce, so the refused update can never - * execute; nothing else changed, and the cache was left as it is; - * the revisions are in [message]. Not retryable as is: the next - * update may ask for the proved revision plus one. Port of Swift - * `PlatformWalletError.identityRevisionRefused`. - */ - class IdentityRevisionRefused(message: String, cause: Throwable? = null) : - PlatformWallet(message, cause) - /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -792,7 +778,6 @@ sealed class DashSdkError( 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds 60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs 62 -> PlatformWallet.IdentityRevisionMismatch(message, cause) // ErrorIdentityRevisionMismatch - 63 -> PlatformWallet.IdentityRevisionRefused(message, cause) // ErrorIdentityRevisionRefused // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index cab5efd19d8..70d53c9240a 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -221,17 +221,8 @@ class IdentityUpdates internal constructor( * before anything is signed or broadcast, so a caller that recorded * [revision] beforehand knows the only revision this update can land at * (Drive takes an identity update only at one above the identity's - * revision). When Drive refuses the update for its revision and proofs - * show the refused transition's nonce spent and the identity below that - * revision, it throws - * [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionRefused]. - * Kotlin has no counterpart of Swift - * `ManagedPlatformWallet.nextIdentityUpdateRevision`: the revisions of - * such a refusal are only in its message, so the revision to pin next is - * not available as a value. - * `null` takes whatever the cache gives, and on such a refusal is sent - * once more at one above the proved revision. Port of the `revision:` argument - * of Swift `ManagedPlatformWallet.updateIdentity`. + * revision). `null` takes whatever the cache gives. Port of the + * `revision:` argument of Swift `ManagedPlatformWallet.updateIdentity`. */ suspend fun update( walletHandle: Long, diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index 3ba68214c8d..d3ccbc34b28 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -29,16 +29,6 @@ class DashSdkErrorTest { assertFalse(mapped.isRetryable) } - @Test - fun shouldPreserveIdentityRevisionRefusedAsItsOwnError() { - val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 63, "refused 7, Drive holds 5") - val mapped = DashSdkError.fromNative(native) - assertTrue(mapped is DashSdkError.PlatformWallet.IdentityRevisionRefused) - assertEquals(native.message, mapped.message) - assertEquals(native, mapped.cause) - assertFalse(mapped.isRetryable) - } - @Test fun mapsNativeCodesToHierarchy() { // Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 0180c4941d6..3f33d9e6bdf 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 64** — 27–63 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 63** — 27–62 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -124,8 +124,8 @@ identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed -by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 and 63 proposed by #5487 for -`ErrorIdentityRevisionMismatch` and `ErrorIdentityRevisionRefused`; 28, 30, +by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 proposed by #5487 for +`ErrorIdentityRevisionMismatch`; 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -180,7 +180,6 @@ Fork-era numbers remain in the collision history, which is immutable record. | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | | 62 | `ErrorIdentityRevisionMismatch` | #5487 | Proposed — 61 is held by open #5395 (`ErrorCoreFundsAwaitingNetwork`), so this takes 62 (#5395 records its own row). An identity update asked for a revision (`update_identity_with_external_signer_at_revision`) the cached identity would not give it; refused before signing, nothing broadcast, the expected and actual revisions in the message. Not retryable as is: the host refreshes and decides again. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `identityRevisionMismatch` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.IdentityRevisionMismatch` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin | -| 63 | `ErrorIdentityRevisionRefused` | #5487 | Proposed — Drive refused an identity update asked for a revision (`update_identity_with_external_signer_at_revision`) for its revision, and proofs show its nonce spent and the identity at a revision below the one it would take: the cache is ahead of the chain. Drive charged a processing fee and took the nonce, so the refused update can never execute; nothing else changed, and the cache was left as it is; the revisions are in the message. Not retryable as is: read the revision Drive takes from `platform_wallet_identity_next_update_revision` and update again asking for it. Mirrored in Swift (`errorIdentityRevisionRefused = 63`, `init(ffi:)` arm, typed `PlatformWalletError.identityRevisionRefused`, pinned by `ErrorHandlingTests.testIdentityRevisionRefusedFFIResultMapping`) and Kotlin (`63 ->` arm to `DashSdkError.PlatformWallet.IdentityRevisionRefused`, pinned by `DashSdkErrorTest.shouldPreserveIdentityRevisionRefusedAsItsOwnError`); Rust pins it in `should_preserve_identity_revision_refused_code_and_message` | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 72d6133738a..9ba8b38384d 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -622,17 +622,6 @@ pub enum PlatformWalletFFIResultCode { /// are in the message. ErrorIdentityRevisionMismatch = 62, - /// Maps `PlatformWalletError::IdentityRevisionRefused`: Drive refused an - /// identity update asked to carry a revision, for its revision, and - /// proofs show its nonce spent and the identity at a revision below the - /// one it would take (a cache ahead of the chain). Drive charged a - /// processing fee and took the nonce, so the refused update can never - /// execute; nothing else changed, and the cache was left as it is. Read - /// the revision Drive takes from - /// `platform_wallet_identity_next_update_revision`, record it, and - /// update again asking for it. The revisions are in the message. - ErrorIdentityRevisionRefused = 63, - /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1050,9 +1039,6 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::IdentityRevisionMismatch { .. } => { PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch } - PlatformWalletError::IdentityRevisionRefused { .. } => { - PlatformWalletFFIResultCode::ErrorIdentityRevisionRefused - } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. @@ -1714,24 +1700,6 @@ mod tests { assert_eq!(message_of(&result), rendered); } - #[test] - fn should_preserve_identity_revision_refused_code_and_message() { - let error = PlatformWalletError::IdentityRevisionRefused { - identity_id: Identifier::from([7; 32]), - refused: 7, - drive_revision: 5, - }; - let rendered = error.to_string(); - let result: PlatformWalletFFIResult = error.into(); - - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorIdentityRevisionRefused - ); - assert_eq!(result.code as i32, 63, "the host ABI code must stay stable"); - assert_eq!(message_of(&result), rendered); - } - #[test] fn should_preserve_shielded_identity_debit_pending_code_and_message() { let error = PlatformWalletError::ShieldedIdentityDebitPending { diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index d8bae2451a2..e22e7591ac4 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -348,13 +348,7 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( /// update must carry. When the cached identity would give the transition /// another revision (it moved since the caller read it), the call fails /// before anything is signed or broadcast, so a caller that recorded -/// `revision` knows the only revision this update can land at. Read the -/// revision to record from [`platform_wallet_identity_next_update_revision`]. -/// When Drive refuses the update for its revision and proofs show the -/// refused transition's nonce spent and the identity below that revision, -/// the call fails with `ErrorIdentityRevisionRefused` (63) and the next -/// call to that reader gives the revision Drive takes. Any other failure -/// keeps its own code. +/// `revision` knows the only revision this update can land at. /// /// # Safety /// Same pointer, length and lifetime rules as @@ -383,37 +377,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision ) } -/// The revision the next update of `identity_id` carries, into -/// `out_revision`: one above the cached identity's, or, after -/// Drive refused the last update for its revision below the cache's -/// (`ErrorIdentityRevisionRefused` for a pinned update), one above the -/// proved revision while the cache is still where it was then. The note -/// lives in memory: after a restart this gives one above the cache again. -/// Record it before submitting and pass it to -/// [`platform_wallet_update_identity_with_signer_at_revision`]. -/// -/// # Safety -/// `identity_id` must point to 32 readable bytes and `out_revision` to a -/// writable `u64`, both for the duration of the call. -#[no_mangle] -pub unsafe extern "C" fn platform_wallet_identity_next_update_revision( - wallet_handle: Handle, - identity_id: *const u8, - out_revision: *mut u64, -) -> PlatformWalletFFIResult { - check_ptr!(out_revision); - *out_revision = 0; - let id = unwrap_result_or_return!(read_identifier(identity_id)); - - let option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { - let identity_wallet = wallet.identity().clone(); - block_on_worker(async move { identity_wallet.next_update_revision(&id).await }) - }); - let result = unwrap_option_or_return!(option); - *out_revision = unwrap_result_or_return!(result); - PlatformWalletFFIResult::ok() -} - #[allow(clippy::too_many_arguments)] unsafe fn update_identity_with_signer( wallet_handle: Handle, @@ -609,44 +572,6 @@ mod tests { }) } - /// The reader gives one above the cached revision for an identity the - /// wallet holds, and refuses a null out-pointer and an unknown identity. - #[test] - fn should_read_the_next_update_revision_through_the_export() { - let identity_id = Identifier::from([0x22; 32]); - let id_bytes = identity_id.to_buffer(); - let (_manager, handle) = wallet_handle_at_revision(identity_id, 5); - - let mut revision = u64::MAX; - let result = unsafe { - platform_wallet_identity_next_update_revision(handle, id_bytes.as_ptr(), &mut revision) - }; - assert_eq!( - result.code, - PlatformWalletFFIResultCode::Success, - "{}", - message_of(&result) - ); - assert_eq!(revision, 6); - - let result = unsafe { - platform_wallet_identity_next_update_revision( - handle, - id_bytes.as_ptr(), - ptr::null_mut(), - ) - }; - assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); - - let unknown = Identifier::from([0x23; 32]).to_buffer(); - let mut revision = u64::MAX; - let result = unsafe { - platform_wallet_identity_next_update_revision(handle, unknown.as_ptr(), &mut revision) - }; - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); - assert_eq!(revision, 0); - } - /// The `_at_revision` export hands its revision to the wallet's guard: a /// revision the cached identity would not give the transition comes back /// as `ErrorIdentityRevisionMismatch` (62) before the signer is diff --git a/packages/rs-platform-wallet-ffi/src/persistence.rs b/packages/rs-platform-wallet-ffi/src/persistence.rs index 322d1240349..09e06b1fe14 100644 --- a/packages/rs-platform-wallet-ffi/src/persistence.rs +++ b/packages/rs-platform-wallet-ffi/src/persistence.rs @@ -3501,13 +3501,6 @@ impl PlatformWalletPersistence for FFIPersister { )); } managed.last_updated_balance_block_time = found.then(|| stamp.into()); - // Every fetch folded into the stored key states moved - // the balance's block to its own or above, so it bounds - // their age when the key watermark is not restored. - if managed.last_synced_keys_block_time.is_none() { - managed.last_synced_keys_block_time = - managed.last_updated_balance_block_time; - } } } } @@ -7650,11 +7643,6 @@ mod tests { .identity_manager .wallet_identities[&[42; 32]][&0]; assert_eq!(managed.last_updated_balance_block_time.is_some(), mode == 1); - // The restored balance's block bounds the stored key states' age. - assert_eq!( - managed.last_synced_keys_block_time, - managed.last_updated_balance_block_time - ); assert_eq!( dpp::identity::accessors::IdentityGettersV0::balance(&managed.identity), 123 diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index c0a2ae0e9ad..918c69d5a82 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1148,8 +1148,7 @@ impl Merge for IdentityChangeSet { // IdentityEntry is a scalar-only snapshot via // `IdentityEntry::from_managed`; public keys / private keys // live in the sibling `IdentityKeysChangeSet`. "Later wins" - // for scalar fields, revision-gated for balance/revision; the - // key watermark keeps the later block. + // for scalar fields, revision-gated for balance/revision. for (id, entry) in other.identities { self.identities .entry(id) @@ -1164,11 +1163,7 @@ impl Merge for IdentityChangeSet { existing.last_updated_balance_block_time = entry.last_updated_balance_block_time; } - // The key watermark only moves up, at any revision. - existing.last_synced_keys_block_time = BlockTime::later_watermark( - existing.last_synced_keys_block_time, - entry.last_synced_keys_block_time, - ); + existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; // `wallet_id` is immutable per identity (SHA256 of // root public key + chain code), so LWW and FWW are @@ -2692,39 +2687,6 @@ mod tests { } } - #[test] - fn should_merge_the_later_key_watermark() { - let id = Identifier::from([0x54; 32]); - let newer = Some(BlockTime::new(20, 50, 2000)); - for (incoming, expected) in [ - (None, newer), - (Some(BlockTime::new(10, 42, 1000)), newer), - (newer, newer), - ( - Some(BlockTime::new(30, 60, 3000)), - Some(BlockTime::new(30, 60, 3000)), - ), - ] { - for revision in [6, 7, 8] { - let mut old = identity_entry_with_contested(id, &[]); - old.revision = 7; - old.last_synced_keys_block_time = newer; - let mut later_entry = old.clone(); - later_entry.revision = revision; - later_entry.last_synced_keys_block_time = incoming; - let mut changes = IdentityChangeSet::default(); - changes.identities.insert(id, old); - let mut later = IdentityChangeSet::default(); - later.identities.insert(id, later_entry); - changes.merge(later); - assert_eq!( - changes.identities[&id].last_synced_keys_block_time, - expected - ); - } - } - } - #[test] fn test_empty_changeset() { let cs = PlatformWalletChangeSet::default(); diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7a951e7376a..7a9a371bdf8 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -100,24 +100,6 @@ pub enum PlatformWalletError { actual: u64, }, - /// Drive refused an identity update asked to carry `refused` for its - /// revision, and proofs show the refused transition's nonce spent and - /// the identity at `drive_revision`, below the revision it would take: - /// the cache is ahead of the chain. Drive charged a processing fee and - /// took the nonce, so the refused transition can never execute; nothing - /// else changed. The cache is left as it is; the next update may be - /// asked to carry `drive_revision + 1` while the cache has not moved - /// (`IdentityWallet::next_update_revision` gives it). - #[error( - "identity {identity_id}: Drive refused the update at revision {refused} and holds \ - revision {drive_revision}, below the cached identity's" - )] - IdentityRevisionRefused { - identity_id: Identifier, - refused: u64, - drive_revision: u64, - }, - /// A DashPay invitation link's voucher key is for the other network (a /// testnet link opened in a mainnet wallet, or the reverse). Definitive: /// the claim refuses the link before any network work. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 58acd7effc2..242e5701c1f 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -1,25 +1,18 @@ //! Mutate an identity's public-key set. use async_trait::async_trait; -use dash_sdk::platform::Fetch; -use dash_sdk::query_types::{IdentityBalanceAndRevision, IdentityNonceFetcher}; use dpp::address_funds::AddressWitness; -use dpp::consensus::state::state_error::StateError; -use dpp::consensus::ConsensusError; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; -use dpp::identity::identity_nonce::{validate_identity_nonce_update, MergeIdentityNonceResult}; -use dpp::identity::identity_public_key::accessors::v0::{ - IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, -}; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; use dpp::platform_value::BinaryData; -use dpp::prelude::{Identifier, IdentityNonce}; +use dpp::prelude::Identifier; use dpp::ProtocolError; use std::time::{SystemTime, UNIX_EPOCH}; @@ -97,17 +90,11 @@ impl IdentityWallet { /// back: a fetched identity at a lower revision than the cache's keeps /// the cache's revision, key set and every disable (see /// `ManagedIdentity::take_fetched_identity`). When Drive refuses the - /// update for its revision (a cache an older build left ahead of the - /// chain, which no fetch heals since each comes back older), and proofs - /// show the refused transition's nonce spent and the identity below its - /// revision, the update is sent once more at one above the proved - /// revision; the cache is not lowered, and the landed update brings the - /// chain to the cache's revision (shipped builds leave a cache at most - /// one past the chain, since every later update is refused). Without - /// those proofs the refusal fails the call as it is. Drive charges for - /// each refused update. When the call - /// fails after the broadcast (the proof wait timed out, say), the update - /// may have landed without this step: refresh before the next update. + /// update for its revision and holds a lower one than the cache, the + /// cache takes Drive's (see `take_rejected_revision`), so the next + /// update carries the right one. When the call fails after the + /// broadcast (the proof wait timed out, say), the update may have + /// landed without this step: refresh before the next update. pub async fn update_identity_with_external_signer( &self, identity_id: &Identifier, @@ -134,25 +121,15 @@ impl IdentityWallet { /// the transition must carry. /// /// The transition takes the cached identity's revision plus one, read - /// when the update starts (or, asked for it, one above the proved - /// revision after Drive refused the last update below the cache). A - /// caller that records the revision before + /// when the update starts. A caller that records the revision before /// submitting (to tell later whether the update can still land: Drive /// takes an identity update only at one above the identity's revision) /// cannot see that read, and a refresh in between moves it. With /// `revision` set, an update that would carry another one fails with /// [`PlatformWalletError::IdentityRevisionMismatch`] before anything is /// signed or broadcast, so the revision the caller recorded is the only - /// one this update can land at. Read the revision to record from - /// [`Self::next_update_revision`]. When Drive refuses the update for its - /// revision and proofs show the refused transition's nonce spent and the - /// identity below its revision, the update is not sent again at another - /// revision: it fails with [`PlatformWalletError::IdentityRevisionRefused`], - /// and [`Self::next_update_revision`] then gives the revision Drive - /// takes, which the next update may be asked to carry. `None` takes - /// whatever the cache gives, as - /// [`Self::update_identity_with_external_signer`] does, and is sent once - /// more on such a refusal. + /// one this update can land at. `None` takes whatever the cache gives, + /// as [`Self::update_identity_with_external_signer`] does. pub async fn update_identity_with_external_signer_at_revision( &self, identity_id: &Identifier, @@ -174,7 +151,7 @@ impl IdentityWallet { // for the derivation breadcrumb on the post-broadcast apply // pass below). Read lock only — the broadcast itself doesn't // touch local state. - let (mut identity, identity_index, refused_at) = { + let (mut identity, identity_index) = { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -194,15 +171,12 @@ impl IdentityWallet { // identity_index, key_index)` derivation metadata for // the iOS Keychain re-derivation path. let index = manager.identity_index(identity_id); - let refused_at = manager - .identity(identity_id) - .and_then(|m| m.revision_refused_at); - (identity, index, refused_at) + (identity, index) }; - let built_from = identity.revision(); - let mut transition_revision = - update_revision(identity_id, built_from, revision, refused_at)?; + // Increment revision for the update transition. + let transition_revision = update_revision(identity_id, identity.revision(), revision)?; + identity.set_revision(transition_revision); // Pick the MASTER signing key — DPP requires identity update // transitions to be authorized by MASTER specifically. @@ -221,96 +195,58 @@ impl IdentityWallet { ) })?; + let identity_nonce = self + .sdk + .get_identity_nonce(identity.id(), true, settings) + .await?; + let user_fee_increase = settings .and_then(|s| s.user_fee_increase) .unwrap_or_default(); - // Drive refuses an update whose revision is not one above the - // identity's, and says which revision it holds. That word comes - // without a proof, so it only prompts two proved reads (see - // `prove_refusal`): the identity's nonce, which must show the - // refused transition's nonce spent so that transition can never - // execute, and the identity's revision. When that is below the - // refused transition's by more than one, an update asked to - // carry no revision is sent once more at one above the proved - // revision; the cache itself is never lowered. That re-send cannot - // land the update twice: the refused transition can no longer - // execute, and had it executed after all (a node that lied about - // the refusal), the chain is at its revision or above, and Drive - // refuses the re-send, whose revision is not above it. A proof that - // lags costs one more paid refusal, which is proved again; the note a - // pinned update reads keeps the highest revision proved, and goes - // once a proof shows Drive at or above the cache's. - let mut retried = false; - loop { - identity.set_revision(transition_revision); - let identity_nonce = self - .sdk - .get_identity_nonce(identity.id(), true, settings) - .await?; - - let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( - &identity, - &master_key_id, - add_public_keys.clone(), - disable_public_keys.clone(), - identity_nonce, - user_fee_increase, - &SignerRef(signer), - self.sdk.version(), - None, - ) - .await - .map_err(|e| { - PlatformWalletError::InvalidIdentityData(format!( - "Failed to create identity update transition: {}", - e - )) - })?; + // `try_from_identity_with_signer` consumes the keys vec, so + // clone before handing it off — we need the originals to + // apply locally after the broadcast succeeds. + let added_keys_for_local_apply = add_public_keys.clone(); + let disabled_ids_for_local_apply = disable_public_keys.clone(); - let Err(e) = state_transition - .broadcast_and_wait::(&self.sdk, settings) - .await - else { - break; - }; - let proved = if is_revision_refusal(identity_id, &e) { - self.prove_refusal(identity_id, identity_nonce, settings) - .await - } else { - None - }; - if let Some(proved) = proved { - self.note_refused_revision(identity_id, built_from, proved) - .await; - } - match after_refusal(proved, transition_revision, revision.is_some(), retried) { - AfterRefusal::Retry(next) => { - retried = true; - transition_revision = next; - } - AfterRefusal::Refused(drive_revision) => { - return Err(PlatformWalletError::IdentityRevisionRefused { - identity_id: *identity_id, - refused: transition_revision, - drive_revision, - }); - } - AfterRefusal::Fail => { - return Err(PlatformWalletError::InvalidIdentityData(format!( - "Failed to broadcast identity update: {}", - e - ))); - } - } + let state_transition = IdentityUpdateTransition::try_from_identity_with_signer( + &identity, + &master_key_id, + add_public_keys, + disable_public_keys, + identity_nonce, + user_fee_increase, + &SignerRef(signer), + self.sdk.version(), + None, + ) + .await + .map_err(|e| { + PlatformWalletError::InvalidIdentityData(format!( + "Failed to create identity update transition: {}", + e + )) + })?; + + if let Err(e) = state_transition + .broadcast_and_wait::(&self.sdk, settings) + .await + { + self.take_rejected_revision(identity_id, transition_revision, &e) + .await; + return Err(PlatformWalletError::InvalidIdentityData(format!( + "Failed to broadcast identity update: {}", + e + ))); } self.apply_landed_update( identity_id, identity_index, transition_revision, - add_public_keys, - disable_public_keys, + added_keys_for_local_apply, + disabled_ids_for_local_apply, ) .await } @@ -326,18 +262,16 @@ impl IdentityWallet { /// duplicate. /// - The Swift `PersistentPublicKey` rows never receive /// the new keys (rows arrive via the - /// `IdentityKeysChangeSet` persister callback). + /// `IdentityKeysChangeSet` persister callback that + /// `add_key` fires). /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// - /// A cache behind `transition_revision` moves to it. The update's keys - /// are then stored with the scalar snapshot, revision included, as one - /// fallible write under the write lock, so the stored revision never - /// runs ahead of or behind the stored keys, and the snapshot is stored - /// even when no key matched: an added key with the breadcrumb the update - /// gives it, which lets the iOS Keychain re-derive the matching private - /// bytes from the wallet seed + DIP-9 path, and a disabled key without - /// one (the persisters keep what the row holds). + /// `ManagedIdentity::add_key` handles both the in-memory + /// mutation AND the `IdentityKeysChangeSet` upsert. Run it + /// on each new key under a write lock; the breadcrumb lets + /// the iOS Keychain re-derive the matching private bytes + /// from the wallet seed + DIP-9 path. /// /// A cache already refreshed to `transition_revision` or past it (a /// fetch answered between the update's snapshot and this step) holds the @@ -345,15 +279,11 @@ impl IdentityWallet { /// whatever later updates did: the payload is not applied over it. The /// fetch that put it there may not have been stored (a failed mobile /// round leaves the fold in memory), so the update's keys are stored - /// from the cache's own copies (a refresh stores its rows without a - /// breadcrumb). A cache an older build left ahead of the chain is past - /// the transition without holding it (the update was sent at the - /// revision Drive takes): an added key it lacks takes the update's copy. - /// Either way a disabled key the cache holds enabled is disabled with - /// the local placeholder time, which the next fetch at the update's - /// revision or above reconciles to the on-chain block time; a cache - /// that holds the update already shows both. Any note of a refused - /// revision goes: Drive took this one. + /// here from the cache's own copies, with the scalar snapshot, as one + /// fallible write: an added key with the breadcrumb the update gives it + /// (a refresh stores its rows without one), a disabled key without one + /// (the persisters keep what the row holds). The snapshot is stored even + /// when no key matched. async fn apply_landed_update( &self, identity_id: &Identifier, @@ -371,56 +301,115 @@ impl IdentityWallet { let Some(managed) = info.identity_manager.managed_identity_mut(identity_id) else { return Ok(()); }; - // Landed: Drive took this revision, so a refusal noted before it - // says nothing any more. - managed.revision_refused_at = None; - if managed.identity.revision() < transition_revision { - // The identity is at the transition's revision now, so a - // subsequent update must not reuse the pre-broadcast value. - managed.identity.set_revision(transition_revision); - } - let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); - let mut keys: Vec = added_keys - .into_iter() - .map(|key| KeyWithBreadcrumb { - breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), - key: managed - .identity - .public_keys() - .get(&key.id()) - .cloned() - .unwrap_or(key), - }) - .collect(); - keys.extend( - disabled_ids - .iter() - .filter(|id| !added_ids.contains(id)) - .filter_map(|id| managed.identity.public_keys().get(id).cloned()) - .map(|mut key| { - if !key.is_disabled() { - key.set_disabled_at(local_disable_time()); - } - KeyWithBreadcrumb { + if managed.identity.revision() >= transition_revision { + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let mut keys: Vec = added_keys + .into_iter() + .map(|key| KeyWithBreadcrumb { + breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), + key: managed + .identity + .public_keys() + .get(&key.id()) + .cloned() + .unwrap_or(key), + }) + .collect(); + keys.extend( + disabled_ids + .iter() + .filter(|id| !added_ids.contains(id)) + .filter_map(|id| managed.identity.public_keys().get(id).cloned()) + .map(|key| KeyWithBreadcrumb { key, breadcrumb: None, - } - }), - ); - managed - .store_keys_with_snapshot(keys, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - }) + }), + ); + return managed + .store_keys_with_snapshot(keys, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }); + } + + // The identity is at the transition's revision now, so a + // subsequent update must not reuse the pre-broadcast value. + managed.identity.set_revision(transition_revision); + + for key in added_keys { + let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); + managed + .add_key(key, breadcrumb, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + })?; + } + + if !disabled_ids.is_empty() { + // Disable-side counterpart to the `add_key` loop + // above: stamp `disabled_at` on the matching cached + // keys and fire the persister so the Swift + // `PersistentPublicKey.disabledAt` rows flip without + // a network re-fetch. The local wall-clock timestamp + // is a placeholder — the next fetch at this revision or + // above reconciles it to the authoritative on-chain block + // time. `disable_keys` rebuilds the derivation breadcrumb + // from the managed identity's own wallet slot, so the + // disabled key keeps its private-key linkage. + let disabled_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + managed.disable_keys(&disabled_ids, disabled_at, &self.persister); + } + + Ok(()) } - /// Note the identity's `proved` revision after a refusal of an update - /// built on the cached revision `built_from`, while the cache is still - /// there (see [`noted_refusal`]). - async fn note_refused_revision(&self, identity_id: &Identifier, built_from: u64, proved: u64) { + /// Take Drive's revision from an update it refused for its revision. + /// + /// Drive takes an identity update only at one above the identity's + /// revision and otherwise refuses it in the block with + /// `InvalidIdentityRevisionError`, which carries the revision it holds. + /// A cache ahead of that value cannot heal from a fetch, which comes back + /// older and keeps the cache's revision, so every update would be + /// refused again. A build before this one could leave a cache one past + /// the chain (a fetch answered between an update's snapshot and its + /// bump, which then counted twice). The cached revision moves down to + /// Drive's and is stored with the scalar snapshot, so the next update + /// built from the cache carries the right one; the key set is left as + /// it is. + /// + /// The cache is lowered only while it is still at the revision the + /// refused transition was built from (`transition_revision - 1`): a + /// fetch or another update that moved it since knows more than this + /// refusal. A cache at Drive's revision or below is left alone: raising + /// it would take no keys with it. The error comes from one node without + /// a proof, so a node that lies low gets later updates refused until a + /// fetch, which is taken at or above the lowered revision, brings the + /// cache back; it can never lock the cache ahead. A snapshot at a higher + /// revision still buffered in the store wins the merge over this one, + /// so a restart before it is written can bring the old revision back, + /// to be lowered again by the next refusal. A failed store is logged; + /// the caller gets the refusal either way. + async fn take_rejected_revision( + &self, + identity_id: &Identifier, + transition_revision: u64, + error: &dash_sdk::Error, + ) { + let Some(rejection) = as_identity_revision_invalid(error) else { + return; + }; + if rejection.identity_id() != identity_id { + return; + } + let current = *rejection.current_revision(); let mut wm = self.wallet_manager.write().await; let Some(managed) = wm .get_wallet_info_mut(&self.wallet_id) @@ -428,72 +417,17 @@ impl IdentityWallet { else { return; }; - if managed.identity.revision() == built_from { - managed.revision_refused_at = - noted_refusal(managed.revision_refused_at, built_from, proved); + let built_from = transition_revision.saturating_sub(1); + if managed.identity.revision() != built_from || built_from <= current { + return; } - } - - /// The identity's proved revision after Drive refused an update of it - /// with `nonce` for its revision, or `None` unless a proof shows `nonce` - /// spent (see [`spent_refusal`]). A failed read counts as no proof. - async fn prove_refusal( - &self, - identity_id: &Identifier, - nonce: IdentityNonce, - settings: Option, - ) -> Option { - let request_settings = settings.map(|s| s.request_settings).unwrap_or_default(); - let stored_nonce = - IdentityNonceFetcher::fetch_with_settings(&self.sdk, *identity_id, request_settings) - .await - .ok()? - .map(|fetched| fetched.0); - let revision = IdentityBalanceAndRevision::fetch_with_settings( - &self.sdk, - *identity_id, - request_settings, - ) - .await - .ok()? - .map(|(_, revision)| revision); - spent_refusal(identity_id, nonce, stored_nonce, revision) - } - - /// The revision the next update of `identity_id` carries: one above the - /// cached identity's, or one above the proved revision after Drive - /// refused the last update for its revision, while the cache is still - /// where it was then ([`PlatformWalletError::IdentityRevisionRefused`]). - /// That note lives in memory: after a restart this is one above the - /// cache again, and an update asked for the noted revision fails before - /// signing with [`PlatformWalletError::IdentityRevisionMismatch`]; one - /// asked for this revision is refused once more, which notes the proved - /// revision again. - /// A caller that records the revision before submitting reads it here - /// and passes it to [`Self::update_identity_with_external_signer_at_revision`]. - /// After such a refusal the cached revision is at or above the one - /// returned while the chain is below it, so settle a record of an update - /// sent at it against a proved revision (a fetch whose answer is at or - /// above the record), not against the cache. - pub async fn next_update_revision( - &self, - identity_id: &Identifier, - ) -> Result { - let wm = self.wallet_manager.read().await; - let managed = wm - .get_wallet_info(&self.wallet_id) - .ok_or_else(|| { - PlatformWalletError::WalletNotFound( - "Wallet info not found in wallet manager".to_string(), - ) - })? - .identity_manager - .identity(identity_id) - .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; - let cached = managed.identity.revision(); - match managed.revision_refused_at { - Some((at, drive)) if at == cached => Ok(drive + 1), - _ => update_revision(identity_id, cached, None, None), + managed.identity.set_revision(current); + if let Err(e) = managed.store_keys_with_snapshot(vec![], &self.persister) { + tracing::error!( + identity = %identity_id, + revision = current, + "identity revision from Drive not persisted: {e}" + ); } } @@ -565,14 +499,11 @@ impl IdentityWallet { } /// The revision an update of an identity cached at `cached` carries: one -/// above it. With `expected` set, any other value is refused, except one -/// above the proved revision after Drive refused the last update below the -/// cache, while the cache is still at `cached` (`refused_at`). +/// above it. With `expected` set, any other value is refused. fn update_revision( identity_id: &Identifier, cached: u64, expected: Option, - refused_at: Option<(u64, u64)>, ) -> Result { let next = cached.checked_add(1).ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( @@ -580,116 +511,13 @@ fn update_revision( )) })?; match expected { - Some(expected) if expected == next => Ok(next), - // Drive refused the last update and a proof showed a revision below - // the cache's, which is still where it was: the caller asks for the - // one Drive takes (`next_update_revision`). - Some(expected) - if refused_at.is_some_and(|(at, drive)| at == cached && drive + 1 == expected) => - { - Ok(expected) - } - Some(expected) => Err(PlatformWalletError::IdentityRevisionMismatch { + Some(expected) if expected != next => Err(PlatformWalletError::IdentityRevisionMismatch { identity_id: *identity_id, expected, actual: next, }), - None => Ok(next), - } -} - -/// What an update does after a failed broadcast. -#[derive(Debug, PartialEq, Eq)] -enum AfterRefusal { - /// Send the update once more, at this revision. - Retry(u64), - /// Fail with `IdentityRevisionRefused`, Drive at this revision. - Refused(u64), - /// Fail with the broadcast's error. - Fail, -} - -/// The step after a failed broadcast of an update at `transition`, `proved` -/// being the identity's proved revision when a proof shows Drive refused the -/// update for its revision (see [`spent_refusal`]). Only a revision below -/// `transition` by more than one leads anywhere, since only then can -/// another transition at a revision no higher than `transition`'s land: a -/// pinned update fails with it, an update asked for no revision is sent -/// once more at one above it. -fn after_refusal( - proved: Option, - transition: u64, - pinned: bool, - retried: bool, -) -> AfterRefusal { - let Some((drive, next)) = proved - .and_then(|drive| Some((drive, drive.checked_add(1)?))) - .filter(|&(_, next)| next < transition) - else { - return AfterRefusal::Fail; - }; - if pinned { - AfterRefusal::Refused(drive) - } else if retried { - AfterRefusal::Fail - } else { - AfterRefusal::Retry(next) - } -} - -/// Whether `error` is Drive's refusal of an update of `identity_id` for its -/// revision (`InvalidIdentityRevisionError`). It comes without a proof, so -/// the revision it carries is not read. -fn is_revision_refusal(identity_id: &Identifier, error: &dash_sdk::Error) -> bool { - as_identity_revision_invalid(error) - .is_some_and(|rejection| rejection.identity_id() == identity_id) -} - -/// The proved revision `revision` of an identity whose update with `nonce` -/// Drive refused, or `None` unless its proved nonce word `stored_nonce` -/// shows `nonce` spent by Drive's own merge rule -/// (`validate_identity_nonce_update` finds it already present or too far in -/// the past): then the refused transition can never execute, even one a -/// node kept back while it reported the refusal. -fn spent_refusal( - identity_id: &Identifier, - nonce: IdentityNonce, - stored_nonce: Option, - revision: Option, -) -> Option { - let spent = stored_nonce.is_some_and(|stored| { - matches!( - validate_identity_nonce_update(stored, nonce, *identity_id).first_error(), - Some(ConsensusError::StateError(StateError::InvalidIdentityNonceError(error))) - if !matches!(error.error(), MergeIdentityNonceResult::NonceTooFarInFuture) - ) - }); - revision.filter(|_| spent) -} - -/// The refusal note after a proof showed the identity at `proved` once an -/// update built on the cached revision `built_from` was refused: none when -/// `proved` is at or above the cache's (the cache is not ahead after all), -/// otherwise `(built_from, drive)` with `drive` the higher of `proved` and -/// what `noted` holds for `built_from` (revisions only grow, so a lower -/// proof lags). -fn noted_refusal(noted: Option<(u64, u64)>, built_from: u64, proved: u64) -> Option<(u64, u64)> { - if proved >= built_from { - return None; + _ => Ok(next), } - let drive = noted - .filter(|(at, _)| *at == built_from) - .map_or(proved, |(_, drive)| drive.max(proved)); - Some((built_from, drive)) -} - -/// The local placeholder for a disable's `disabled_at`; the next fetch at -/// the update's revision or above takes the on-chain block time. -fn local_disable_time() -> u64 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default() } #[cfg(test)] @@ -699,159 +527,31 @@ mod update_revision_tests { #[test] fn should_carry_one_above_the_cached_revision() { let id = Identifier::from([7u8; 32]); - assert_eq!(update_revision(&id, 4, None, None).unwrap(), 5); - assert_eq!(update_revision(&id, 4, Some(5), None).unwrap(), 5); + assert_eq!(update_revision(&id, 4, None).unwrap(), 5); + assert_eq!(update_revision(&id, 4, Some(5)).unwrap(), 5); } #[test] fn should_refuse_an_update_asked_for_another_revision() { let id = Identifier::from([7u8; 32]); // The cache moved past the revision the caller read. - match update_revision(&id, 5, Some(5), None) { + match update_revision(&id, 5, Some(5)) { Err(PlatformWalletError::IdentityRevisionMismatch { expected, actual, .. }) => assert_eq!((expected, actual), (5, 6)), other => panic!("unexpected: {other:?}"), } assert!(matches!( - update_revision(&id, 3, Some(5), None), + update_revision(&id, 3, Some(5)), Err(PlatformWalletError::IdentityRevisionMismatch { .. }) )); } - #[test] - fn should_take_drives_revision_asked_for_only_while_the_cache_is_where_it_was_refused() { - let id = Identifier::default(); - // Cache at 6, Drive refused and holds 4: the caller may ask for 5. - assert_eq!(update_revision(&id, 6, Some(5), Some((6, 4))).unwrap(), 5); - // One above the cache still goes. - assert_eq!(update_revision(&id, 6, Some(7), Some((6, 4))).unwrap(), 7); - // The cache moved since the refusal, or the caller asks for another. - for (cached, expected, refused_at) in [(7, 5, (6, 4)), (6, 4, (6, 4)), (6, 5, (5, 4))] { - assert!( - matches!( - update_revision(&id, cached, Some(expected), Some(refused_at)), - Err(PlatformWalletError::IdentityRevisionMismatch { .. }) - ), - "cached {cached}, asked {expected}, refused at {refused_at:?}" - ); - } - // An update asked for no revision carries one above the cache. - assert_eq!(update_revision(&id, 6, None, Some((6, 4))).unwrap(), 7); - } - - #[test] - fn should_send_once_more_at_the_proved_revision_only_once() { - // Update at 7, proofs show Drive at 5: sent once more at 6, or, - // pinned, refused with 5. - assert_eq!( - after_refusal(Some(5), 7, false, false), - AfterRefusal::Retry(6) - ); - assert_eq!( - after_refusal(Some(5), 7, true, false), - AfterRefusal::Refused(5) - ); - // The second send is refused too: no third. - assert_eq!(after_refusal(Some(4), 6, false, true), AfterRefusal::Fail); - // Drive would take the transition (6), is at it or above, or no - // proof shows the refusal. - for proved in [Some(6), Some(7), Some(9), Some(u64::MAX), None] { - assert_eq!( - after_refusal(proved, 7, false, false), - AfterRefusal::Fail, - "proved {proved:?}" - ); - assert_eq!( - after_refusal(proved, 7, true, false), - AfterRefusal::Fail, - "proved {proved:?}" - ); - } - } - - #[test] - fn should_take_a_refused_identity_s_revision_only_from_a_spent_nonce() { - let id = Identifier::default(); - let missing_bit = |position: u64| { - 1u64 << (position - 1 - + dpp::identity::identity_nonce::IDENTITY_NONCE_VALUE_FILTER_MAX_BYTES) - }; - // Nonce 10 is spent: at the tip, merged below it, or past the window. - for stored in [10, 12, 40] { - assert_eq!( - spent_refusal(&id, 10, Some(stored), Some(5)), - Some(5), - "stored {stored}" - ); - } - // The nonce can still execute: above the tip, missing below it, too - // far ahead, or no nonce stored. - for (nonce, stored) in [ - (10, Some(9)), - (10, Some(12 | missing_bit(2))), - (30, Some(1)), - (10, None), - ] { - assert_eq!( - spent_refusal(&id, nonce, stored, Some(5)), - None, - "stored {stored:?}, nonce {nonce}" - ); - } - assert_eq!(spent_refusal(&id, 10, Some(10), None), None); - } - - #[test] - fn should_keep_the_highest_proved_revision_until_drive_reaches_the_cache() { - // Cache at 6, proved at 5: noted. - assert_eq!(noted_refusal(None, 6, 5), Some((6, 5))); - // A lagging proof does not take the note lower, a newer one raises it. - assert_eq!(noted_refusal(Some((6, 5)), 6, 4), Some((6, 5))); - assert_eq!(noted_refusal(Some((6, 4)), 6, 5), Some((6, 5))); - // A note against another cached revision says nothing. - assert_eq!(noted_refusal(Some((7, 5)), 6, 4), Some((6, 4))); - // Drive at the cache's revision or above: the cache is not ahead. - for proved in [6, 8] { - assert_eq!(noted_refusal(Some((6, 5)), 6, proved), None); - } - } - - #[test] - fn should_tell_a_revision_refusal_only_of_this_identity() { - use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; - - let id = Identifier::default(); - let refusal = |identity_id, current| -> ConsensusError { - InvalidIdentityRevisionError::new(identity_id, current).into() - }; - let wait_stream = dash_sdk::Error::StateTransitionBroadcastError( - dash_sdk::error::StateTransitionBroadcastError { - code: 40203, - message: String::new(), - cause: Some(refusal(id, 4)), - }, - ); - let bare = - dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal(id, 4)))); - assert!(is_revision_refusal(&id, &wait_stream)); - assert!(is_revision_refusal(&id, &bare)); - let other = dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(refusal( - Identifier::from([7; 32]), - 4, - )))); - assert!(!is_revision_refusal(&id, &other)); - assert!(!is_revision_refusal( - &id, - &dash_sdk::Error::Generic("timeout".to_string()) - )); - } - #[test] fn should_refuse_an_identity_at_the_last_revision() { let id = Identifier::from([7u8; 32]); assert!(matches!( - update_revision(&id, u64::MAX, None, None), + update_revision(&id, u64::MAX, None), Err(PlatformWalletError::InvalidIdentityData(_)) )); } @@ -863,7 +563,7 @@ mod update_identity_at_revision_tests { use super::*; use crate::changeset::PlatformWalletPersistence; use crate::wallet::persister::NoPlatformPersistence; - use crate::{BlockTime, PlatformWallet}; + use crate::PlatformWallet; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -1047,24 +747,21 @@ mod update_identity_at_revision_tests { assert_eq!(identity.revision(), 5); assert!(identity.public_keys()[&1].disabled_at().is_some()); - // A cache refreshed to 5 or past it already holds the update (key 3 - // added, key 1 disabled for good) and what came after, and is left - // as it is: one past the chain would get the next update refused. + // A cache refreshed to 5 or past it already holds the update and + // what came after, and is left as it is: one past the chain would + // get the next update refused. for cached in [5, 6] { let wallet = wallet_at_revision(cached).await; let wallet = wallet.identity(); { let mut wm = wallet.wallet_manager.write().await; - let managed = wm - .get_wallet_info_mut(&wallet.wallet_id) + wm.get_wallet_info_mut(&wallet.wallet_id) .unwrap() .identity_manager .managed_identity_mut(&identity_id) - .unwrap(); - managed.identity.add_public_key(added.clone()); - let mut key_1 = managed.identity.public_keys()[&1].clone(); - key_1.set_disabled_at(42); - managed.identity.add_public_key(key_1); + .unwrap() + .identity + .add_public_key(added.clone()); } let before = cached_identity(wallet, &identity_id).await; wallet @@ -1180,221 +877,128 @@ mod update_identity_at_revision_tests { assert!(changesets[0].identities.is_some()); } - /// A cache at 4 takes an update landed at 5 and stores the revision with - /// the update's keys as one write, also when the disabled key is not in - /// the cache; a store that fails fails the call. - #[tokio::test] - async fn should_store_a_landed_revision_with_its_keys_in_one_write() { - let identity_id = Identifier::default(); - let stored_revision = |changesets: &[crate::changeset::PlatformWalletChangeSet]| { - changesets[0] - .identities - .as_ref() - .and_then(|identities| identities.identities.get(&identity_id)) - .map(|entry| entry.revision) - }; - for (added, disabled) in [(vec![key_3()], vec![1]), (vec![], vec![9])] { - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(4, store.clone()).await; - let wallet = wallet.identity(); - store.take(); - wallet - .apply_landed_update(&identity_id, Some(0), 5, added.clone(), disabled.clone()) - .await - .unwrap(); - let changesets = store.take(); - assert_eq!( - changesets.len(), - 1, - "added {added:?}, disabled {disabled:?}" - ); - assert_eq!(stored_revision(&changesets), Some(5)); - let rows = changesets[0] - .identity_keys - .as_ref() - .map_or(0, |keys| keys.upserts.len()); - assert_eq!(rows, if added.is_empty() { 0 } else { 2 }); - } - - let store = Arc::new(FailingStore::default()); - let wallet = wallet_at_revision_on(4, store.clone()).await; - store.0.store(true, Ordering::SeqCst); - let error = wallet - .identity() - .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) - .await - .unwrap_err(); - assert!( - matches!(error, PlatformWalletError::Persistence(_)), - "{error:?}" - ); - } - - /// Refuses every store once set. - #[derive(Default)] - struct FailingStore(std::sync::atomic::AtomicBool); - - impl PlatformWalletPersistence for FailingStore { - fn store( - &self, - _: crate::wallet::platform_wallet::WalletId, - _: crate::changeset::PlatformWalletChangeSet, - ) -> Result<(), crate::changeset::PersistenceError> { - if self.0.load(Ordering::SeqCst) { - return Err(crate::changeset::PersistenceError::backend("store refused")); - } - Ok(()) - } - fn flush( - &self, - _: crate::wallet::platform_wallet::WalletId, - ) -> Result<(), crate::changeset::PersistenceError> { - Ok(()) - } - fn load( - &self, - ) -> Result - { - Ok(Default::default()) - } - } - - async fn refused_at(wallet: &IdentityWallet) -> Option<(u64, u64)> { - let wm = wallet.wallet_manager.read().await; - wm.get_wallet_info(&wallet.wallet_id) - .unwrap() - .identity_manager - .identity(&Identifier::default()) - .unwrap() - .revision_refused_at - } - - /// A cache at 6 that an older build left one past the chain at 5. The - /// update asked for 7 is refused and Drive says 5; that is noted, the - /// cache is not lowered, and the next update may be asked for 6, which - /// gets past the revision check. Drive saying the cache is not ahead, or - /// a fetch the cache takes whole (one at its revision or above), clears - /// the note. - #[tokio::test] - async fn should_let_the_next_update_carry_drives_revision_without_lowering_the_cache() { - let identity_id = Identifier::default(); - let wallet = wallet_at_revision(6).await; - let wallet = wallet.identity(); - assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 7); - - // A refusal noted against another revision than the cache's is not. - wallet.note_refused_revision(&identity_id, 5, 4).await; - assert_eq!(refused_at(wallet).await, None); - - wallet.note_refused_revision(&identity_id, 6, 5).await; - assert_eq!(cached_identity(wallet, &identity_id).await.revision(), 6); - assert_eq!(wallet.next_update_revision(&identity_id).await.unwrap(), 6); - - let signer = RecordingSigner::default(); - let error = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(6), - &signer, - Some(PutSettings { - request_settings: dash_sdk::RequestSettings { - retries: Some(0), - ..Default::default() - }, - ..Default::default() - }), - ) - .await - .unwrap_err(); - assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); - assert!(signer.0.load(Ordering::SeqCst) > 0); - - // A fetch taken at the cache's revision or above clears it. - { - let mut wm = wallet.wallet_manager.write().await; - let managed = wm - .get_wallet_info_mut(&wallet.wallet_id) - .unwrap() - .identity_manager - .managed_identity_mut(&identity_id) - .unwrap(); - let fetched = managed.identity.clone(); - assert!(managed.take_fetched_identity(&fetched, BlockTime::new(1, 0, 0))); + /// Drive's refusal of an update for an identity it holds at `current`, + /// in the shape `shape` picks: the wait-stream envelope Drive answers + /// with, or a bare consensus error, matched as well. + fn revision_refusal(identity_id: Identifier, current: u64, shape: usize) -> dash_sdk::Error { + use dpp::consensus::state::identity::invalid_identity_revision_error::InvalidIdentityRevisionError; + use dpp::consensus::ConsensusError; + + let cause: ConsensusError = InvalidIdentityRevisionError::new(identity_id, current).into(); + match shape { + 0 => dash_sdk::Error::StateTransitionBroadcastError( + dash_sdk::error::StateTransitionBroadcastError { + code: 40203, + message: cause.to_string(), + cause: Some(cause), + }, + ), + _ => dash_sdk::Error::Protocol(ProtocolError::ConsensusError(Box::new(cause))), } - assert_eq!(refused_at(wallet).await, None); } - /// Drive's refusal of an update with nonce 10 gives the proved revision - /// only when both proved reads answer: the nonce word shows 10 spent and - /// the revision is 5. A read that fails counts as no proof. + /// A cache one past the chain (6 over Drive's 5, which a build before + /// this one could leave) is refused on every update, and no fetch heals + /// it: each comes back older. Drive's refusal carries 5, the cache takes + /// it and stores it with the scalar snapshot, keys untouched, and the + /// next update carries 6 and gets past the revision check. #[tokio::test] - async fn should_prove_a_refusal_only_when_both_reads_answer() { + async fn should_take_drives_revision_from_an_update_refused_for_its_revision() { let identity_id = Identifier::default(); - let mut sdk = dash_sdk::SdkBuilder::new_mock().build().unwrap(); - sdk.mock() - .expect_fetch::( - identity_id, - Some(IdentityNonceFetcher(10)), - ) - .await - .unwrap(); - let settings = Some(PutSettings { + let no_retries = Some(PutSettings { request_settings: dash_sdk::RequestSettings { retries: Some(0), ..Default::default() }, ..Default::default() }); - let wallet = - wallet_with_signing_keys_on(sdk.clone(), Arc::new(NoPlatformPersistence)).await; - assert_eq!( - wallet - .identity() - .prove_refusal(&identity_id, 10, settings) - .await, - None - ); + for shape in [0, 1] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(6, store.clone()).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + let stuck = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(6), + &RecordingSigner::default(), + no_retries, + ) + .await + .unwrap_err(); + assert!( + matches!(stuck, PlatformWalletError::IdentityRevisionMismatch { .. }), + "{stuck:?}" + ); + store.take(); - sdk.mock() - .expect_fetch::(identity_id, Some((0, 5))) - .await - .unwrap(); - let wallet = wallet_with_signing_keys_on(sdk, Arc::new(NoPlatformPersistence)).await; - assert_eq!( wallet - .identity() - .prove_refusal(&identity_id, 10, settings) - .await, - Some(5) - ); + .take_rejected_revision(&identity_id, 7, &revision_refusal(identity_id, 5, shape)) + .await; + let identity = cached_identity(wallet, &identity_id).await; + assert_eq!(identity.revision(), 5, "shape {shape}"); + assert_eq!(identity.public_keys(), before.public_keys()); + let changesets = store.take(); + assert_eq!(changesets.len(), 1); + let stored = changesets[0] + .identities + .as_ref() + .and_then(|cs| cs.identities.get(&identity_id)) + .expect("the scalar snapshot"); + assert_eq!(stored.revision, 5); + + let signer = RecordingSigner::default(); + let error = wallet + .update_identity_with_external_signer_at_revision( + &identity_id, + vec![], + vec![1], + Some(6), + &signer, + no_retries, + ) + .await + .unwrap_err(); + assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); + assert!(signer.0.load(Ordering::SeqCst) > 0); + } } - /// The update asked for Drive's revision lands at 6 on a cache an older - /// build left at 6 without it: the cache is at the chain's revision now - /// and takes the update it lacks, key 3 added and key 1 disabled, and - /// the note of the refusal goes. + /// The cache is left alone when the refusal does not show it ahead of + /// Drive: Drive at the cache's revision or above it (raising the cache + /// would take no keys, and a lying node could lock it ahead), a cache + /// that moved after the refused transition was built (a later update + /// landed, or a fetch took a newer identity), a refusal for another + /// identity, or another error. #[tokio::test] - async fn should_take_an_update_landed_at_drives_revision_into_a_cache_ahead_without_it() { + async fn should_keep_the_cached_revision_unless_drive_holds_a_lower_one() { let identity_id = Identifier::default(); - let store = Arc::new(CapturingStore::default()); - let wallet = wallet_at_revision_on(6, store.clone()).await; - let wallet = wallet.identity(); - wallet.note_refused_revision(&identity_id, 6, 5).await; - store.take(); - wallet - .apply_landed_update(&identity_id, Some(0), 6, vec![key_3()], vec![1]) - .await - .unwrap(); - let identity = cached_identity(wallet, &identity_id).await; - assert_eq!(identity.revision(), 6); - assert_eq!(identity.public_keys().get(&3), Some(&key_3())); - assert!(identity.public_keys()[&1].is_disabled()); - assert_eq!(refused_at(wallet).await, None); - let stored = store.stored_key(identity_id, 1).expect("key 1 stored"); - assert!(stored.public_key.is_disabled()); - assert!(store.stored_key(identity_id, 3).is_some()); + let other = Identifier::from([7; 32]); + for (cached, transition, error) in [ + (5, 6, revision_refusal(identity_id, 5, 0)), + (5, 6, revision_refusal(identity_id, 9, 1)), + (7, 6, revision_refusal(identity_id, 5, 0)), + (6, 6, revision_refusal(identity_id, 3, 1)), + (5, 6, revision_refusal(other, 2, 0)), + (5, 6, dash_sdk::Error::Generic("timeout".to_string())), + ] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(cached, store.clone()).await; + let wallet = wallet.identity(); + let before = cached_identity(wallet, &identity_id).await; + store.take(); + wallet + .take_rejected_revision(&identity_id, transition, &error) + .await; + assert_eq!( + cached_identity(wallet, &identity_id).await, + before, + "cached at {cached}: {error:?}" + ); + assert!(store.take().is_empty(), "cached at {cached}: {error:?}"); + } } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs index c0fc004bacc..6dd0e7a0110 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/identity_ops.rs @@ -17,7 +17,6 @@ use dpp::identity::{Identity, IdentityPublicKey}; use dpp::identity::{KeyID, TimestampMillis}; use dpp::prelude::Identifier; use dpp::util::hash::ripemd160_sha256; -use std::cmp::Ordering; use std::collections::BTreeMap; /// Compute the 20-byte RIPEMD160(SHA256) hash of the DPP public-key @@ -89,7 +88,6 @@ impl ManagedIdentity { identity_index: Some(identity_index), last_updated_balance_block_time: None, pending_balance_snapshot: None, - revision_refused_at: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -111,7 +109,6 @@ impl ManagedIdentity { identity_index: None, last_updated_balance_block_time: None, pending_balance_snapshot: None, - revision_refused_at: None, last_synced_keys_block_time: None, status: Default::default(), dpns_names: Vec::new(), @@ -404,51 +401,32 @@ impl ManagedIdentity { /// claims none), but they only ever rise, so each shared key keeps the /// higher of the two (see `with_raised_limits`) at any revision. /// - /// A fetch above the cache's revision is otherwise taken whole, key - /// states included. So is one at the cache's revision from a block at or - /// above the key watermark (`last_synced_keys_block_time`, the block of - /// the last fetch taken whole, or none; a proof from that same block - /// shows the same key states): a masternode identity's keys are disabled - /// and re-enabled by Core at revision 0, so a newer fetch at the same - /// revision may re-enable a key the cache holds disabled, or disable one - /// it holds enabled. A fetch at the cache's revision from a block below - /// the watermark is older than the key states the cache took, so it - /// changes no key state; only a raised limit is taken. The watermark is - /// stored with the scalar snapshot. A store that restores only the - /// balance's block gives that as the watermark (every fetch folded in - /// reached it); one that restores neither leaves none, so there the - /// first fetch after a restart is taken whole. - /// - /// Revisions only grow on Platform, so a fetch at a lower revision was - /// answered before an update the cache already holds (one applied - /// locally after it landed, or a newer refresh, from a node that lags - /// behind): the cache keeps its revision and key set; it takes only a - /// disable the fetch shows on a key the cache holds enabled (an identity - /// update's disable is permanent) and a raised limit. A fetch taken - /// whole moves the watermark to its block. Returns whether it was taken - /// whole. + /// A fetch at the cache's revision or above is otherwise taken whole, + /// key states included: a masternode identity's keys are disabled and + /// re-enabled by Core at revision 0, so such a fetch may re-enable a key + /// the cache holds disabled. Revisions only grow on Platform, so a fetch + /// at a lower revision was answered before an update the cache already + /// holds (one applied locally after it landed, or a newer refresh, from a + /// node that lags behind): the cache keeps its revision and key set; it + /// takes only a disable the fetch shows on a key the cache holds enabled + /// (an identity update's disable is permanent) and a raised limit. + /// Returns whether it was taken whole. /// /// Only the in-memory identity changes here; the caller persists. pub fn take_fetched_identity(&mut self, fetched: &Identity, block_time: BlockTime) -> bool { let balance = self.identity.balance(); let cached = self.identity.public_keys().clone(); - let fold = self.key_fold(fetched, block_time); - let taken = fold == KeyFold::Whole; - if fetched.revision() >= self.identity.revision() { - self.revision_refused_at = None; - } + let taken = fetched.revision() >= self.identity.revision(); let mut keys = if taken { self.identity = fetched.clone(); self.identity.set_balance(balance); - self.last_synced_keys_block_time = - BlockTime::later_watermark(self.last_synced_keys_block_time, Some(block_time)); fetched.public_keys().clone() } else { cached.clone() }; for (key_id, key) in keys.iter_mut() { if let Some(fetched_key) = fetched.public_keys().get(key_id) { - if let Some(folded) = fold_fetched_key(cached.get(key_id), fetched_key, fold) { + if let Some(folded) = fold_fetched_key(cached.get(key_id), fetched_key, taken) { *key = folded; } } @@ -458,23 +436,6 @@ impl ManagedIdentity { taken } - /// How the keys of `fetched`, proved at `block_time`, fold into the - /// cache's (see [`Self::take_fetched_identity`]). - fn key_fold(&self, fetched: &Identity, block_time: BlockTime) -> KeyFold { - match fetched.revision().cmp(&self.identity.revision()) { - Ordering::Greater => KeyFold::Whole, - Ordering::Equal - if self - .last_synced_keys_block_time - .is_none_or(|synced| block_time.height >= synced.height) => - { - KeyFold::Whole - } - Ordering::Equal => KeyFold::OlderBlock, - Ordering::Less => KeyFold::Older, - } - } - /// Fold `fetched` (see [`Self::take_fetched_identity`]) and store the row /// of every key the fold adds or changes (a disable, a raised limit, a /// key added at a newer revision, on this device or another) with the @@ -531,7 +492,7 @@ impl ManagedIdentity { block_time: BlockTime, persister: &WalletPersister, ) -> Result<(), PersistenceError> { - let decisions = self.key_decisions_for_fetch(fetched, decisions, block_time); + let decisions = self.key_decisions_for_fetch(fetched, decisions); self.take_fetched_identity(fetched, block_time); let keys_cs = self.layer_keys(decisions); self.store_keys_and_balance(keys_cs, persister) @@ -541,24 +502,24 @@ impl ManagedIdentity { /// key folded as [`Self::take_fetched_identity`] folds it, so storing its /// breadcrumb (a key's derivation never changes) stores what the fold /// holds: never a raised limit taken back, nor a disable an older fetch - /// lacks. For a fetch taken whole that is the fetched key. For any other - /// it is the cache's copy, from an older fetch disabled when only the - /// fetch's copy is; either way each limit is the higher of the two. A - /// key the cache lacks is left out of the decisions of a fetch not taken - /// whole, since such an answer cannot show whether a later update - /// disabled it, and a newer fetch will bring it. + /// lacks. For a fetch at the cache's + /// revision or above that is the fetched key. For an older fetch it is + /// the cache's copy, disabled when only the fetch's copy is; either way + /// each limit is the higher of the two. + /// A key the cache lacks is left out of an older fetch's decisions, since + /// an older answer cannot show whether a later update disabled it, and a + /// fetch at least as new will bring it. fn key_decisions_for_fetch( &self, fetched: &Identity, decisions: Vec, - block_time: BlockTime, ) -> Vec { - let fold = self.key_fold(fetched, block_time); + let taken = fetched.revision() >= self.identity.revision(); decisions .into_iter() .filter_map(|mut decision| { let cached = self.identity.public_keys().get(&decision.key.id()); - decision.key = fold_fetched_key(cached, &decision.key, fold)?; + decision.key = fold_fetched_key(cached, &decision.key, taken)?; Some(decision) }) .collect() @@ -823,39 +784,19 @@ impl ManagedIdentity { } } -/// How a fetched identity's keys fold into the cache's (see -/// [`ManagedIdentity::take_fetched_identity`]). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum KeyFold { - /// Above the cache's revision, or at it from a block at or above the key - /// watermark: its key states are taken. - Whole, - /// At the cache's revision from a block below the key watermark: no key - /// state is taken. - OlderBlock, - /// Below the cache's revision: only a disable is taken. - Older, -} - /// How a fetched key lands in the cache, the one rule the identity fold and -/// its key decisions share. From a fetch taken whole it is the fetched key. -/// From any other it is the cache's copy, or nothing for a key the cache -/// lacks; from an older fetch, disabled when only the fetch's copy is. -/// Either way each limit is the higher of the two copies'. +/// its key decisions share. From a fetch taken whole (`taken`) it is the +/// fetched key. From an older fetch it is the cache's copy, disabled when +/// only the fetch's copy is, or nothing for a key the cache lacks. Either +/// way each limit is the higher of the two copies'. fn fold_fetched_key( cached: Option<&IdentityPublicKey>, fetched: &IdentityPublicKey, - fold: KeyFold, + taken: bool, ) -> Option { - match fold { - KeyFold::Older => { - return cached - .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); - } - KeyFold::OlderBlock => { - return cached.map(|cached| with_raised_limits(cached.clone(), fetched)); - } - KeyFold::Whole => {} + if !taken { + return cached + .map(|cached| with_raised_limits(with_disable_from(cached, fetched), fetched)); } let key = fetched.clone(); let Some(cached) = cached else { @@ -972,12 +913,11 @@ mod tests { /// A fetch at a lower revision than the cache's was answered before an /// update the cache holds: the cache keeps its revision and key set, and /// takes only a disable the fetch shows on a key it holds enabled and a - /// raised limit. A fetch at a higher revision, or at the same one from a - /// block at or above the key watermark, replaces the cache, key states - /// included, but never takes back a raise. Either way the balance - /// follows the proof's block, not the revision. + /// raised limit. A fetch at the same revision or higher replaces the + /// cache, key states included, but never takes back a raise. Either way + /// the balance follows the proof's block, not the revision. #[test] - fn should_take_a_fetched_identity_whole_only_from_a_newer_revision_or_block() { + fn should_take_a_fetched_identity_whole_only_when_it_is_not_older() { fn limited(id: KeyID, budget: u64, disabled_at: Option) -> IdentityPublicKey { IdentityPublicKey::V1(IdentityPublicKeyV1 { id, @@ -1049,68 +989,16 @@ mod tests { assert!(managed.take_fetched_identity(&newer, block(12))); assert_eq!(managed.identity, newer); - // ...key states included, at the cache's revision only from a block - // at or above the key watermark the last fetch taken whole set (Core - // re-enables a masternode identity's key at revision 0). One from a - // block below it is older: it changes no key state, nor a balance - // its proof does not order after the cache's. - assert_eq!(managed.last_synced_keys_block_time, Some(block(12))); - let reenabled = at(7, 35, vec![limited(3, 550, None)]); - for height in [10, 11] { - assert!(!managed.take_fetched_identity(&reenabled, block(height))); - assert_eq!(managed.identity, newer); + // ...key states included (Core re-enables a masternode identity's + // key at revision 0), except for a balance its proof does not order + // after the cache's and a budget the cache holds higher: a delayed + // answer cannot take a raise back. + let delayed = at(7, 35, vec![limited(3, 550, None)]); + for height in [11, 12] { + assert!(managed.take_fetched_identity(&delayed, block(height))); + assert_eq!(managed.identity, at(7, 40, vec![limited(3, 600, None)])); assert_eq!(managed.last_updated_balance_block_time, Some(block(12))); } - // Taken whole from above it, but a budget the cache holds higher - // stays: a delayed answer cannot take a raise back. - assert!(managed.take_fetched_identity(&reenabled, block(13))); - assert_eq!(managed.identity, at(7, 35, vec![limited(3, 600, None)])); - assert_eq!(managed.last_synced_keys_block_time, Some(block(13))); - } - - /// Core disables and re-enables a masternode identity's keys at revision - /// 0, so key states at one revision are ordered by proof height: a - /// lagging answer at the cache's revision brings back neither a disable - /// Core lifted nor a key state Core changed since, and a cache with no - /// watermark takes its first answer whole. - #[test] - fn should_order_key_states_at_the_cache_s_revision_by_proof_height() { - let identity = |disabled_at: Option| { - let mut key = key(3); - if let Some(disabled_at) = disabled_at { - key.set_disabled_at(disabled_at); - } - Identity::V0(IdentityV0 { - id: Identifier::from([1u8; 32]), - public_keys: BTreeMap::from([(3, key)]), - balance: 0, - revision: 0, - }) - }; - let block = |height| BlockTime::new(height, 1, height); - let mut managed = ManagedIdentity::new(identity(Some(5)), 0); - // No watermark: the answer at block 20, the key re-enabled, is taken. - assert!(managed.take_fetched_identity(&identity(None), block(20))); - assert_eq!(managed.identity, identity(None)); - assert_eq!(managed.last_synced_keys_block_time, Some(block(20))); - // A lagging answer from before the re-enable does not disable it again. - for height in [15, 19] { - assert!(!managed.take_fetched_identity(&identity(Some(5)), block(height))); - assert_eq!(managed.identity, identity(None), "block {height}"); - } - // Core disables it again at a later block: taken. - assert!(managed.take_fetched_identity(&identity(Some(25)), block(25))); - assert_eq!(managed.identity, identity(Some(25))); - // The decisions stored with a fetch follow the same order. - let decisions = managed.key_decisions_for_fetch( - &identity(None), - vec![KeyWithBreadcrumb { - key: key(3), - breadcrumb: None, - }], - block(22), - ); - assert_eq!(decisions[0].key, identity(Some(25)).public_keys()[&3]); } /// Each limit is the higher of the two copies', since consensus only @@ -1170,13 +1058,12 @@ mod tests { }, ] }; - let folded = - managed.key_decisions_for_fetch(&fetched(5), decisions(), BlockTime::new(1, 0, 0)); + let folded = managed.key_decisions_for_fetch(&fetched(5), decisions()); assert_eq!(folded.len(), 1); assert_eq!(folded[0].key, disabled); assert_eq!( managed - .key_decisions_for_fetch(&fetched(6), decisions(), BlockTime::new(1, 0, 0)) + .key_decisions_for_fetch(&fetched(6), decisions()) .len(), 2 ); @@ -1201,11 +1088,7 @@ mod tests { key: key(3).with_limits(Some(100), None), breadcrumb: None, }]; - let folded = managed.key_decisions_for_fetch( - &identity(revision, 100), - decisions, - BlockTime::new(1, 0, 0), - ); + let folded = managed.key_decisions_for_fetch(&identity(revision, 100), decisions); assert_eq!(folded[0].key.total_budget(), Some(500), "at {revision}"); } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs index 8473135f85f..436dbaaabf6 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/mod.rs @@ -58,17 +58,7 @@ pub struct ManagedIdentity { /// retry. Query results are published only after that retry commits. pending_balance_snapshot: Option<(u64, BlockTime)>, - /// The identity's proved revision after Drive refused an update of it - /// for its revision, below the cached one, with the cached revision it - /// was refused against: `(cached, drive)`. Noted only once proofs show - /// the refused transition's nonce spent (it can never execute). Memory - /// only: it lets the next update be asked to carry `drive + 1` while the - /// cache is still at `cached`, and changes nothing else. - pub(crate) revision_refused_at: Option<(u64, u64)>, - - /// Block of the last fetch whose key states the cache took whole: a - /// fetch at the cached revision takes its key states only from a block - /// at or above it (see `take_fetched_identity`). + /// Last block time when keys were synced for this identity pub last_synced_keys_block_time: Option, /// Identity lifecycle status on Platform. @@ -266,9 +256,6 @@ mod tests { managed.last_synced_keys_block_time.unwrap().timestamp, 9876543210 ); - - managed.update_keys_sync_block_time(BlockTime::new(40000, 440000, 9876500000), &p); - assert_eq!(managed.last_synced_keys_block_time, Some(block_time)); } #[test] diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs index 9ce4792d307..8bf238c99f7 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/managed_identity/sync.rs @@ -178,19 +178,15 @@ impl ManagedIdentity { } } - /// Raise the key watermark (`last_synced_keys_block_time`) to - /// `block_time`, unless it is already at or above it, and persist the - /// scalar snapshot. A fetch at the cached revision then takes its key - /// states only from that block or a later one (see - /// `take_fetched_identity`), so raise it only from a block whose key - /// states the cache holds. + /// Update the last keys sync block time. + /// + /// Persists the resulting changeset via `persister` and returns `()`. pub fn update_keys_sync_block_time( &mut self, block_time: BlockTime, persister: &WalletPersister, ) { - self.last_synced_keys_block_time = - BlockTime::later_watermark(self.last_synced_keys_block_time, Some(block_time)); + self.last_synced_keys_block_time = Some(block_time); let cs = self.snapshot_changeset(); if let Err(e) = persister.store(cs.into()) { tracing::error!("Failed to persist changeset: {}", e); diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs index c1221065754..03f8ed10004 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/manager/apply.rs @@ -17,7 +17,6 @@ use super::{IdentityLocation, IdentityManager}; use crate::changeset::{IdentityEntry, IdentityKeyEntry}; use crate::wallet::identity::state::managed_identity::ManagedIdentity; -use crate::BlockTime; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; use dpp::identity::v0::IdentityV0; @@ -39,8 +38,7 @@ impl IdentityManager { /// as applying it once. If the identity already exists in either /// bucket, the scalar fields are updated in place; balance/revision /// are gated on `entry.revision >= existing.identity.revision()` - /// matching the merge policy on `IdentityChangeSet`, and the key - /// watermark keeps the later of the two blocks. DPNS labels and + /// matching the merge policy on `IdentityChangeSet`. DPNS labels and /// contested DPNS labels are complete canonical snapshots and are /// assigned wholesale. pub(crate) fn apply_identity_entry(&mut self, entry: IdentityEntry) { @@ -57,10 +55,7 @@ impl IdentityManager { ); existing.identity.set_revision(entry.revision); } - existing.last_synced_keys_block_time = BlockTime::later_watermark( - existing.last_synced_keys_block_time, - entry.last_synced_keys_block_time, - ); + existing.last_synced_keys_block_time = entry.last_synced_keys_block_time; existing.status = entry.status; *existing.dashpay_profile_mut() = entry.dashpay_profile; // DPNS names: wholesale assign, matching the changeset's @@ -257,43 +252,4 @@ mod tests { .contested_dpns_names .is_empty()); } - - #[test] - fn should_keep_the_later_key_watermark_on_replay() { - let id = Identifier::from([0x54; 32]); - let mut manager = IdentityManager::default(); - let mut newer = entry(id, &[]); - newer.revision = 7; - newer.last_synced_keys_block_time = Some(BlockTime::new(20, 50, 2000)); - manager.apply_identity_entry(newer); - for (revision, older) in [ - (6, None), - (8, None), - (6, Some(BlockTime::new(10, 42, 1000))), - (8, Some(BlockTime::new(10, 42, 1000))), - ] { - let mut stale = entry(id, &[]); - stale.revision = revision; - stale.last_synced_keys_block_time = older; - manager.apply_identity_entry(stale); - assert_eq!( - manager - .locate_mut(&id) - .expect("identity must exist") - .last_synced_keys_block_time, - Some(BlockTime::new(20, 50, 2000)) - ); - } - let mut later = entry(id, &[]); - later.revision = 6; - later.last_synced_keys_block_time = Some(BlockTime::new(30, 60, 3000)); - manager.apply_identity_entry(later); - assert_eq!( - manager - .locate_mut(&id) - .expect("identity must exist") - .last_synced_keys_block_time, - Some(BlockTime::new(30, 60, 3000)) - ); - } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs index 1de424316a9..073ad187b9d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/types/block_time.rs @@ -37,17 +37,6 @@ impl BlockTime { pub fn is_older_than(&self, current_timestamp: TimestampMillis, max_age_millis: u64) -> bool { (current_timestamp - self.timestamp) > max_age_millis } - - /// The later of two key watermarks, by Platform height. A watermark only - /// moves up: an older or absent one in a later snapshot must not reopen - /// the cache to key states from below the block it already took. - pub(crate) fn later_watermark(kept: Option, incoming: Option) -> Option { - match (kept, incoming) { - (Some(kept), Some(incoming)) if incoming.height <= kept.height => Some(kept), - (kept, None) => kept, - (_, incoming) => incoming, - } - } } impl From for BlockTime { diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 6f46d6d3014..1b246b4d64f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3737,14 +3737,8 @@ extension ManagedPlatformWallet { /// whose message carries the expected and actual revisions), /// so a caller that recorded `revision` beforehand knows the only /// revision this update can land at: Drive takes an identity update - /// only at one above the identity's revision. Read the revision to - /// record from `nextIdentityUpdateRevision(identityId:)`. When Drive - /// refuses the update for its revision and proofs show the refused - /// transition's nonce spent and the identity below that revision, the - /// call throws `PlatformWalletError.identityRevisionRefused` and the - /// reader then gives the revision Drive takes. `nil` takes whatever the - /// cache gives, and on such a refusal is sent once more at one above the - /// proved revision. + /// only at one above the identity's revision. `nil` takes whatever the + /// cache gives. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], @@ -3799,32 +3793,6 @@ extension ManagedPlatformWallet { }.value } - /// The revision the next `updateIdentity` of `identityId` carries: one - /// above the cached identity's, or, after - /// `PlatformWalletError.identityRevisionRefused`, one above the revision - /// proved while the cache is still where it was then. Record it before - /// submitting and pass it as `updateIdentity(…, revision:)`. The note of - /// a refusal lives in memory: after a restart this gives one above the - /// cached identity's again. - public func nextIdentityUpdateRevision(identityId: Identifier) async throws -> UInt64 { - let idBytes: [UInt8] = identityId.withFFIBytes { ptr in - Array(UnsafeBufferPointer(start: ptr, count: 32)) - } - // `self` stays alive for the whole call: a deinit mid-call would - // destroy the handle the FFI is still using. - return try await Task.detached(priority: .userInitiated) { [self] () -> UInt64 in - try withExtendedLifetime(self) { - var revision: UInt64 = 0 - let result = idBytes.withUnsafeBufferPointer { idBp in - platform_wallet_identity_next_update_revision( - handle, idBp.baseAddress!, &revision) - } - try result.check() - return revision - } - }.value - } - /// Raise the usage limits of one of an identity's keys (protocol /// version 14), signing the resulting `IdentityKeyLimitsUpdate` with /// the identity's MASTER key (or a CRITICAL authentication key that diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 0191bd4127d..2ec67466028 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -267,11 +267,6 @@ public enum PlatformWalletResultCode: Int32, Sendable { /// An identity update asked for a revision the cached identity would not /// give it; refused before signing, nothing was broadcast. case errorIdentityRevisionMismatch = 62 - /// Drive refused an identity update asked for a revision, for its - /// revision, and proofs show the identity below it. Nothing changed but - /// a processing fee and the nonce; - /// `nextIdentityUpdateRevision` gives the one Drive takes. - case errorIdentityRevisionRefused = 63 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -405,8 +400,6 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorAssetLockTooManyInputs case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH: self = .errorIdentityRevisionMismatch - case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_REFUSED: - self = .errorIdentityRevisionRefused case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -573,15 +566,6 @@ public enum PlatformWalletError: LocalizedError { /// the expected and actual revisions are in the message. Refresh the /// identity and decide again rather than retrying the same call. case identityRevisionMismatch(String) - /// `updateIdentity(…, revision:)` was refused by Drive for its revision, - /// and proofs show its nonce spent and the identity at a revision below - /// the one it would take (a cache ahead of the chain). Drive charged a - /// processing fee and took the nonce, so the refused update can never - /// execute; nothing else changed, and the cache was left as it is; the - /// revisions are in the message. Read the revision Drive takes from - /// `nextIdentityUpdateRevision(identityId:)`, record it, and update - /// again asking for it. - case identityRevisionRefused(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -826,7 +810,6 @@ public enum PlatformWalletError: LocalizedError { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), .identityNotFound(let m), .identityBalanceUnavailable(let m), .identityRevisionMismatch(let m), - .identityRevisionRefused(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), @@ -940,7 +923,6 @@ public enum PlatformWalletError: LocalizedError { case .errorIdentityNotFound: self = .identityNotFound(detail) case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) case .errorIdentityRevisionMismatch: self = .identityRevisionMismatch(detail) - case .errorIdentityRevisionRefused: self = .identityRevisionRefused(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index f154b571c18..2b42a2fac11 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -47,20 +47,6 @@ final class ErrorHandlingTests: XCTestCase { XCTAssertEqual(error.errorDescription, "asked 5, would give 6") } - func testIdentityRevisionRefusedFFIResultMapping() { - XCTAssertEqual( - PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_REFUSED), - .errorIdentityRevisionRefused - ) - XCTAssertEqual(PlatformWalletResultCode.errorIdentityRevisionRefused.rawValue, 63) - let error = PlatformWalletError(code: .errorIdentityRevisionRefused, message: "refused 7, Drive holds 5") - guard case .identityRevisionRefused(let detail) = error else { - return XCTFail("the Drive revision refusal lost its typed case") - } - XCTAssertEqual(detail, "refused 7, Drive holds 5") - XCTAssertEqual(error.errorDescription, "refused 7, Drive holds 5") - } - func testAssetLockInsufficientFundsFFIResultMapping() { // The asset-lock coin-selection shortfall (dashpay/platform#4073). // Swift could not decode code 29 at all before this mirror existed — From 610aa57d2a95828f5a052f7fa4029541c91d87f1 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:30:32 +0200 Subject: [PATCH 70/72] fix(platform-wallet): store a landed revision with its keys as one write When the cache is behind the transition, apply_landed_update now layers the update's added keys and disables in memory. It stores them with the scalar snapshot, revision included, through store_keys_with_snapshot, as the cache-ahead branch already does. A disable of a key the cache does not hold therefore still stores the landed revision. A failed store is returned, where before a failed disable was only logged. The IdentityKeyEntry::wallet_id doc now says that a None travels with the breadcrumb: an update to a held row keeps the wallet_id the row holds, and the key is not watch-only because of it. Co-Authored-By: Claude Opus 5.5 --- .../src/changeset/changeset.rs | 6 +- .../src/wallet/identity/network/update.rs | 168 ++++++++++++++---- 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/packages/rs-platform-wallet/src/changeset/changeset.rs b/packages/rs-platform-wallet/src/changeset/changeset.rs index 918c69d5a82..e8beae1b385 100644 --- a/packages/rs-platform-wallet/src/changeset/changeset.rs +++ b/packages/rs-platform-wallet/src/changeset/changeset.rs @@ -1052,8 +1052,10 @@ pub struct IdentityKeyEntry { /// Precomputed on the Rust side so clients don't need /// RIPEMD-160 implementations of their own. pub public_key_hash: [u8; 20], - /// The wallet whose mnemonic derives this key. `None` for - /// watched-only identities with no wallet association. + /// The wallet whose mnemonic derives this key. It travels with + /// [`Self::derivation_indices`]: `None` alongside them gives no wallet + /// either, and an update to a held row keeps the `wallet_id` the row + /// already holds, as it keeps the breadcrumb. pub wallet_id: Option<[u8; 32]>, /// DIP-9 `(identity_index, key_index)` pair the client needs to /// re-derive the private key. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 242e5701c1f..9814fc7c593 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -5,7 +5,9 @@ use dpp::address_funds::AddressWitness; use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; -use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::identity_public_key::accessors::v0::{ + IdentityPublicKeyGettersV0, IdentityPublicKeySettersV0, +}; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyType; @@ -267,11 +269,12 @@ impl IdentityWallet { /// - The Identity Keys list in the iOS app keeps showing /// only the pre-update key set. /// - /// `ManagedIdentity::add_key` handles both the in-memory - /// mutation AND the `IdentityKeysChangeSet` upsert. Run it - /// on each new key under a write lock; the breadcrumb lets - /// the iOS Keychain re-derive the matching private bytes - /// from the wallet seed + DIP-9 path. + /// A cache behind `transition_revision` moves to it, and the update's + /// keys are layered in memory and stored with the scalar snapshot, + /// revision included, as one fallible write under the write lock, so + /// the landed revision is stored even when no key matched. An added + /// key carries the breadcrumb that lets the iOS Keychain re-derive the + /// matching private bytes from the wallet seed + DIP-9 path. /// /// A cache already refreshed to `transition_revision` or past it (a /// fetch answered between the update's snapshot and this step) holds the @@ -339,36 +342,48 @@ impl IdentityWallet { // subsequent update must not reuse the pre-broadcast value. managed.identity.set_revision(transition_revision); - for key in added_keys { - let breadcrumb = identity_index.map(|idx| (self.wallet_id, idx, key.id())); - managed - .add_key(key, breadcrumb, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - })?; - } - - if !disabled_ids.is_empty() { - // Disable-side counterpart to the `add_key` loop - // above: stamp `disabled_at` on the matching cached - // keys and fire the persister so the Swift - // `PersistentPublicKey.disabledAt` rows flip without - // a network re-fetch. The local wall-clock timestamp - // is a placeholder — the next fetch at this revision or - // above reconciles it to the authoritative on-chain block - // time. `disable_keys` rebuilds the derivation breadcrumb - // from the managed identity's own wallet slot, so the - // disabled key keeps its private-key linkage. - let disabled_at = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_millis() as u64) - .unwrap_or_default(); - managed.disable_keys(&disabled_ids, disabled_at, &self.persister); - } - - Ok(()) + // The added keys, then the disabled ones stamped with the local + // wall-clock time, a placeholder the next fetch at this revision or + // above reconciles to the on-chain block time. A disabled key keeps + // the breadcrumb of the managed identity's own wallet slot, so it + // keeps its private-key linkage. They are stored with the scalar + // snapshot as one write, so the landed revision is stored even when + // no key matched. + let added_ids: Vec = added_keys.iter().map(|key| key.id()).collect(); + let mut keys: Vec = added_keys + .into_iter() + .map(|key| KeyWithBreadcrumb { + breadcrumb: identity_index.map(|idx| (self.wallet_id, idx, key.id())), + key, + }) + .collect(); + let disabled_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or_default(); + let slot = managed.wallet_id.zip(managed.identity_index); + keys.extend( + disabled_ids + .iter() + .filter(|id| !added_ids.contains(id)) + .filter_map(|id| managed.identity.public_keys().get(id).cloned()) + .map(|mut key| { + key.set_disabled_at(disabled_at); + KeyWithBreadcrumb { + breadcrumb: slot.map(|(wallet_id, identity_index)| { + (wallet_id, identity_index, key.id()) + }), + key, + } + }), + ); + managed + .store_keys_with_snapshot(keys, &self.persister) + .map_err(|e| { + PlatformWalletError::Persistence(format!( + "identity key not persisted after update: {e}" + )) + }) } /// Take Drive's revision from an update it refused for its revision. @@ -877,6 +892,87 @@ mod update_identity_at_revision_tests { assert!(changesets[0].identities.is_some()); } + /// A cache at 4 behind an update that landed at 5 stores the landed + /// revision with the update's keys as one write: an added key with a + /// disable, and a disable of a key the cache lacks, which stores the + /// snapshot alone. A failed store is returned. + #[tokio::test] + async fn should_store_a_landed_revision_with_its_keys_in_one_write() { + let identity_id = Identifier::default(); + let stored_revision = |changesets: &[crate::changeset::PlatformWalletChangeSet]| { + changesets[0] + .identities + .as_ref() + .and_then(|identities| identities.identities.get(&identity_id)) + .map(|entry| entry.revision) + }; + for (added, disabled) in [(vec![key_3()], vec![1]), (vec![], vec![9])] { + let store = Arc::new(CapturingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + let wallet = wallet.identity(); + store.take(); + wallet + .apply_landed_update(&identity_id, Some(0), 5, added.clone(), disabled.clone()) + .await + .unwrap(); + let changesets = store.take(); + assert_eq!( + changesets.len(), + 1, + "added {added:?}, disabled {disabled:?}" + ); + assert_eq!(stored_revision(&changesets), Some(5)); + let rows = changesets[0] + .identity_keys + .as_ref() + .map_or(0, |keys| keys.upserts.len()); + assert_eq!(rows, if added.is_empty() { 0 } else { 2 }); + assert_eq!(cached_identity(wallet, &identity_id).await.revision(), 5); + } + + let store = Arc::new(FailingStore::default()); + let wallet = wallet_at_revision_on(4, store.clone()).await; + store.0.store(true, Ordering::SeqCst); + let error = wallet + .identity() + .apply_landed_update(&identity_id, Some(0), 5, vec![], vec![1]) + .await + .unwrap_err(); + assert!( + matches!(error, PlatformWalletError::Persistence(_)), + "{error:?}" + ); + } + + /// Refuses every store once set. + #[derive(Default)] + struct FailingStore(std::sync::atomic::AtomicBool); + + impl PlatformWalletPersistence for FailingStore { + fn store( + &self, + _: crate::wallet::platform_wallet::WalletId, + _: crate::changeset::PlatformWalletChangeSet, + ) -> Result<(), crate::changeset::PersistenceError> { + if self.0.load(Ordering::SeqCst) { + return Err(crate::changeset::PersistenceError::backend("store refused")); + } + Ok(()) + } + fn flush( + &self, + _: crate::wallet::platform_wallet::WalletId, + ) -> Result<(), crate::changeset::PersistenceError> { + Ok(()) + } + fn load( + &self, + ) -> Result + { + Ok(Default::default()) + } + } + /// Drive's refusal of an update for an identity it holds at `current`, /// in the shape `shape` picks: the wait-stream envelope Drive answers /// with, or a bare consensus error, matched as well. From 51d7be8049dd70c2005f8e16789f0e248229ad26 Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 28 Oct 2026 13:31:02 +0200 Subject: [PATCH 71/72] refactor(platform-wallet): drop the revision pin from the identity update The maintainers agreed to settle an update's fate through a broadcast outbox instead of a caller-supplied revision, so the pin goes: - `update_identity_with_external_signer_at_revision` folds back into `update_identity_with_external_signer`; `update_revision` keeps only the `checked_add` guard. - `PlatformWalletError::IdentityRevisionMismatch`, the FFI `platform_wallet_update_identity_with_signer_at_revision` export and result code 62 are removed. - The Swift `revision:` argument, the Kotlin `revision` parameters and the JNI `hasRevision`/`revision` arguments are removed; those files are back to v5.1-dev, including the `updateIdentity` lifetime change. The fetch fold, the landed-update handling, the 40203 heal and the persistence fixes stay. Co-Authored-By: Claude Opus 5.5 --- .../dashsdk/errors/DashSdkError.kt | 10 - .../dashsdk/ffi/TransactionsNative.kt | 12 +- .../dashsdk/identity/IdentityUpdates.kt | 24 +- .../dashsdk/errors/DashSdkErrorTest.kt | 10 - .../ERROR_CODE_REGISTRY.md | 7 +- packages/rs-platform-wallet-ffi/src/error.rs | 29 --- .../src/identity_update.rs | 237 +----------------- packages/rs-platform-wallet/src/error.rs | 14 -- .../src/wallet/identity/network/update.rs | 218 ++-------------- .../rs-unified-sdk-jni/src/transactions.rs | 109 ++------ .../ManagedPlatformWallet.swift | 69 +++-- .../PlatformWallet/PlatformWalletResult.swift | 14 +- .../ErrorHandlingTests.swift | 14 -- 13 files changed, 75 insertions(+), 692 deletions(-) diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt index 4f4eb0fb4ce..3227c3d606b 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/errors/DashSdkError.kt @@ -240,15 +240,6 @@ sealed class DashSdkError( class AssetLockTooManyInputs(message: String, cause: Throwable? = null) : PlatformWallet(message, cause) - /** - * `ErrorIdentityRevisionMismatch` (native code 62). An identity update - * asked for a revision the cached identity would not give it. Nothing - * was signed or broadcast; the expected and actual revisions are in - * [message]. Not retryable as is: refresh the identity and decide again. - */ - class IdentityRevisionMismatch(message: String, cause: Throwable? = null) : - PlatformWallet(message, cause) - /** * `ErrorShieldedNoRecordedAnchor` (native code 19). A shielded spend * could not be built against a Platform-recorded anchor because the @@ -777,7 +768,6 @@ sealed class DashSdkError( 27 -> PlatformWallet.ShutdownIncomplete(message, cause) // ErrorShutdownIncomplete 29 -> PlatformWallet.AssetLockInsufficientFunds(message, cause) // ErrorAssetLockInsufficientFunds 60 -> PlatformWallet.AssetLockTooManyInputs(message, cause) // ErrorAssetLockTooManyInputs - 62 -> PlatformWallet.IdentityRevisionMismatch(message, cause) // ErrorIdentityRevisionMismatch // The deferred-token trio sits at the contiguous block 34-36 because // 27-33 are claimed elsewhere: 27 ErrorShutdownIncomplete // (dashpay/platform#4268, merged), 29 ErrorAssetLockInsufficientFunds diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt index 55b22c55dd5..a328b0e9396 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/TransactionsNative.kt @@ -32,9 +32,7 @@ internal object TransactionsNative { * Add public keys and/or disable existing key ids on an identity, * signing the resulting `IdentityUpdateTransition` via [signerHandle] * (the identity's MASTER auth key). Bridges - * `platform_wallet_update_identity_with_signer`, or - * `platform_wallet_update_identity_with_signer_at_revision` with - * [hasRevision] — the exact call Swift + * `platform_wallet_update_identity_with_signer` — the exact call Swift * `AddIdentityKeyView.submit` makes through `wallet.updateIdentity(...)`. * * @param addPubkeysBlob big-endian rows for the keys to add: `u32 @@ -46,12 +44,6 @@ internal object TransactionsNative { * `u64 totalBudget` (bit 0) and `u64 expiresAt` (bit 1). May be empty. * @param disablePublicKeyIds key ids to disable; may be empty. At least * one of add / disable must be non-empty. - * @param hasRevision whether the update must carry [revision]: one the - * cached identity would give another revision is refused before - * anything is signed (bridges - * `platform_wallet_update_identity_with_signer_at_revision`). - * @param revision the revision the update must carry; read only with - * [hasRevision]. */ external fun updateIdentity( walletHandle: Long, @@ -59,8 +51,6 @@ internal object TransactionsNative { addPubkeysBlob: ByteArray, disablePublicKeyIds: IntArray, signerHandle: Long, - hasRevision: Boolean, - revision: Long, ) /** diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt index 70d53c9240a..154fd06dbd3 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/identity/IdentityUpdates.kt @@ -187,42 +187,29 @@ class IdentityUpdates internal constructor( /** * Add a single public key to [identityId], signed via [signerHandle]. - * Convenience over [update] for the single-key `AddIdentityKeyView` flow; - * [revision] is passed to [update]. + * Convenience over [update] for the single-key `AddIdentityKeyView` flow. */ suspend fun addKey( walletHandle: Long, identityId: ByteArray, key: IdentityPubkey, signerHandle: Long, - revision: Long? = null, - ) = update(walletHandle, identityId, listOf(key), emptyList(), signerHandle, revision) + ) = update(walletHandle, identityId, listOf(key), emptyList(), signerHandle) /** - * Disable existing key ids on [identityId], signed via [signerHandle]; - * [revision] is passed to [update]. + * Disable existing key ids on [identityId], signed via [signerHandle]. */ suspend fun disableKeys( walletHandle: Long, identityId: ByteArray, keyIds: List, signerHandle: Long, - revision: Long? = null, - ) = update(walletHandle, identityId, emptyList(), keyIds, signerHandle, revision) + ) = update(walletHandle, identityId, emptyList(), keyIds, signerHandle) /** * Add [addPublicKeys] and/or disable [disablePublicKeyIds] on * [identityId] in a single `IdentityUpdateTransition`, signed via * [signerHandle]. At least one of add / disable must be non-empty. - * - * The transition carries the cached identity's revision plus one, read - * when the update starts. With [revision] set, an update that would carry - * another one throws [org.dashfoundation.dashsdk.errors.DashSdkError.PlatformWallet.IdentityRevisionMismatch] - * before anything is signed or broadcast, so a caller that recorded - * [revision] beforehand knows the only revision this update can land at - * (Drive takes an identity update only at one above the identity's - * revision). `null` takes whatever the cache gives. Port of the - * `revision:` argument of Swift `ManagedPlatformWallet.updateIdentity`. */ suspend fun update( walletHandle: Long, @@ -230,7 +217,6 @@ class IdentityUpdates internal constructor( addPublicKeys: List = emptyList(), disablePublicKeyIds: List = emptyList(), signerHandle: Long, - revision: Long? = null, ) = gate.op { require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" @@ -248,8 +234,6 @@ class IdentityUpdates internal constructor( IdentityPubkeyCodec.encode(addPublicKeys), disablePublicKeyIds.toIntArray(), signerHandle, - revision != null, - revision ?: 0L, ) } } diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt index d3ccbc34b28..fd09ae4051a 100644 --- a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/errors/DashSdkErrorTest.kt @@ -19,16 +19,6 @@ class DashSdkErrorTest { assertFalse(mapped.isRetryable) } - @Test - fun shouldPreserveIdentityRevisionMismatchAsItsOwnError() { - val native = DashSDKException(DashSdkError.PLATFORM_WALLET_CODE_OFFSET + 62, "asked 5, would give 6") - val mapped = DashSdkError.fromNative(native) - assertTrue(mapped is DashSdkError.PlatformWallet.IdentityRevisionMismatch) - assertEquals(native.message, mapped.message) - assertEquals(native, mapped.cause) - assertFalse(mapped.isRetryable) - } - @Test fun mapsNativeCodesToHierarchy() { // Codes mirror DashSDKErrorCode in rs-sdk-ffi/src/error.rs. diff --git a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md index 3f33d9e6bdf..a916a4eb433 100644 --- a/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md +++ b/packages/rs-platform-wallet-ffi/ERROR_CODE_REGISTRY.md @@ -114,7 +114,7 @@ These are shipped ABI. Do not renumber. | 98 | `NotFound` | Sentinel — `Option` returned as an error | | 99 | `ErrorUnknown` | Sentinel — unmapped/flattened errors | -**Next allocatable integer: 63** — 27–62 are all claimed (27, 29, 31, 34–42 +**Next allocatable integer: 61** — 27–60 are all claimed (27, 29, 31, 34–42 and 46 merged; 43–45 proposed by active #4313 at head `0302b188ab`; 47 and 48 proposed by active #4356 (47 renumbered from 42, 48 from 43 — see their rows below); 49–54 proposed by active #4586 (the persister @@ -123,9 +123,7 @@ shield debits and durable recovery errors; 58 proposed by #4799 for an unavailab identity balance response; 59 proposed by #5322 for an SPV teardown that needs a process restart; 60 proposed by the pooled Core-to-shielded wallet drain for `ErrorAssetLockTooManyInputs` (Rust, Swift and Kotlin mirrors — -renumbered from 59 when #5322 took that number on `v5.1-dev`); 61 proposed -by open #5395 for `ErrorCoreFundsAwaitingNetwork`; 62 proposed by #5487 for -`ErrorIdentityRevisionMismatch`; 28, 30, +renumbered from 59 when #5322 took that number on `v5.1-dev`); 28, 30, 32 and 33 reserved). **28, 30, 32 and 33 are RESERVED, not free**: 28 and 30 were vacated when the reservation trio moved to 34–36; 32 and 33 lapsed when their in-repo owners @@ -179,7 +177,6 @@ Fork-era numbers remain in the collision history, which is immutable record. | 58 | `ErrorIdentityBalanceUnavailable` | #4799 | Proposed — Platform returned no balance for a managed identity. Retrying the read is safe; this is distinct from missing wallet ownership and must not trigger registration or funding | | 59 | `ErrorSpvProcessRestartRequired` | #5322 | Proposed — SPV startup or teardown panicked; restart the host process. Not retryable, unlike 27, which SPV start, stop and storage clear now return while an SPV teardown is still running. Typed in Rust, Swift and Kotlin (the PR also types 27 on Kotlin) | | 60 | `ErrorAssetLockTooManyInputs` | Core-to-shielded pooled Max | Proposed — the full drain needs more inputs than an asset lock may carry (100 on Dash Core relay policy and Platform consensus); no partial drain was broadcast. Rust, Swift and Kotlin retain the typed refusal. Renumbered from 59 after #5322 claimed it | -| 62 | `ErrorIdentityRevisionMismatch` | #5487 | Proposed — 61 is held by open #5395 (`ErrorCoreFundsAwaitingNetwork`), so this takes 62 (#5395 records its own row). An identity update asked for a revision (`update_identity_with_external_signer_at_revision`) the cached identity would not give it; refused before signing, nothing broadcast, the expected and actual revisions in the message. Not retryable as is: the host refreshes and decides again. Rust value + pin test, Swift raw case + `init(ffi:)` arm + typed `identityRevisionMismatch` + `ErrorHandlingTests` pin, Kotlin typed `PlatformWallet.IdentityRevisionMismatch` + `fromPlatformWalletNative` arm + `DashSdkErrorTest` pin | **Code 31 left this table on 2026-08-04.** `ErrorSigningKeyUnavailable` sat here as #4183's proposal until #4183 merged (`189a3abb1c`); it is now in the merged diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 9ba8b38384d..0bc67f532c9 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -614,14 +614,6 @@ pub enum PlatformWalletFFIResultCode { /// host must not silently substitute a partial drain. ErrorAssetLockTooManyInputs = 60, - /// Maps `PlatformWalletError::IdentityRevisionMismatch`: an identity - /// update asked for a revision the cached identity would not give it. - /// Refused before signing; nothing was broadcast. The host's recorded - /// revision is stale (or the cache is): refresh the identity and decide - /// again, instead of retrying the same call. `expected` and `actual` - /// are in the message. - ErrorIdentityRevisionMismatch = 62, - /// The named thing does not exist. /// /// Originally (and still mostly) the code for every `Option` returned as an @@ -1036,9 +1028,6 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::AssetLockTooManyInputs { .. } => { PlatformWalletFFIResultCode::ErrorAssetLockTooManyInputs } - PlatformWalletError::IdentityRevisionMismatch { .. } => { - PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch - } // A quiesce/drain barrier that did not complete within budget // (clear/reset paths). The host must fail closed: keep its // callback context alive and skip any paired persistence wipe. @@ -1682,24 +1671,6 @@ mod tests { assert_eq!(msg, rendered, "Display payload must survive verbatim"); } - #[test] - fn should_preserve_identity_revision_mismatch_code_and_message() { - let error = PlatformWalletError::IdentityRevisionMismatch { - identity_id: Identifier::from([7; 32]), - expected: 5, - actual: 6, - }; - let rendered = error.to_string(); - let result: PlatformWalletFFIResult = error.into(); - - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch - ); - assert_eq!(result.code as i32, 62, "the host ABI code must stay stable"); - assert_eq!(message_of(&result), rendered); - } - #[test] fn should_preserve_shielded_identity_debit_pending_code_and_message() { let error = PlatformWalletError::ShieldedIdentityDebitPending { diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index e22e7591ac4..32b7de4c8bb 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -301,26 +301,6 @@ pub unsafe extern "C" fn platform_wallet_parse_identity_update_transition_free( /// Update an identity by adding new public keys and/or disabling /// existing key IDs, signing the resulting `IdentityUpdateTransition` /// with the supplied `signer_handle`. -/// -/// # Safety -/// - `wallet_handle` must come from the platform-wallet handle registry. -/// - `identity_id` must be null (refused) or point at a 32-byte buffer. -/// - When `add_public_keys_count` is non-zero, `add_public_keys` must -/// point at a valid, aligned `[IdentityPubkeyFFI; add_public_keys_count]` -/// array whose rows satisfy the contract documented on -/// [`IdentityPubkeyFFI`]. -/// - When `disable_public_key_ids_count` is non-zero, -/// `disable_public_key_ids` must point at a valid, aligned -/// `[u32; disable_public_key_ids_count]` array. -/// - `signer_handle` must be a valid, non-destroyed `SignerHandle` from -/// rs-sdk-ffi's signer constructors (typically `KeychainSigner.handle`, -/// made by `dash_sdk_signer_create_with_ctx`). It is borrowed until the -/// call returns and this function does NOT destroy it. Its callbacks run -/// on a runtime worker thread while the calling thread blocks, so they -/// must be safe to call from any thread, must not wait on the calling -/// thread, and must not call back into this FFI. -/// - Every buffer above stays owned by the caller and must stay valid and -/// unmodified until the call returns; none is retained after it. #[no_mangle] #[allow(clippy::too_many_arguments)] pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( @@ -331,62 +311,6 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, signer_handle: *mut SignerHandle, -) -> PlatformWalletFFIResult { - update_identity_with_signer( - wallet_handle, - identity_id, - add_public_keys, - add_public_keys_count, - disable_public_key_ids, - disable_public_key_ids_count, - None, - signer_handle, - ) -} - -/// [`platform_wallet_update_identity_with_signer`], with the revision the -/// update must carry. When the cached identity would give the transition -/// another revision (it moved since the caller read it), the call fails -/// before anything is signed or broadcast, so a caller that recorded -/// `revision` knows the only revision this update can land at. -/// -/// # Safety -/// Same pointer, length and lifetime rules as -/// [`platform_wallet_update_identity_with_signer`]. -#[no_mangle] -#[allow(clippy::too_many_arguments)] -pub unsafe extern "C" fn platform_wallet_update_identity_with_signer_at_revision( - wallet_handle: Handle, - identity_id: *const u8, - add_public_keys: *const IdentityPubkeyFFI, - add_public_keys_count: usize, - disable_public_key_ids: *const u32, - disable_public_key_ids_count: usize, - revision: u64, - signer_handle: *mut SignerHandle, -) -> PlatformWalletFFIResult { - update_identity_with_signer( - wallet_handle, - identity_id, - add_public_keys, - add_public_keys_count, - disable_public_key_ids, - disable_public_key_ids_count, - Some(revision), - signer_handle, - ) -} - -#[allow(clippy::too_many_arguments)] -unsafe fn update_identity_with_signer( - wallet_handle: Handle, - identity_id: *const u8, - add_public_keys: *const IdentityPubkeyFFI, - add_public_keys_count: usize, - disable_public_key_ids: *const u32, - disable_public_key_ids_count: usize, - revision: Option, - signer_handle: *mut SignerHandle, ) -> PlatformWalletFFIResult { check_ptr!(signer_handle); @@ -451,14 +375,7 @@ unsafe fn update_identity_with_signer( block_on_worker(async move { let signer: &VTableSigner = &*(signer_addr as *const VTableSigner); identity_wallet - .update_identity_with_external_signer_at_revision( - &id, - add_keys, - disable_ids, - revision, - signer, - None, - ) + .update_identity_with_external_signer(&id, add_keys, disable_ids, signer, None) .await }) }); @@ -470,164 +387,12 @@ unsafe fn update_identity_with_signer( #[cfg(test)] mod tests { use super::*; - use dpp::identity::accessors::{IdentityGettersV0, IdentitySettersV0}; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; - use dpp::identity::Identity; use dpp::platform_value::BinaryData; use dpp::prelude::Identifier; use dpp::serialization::PlatformSerializable; use dpp::state_transition::identity_update_transition::v0::IdentityUpdateTransitionV0; use dpp::state_transition::public_key_in_creation::v0::IdentityPublicKeyInCreationV0; - use dpp::version::PlatformVersion; - use platform_wallet::test_support::test_platform_wallet_manager; - use rs_sdk_ffi::{ - dash_sdk_signer_create_with_ctx, dash_sdk_signer_destroy, SignCompletionCallback, - }; - use std::ffi::c_void; - use std::ffi::CStr; - use std::sync::atomic::{AtomicUsize, Ordering}; - - use crate::runtime::runtime; - - /// Counts every call the update makes into a signer created through - /// `dash_sdk_signer_create_with_ctx`; `ctx` points at the counter. - unsafe extern "C" fn counting_sign_async( - ctx: *const c_void, - _pubkey_bytes: *const u8, - _pubkey_len: usize, - _key_type: u8, - _data: *const u8, - _data_len: usize, - completion_ctx: *mut c_void, - completion: SignCompletionCallback, - ) { - (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); - let message = CString::new("counting signer").unwrap(); - completion(completion_ctx, ptr::null(), 0, 0, message.as_ptr()); - } - - unsafe extern "C" fn counting_can_sign( - ctx: *const c_void, - _pubkey_bytes: *const u8, - _pubkey_len: usize, - _key_type: u8, - ) -> bool { - (*(ctx as *const AtomicUsize)).fetch_add(1, Ordering::SeqCst); - true - } - - /// A signer handle whose every callback bumps `calls`. Destroy it with - /// `dash_sdk_signer_destroy` before `calls` goes out of scope. - fn counting_signer(calls: &AtomicUsize) -> *mut SignerHandle { - unsafe { - dash_sdk_signer_create_with_ctx( - calls as *const AtomicUsize as *mut c_void, - counting_sign_async, - counting_can_sign, - None, - ) - } - } - - fn message_of(result: &PlatformWalletFFIResult) -> String { - assert!(!result.message.is_null()); - unsafe { CStr::from_ptr(result.message) } - .to_string_lossy() - .into_owned() - } - - /// A wallet handle whose cached identity `identity_id` is at `revision` - /// and holds a signable MASTER key (id 0), so an update that got past - /// the revision check would consult the signer. The manager owns the - /// wallet and must outlive the handle. - fn wallet_handle_at_revision(identity_id: Identifier, revision: u64) -> (impl Sized, Handle) { - runtime().block_on(async { - let (manager, wallet_id) = test_platform_wallet_manager().await; - let wallet = manager.get_wallet(&wallet_id).await.expect("test wallet"); - let mut identity = - Identity::default_versioned(PlatformVersion::latest()).expect("identity"); - identity.set_id(identity_id); - identity.add_public_key( - IdentityPublicKeyV0 { - id: 0, - purpose: Purpose::AUTHENTICATION, - security_level: SecurityLevel::MASTER, - key_type: KeyType::ECDSA_SECP256K1, - data: vec![0x02; KeyType::ECDSA_SECP256K1.default_size()].into(), - ..Default::default() - } - .into(), - ); - identity.set_revision(revision); - { - let mut wm = wallet.wallet_manager().write().await; - wm.get_wallet_info_mut(&wallet_id) - .expect("wallet info") - .identity_manager - .add_identity(identity, 0, wallet_id, wallet.persister()) - .expect("add identity"); - } - let handle = PLATFORM_WALLET_STORAGE.insert(wallet); - (manager, handle) - }) - } - - /// The `_at_revision` export hands its revision to the wallet's guard: a - /// revision the cached identity would not give the transition comes back - /// as `ErrorIdentityRevisionMismatch` (62) before the signer is - /// consulted. The matching revision gets past the guard to the signer - /// (and then fails at the mock SDK), so the zero count comes from the - /// refusal, not from the fixture. - #[test] - fn should_forward_the_revision_to_the_guard_before_signing() { - let identity_id = Identifier::from([0x21; 32]); - let id_bytes = identity_id.to_buffer(); - let disable_ids = [1u32]; - let call = |handle: Handle, revision: u64, signer: *mut SignerHandle| unsafe { - platform_wallet_update_identity_with_signer_at_revision( - handle, - id_bytes.as_ptr(), - ptr::null(), - 0, - disable_ids.as_ptr(), - disable_ids.len(), - revision, - signer, - ) - }; - - // Cached at 5, so the transition would carry 6. - let (_manager, handle) = wallet_handle_at_revision(identity_id, 5); - for asked in [5, 7] { - let calls = AtomicUsize::new(0); - let signer = counting_signer(&calls); - let result = call(handle, asked, signer); - assert_eq!( - result.code, - PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch, - "asked {asked}: {}", - message_of(&result) - ); - assert_eq!(result.code as i32, 62); - assert_eq!(calls.load(Ordering::SeqCst), 0, "asked {asked}"); - unsafe { dash_sdk_signer_destroy(signer) }; - } - - let calls = AtomicUsize::new(0); - let signer = counting_signer(&calls); - let result = call(handle, 6, signer); - assert_ne!(result.code, PlatformWalletFFIResultCode::Success); - assert_ne!( - result.code, - PlatformWalletFFIResultCode::ErrorIdentityRevisionMismatch, - "{}", - message_of(&result) - ); - assert!(calls.load(Ordering::SeqCst) > 0); - unsafe { dash_sdk_signer_destroy(signer) }; - - PLATFORM_WALLET_STORAGE.remove(handle); - } const YAPPR_TAGLESS_IDENTITY_UPDATE_FIXTURE_HEX: &str = concat!( "0089fd6ddba75136a4fea02dc7d89ef0ca5bcc32ccf12fb8da6a1a03740567ae72", diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 7a9a371bdf8..a1e0103c7ab 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -86,20 +86,6 @@ pub enum PlatformWalletError { #[error("Invalid identity data: {0}")] InvalidIdentityData(String), - /// An identity update was asked to carry a revision the cached identity - /// would not give it: the cache moved since the caller read the - /// revision, or the caller read it elsewhere. Raised before the - /// transition is signed, so nothing was broadcast. - #[error( - "identity {identity_id}: the update was asked to carry revision {expected}, \ - but the cached identity would give it {actual}; nothing was broadcast" - )] - IdentityRevisionMismatch { - identity_id: Identifier, - expected: u64, - actual: u64, - }, - /// A DashPay invitation link's voucher key is for the other network (a /// testnet link opened in a mainnet wallet, or the reverse). Definitive: /// the claim refuses the link before any network work. diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 9814fc7c593..5dfb1c1c5d9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -105,42 +105,6 @@ impl IdentityWallet { signer: &S, settings: Option, ) -> Result<(), PlatformWalletError> - where - S: Signer + Send + Sync, - { - self.update_identity_with_external_signer_at_revision( - identity_id, - add_public_keys, - disable_public_keys, - None, - signer, - settings, - ) - .await - } - - /// [`Self::update_identity_with_external_signer`], with the revision - /// the transition must carry. - /// - /// The transition takes the cached identity's revision plus one, read - /// when the update starts. A caller that records the revision before - /// submitting (to tell later whether the update can still land: Drive - /// takes an identity update only at one above the identity's revision) - /// cannot see that read, and a refresh in between moves it. With - /// `revision` set, an update that would carry another one fails with - /// [`PlatformWalletError::IdentityRevisionMismatch`] before anything is - /// signed or broadcast, so the revision the caller recorded is the only - /// one this update can land at. `None` takes whatever the cache gives, - /// as [`Self::update_identity_with_external_signer`] does. - pub async fn update_identity_with_external_signer_at_revision( - &self, - identity_id: &Identifier, - add_public_keys: Vec, - disable_public_keys: Vec, - revision: Option, - signer: &S, - settings: Option, - ) -> Result<(), PlatformWalletError> where S: Signer + Send + Sync, { @@ -177,7 +141,7 @@ impl IdentityWallet { }; // Increment revision for the update transition. - let transition_revision = update_revision(identity_id, identity.revision(), revision)?; + let transition_revision = update_revision(identity_id, identity.revision())?; identity.set_revision(transition_revision); // Pick the MASTER signing key — DPP requires identity update @@ -514,25 +478,13 @@ impl IdentityWallet { } /// The revision an update of an identity cached at `cached` carries: one -/// above it. With `expected` set, any other value is refused. -fn update_revision( - identity_id: &Identifier, - cached: u64, - expected: Option, -) -> Result { - let next = cached.checked_add(1).ok_or_else(|| { +/// above it, refused at the last revision rather than wrapped. +fn update_revision(identity_id: &Identifier, cached: u64) -> Result { + cached.checked_add(1).ok_or_else(|| { PlatformWalletError::InvalidIdentityData(format!( "identity {identity_id} is at the last revision" )) - })?; - match expected { - Some(expected) if expected != next => Err(PlatformWalletError::IdentityRevisionMismatch { - identity_id: *identity_id, - expected, - actual: next, - }), - _ => Ok(next), - } + }) } #[cfg(test)] @@ -542,38 +494,21 @@ mod update_revision_tests { #[test] fn should_carry_one_above_the_cached_revision() { let id = Identifier::from([7u8; 32]); - assert_eq!(update_revision(&id, 4, None).unwrap(), 5); - assert_eq!(update_revision(&id, 4, Some(5)).unwrap(), 5); - } - - #[test] - fn should_refuse_an_update_asked_for_another_revision() { - let id = Identifier::from([7u8; 32]); - // The cache moved past the revision the caller read. - match update_revision(&id, 5, Some(5)) { - Err(PlatformWalletError::IdentityRevisionMismatch { - expected, actual, .. - }) => assert_eq!((expected, actual), (5, 6)), - other => panic!("unexpected: {other:?}"), - } - assert!(matches!( - update_revision(&id, 3, Some(5)), - Err(PlatformWalletError::IdentityRevisionMismatch { .. }) - )); + assert_eq!(update_revision(&id, 4).unwrap(), 5); } #[test] fn should_refuse_an_identity_at_the_last_revision() { let id = Identifier::from([7u8; 32]); assert!(matches!( - update_revision(&id, u64::MAX, None), + update_revision(&id, u64::MAX), Err(PlatformWalletError::InvalidIdentityData(_)) )); } } #[cfg(test)] -mod update_identity_at_revision_tests { +mod update_identity_tests { use super::super::signing_key::tests::{wallet_with_signing_keys_on, CapturingStore}; use super::*; use crate::changeset::PlatformWalletPersistence; @@ -581,33 +516,9 @@ mod update_identity_at_revision_tests { use crate::PlatformWallet; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeySettersV0; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; - use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::atomic::Ordering; use std::sync::Arc; - /// Counts every call the update makes into it. - #[derive(Debug, Default)] - struct RecordingSigner(AtomicUsize); - - #[async_trait] - impl Signer for RecordingSigner { - async fn sign(&self, _: &IdentityPublicKey, _: &[u8]) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - Err(ProtocolError::Generic("recording signer".to_string())) - } - async fn sign_create_witness( - &self, - _: &IdentityPublicKey, - _: &[u8], - ) -> Result { - self.0.fetch_add(1, Ordering::SeqCst); - Err(ProtocolError::Generic("recording signer".to_string())) - } - fn can_sign_with(&self, _: &IdentityPublicKey) -> bool { - self.0.fetch_add(1, Ordering::SeqCst); - true - } - } - async fn cached_identity(wallet: &IdentityWallet, identity_id: &Identifier) -> Identity { let wm = wallet.wallet_manager.read().await; wm.get_wallet_info(&wallet.wallet_id) @@ -620,8 +531,7 @@ mod update_identity_at_revision_tests { } /// A wallet whose cached identity is at `revision` and also holds a - /// signable MASTER key (id 0), so an update that got past the revision - /// check would consult the signer and reach the mock SDK. + /// signable MASTER key (id 0). async fn wallet_at_revision(revision: u64) -> Arc { wallet_at_revision_on(revision, Arc::new(NoPlatformPersistence)).await } @@ -661,68 +571,6 @@ mod update_identity_at_revision_tests { wallet } - /// The mock SDK has no expectations, so the update fails if it gets as - /// far as the nonce fetch; the refusal must come before it and before - /// the signer is consulted. - #[tokio::test] - async fn should_refuse_another_revision_before_signing_or_reaching_platform() { - let identity_id = Identifier::default(); - // Cache moved past the recorded revision (5 → 6), or is behind it (3 → 4). - for (cached, expected, actual) in [(5, 5, 6), (3, 5, 4)] { - let wallet = wallet_at_revision(cached).await; - let wallet = wallet.identity(); - let before = cached_identity(wallet, &identity_id).await; - let signer = RecordingSigner::default(); - let error = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(expected), - &signer, - None, - ) - .await - .unwrap_err(); - match error { - PlatformWalletError::IdentityRevisionMismatch { - identity_id: id, - expected: e, - actual: a, - } => assert_eq!((id, e, a), (identity_id, expected, actual)), - other => panic!("unexpected: {other:?}"), - } - assert_eq!(signer.0.load(Ordering::SeqCst), 0); - assert_eq!(cached_identity(wallet, &identity_id).await, before); - } - - // Control: at the matching revision the same update gets past the - // check, consults the signer and then fails at the mock SDK, so the - // zero count above comes from the refusal, not from the fixture. - let wallet = wallet_at_revision(4).await; - let signer = RecordingSigner::default(); - let error = wallet - .identity() - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(5), - &signer, - Some(PutSettings { - request_settings: dash_sdk::RequestSettings { - retries: Some(0), - ..Default::default() - }, - ..Default::default() - }), - ) - .await - .unwrap_err(); - assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); - assert!(signer.0.load(Ordering::SeqCst) > 0); - } - /// The key the landed-update tests add. fn key_3() -> IdentityPublicKey { IdentityPublicKeyV0 { @@ -996,38 +844,17 @@ mod update_identity_at_revision_tests { /// A cache one past the chain (6 over Drive's 5, which a build before /// this one could leave) is refused on every update, and no fetch heals /// it: each comes back older. Drive's refusal carries 5, the cache takes - /// it and stores it with the scalar snapshot, keys untouched, and the - /// next update carries 6 and gets past the revision check. + /// it and stores it with the scalar snapshot, keys untouched, so the + /// next update is built at 6. #[tokio::test] async fn should_take_drives_revision_from_an_update_refused_for_its_revision() { let identity_id = Identifier::default(); - let no_retries = Some(PutSettings { - request_settings: dash_sdk::RequestSettings { - retries: Some(0), - ..Default::default() - }, - ..Default::default() - }); for shape in [0, 1] { let store = Arc::new(CapturingStore::default()); let wallet = wallet_at_revision_on(6, store.clone()).await; let wallet = wallet.identity(); let before = cached_identity(wallet, &identity_id).await; - let stuck = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(6), - &RecordingSigner::default(), - no_retries, - ) - .await - .unwrap_err(); - assert!( - matches!(stuck, PlatformWalletError::IdentityRevisionMismatch { .. }), - "{stuck:?}" - ); + assert_eq!(update_revision(&identity_id, before.revision()).unwrap(), 7); store.take(); wallet @@ -1044,21 +871,10 @@ mod update_identity_at_revision_tests { .and_then(|cs| cs.identities.get(&identity_id)) .expect("the scalar snapshot"); assert_eq!(stored.revision, 5); - - let signer = RecordingSigner::default(); - let error = wallet - .update_identity_with_external_signer_at_revision( - &identity_id, - vec![], - vec![1], - Some(6), - &signer, - no_retries, - ) - .await - .unwrap_err(); - assert!(matches!(error, PlatformWalletError::Sdk(_)), "{error:?}"); - assert!(signer.0.load(Ordering::SeqCst) > 0); + assert_eq!( + update_revision(&identity_id, identity.revision()).unwrap(), + 6 + ); } } diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 3dc253555a1..c9347bedfc8 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -132,27 +132,12 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── -/// The revision `updateIdentity` pins, or the message to throw: `None` -/// without `hasRevision` (whatever `revision` holds), and a negative -/// `revision` with it is refused rather than bit-cast to a huge one. -fn update_revision_arg(has_revision: bool, revision: i64) -> Result, &'static str> { - if !has_revision { - return Ok(None); - } - if revision < 0 { - return Err("revision must be non-negative"); - } - Ok(Some(revision as u64)) -} - /// Add public keys and/or disable existing key ids on an identity, signing /// the resulting `IdentityUpdateTransition` via the external `signerHandle` /// (the identity's MASTER auth key) — the exact call /// `AddIdentityKeyView.submit` makes through `wallet.updateIdentity(...)`. /// -/// Thin marshaler over `platform_wallet_update_identity_with_signer` (or -/// `platform_wallet_update_identity_with_signer_at_revision` with -/// `hasRevision`). The +/// Thin marshaler over `platform_wallet_update_identity_with_signer`. The /// derive + Keystore-persist of the new keys' private material happens on /// the Kotlin side BEFORE this call (mirroring the Swift flow); here we only /// carry the on-chain public rows. @@ -183,12 +168,8 @@ fn update_revision_arg(has_revision: bool, revision: i64) -> Result, /// /// `disablePublicKeyIds` is a JVM `int[]` of key ids to disable (may be /// empty). At least one of add / disable must be non-empty for the FFI to -/// build a transition. With `hasRevision`, the update must carry `revision`, -/// and one the cached identity would give another revision is refused -/// before anything is signed -/// (`platform_wallet_update_identity_with_signer_at_revision`). Returns -/// nothing on success (throws on error) — Room learns of the change through -/// the persistence changeset. +/// build a transition. Returns nothing on success (throws on error) — Room +/// learns of the change through the persistence changeset. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentity( mut env: JNIEnv, @@ -198,8 +179,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up add_pubkeys_blob: JByteArray, disable_public_key_ids: jni::objects::JIntArray, signer_handle: jlong, - has_revision: jni::sys::jboolean, - revision: jlong, ) { guard(&mut env, (), |env| { let Some(id) = read_id32(env, &identity_id, "identityId") else { @@ -255,51 +234,27 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up return; } - let revision = match update_revision_arg(has_revision != 0, revision) { - Ok(revision) => revision, - Err(message) => { - throw_sdk_exception(env, 1, message); - return; - } - }; - // Build the FFI rows referencing the owned buffers in `decoded`. let ffi_rows: Vec = decoded.iter().map(|row| row.to_ffi()).collect(); - let rows = if ffi_rows.is_empty() { - ptr::null() - } else { - ffi_rows.as_ptr() - }; - let disable = if disable_ids.is_empty() { - ptr::null() - } else { - disable_ids.as_ptr() - }; let result = unsafe { - match revision { - Some(revision) => { - platform_wallet_ffi::platform_wallet_update_identity_with_signer_at_revision( - wallet_handle as Handle, - id.as_ptr(), - rows, - ffi_rows.len(), - disable, - disable_ids.len(), - revision, - signer_handle as *mut SignerHandle, - ) - } - None => platform_wallet_ffi::platform_wallet_update_identity_with_signer( - wallet_handle as Handle, - id.as_ptr(), - rows, - ffi_rows.len(), - disable, - disable_ids.len(), - signer_handle as *mut SignerHandle, - ), - } + platform_wallet_ffi::platform_wallet_update_identity_with_signer( + wallet_handle as Handle, + id.as_ptr(), + if ffi_rows.is_empty() { + ptr::null() + } else { + ffi_rows.as_ptr() + }, + ffi_rows.len(), + if disable_ids.is_empty() { + ptr::null() + } else { + disable_ids.as_ptr() + }, + disable_ids.len(), + signer_handle as *mut SignerHandle, + ) }; // `decoded` / `ffi_rows` / `disable_ids` own the buffers the pointers // reference; they stay in scope through the FFI call above. @@ -997,27 +952,3 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn should_pin_a_revision_only_with_has_revision_and_refuse_a_negative_one() { - assert_eq!(update_revision_arg(false, 5), Ok(None)); - // Without `hasRevision` the value is not read, negative or not. - assert_eq!(update_revision_arg(false, -1), Ok(None)); - assert_eq!(update_revision_arg(true, 0), Ok(Some(0))); - assert_eq!( - update_revision_arg(true, i64::MAX), - Ok(Some(i64::MAX as u64)) - ); - for revision in [-1, i64::MIN] { - assert_eq!( - update_revision_arg(true, revision), - Err("revision must be non-negative"), - "{revision}" - ); - } - } -} diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 1b246b4d64f..548e2d19c83 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3729,21 +3729,10 @@ extension ManagedPlatformWallet { /// otherwise subsequent operations that try to sign with the /// newly-added keys will fail. The signer here only signs the /// update transition itself with an existing MASTER key. - /// - /// The transition carries the cached identity's revision plus one, - /// read when the Rust side starts the update. With `revision` set, an - /// update that would carry another one throws before anything is - /// signed or broadcast (`PlatformWalletError.identityRevisionMismatch`, - /// whose message carries the expected and actual revisions), - /// so a caller that recorded `revision` beforehand knows the only - /// revision this update can land at: Drive takes an identity update - /// only at one above the identity's revision. `nil` takes whatever the - /// cache gives. public func updateIdentity( identityId: Identifier, addPublicKeys: [ManagedPlatformWallet.IdentityPubkey] = [], disablePublicKeyIds: [UInt32] = [], - revision: UInt64? = nil, signer: KeychainSigner ) async throws { let handle = self.handle @@ -3753,39 +3742,39 @@ extension ManagedPlatformWallet { } let addPubkeys = addPublicKeys let disableIds = disablePublicKeyIds - // `self` stays alive for the whole call: a deinit mid-call would - // destroy the handle the FFI is still using. - try await Task.detached(priority: .userInitiated) { [self] in + try await Task.detached(priority: .userInitiated) { + _ = signer // Mirror the registration FFI's pubkey-pinning pattern via // `withPubkeyFFIArray` so each `pubkey_bytes` pointer the // FFI sees stays valid for the call duration. let pubkeyBuffers: [Data] = addPubkeys.map { $0.pubkeyBytes } - // The Rust worker borrows the signer's vtable for the whole - // synchronous call, so the signer must outlive it. - let result = withExtendedLifetime((self, signer)) { - idBytes.withUnsafeBufferPointer { - idBp -> PlatformWalletFFIResult in - disableIds.withUnsafeBufferPointer { disableBp -> PlatformWalletFFIResult in - let update = { (rows: UnsafePointer?, count: Int) -> PlatformWalletFFIResult in - let disable = disableIds.isEmpty ? nil : disableBp.baseAddress - guard let revision else { - return platform_wallet_update_identity_with_signer( - handle, idBp.baseAddress!, rows, UInt(count), - disable, UInt(disableIds.count), signerHandle) - } - return platform_wallet_update_identity_with_signer_at_revision( - handle, idBp.baseAddress!, rows, UInt(count), - disable, UInt(disableIds.count), revision, signerHandle) - } - if addPubkeys.isEmpty { - return update(nil, 0) - } - return ManagedPlatformWallet.withPubkeyFFIArray( - addPubkeys, - buffers: pubkeyBuffers - ) { ffiRowsPtr, ffiRowsCount in - update(ffiRowsPtr, ffiRowsCount) - } + let result = idBytes.withUnsafeBufferPointer { + idBp -> PlatformWalletFFIResult in + disableIds.withUnsafeBufferPointer { disableBp in + if addPubkeys.isEmpty { + return platform_wallet_update_identity_with_signer( + handle, + idBp.baseAddress!, + nil, + 0, + disableIds.isEmpty ? nil : disableBp.baseAddress, + UInt(disableIds.count), + signerHandle + ) + } + return ManagedPlatformWallet.withPubkeyFFIArray( + addPubkeys, + buffers: pubkeyBuffers + ) { ffiRowsPtr, ffiRowsCount in + platform_wallet_update_identity_with_signer( + handle, + idBp.baseAddress!, + ffiRowsPtr, + UInt(ffiRowsCount), + disableIds.isEmpty ? nil : disableBp.baseAddress, + UInt(disableIds.count), + signerHandle + ) } } } diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift index 2ec67466028..e7e746105e6 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletResult.swift @@ -264,9 +264,6 @@ public enum PlatformWalletResultCode: Int32, Sendable { case errorSpvProcessRestartRequired = 59 /// A whole-wallet asset lock exceeds the input cap; nothing was broadcast. case errorAssetLockTooManyInputs = 60 - /// An identity update asked for a revision the cached identity would not - /// give it; refused before signing, nothing was broadcast. - case errorIdentityRevisionMismatch = 62 /// The named thing does not exist. Besides the handle/lookup failures this /// has always covered, BOTH deferred-send paths report the /// wallet-was-REMOVED case here. @@ -398,8 +395,6 @@ public enum PlatformWalletResultCode: Int32, Sendable { self = .errorSpvProcessRestartRequired case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_ASSET_LOCK_TOO_MANY_INPUTS: self = .errorAssetLockTooManyInputs - case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH: - self = .errorIdentityRevisionMismatch case PLATFORM_WALLET_FFI_RESULT_CODE_NOT_FOUND: self = .notFound case PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_UNKNOWN: @@ -561,11 +556,6 @@ public enum PlatformWalletError: LocalizedError { case identityNotFound(String) /// A managed identity has no balance in the Platform response; the read may be retried. case identityBalanceUnavailable(String) - /// `updateIdentity(…, revision:)` was asked for a revision the cached - /// identity would not give the update. Nothing was signed or broadcast; - /// the expected and actual revisions are in the message. Refresh the - /// identity and decide again rather than retrying the same call. - case identityRevisionMismatch(String) case contactNotFound(String) case utf8Conversion(String) case serialization(String) @@ -809,8 +799,7 @@ public enum PlatformWalletError: LocalizedError { switch self { case .nullPointer(let m), .invalidHandle(let m), .invalidParameter(let m), .invalidIdentifier(let m), .invalidNetwork(let m), .walletOperation(let m), - .identityNotFound(let m), .identityBalanceUnavailable(let m), .identityRevisionMismatch(let m), - .contactNotFound(let m), .utf8Conversion(let m), + .identityNotFound(let m), .identityBalanceUnavailable(let m), .contactNotFound(let m), .utf8Conversion(let m), .serialization(let m), .deserialization(let m), .memoryAllocation(let m), .arithmeticOverflow(let m), .noSelectableInputs(let m), .coreInsufficientFunds(let m), @@ -922,7 +911,6 @@ public enum PlatformWalletError: LocalizedError { case .errorWalletOperation: self = .walletOperation(detail) case .errorIdentityNotFound: self = .identityNotFound(detail) case .errorIdentityBalanceUnavailable: self = .identityBalanceUnavailable(detail) - case .errorIdentityRevisionMismatch: self = .identityRevisionMismatch(detail) case .errorContactNotFound: self = .contactNotFound(detail) case .errorInvalidNetwork: self = .invalidNetwork(detail) case .errorInvalidIdentifier: self = .invalidIdentifier(detail) diff --git a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift index 2b42a2fac11..f18b44a9fce 100644 --- a/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift +++ b/packages/swift-sdk/SwiftTests/SwiftDashSDKTests/ErrorHandlingTests.swift @@ -33,20 +33,6 @@ final class ErrorHandlingTests: XCTestCase { ) } - func testIdentityRevisionMismatchFFIResultMapping() { - XCTAssertEqual( - PlatformWalletResultCode(ffi: PLATFORM_WALLET_FFI_RESULT_CODE_ERROR_IDENTITY_REVISION_MISMATCH), - .errorIdentityRevisionMismatch - ) - XCTAssertEqual(PlatformWalletResultCode.errorIdentityRevisionMismatch.rawValue, 62) - let error = PlatformWalletError(code: .errorIdentityRevisionMismatch, message: "asked 5, would give 6") - guard case .identityRevisionMismatch(let detail) = error else { - return XCTFail("the revision refusal lost its typed case") - } - XCTAssertEqual(detail, "asked 5, would give 6") - XCTAssertEqual(error.errorDescription, "asked 5, would give 6") - } - func testAssetLockInsufficientFundsFFIResultMapping() { // The asset-lock coin-selection shortfall (dashpay/platform#4073). // Swift could not decode code 29 at all before this mirror existed — From 8df7515ebc39f8c43a7cab34e6f85a44c0d7bd5c Mon Sep 17 00:00:00 2001 From: Roman <51091564+jeanpierreroma@users.noreply.github.com> Date: Wed, 28 Oct 2026 18:35:42 +0200 Subject: [PATCH 72/72] fix(platform-wallet): tell the caller not to resubmit a landed update the wallet could not store Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/identity/network/update.rs | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs index 5dfb1c1c5d9..e8a3777fb63 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -22,7 +22,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; -use crate::changeset::KeyWithBreadcrumb; +use crate::changeset::{KeyWithBreadcrumb, PersistenceError}; use crate::error::{as_identity_revision_invalid, PlatformWalletError}; use super::signing_key::AvailableSigningKey; @@ -295,11 +295,7 @@ impl IdentityWallet { ); return managed .store_keys_with_snapshot(keys, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - }); + .map_err(landed_but_not_stored); } // The identity is at the transition's revision now, so a @@ -343,11 +339,7 @@ impl IdentityWallet { ); managed .store_keys_with_snapshot(keys, &self.persister) - .map_err(|e| { - PlatformWalletError::Persistence(format!( - "identity key not persisted after update: {e}" - )) - }) + .map_err(landed_but_not_stored) } /// Take Drive's revision from an update it refused for its revision. @@ -487,6 +479,16 @@ fn update_revision(identity_id: &Identifier, cached: u64) -> Result PlatformWalletError { + PlatformWalletError::Persistence(format!( + "identity update landed on Platform and is in the wallet's cache, but the wallet could not \ + store it ({e}); do not resubmit it" + )) +} + #[cfg(test)] mod update_revision_tests { use super::*; @@ -787,7 +789,7 @@ mod update_identity_tests { .await .unwrap_err(); assert!( - matches!(error, PlatformWalletError::Persistence(_)), + matches!(&error, PlatformWalletError::Persistence(text) if text.contains("do not resubmit")), "{error:?}" ); }