From e5584d49ba0ed8fd5030989b0c0200b0d4eec701 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:01:21 +0000 Subject: [PATCH 01/21] feat(platform-wallet): let hosts ask whether keys may be disabled under DIP-11 IdentityWallet::check_keys_can_be_disabled judges the managed identity the update path builds from with dpp's check_keys_can_be_disabled at the caller's clock, and platform_wallet_check_keys_can_be_disabled marshals it: a refusal is a verdict (allowed, refused key id, security level), an unknown wallet or identity an error. Hosts can offer only the keys the rule allows instead of deciding it themselves. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../src/identity_update.rs | 195 ++++++++++++++++++ .../src/wallet/identity/network/update.rs | 145 ++++++++++++- 2 files changed, 339 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 32b7de4c8bb..1cd0117ff4b 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -12,6 +12,7 @@ use std::slice; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; +use dpp::identity::key_disable_rule::LastAuthenticationKeyError; use dpp::identity::KeyID; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; @@ -384,6 +385,77 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( PlatformWalletFFIResult::ok() } +/// Verdict of [`platform_wallet_check_keys_can_be_disabled`]. +#[repr(C)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct KeyDisableCheckFFI { + /// Whether every listed key may be disabled. + pub allowed: bool, + /// When not `allowed`: the key that cannot be disabled. + pub refused_key_id: u32, + /// When not `allowed`: the security level (DPP discriminant, 0 = MASTER) + /// that would be left without a valid authentication key. + pub refused_security_level: u8, +} + +impl From> for KeyDisableCheckFFI { + fn from(verdict: Result<(), LastAuthenticationKeyError>) -> Self { + match verdict { + Ok(()) => Self { + allowed: true, + ..Self::default() + }, + Err(refusal) => Self { + allowed: false, + refused_key_id: refusal.key_id, + refused_security_level: refusal.security_level as u8, + }, + } + } +} + +/// Whether disabling `disable_public_key_ids` of the wallet's identity +/// `identity_id` follows DIP-11: every security level they touch keeps a +/// valid authentication key. `now_ms` is the host's wall clock in Unix +/// milliseconds, so a key with an expiry counts until it expires. +/// +/// Marshals `IdentityWallet::check_keys_can_be_disabled`, which judges the +/// identity [`platform_wallet_update_identity_with_signer`] builds the update +/// from. A refusal is a verdict, written to `out_check` with a success result; +/// an error result means the wallet handle or the identity is unknown. +#[no_mangle] +pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( + wallet_handle: Handle, + identity_id: *const u8, + disable_public_key_ids: *const u32, + disable_public_key_ids_count: usize, + now_ms: u64, + out_check: *mut KeyDisableCheckFFI, +) -> PlatformWalletFFIResult { + check_ptr!(out_check); + + let id = unwrap_result_or_return!(read_identifier(identity_id)); + 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 option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { + let identity_wallet = wallet.identity().clone(); + block_on_worker(async move { + identity_wallet + .check_keys_can_be_disabled(&id, &disable_ids, now_ms) + .await + }) + }); + let result = unwrap_option_or_return!(option); + let verdict = unwrap_result_or_return!(result); + *out_check = verdict.into(); + PlatformWalletFFIResult::ok() +} + #[cfg(test)] mod tests { use super::*; @@ -722,3 +794,126 @@ mod tests { assert_eq!(out.disable_public_key_ids_count, 0); } } + +#[cfg(test)] +mod key_disable_check_tests { + use super::*; + use crate::runtime::runtime; + use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; + use dpp::identity::v0::IdentityV0; + use dpp::identity::Identity; + use platform_wallet::test_support::test_platform_wallet_manager; + use platform_wallet::wallet::persister::NoPlatformPersistence; + use platform_wallet::WalletPersister; + use std::collections::BTreeMap; + use std::sync::Arc; + + const IDENTITY_ID: [u8; 32] = [1; 32]; + const NOW_MS: u64 = 1_800_000_000_000; + + /// MASTER 0, CRITICAL 1, HIGH 2: one authentication key per level. + fn identity() -> Identity { + let keys = [ + (0, SecurityLevel::MASTER), + (1, SecurityLevel::CRITICAL), + (2, SecurityLevel::HIGH), + ] + .map(|(id, security_level)| { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::AUTHENTICATION, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + }); + IdentityV0 { + id: Identifier::from(IDENTITY_ID), + public_keys: keys + .into_iter() + .map(|key| (key.id(), key)) + .collect::>(), + balance: 0, + revision: 0, + } + .into() + } + + unsafe fn check( + handle: Handle, + identity_id: &[u8; 32], + key_ids: &[u32], + ) -> (PlatformWalletFFIResult, KeyDisableCheckFFI) { + let mut out = KeyDisableCheckFFI::default(); + let result = platform_wallet_check_keys_can_be_disabled( + handle, + identity_id.as_ptr(), + key_ids.as_ptr(), + key_ids.len(), + NOW_MS, + &mut out, + ); + (result, out) + } + + #[test] + fn should_report_the_dip11_verdict_for_the_wallets_identity() { + // Setup only inside the runtime: the FFI entry point blocks on its own + // worker and must not run nested in another runtime. + let (manager, handle) = runtime().block_on(async { + let (manager, wallet_id) = test_platform_wallet_manager().await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let persister = WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); + let mut wm = wallet.identity().wallet_manager_write().await; + wm.get_wallet_info_mut(&wallet_id) + .expect("wallet info") + .identity_manager + .add_identity(identity(), 0, wallet_id, &persister) + .expect("add identity"); + } + (manager, PLATFORM_WALLET_STORAGE.insert(wallet)) + }); + + let (result, refused) = unsafe { check(handle, &IDENTITY_ID, &[1]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + refused, + KeyDisableCheckFFI { + allowed: false, + refused_key_id: 1, + refused_security_level: SecurityLevel::CRITICAL as u8, + } + ); + + // CRITICAL 1 stands in for HIGH 2. + let (result, allowed) = unsafe { check(handle, &IDENTITY_ID, &[2]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert!(allowed.allowed, "{allowed:?}"); + + // An identity the wallet does not hold is an error, not a verdict. + let (result, _) = unsafe { check(handle, &[9; 32], &[1]) }; + assert_ne!(result.code, PlatformWalletFFIResultCode::Success); + + PLATFORM_WALLET_STORAGE.remove(handle); + drop(manager); + } + + #[test] + fn should_refuse_a_null_out_pointer() { + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + 0, + IDENTITY_ID.as_ptr(), + ptr::null(), + 0, + NOW_MS, + ptr::null_mut(), + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::ErrorNullPointer); + } +} 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 aa738e5b017..e12d4af8f02 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -6,13 +6,15 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::key_disable_rule::{check_keys_can_be_disabled, LastAuthenticationKeyError}; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; +use dpp::identity::KeyID; 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, TimestampMillis}; use dpp::ProtocolError; use dpp::identity::signer::Signer; @@ -61,6 +63,46 @@ where // --------------------------------------------------------------------------- impl IdentityWallet { + /// Whether disabling `disable_public_keys` of the managed identity + /// `identity_id` keeps a valid authentication key at every security level + /// it touches (DIP-11): the inner `Err` names the key that cannot be + /// disabled and its level. + /// + /// Judges the identity this wallet holds, which is the one + /// [`Self::update_identity_with_external_signer`] builds the update from, + /// with dpp's [`check_keys_can_be_disabled`] at `now_ms`, the caller's + /// wall clock in Unix milliseconds. The builder has no clock and counts no + /// key with an expiry, so an interface that offers only the keys this + /// allows can still see an update refused when the only stand-in key + /// expires; it never offers a key the builder would let through but this + /// refuses. + /// + /// The outer `Err` is a wallet failure: the wallet or the identity is not + /// loaded. + pub async fn check_keys_can_be_disabled( + &self, + identity_id: &Identifier, + disable_public_keys: &[KeyID], + now_ms: TimestampMillis, + ) -> Result, PlatformWalletError> { + let wm = self.wallet_manager.read().await; + let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { + PlatformWalletError::WalletNotFound( + "Wallet info not found in wallet manager".to_string(), + ) + })?; + let managed = info + .identity_manager + .identity(identity_id) + .ok_or(PlatformWalletError::IdentityNotFound(*identity_id))?; + Ok(check_keys_can_be_disabled( + &managed.identity, + &[], + disable_public_keys, + Some(now_ms), + )) + } + /// Update an identity using an externally-supplied signer. /// /// Signing is routed through the supplied `&S: Signer`. @@ -477,6 +519,107 @@ mod tests { ); } + async fn manager_holding( + identity: Identity, + ) -> ( + Arc>, + Arc, + ) { + let (manager, wallet_id) = test_platform_wallet_manager_with_sdk( + dash_sdk::SdkBuilder::new_mock().build().expect("mock sdk"), + ) + .await; + let wallet = manager.get_wallet(&wallet_id).await.expect("wallet"); + { + let persister = WalletPersister::new(wallet_id, Arc::new(NoPlatformPersistence)); + let mut wm = manager.wallet_manager.write().await; + let info = wm.get_wallet_info_mut(&wallet_id).expect("wallet info"); + info.identity_manager + .add_identity(identity, 0, wallet_id, &persister) + .expect("add identity"); + } + (manager, wallet) + } + + const NOW_MS: TimestampMillis = 1_800_000_000_000; + + #[tokio::test] + async fn should_judge_a_disable_against_the_managed_identity() { + let (_manager, wallet) = manager_holding(identity()).await; + let identity_id = Identifier::from([1; 32]); + + let refused = wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS) + .await + .expect("the identity is loaded"); + assert_eq!( + refused, + Err(LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + }) + ); + // CRITICAL 1 stands in for HIGH 2. + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[2], NOW_MS) + .await + .expect("the identity is loaded"), + Ok(()) + ); + } + + #[tokio::test] + async fn should_count_an_unexpired_key_with_the_callers_clock() { + // The builder counts no key with an expiry; a caller with a clock does. + let mut identity = identity(); + identity.add_public_key( + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id: 3, + purpose: Purpose::AUTHENTICATION, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![5; 33]), + disabled_at: None, + }) + .with_limits(None, Some(NOW_MS + 1)), + ); + let (_manager, wallet) = manager_holding(identity).await; + let identity_id = Identifier::from([1; 32]); + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS) + .await + .expect("the identity is loaded"), + Ok(()) + ); + assert!(wallet + .identity() + .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS + 1) + .await + .expect("the identity is loaded") + .is_err()); + } + + #[tokio::test] + async fn should_report_an_identity_the_wallet_does_not_hold() { + let (_manager, wallet) = manager_holding(identity()).await; + let unknown = Identifier::from([9; 32]); + let result = wallet + .identity() + .check_keys_can_be_disabled(&unknown, &[1], NOW_MS) + .await; + assert!( + matches!(result, Err(PlatformWalletError::IdentityNotFound(id)) if id == unknown), + "{result:?}" + ); + } + /// The caller-supplied-identity entry point refuses the same way. #[tokio::test] async fn should_refuse_disabling_the_last_critical_key_with_a_supplied_identity() { From 5b6c8f77d7451b2e12ffb882f4eb9ba6add3eef7 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:01:21 +0000 Subject: [PATCH 02/21] feat(kotlin-sdk): bridge the DIP-11 disable check over JNI TransactionsNative.checkKeysCanBeDisabled marshals platform_wallet_check_keys_can_be_disabled and returns [allowed, refusedKeyId, refusedSecurityLevel]. The int[] key-id reader is shared with updateIdentity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../rs-unified-sdk-jni/src/transactions.rs | 132 +++++++++++++----- 1 file changed, 98 insertions(+), 34 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index c9347bedfc8..98bec10bb7b 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -16,6 +16,8 @@ //! same screen: //! * identity add/disable keys -> `platform_wallet_update_identity_with_signer` //! (`AddIdentityKeyView.submit` -> `wallet.updateIdentity(...)`), +//! * whether keys may be disabled -> `platform_wallet_check_keys_can_be_disabled` +//! (`KeyDisableGate` via `wallet.checkKeysCanBeDisabled(...)`), //! * document purchase / set-price -> //! `platform_wallet_document_purchase` / `platform_wallet_document_set_price` //! (`ManagedPlatformWallet.purchaseDocument`), @@ -40,11 +42,12 @@ use crate::pubkey_rows::decode_update_pubkeys_blob; use crate::support::{guard, net_from_ord, take_pwffi_error, throw_sdk_exception}; -use jni::objects::{JByteArray, JClass, JString}; -use jni::sys::{jboolean, jint, jlong, jstring}; +use jni::objects::{JByteArray, JClass, JIntArray, JString}; +use jni::sys::{jboolean, jint, jlong, jlongArray, jstring}; use jni::JNIEnv; use platform_wallet_ffi::handle::Handle; use platform_wallet_ffi::identity_registration_with_signer::IdentityPubkeyFFI; +use platform_wallet_ffi::identity_update::KeyDisableCheckFFI; use rs_sdk_ffi::SignerHandle; use std::ffi::{CStr, CString}; use std::os::raw::c_char; @@ -132,6 +135,34 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── +/// Read key ids from a JVM `int[]` into a `Vec`. A null or empty array +/// is a legitimate "no keys"; jni 0.21 has no `convert_int_array`, so read the +/// length then the region into an owned buffer. Throws + returns `None` on a +/// negative id, which would otherwise bit-cast to a bogus huge `u32`. +fn read_key_ids(env: &mut JNIEnv, key_ids: &JIntArray) -> Option> { + if key_ids.is_null() { + return Some(Vec::new()); + } + match env.get_array_length(key_ids) { + Ok(len) if len > 0 => { + let mut buf = vec![0i32; len as usize]; + if env.get_int_array_region(key_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 @@ -177,7 +208,7 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up wallet_handle: jlong, identity_id: JByteArray, add_pubkeys_blob: JByteArray, - disable_public_key_ids: jni::objects::JIntArray, + disable_public_key_ids: JIntArray, signer_handle: jlong, ) { guard(&mut env, (), |env| { @@ -192,37 +223,8 @@ 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() - } - } + let Some(disable_ids) = read_key_ids(env, &disable_public_key_ids) else { + return; }; if decoded.is_empty() && disable_ids.is_empty() { @@ -262,6 +264,68 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up }) } +/// Whether disabling `keyIds` of the wallet's identity follows DIP-11 — +/// every security level they touch keeps a valid authentication key — judged +/// at `nowMs`, the host's wall clock in Unix milliseconds. Thin marshaler over +/// `platform_wallet_check_keys_can_be_disabled`. +/// +/// Returns a `long[3]`: `[allowed (0/1), refusedKeyId, refusedSecurityLevel]`, +/// the last two meaningful only when not allowed (security level as the DPP +/// discriminant, 0 = MASTER). Throws when the wallet or the identity is not +/// loaded. +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_checkKeysCanBeDisabled( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + identity_id: JByteArray, + key_ids: JIntArray, + now_ms: jlong, +) -> jlongArray { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let Some(key_ids) = read_key_ids(env, &key_ids) else { + return ptr::null_mut(); + }; + if now_ms < 0 { + throw_sdk_exception(env, 1, "nowMs must be non-negative"); + return ptr::null_mut(); + } + let mut check = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_check_keys_can_be_disabled( + wallet_handle as Handle, + id.as_ptr(), + if key_ids.is_empty() { + ptr::null() + } else { + key_ids.as_ptr() + }, + key_ids.len(), + now_ms as u64, + &mut check, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let out = [ + jlong::from(check.allowed), + jlong::from(check.refused_key_id), + jlong::from(check.refused_security_level), + ]; + let Ok(arr) = env.new_long_array(3) else { + return ptr::null_mut(); + }; + if env.set_long_array_region(&arr, 0, &out).is_err() { + return ptr::null_mut(); + } + arr.into_raw() + }) +} + /// Raise the limits of one of the identity's keys through /// `platform_wallet_update_identity_key_limits_with_signer`: add /// `addBudget` credits to its total budget (and to what is left of it) when From bfb5871e1ec0c1540d8f9b6f4d632a35c98b2a53 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:05:34 +0000 Subject: [PATCH 03/21] feat(swift-sdk): ask Rust whether identity keys may be disabled ManagedPlatformWallet.checkKeysCanBeDisabled marshals platform_wallet_check_keys_can_be_disabled with the device clock and returns nil or a KeyDisableRefusal (key id, security level). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../ManagedPlatformWallet.swift | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 548e2d19c83..4e16c73e95f 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3782,6 +3782,63 @@ extension ManagedPlatformWallet { }.value } + /// Why Rust refused disabling a set of keys under DIP-11: `keyId` is + /// the key whose `securityLevel` would be left without a valid + /// authentication key. + public struct KeyDisableRefusal: Equatable, Sendable { + public let keyId: UInt32 + public let securityLevel: SecurityLevel + + public init(keyId: UInt32, securityLevel: SecurityLevel) { + self.keyId = keyId + self.securityLevel = securityLevel + } + } + + /// Whether `keyIds` of `identityId` may be disabled under DIP-11 — + /// every security level they touch keeps a valid authentication key — + /// judged by Rust against this wallet's identity at `now`: `nil` when + /// they may, otherwise the refusal. Marshals + /// `platform_wallet_check_keys_can_be_disabled`; throws when the + /// identity is not loaded in this wallet. + public func checkKeysCanBeDisabled( + identityId: Identifier, + keyIds: [UInt32], + now: Date = Date() + ) async throws -> KeyDisableRefusal? { + let handle = self.handle + let idBytes: [UInt8] = identityId.withFFIBytes { ptr in + Array(UnsafeBufferPointer(start: ptr, count: 32)) + } + let ids = keyIds + let nowMs = UInt64(max(0, now.timeIntervalSince1970 * 1000)) + return try await Task.detached(priority: .userInitiated) { + var check = KeyDisableCheckFFI() + let result = idBytes.withUnsafeBufferPointer { idBp in + ids.withUnsafeBufferPointer { idsBp in + platform_wallet_check_keys_can_be_disabled( + handle, + idBp.baseAddress!, + ids.isEmpty ? nil : idsBp.baseAddress, + UInt(ids.count), + nowMs, + &check + ) + } + } + try result.check() + if check.allowed { + return nil + } + guard let level = SecurityLevel(rawValue: check.refused_security_level) else { + throw PlatformWalletError.invalidParameter( + "unknown security level \(check.refused_security_level) in a key-disable refusal" + ) + } + return KeyDisableRefusal(keyId: check.refused_key_id, securityLevel: level) + }.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 From 9c77ce94018c210fde752216e6be78281b7271ba Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:05:34 +0000 Subject: [PATCH 04/21] refactor(swift-example-app): take the DIP-11 disable verdict from Rust in KeyDisableGate The gate counted authentication keys by purpose and banned every MASTER disable, diverging from dpp's check_keys_can_be_disabled in both directions. It now renders the verdict Rust gives through checkKeysCanBeDisabled, asked whenever the key set changes and again before submitting. The last-transfer-key guard, which is not a DIP rule and lives nowhere in Rust, stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../Services/KeyDisableGate.swift | 110 +++++++----- .../SwiftExampleApp/Views/KeyDetailView.swift | 42 ++++- .../SwiftExampleApp/Views/KeysListView.swift | 47 ++++- .../KeyDisableGateTests.swift | 170 ++++++++---------- 4 files changed, 216 insertions(+), 153 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift index 18ecc1aab74..8379bc92cb9 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift @@ -3,25 +3,21 @@ import SwiftDashSDK /// Client-side safety gate for the **Disable Key** action. /// -/// This is a pre-flight UI guard, not protocol logic. It mixes two -/// kinds of refusal: +/// This is a pre-flight UI guard. It decides nothing about DIP-11 itself: /// -/// - **Master key (consensus):** disabling a lone master key really is -/// rejected by consensus (`validate_master_key_uniqueness_v0`), so -/// refusing it here just avoids spending fees on a transition we -/// already know `rs-drive-abci` will reject. -/// - **Last auth / last transfer (client-side self-brick guards):** -/// these are *not* protocol invariants. The drive-abci -/// identity_update state validator only checks that every disabled -/// key id exists in state and that master-key uniqueness holds — it -/// would happily accept disabling the last enabled authentication or -/// transfer key. We refuse those locally purely as a UX safeguard so -/// the user can't strand (brick) their own identity. +/// - **Last valid key of a security level (DIP-11):** Rust judges it +/// (`ManagedPlatformWallet.checkKeysCanBeDisabled`, dpp's +/// `check_keys_can_be_disabled` at the device clock) and the caller +/// hands the verdict in as a `RuleVerdict`. The identity update builder +/// refuses the same updates before signing, so the gate only spares the +/// user a doomed attempt. +/// - **Last transfer key (client-side self-brick guard):** not a protocol +/// or DIP rule, and not in Rust — drive-abci would accept it — but +/// disabling the last enabled transfer key would break credit +/// withdrawals, so we refuse it locally. /// -/// The gate is computed against the identity's full enabled key set -/// (these guards are only meaningful relative to what else remains -/// enabled), so callers pass `allKeys` (the identity's -/// `identityPublicKeys`) alongside the `target` key being disabled. +/// Callers pass `allKeys` (the identity's `identityPublicKeys`) alongside +/// the `target` key being disabled, for the transfer guard. enum KeyDisableGate { /// Result of evaluating whether `target` may be disabled. enum Evaluation: Equatable { @@ -33,47 +29,75 @@ enum KeyDisableGate { case forbidden(reason: String) } - /// Evaluate the disable gate for `target` within `allKeys`. + /// Rust's DIP-11 verdict for disabling the target key alone. + enum RuleVerdict: Equatable { + /// DIP-11 allows disabling the key. + case allowed + /// DIP-11 forbids it: the refusal names the level left without a + /// valid key. + case refused(ManagedPlatformWallet.KeyDisableRefusal) + /// No verdict: the wallet is not loaded, or the check failed or is + /// still running. `reason` is user-facing copy. + case unavailable(reason: String) + } + + /// Ask Rust for the DIP-11 verdict on disabling `keyId` of + /// `identityId` at the device clock. Without a loaded wallet, or when + /// the check fails, there is no verdict and the gate forbids the action. + static func ruleVerdict( + wallet: ManagedPlatformWallet?, + identityId: Identifier, + keyId: UInt32 + ) async -> RuleVerdict { + guard let wallet else { + return .unavailable( + reason: "Identity not attached to a loaded wallet — cannot check or sign the disable transition." + ) + } + do { + if let refusal = try await wallet.checkKeysCanBeDisabled( + identityId: identityId, + keyIds: [keyId] + ) { + return .refused(refusal) + } + return .allowed + } catch { + return .unavailable(reason: error.localizedDescription) + } + } + + /// Evaluate the disable gate for `target` within `allKeys`, given + /// Rust's `ruleVerdict` for disabling `target` alone. /// /// `allKeys` should be the identity's current `identityPublicKeys` /// (the DPP projection). `target` must be one of them. static func evaluate( target: IdentityPublicKey, - allKeys: [IdentityPublicKey] + allKeys: [IdentityPublicKey], + ruleVerdict: RuleVerdict ) -> Evaluation { // Already disabled → nothing to do. if target.disabledAt != nil { return .alreadyDisabled } - // A lone master-key disable is rejected by consensus - // (`validate_master_key_uniqueness_v0`); master-key rotation - // is out of scope for this action. - if target.securityLevel == .master { - return .forbidden( - reason: "Master keys can't be disabled here — a master-key disable is rejected by consensus. Master-key rotation is out of scope." - ) - } - - // Client-side self-brick guard (not a consensus rule): - // drive-abci would accept this transition, but disabling the - // last enabled AUTHENTICATION key would leave the identity - // unable to authenticate / sign future transitions, so we - // refuse it locally to keep the user from stranding the - // identity. - if target.purpose == .authentication - && enabledCount(of: .authentication, in: allKeys) <= 1 { + switch ruleVerdict { + case .refused(let refusal): return .forbidden( - reason: "This is the only enabled authentication key. Disabling it would leave the identity unable to sign — add another authentication key first (Add Key)." + reason: "No other valid authentication key at the \(refusal.securityLevel.name) level would remain. Disabling this key would leave the identity unable to sign what needs that level — add another key first (Add Key)." ) + case .unavailable(let reason): + return .forbidden(reason: reason) + case .allowed: + break } - // Client-side self-brick guard (not a consensus rule): - // drive-abci would accept this too, but disabling the last - // enabled TRANSFER key would break credit withdrawals / - // transfers (ID-10), and re-adding a transfer key currently - // requires the Add Key flow (ID-07). We refuse it locally as a - // UX safeguard. + // Client-side self-brick guard (not a consensus or DIP rule): + // drive-abci would accept this, but disabling the last enabled + // TRANSFER key would break credit withdrawals / transfers + // (ID-10), and re-adding a transfer key currently requires the + // Add Key flow (ID-07). We refuse it locally as a UX safeguard. if target.purpose == .transfer && enabledCount(of: .transfer, in: allKeys) <= 1 { return .forbidden( diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index e370a849424..9ee9cc6257f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -13,6 +13,10 @@ struct KeyDetailView: View { @State private var showDisableConfirm = false @State private var isDisabling = false @State private var disableError: String? + /// Rust's DIP-11 verdict for disabling this key, refreshed whenever the + /// identity's key set changes. + @State private var ruleVerdict: KeyDisableGate.RuleVerdict = + .unavailable(reason: "Checking whether this key may be disabled…") /// What Platform says is left of this key's budget. `.some(nil)` means /// the node answered that the key carries no budget. @State private var remainingBudget: UInt64?? = nil @@ -29,16 +33,27 @@ struct KeyDetailView: View { return result } - /// Pre-flight gate for disabling this key. Evaluated against the - /// identity's current key set so the result reflects "is this the - /// last enabled auth/transfer key" relative to everything else. + /// Pre-flight gate for disabling this key: Rust's DIP-11 verdict plus + /// the last-transfer-key guard over the identity's current key set. private var disableEvaluation: KeyDisableGate.Evaluation { KeyDisableGate.evaluate( target: publicKey, - allKeys: identity.identityPublicKeys + allKeys: identity.identityPublicKeys, + ruleVerdict: ruleVerdict ) } + /// The loaded wallet this identity belongs to, if any. + private var identityWallet: ManagedPlatformWallet? { + identity.wallet?.walletId.flatMap { walletManager.wallet(for: $0) } + } + + /// Changes whenever a key is added or disabled, so the verdict is + /// re-asked. + private var keySetVersion: [String] { + identity.identityPublicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + } + var body: some View { Form { // Key Information Section @@ -140,6 +155,13 @@ struct KeyDetailView: View { } .navigationTitle("Key #\(publicKey.id)") .navigationBarTitleDisplayMode(.inline) + .task(id: keySetVersion) { + ruleVerdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + } .alert("Success", isPresented: $showSuccessAlert) { Button("OK") { dismiss() @@ -398,9 +420,15 @@ struct KeyDetailView: View { /// appears) and pop back to the key list. @MainActor private func disableKey() async { - // Re-check the gate at submit time — the key set could have - // changed since the view rendered (a background sync, a - // sibling disable). Never broadcast a doomed transition. + // Re-check the gate at submit time, with a fresh verdict — the + // key set could have changed since the view rendered (a + // background sync, a sibling disable). Never broadcast a doomed + // transition. + ruleVerdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) guard case .allowed = disableEvaluation else { return } guard let walletId = identity.wallet?.walletId else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index a2e2eed5ff4..ab234590b32 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -20,6 +20,9 @@ struct KeysListView: View { @State private var pendingDisableKey: IdentityPublicKey? @State private var isDisabling = false @State private var disableError: String? + /// Rust's DIP-11 verdict for disabling each enabled key alone, refreshed + /// whenever the key set changes. Absent while loading. + @State private var ruleVerdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] /// What Platform says is left of each budgeted key, fetched once for the /// whole list. Absent while loading or when the query failed. @State private var remainingBudgets: [UInt32: UInt64?] = [:] @@ -28,6 +31,22 @@ struct KeysListView: View { identity.identityPublicKeys } + /// The loaded wallet this identity belongs to, if any. + private var identityWallet: ManagedPlatformWallet? { + identity.wallet?.walletId.flatMap { walletManager.wallet(for: $0) } + } + + /// Changes whenever a key is added or disabled, so the verdicts are + /// re-asked. + private var keySetVersion: [String] { + publicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + } + + private func ruleVerdict(for publicKey: IdentityPublicKey) -> KeyDisableGate.RuleVerdict { + ruleVerdicts[publicKey.id] + ?? .unavailable(reason: "Checking whether this key may be disabled…") + } + private var privateKeysAvailableCount: Int { publicKeys.filter { publicKey in hasPrivateKey(for: publicKey) @@ -110,6 +129,17 @@ struct KeysListView: View { .task(id: publicKeys.compactMap { $0.totalBudget != nil ? $0.id : nil }) { await loadRemainingBudgets() } + .task(id: keySetVersion) { + var verdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] + for publicKey in publicKeys where publicKey.disabledAt == nil { + verdicts[publicKey.id] = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + } + ruleVerdicts = verdicts + } .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -181,7 +211,8 @@ struct KeysListView: View { private func disableSwipeButton(for publicKey: IdentityPublicKey) -> some View { if case .allowed = KeyDisableGate.evaluate( target: publicKey, - allKeys: publicKeys + allKeys: publicKeys, + ruleVerdict: ruleVerdict(for: publicKey) ) { Button(role: .destructive) { pendingDisableKey = publicKey @@ -200,11 +231,19 @@ struct KeysListView: View { private func disableKey(_ publicKey: IdentityPublicKey) async { pendingDisableKey = nil - // Re-check the gate at submit time — the key set could have - // changed since the swipe (background sync, a sibling disable). + // Re-check the gate at submit time, with a fresh verdict — the key + // set could have changed since the swipe (background sync, a sibling + // disable). + let verdict = await KeyDisableGate.ruleVerdict( + wallet: identityWallet, + identityId: identity.identityId, + keyId: publicKey.id + ) + ruleVerdicts[publicKey.id] = verdict guard case .allowed = KeyDisableGate.evaluate( target: publicKey, - allKeys: publicKeys + allKeys: publicKeys, + ruleVerdict: verdict ) else { return } guard let walletId = identity.wallet?.walletId else { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift index 91f5318cf69..a66a9d1ca2d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift @@ -2,9 +2,11 @@ // KeyDisableGateTests.swift // SwiftExampleAppTests // -// Unit coverage for `KeyDisableGate.evaluate` — the pure, single -// source of truth for whether a Disable Key submit is attempted -// (used in KeyDetailView, KeysListView, and the pre-submit re-check). +// Unit coverage for `KeyDisableGate.evaluate` — the single place that +// decides whether a Disable Key submit is attempted (used in +// KeyDetailView, KeysListView, and the pre-submit re-check). The DIP-11 +// verdict itself comes from Rust and is covered there; these tests pin +// how the gate renders it and the last-transfer-key guard. // import XCTest @@ -16,9 +18,8 @@ final class KeyDisableGateTests: XCTestCase { // MARK: - Fixtures /// Build an `IdentityPublicKey` test value with the fields the gate - /// actually reads (`id`, `purpose`, `securityLevel`, `disabledAt`). - /// `keyType`/`data`/`readOnly` are irrelevant to the gate, so they - /// get trivial defaults. + /// actually reads (`id`, `purpose`, `disabledAt`). The rest are + /// irrelevant to the gate, so they get trivial defaults. private func makeKey( id: KeyID, purpose: KeyPurpose, @@ -40,65 +41,80 @@ final class KeyDisableGateTests: XCTestCase { /// A fixed, non-nil disabled timestamp for "already disabled" keys. private let disabledTimestamp: TimestampMillis = 1_700_000_000_000 + private func refusal(_ keyId: KeyID, _ level: SecurityLevel) -> KeyDisableGate.RuleVerdict { + .refused(ManagedPlatformWallet.KeyDisableRefusal(keyId: keyId, securityLevel: level)) + } + // MARK: - 1. Already disabled func testAlreadyDisabledTarget_returnsAlreadyDisabled() { let target = makeKey(id: 5, purpose: .authentication, disabledAt: disabledTimestamp) - // Include a second enabled auth key so the result can only be - // `.alreadyDisabled` (and not, e.g., a last-auth refusal). - let other = makeKey(id: 6, purpose: .authentication) - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, other]) + // Whatever Rust says, a disabled key has nothing left to do. + let result = KeyDisableGate.evaluate( + target: target, + allKeys: [target], + ruleVerdict: refusal(5, .high) + ) XCTAssertEqual(result, .alreadyDisabled) } - // MARK: - 2. Master key + // MARK: - 2. DIP-11 verdict from Rust - func testMasterKey_isForbidden() { - // A master key is also an authentication key in practice; the - // master check runs before the auth check, so this must be - // `.forbidden` regardless of how many other auth keys exist. - let target = makeKey(id: 0, purpose: .authentication, securityLevel: .master) - let other = makeKey(id: 1, purpose: .authentication) + func testRefusedVerdict_isForbiddenNamingTheLevel() { + let target = makeKey(id: 1, purpose: .authentication, securityLevel: .critical) - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, other]) + let result = KeyDisableGate.evaluate( + target: target, + allKeys: [target], + ruleVerdict: refusal(1, .critical) + ) - guard case .forbidden = result else { - return XCTFail("expected .forbidden for a master key, got \(result)") + guard case .forbidden(let reason) = result else { + return XCTFail("expected .forbidden for a refused verdict, got \(result)") } + XCTAssertTrue(reason.contains(SecurityLevel.critical.name), reason) } - // MARK: - 3. Last authentication key - - func testLastEnabledAuthenticationKey_isForbidden() { + func testUnavailableVerdict_isForbiddenWithItsReason() { let target = makeKey(id: 1, purpose: .authentication) - let result = KeyDisableGate.evaluate(target: target, allKeys: [target]) + let result = KeyDisableGate.evaluate( + target: target, + allKeys: [target], + ruleVerdict: .unavailable(reason: "Wallet not loaded") + ) - guard case .forbidden = result else { - return XCTFail("expected .forbidden for the only auth key, got \(result)") - } + XCTAssertEqual(result, .forbidden(reason: "Wallet not loaded")) } - func testAuthenticationKey_withSecondEnabledAuth_isAllowed() { - let target = makeKey(id: 1, purpose: .authentication) - let second = makeKey(id: 2, purpose: .authentication) + func testAllowedVerdict_isAllowedEvenForAMasterKey() { + // Which authentication keys may go is Rust's call: with another + // MASTER key, DIP-11 lets this one be disabled. + let target = makeKey(id: 0, purpose: .authentication, securityLevel: .master) + let other = makeKey(id: 1, purpose: .authentication, securityLevel: .master) - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, second]) + let result = KeyDisableGate.evaluate( + target: target, + allKeys: [target, other], + ruleVerdict: .allowed + ) XCTAssertEqual(result, .allowed) } - // MARK: - 4. Last transfer key + // MARK: - 3. Last transfer key func testLastEnabledTransferKey_isForbidden() { - // Pair with an auth key so the auth rail can't trip first and - // the transfer rail is the one under test. let target = makeKey(id: 3, purpose: .transfer) let auth = makeKey(id: 1, purpose: .authentication) - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, auth]) + let result = KeyDisableGate.evaluate( + target: target, + allKeys: [target, auth], + ruleVerdict: .allowed + ) guard case .forbidden = result else { return XCTFail("expected .forbidden for the only transfer key, got \(result)") @@ -112,93 +128,49 @@ final class KeyDisableGateTests: XCTestCase { let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, secondTransfer, auth] + allKeys: [target, secondTransfer, auth], + ruleVerdict: .allowed ) XCTAssertEqual(result, .allowed) } - // MARK: - 5. Normal non-last key - - func testNormalNonLastKey_isAllowed() { - // A non-auth / non-transfer purpose with no special rails. - let target = makeKey(id: 7, purpose: .encryption) - // Keep an auth key around so the identity stays signable; this - // shouldn't matter for an encryption-key disable, but it makes - // the scenario realistic. - let auth = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate(target: target, allKeys: [target, auth]) - - XCTAssertEqual(result, .allowed) - } - - // MARK: - 6. enabledCount edge cases (exercised through evaluate) - - /// Keys of a *different* purpose must not count toward the auth - /// limit: disabling the only auth key is forbidden even when other - /// purposes have many enabled keys. - func testOtherPurposeKeysDoNotCountTowardAuthLimit() { - let target = makeKey(id: 1, purpose: .authentication) - let encryption = makeKey(id: 2, purpose: .encryption) - let transferA = makeKey(id: 3, purpose: .transfer) - let transferB = makeKey(id: 4, purpose: .transfer) - - let result = KeyDisableGate.evaluate( - target: target, - allKeys: [target, encryption, transferA, transferB] - ) - - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — other-purpose keys must not satisfy the auth requirement, got \(result)" - ) - } - } - - /// Already-disabled keys of the same purpose are excluded from the - /// count: two auth keys where one is already disabled still forbids - /// disabling the remaining enabled one. - func testAlreadyDisabledSamePurposeKeyExcludedFromCount() { - let target = makeKey(id: 1, purpose: .authentication) - let disabledAuth = makeKey( - id: 2, - purpose: .authentication, + /// A disabled sibling transfer key doesn't keep the last enabled + /// transfer key disableable. + func testAlreadyDisabledSiblingTransferKeyExcludedFromCount() { + let target = makeKey(id: 3, purpose: .transfer) + let disabledTransfer = makeKey( + id: 4, + purpose: .transfer, disabledAt: disabledTimestamp ) + let auth = makeKey(id: 1, purpose: .authentication) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, disabledAuth] + allKeys: [target, disabledTransfer, auth], + ruleVerdict: .allowed ) guard case .forbidden = result else { return XCTFail( - "expected .forbidden — a disabled sibling auth key is not an enabled fallback, got \(result)" + "expected .forbidden — a disabled sibling transfer key is not an enabled fallback, got \(result)" ) } } - /// Symmetric transfer-side check: a disabled sibling transfer key - /// doesn't keep the last enabled transfer key disableable. - func testAlreadyDisabledSiblingTransferKeyExcludedFromCount() { - let target = makeKey(id: 3, purpose: .transfer) - let disabledTransfer = makeKey( - id: 4, - purpose: .transfer, - disabledAt: disabledTimestamp - ) + // MARK: - 4. Normal key + + func testNormalKey_isAllowed() { + let target = makeKey(id: 7, purpose: .encryption) let auth = makeKey(id: 1, purpose: .authentication) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, disabledTransfer, auth] + allKeys: [target, auth], + ruleVerdict: .allowed ) - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — a disabled sibling transfer key is not an enabled fallback, got \(result)" - ) - } + XCTAssertEqual(result, .allowed) } } From 0d576407bbb396caf5989c6391657a3eaa9158ec Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:05:34 +0000 Subject: [PATCH 05/21] feat(kotlin-sdk): ask Rust whether identity keys may be disabled IdentityUpdates.checkKeysCanBeDisabled bridges TransactionsNative.checkKeysCanBeDisabled with the device clock and returns null or a KeyDisableRefusal (key id, security level). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../dashsdk/ffi/TransactionsNative.kt | 18 ++++++++ .../dashsdk/identity/IdentityUpdates.kt | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+) 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..f25c0baa4c9 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 @@ -53,6 +53,24 @@ internal object TransactionsNative { signerHandle: Long, ) + /** + * Whether disabling [keyIds] of [identityId] follows DIP-11 — every + * security level they touch keeps a valid authentication key — judged + * at [nowMs], the device's wall clock in Unix milliseconds. Bridges + * `platform_wallet_check_keys_can_be_disabled`. + * + * @return `[allowed (0/1), refusedKeyId, refusedSecurityLevel]`; the last + * two are meaningful only when not allowed (DPP discriminant, 0 = + * MASTER). Throws [DashSDKException] when the wallet or the identity is + * not loaded. + */ + external fun checkKeysCanBeDisabled( + walletHandle: Long, + identityId: ByteArray, + keyIds: IntArray, + nowMs: Long, + ): LongArray + /** * Raise the limits of key [keyId] of [identityId] (protocol version 14): * add [addBudget] credits to its total budget when [hasAddBudget], and 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..81a88593606 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 @@ -173,6 +173,12 @@ data class IdentityPubkey( } } +/** + * Why Rust refused disabling a set of keys under DIP-11: [keyId] is the key + * whose [securityLevel] would be left without a valid authentication key. + */ +data class KeyDisableRefusal(val keyId: Int, val securityLevel: SecurityLevel) + /** * Identity add/disable-keys bridge — port of the identity-update slice of * `ManagedPlatformWallet.swift` (`updateIdentity(addPublicKeys:...)`, driven @@ -238,6 +244,43 @@ class IdentityUpdates internal constructor( } } + /** + * Whether [keyIds] of [identityId] may be disabled under DIP-11, judged by + * Rust against the wallet's identity at [nowMs] (the device clock by + * default): null when they may, otherwise the [KeyDisableRefusal]. + * Bridges `platform_wallet_check_keys_can_be_disabled`; throws when the + * wallet or the identity is not loaded. + */ + suspend fun checkKeysCanBeDisabled( + walletHandle: Long, + identityId: ByteArray, + keyIds: List, + nowMs: Long = System.currentTimeMillis(), + ): KeyDisableRefusal? = gate.op { + require(identityId.size == 32) { + "identityId must be 32 bytes, got ${identityId.size}" + } + require(keyIds.all { it >= 0 }) { + "every key id must be non-negative, got $keyIds" + } + val verdict = mapNativeErrors { + TransactionsNative.checkKeysCanBeDisabled( + walletHandle, + identityId, + keyIds.toIntArray(), + nowMs, + ) + } + if (verdict[0] != 0L) { + null + } else { + KeyDisableRefusal( + keyId = verdict[1].toInt(), + securityLevel = SecurityLevel.entries.first { it.ffiValue.toLong() == verdict[2] }, + ) + } + } + /** * Raise the limits of key [keyId] of [identityId] (protocol version 14): * add [addBudget] credits to its total budget (and to what is left of From baa70df3e839eccb7a451878758615c0cdc4d0a2 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:05:34 +0000 Subject: [PATCH 06/21] refactor(kotlin-example-app): take the DIP-11 disable verdict from Rust in KeyDisableGate Port of the Swift gate change: KeyDisableGate renders Rust's checkKeysCanBeDisabled verdict, produced whenever the key set changes and re-asked before submitting, instead of counting authentication keys by purpose and banning MASTER. The last-transfer-key guard stays. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../example/services/KeyDisableGate.kt | 103 +++++++++++++----- .../example/ui/identity/KeyDetailScreen.kt | 56 +++++++--- packages/kotlin-sdk/PARITY.md | 2 +- 3 files changed, 120 insertions(+), 41 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt index 175d24aef34..ce236f8c3b7 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt @@ -1,23 +1,28 @@ package org.dashfoundation.example.services +import kotlinx.coroutines.CancellationException +import org.dashfoundation.dashsdk.identity.IdentityUpdates +import org.dashfoundation.dashsdk.identity.KeyDisableRefusal import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity /** * Client-side safety gate for the Disable Key action — 1:1 port of * `KeyDisableGate.swift`. * - * A pre-flight UI guard, not protocol logic. It mixes two kinds of refusal: - * - **Master key (consensus):** disabling a lone master key is rejected by - * consensus (`validate_master_key_uniqueness_v0`); refusing locally just - * avoids spending fees on a transition drive-abci will reject. - * - **Last auth / last transfer (client-side self-brick guards):** not - * protocol invariants — drive-abci would accept them — but disabling the - * last enabled authentication or transfer key would strand the identity, - * so we refuse locally. + * A pre-flight UI guard. It decides nothing about DIP-11 itself: + * - **Last valid key of a security level (DIP-11):** Rust judges it + * (`IdentityUpdates.checkKeysCanBeDisabled`, dpp's + * `check_keys_can_be_disabled` at the device clock) and the caller hands + * the verdict in as [RuleVerdict]. The identity update builder refuses the + * same updates before signing, so the gate only spares the user a doomed + * attempt. + * - **Last transfer key (client-side self-brick guard):** not a protocol or + * DIP rule, and not in Rust — drive-abci would accept it — but disabling + * the last enabled transfer key would break credit withdrawals, so we + * refuse locally. * - * Operates on Room [PublicKeyEntity] rows (the string-valued `purpose` / - * `securityLevel` and nullable `disabledAt` the persister writes), so the - * comparisons match the DPP enum names verbatim. + * Operates on Room [PublicKeyEntity] rows (the string-valued `purpose` and + * nullable `disabledAt` the persister writes). */ object KeyDisableGate { @@ -33,27 +38,73 @@ object KeyDisableGate { data class Forbidden(val reason: String) : Evaluation } + /** Rust's DIP-11 verdict for disabling the target key alone. ← Swift `RuleVerdict`. */ + sealed interface RuleVerdict { + /** DIP-11 allows disabling the key. */ + data object Allowed : RuleVerdict + + /** DIP-11 forbids it: [refusal] names the level left without a valid key. */ + data class Refused(val refusal: KeyDisableRefusal) : RuleVerdict + + /** + * No verdict: the wallet is not loaded, or the check failed or is + * still running. [reason] is user-facing copy. + */ + data class Unavailable(val reason: String) : RuleVerdict + } + /** - * Evaluate the disable gate for [target] within [allKeys] (the - * identity's current public keys). [target] must be one of them. + * Ask Rust whether [keyId] of [identityId] may be disabled under DIP-11 + * at the device clock. Without a loaded wallet, or when the check fails, + * there is no verdict and the gate forbids the action. ← Swift + * `ruleVerdict(wallet:identityId:keyId:)`. */ - fun evaluate(target: PublicKeyEntity, allKeys: List): Evaluation { - if (target.disabledAt != null) return Evaluation.AlreadyDisabled - - if (target.securityLevel.equals("MASTER", ignoreCase = true)) { - return Evaluation.Forbidden( - "Master keys can't be disabled here — a master-key disable is rejected by " + - "consensus. Master-key rotation is out of scope.", + suspend fun ruleVerdict( + identityUpdates: IdentityUpdates?, + walletHandle: Long?, + identityId: ByteArray, + keyId: Int, + ): RuleVerdict { + if (identityUpdates == null || walletHandle == null) { + return RuleVerdict.Unavailable( + "Identity not attached to a loaded wallet — cannot check or sign the disable " + + "transition.", + ) + } + return try { + identityUpdates.checkKeysCanBeDisabled(walletHandle, identityId, listOf(keyId)) + ?.let { RuleVerdict.Refused(it) } + ?: RuleVerdict.Allowed + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + RuleVerdict.Unavailable( + e.message ?: "Could not check whether this key may be disabled.", ) } + } - if (target.purpose.equals("AUTHENTICATION", ignoreCase = true) && - enabledCount("AUTHENTICATION", allKeys) <= 1 - ) { - return Evaluation.Forbidden( - "This is the only enabled authentication key. Disabling it would leave the " + - "identity unable to sign — add another authentication key first (Add Key).", + /** + * Evaluate the disable gate for [target] within [allKeys] (the + * identity's current public keys), given Rust's [ruleVerdict] for + * disabling [target] alone. [target] must be one of [allKeys]. + */ + fun evaluate( + target: PublicKeyEntity, + allKeys: List, + ruleVerdict: RuleVerdict, + ): Evaluation { + if (target.disabledAt != null) return Evaluation.AlreadyDisabled + + when (ruleVerdict) { + is RuleVerdict.Refused -> return Evaluation.Forbidden( + "No other valid authentication key at the " + + "${ruleVerdict.refusal.securityLevel.name} level would remain. Disabling " + + "this key would leave the identity unable to sign what needs that level — " + + "add another key first (Add Key).", ) + is RuleVerdict.Unavailable -> return Evaluation.Forbidden(ruleVerdict.reason) + RuleVerdict.Allowed -> Unit } if (target.purpose.equals("TRANSFER", ignoreCase = true) && diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt index 098d90d8e64..6f6b96f044e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -52,8 +53,8 @@ import org.dashfoundation.example.util.toHex * metadata + public key bytes, a biometric-gated private-key reveal (read * from [org.dashfoundation.dashsdk.security.WalletStorage]), and the Key * Status section with the gated destructive Disable Key action: the - * [KeyDisableGate] pre-flight (master / last-auth / last-transfer refusals) - * in front of `PlatformWalletManager.identityUpdates.disableKeys` — the + * [KeyDisableGate] pre-flight (Rust's DIP-11 verdict, plus the last-transfer + * refusal) in front of `PlatformWalletManager.identityUpdates.disableKeys` — the * same `platform_wallet_update_identity_with_signer` call Swift's * `KeyDetailView.disableKey()` makes. */ @@ -90,6 +91,22 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon var showDisableConfirm by remember { mutableStateOf(false) } var isDisabling by remember { mutableStateOf(false) } + // Rust's DIP-11 verdict for disabling this key, re-asked whenever the key + // set or the wallet changes. + val ruleVerdict by produceState( + initialValue = KeyDisableGate.RuleVerdict.Unavailable(CHECKING_DISABLE_RULE), + allKeys, + wallet, + manager, + ) { + value = KeyDisableGate.ruleVerdict( + manager?.identityUpdates, + wallet?.handle, + idBytes, + keyId, + ) + } + LaunchedEffect(key?.publicKeyData) { val row = key hasPrivate = row != null && @@ -193,6 +210,7 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon when (val evaluation = KeyDisableGate.evaluate( target = k.normalizedForGate(), allKeys = allKeys.map { it.normalizedForGate() }, + ruleVerdict = ruleVerdict, )) { KeyDisableGate.Evaluation.AlreadyDisabled -> { LabeledContent("Disabled", "Permanent") @@ -253,23 +271,31 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon TextButton( enabled = !isDisabling, onClick = { - // Re-check the gate at submit time — the key set could - // have changed since the view rendered. Never broadcast - // a doomed transition (← Swift disableKey()). val target = key ?: return@TextButton - val gate = KeyDisableGate.evaluate( - target.normalizedForGate(), - allKeys.map { it.normalizedForGate() }, - ) - if (gate != KeyDisableGate.Evaluation.Allowed) { - showDisableConfirm = false - error = (gate as? KeyDisableGate.Evaluation.Forbidden)?.reason - return@TextButton - } val w = wallet ?: return@TextButton val mgr = manager ?: return@TextButton isDisabling = true scope.launch { + // Re-check the gate at submit time, with a fresh + // verdict — the key set could have changed since + // the view rendered. Never broadcast a doomed + // transition (← Swift disableKey()). + val gate = KeyDisableGate.evaluate( + target.normalizedForGate(), + allKeys.map { it.normalizedForGate() }, + KeyDisableGate.ruleVerdict( + mgr.identityUpdates, + w.handle, + idBytes, + keyId, + ), + ) + if (gate != KeyDisableGate.Evaluation.Allowed) { + showDisableConfirm = false + error = (gate as? KeyDisableGate.Evaluation.Forbidden)?.reason + isDisabling = false + return@launch + } try { mgr.identityUpdates.disableKeys( walletHandle = w.handle, @@ -301,6 +327,8 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon ErrorAlertDialog(message = error, onDismiss = { error = null }) } +private const val CHECKING_DISABLE_RULE = "Checking whether this key may be disabled…" + /** * [KeyDisableGate] compares DPP enum *names*; Room rows may store the * stringified rawValue. Normalize before evaluating. diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 1412fd9424a..a21fe1a7d3e 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -48,7 +48,7 @@ Status legend: | IdentitiesView.swift | ui/identity/IdentitiesHomeScreen.kt · `IdentitiesHome` | ported | | IdentityDetailView.swift | ui/identity/IdentityDetailScreen.kt · `IdentityDetail` | partial — Android reaches the compiled shared one-fetch `platform_wallet_sync_contested_dpns_names` + full cached `managed_identity_get_contested_dpns_names` JNI path, removes the eight-label probe cap, preserves loading/error/unavailable state with retry, and frees cached arrays through an RAII guard. Android identity persistence still drops contested snapshots on restore with no schema slot before reserved v8 invitations; allocation-failure/device and restart gates remain | | IdentityKeyAddition.swift | services/IdentityKeyAdditionFlow.kt | ported — derive → Keystore-persist → `IdentityPubkey` flow with the real keypair deriver injected (`PlatformWalletManager.deriveIdentityKeyPair`); slot assignment, Drive-combination validation, scalar scrubbing | -| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog, submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) | +| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog (Rust's DIP-11 verdict via `IdentityUpdates.checkKeysCanBeDisabled` → `platform_wallet_check_keys_can_be_disabled`, plus the last-transfer-key guard), submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) | | KeychainExplorerView.swift | ui/diagnostics/KeystoreExplorerScreen.kt · `KeystoreExplorer` | ported (adapted: WalletStorage entries masked + AndroidKeyStore aliases; adds biometric-gated mnemonic reveal) | | KeysListView.swift | ui/identity/KeysListScreen.kt · `KeysList` | ported — toolbar Add action opens AddIdentityKeyScreen (← the AddIdentityKeyView sheet) | | LoadIdentityView.swift | ui/identity/LoadIdentityScreen.kt · `LoadIdentity` | ported | From 1e1587ffc405bc28e683105ac5294fc15f3b5ecd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:08:54 +0000 Subject: [PATCH 07/21] docs(platform-wallet): state where the clocked disable check and the builder disagree Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../src/wallet/identity/network/update.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 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 e12d4af8f02..7aefa072409 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -72,10 +72,9 @@ impl IdentityWallet { /// [`Self::update_identity_with_external_signer`] builds the update from, /// with dpp's [`check_keys_can_be_disabled`] at `now_ms`, the caller's /// wall clock in Unix milliseconds. The builder has no clock and counts no - /// key with an expiry, so an interface that offers only the keys this - /// allows can still see an update refused when the only stand-in key - /// expires; it never offers a key the builder would let through but this - /// refuses. + /// key with an expiry, so where the only stand-in key has an expiry this + /// allows a disable the builder then refuses; it never refuses one the + /// builder would let through. /// /// The outer `Err` is a wallet failure: the wallet or the identity is not /// loaded. From c68c661621a0deca223480ef986dcb11bb1247f5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:14:34 +0000 Subject: [PATCH 08/21] feat(platform-wallet): refuse disabling the last enabled transfer key as wallet policy The mobile apps refused this on their own. It is now decided once, in rs-platform-wallet's check_keys_can_be_disabled, next to the DIP-11 rule: KeyDisableRefusal is LastAuthenticationKey or LastEnabledKey { key_id, purpose }. Both identity-update entry points refuse with it before fetching a nonce (DIP-11 keeps its Sdk(Protocol(..)) shape, the transfer refusal is PlatformWalletError::LastEnabledKey, mapped to ErrorInvalidParameter), and the FFI verdict reports the refusal kind with the key, level or purpose. update_identity_with_signer now returns PlatformWalletError, like the other entry point, so it can carry the wallet's refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- packages/rs-platform-wallet-ffi/src/error.rs | 21 ++ .../src/identity_update.rs | 79 ++++--- packages/rs-platform-wallet/src/error.rs | 24 +++ .../src/wallet/identity/mod.rs | 3 +- .../src/wallet/identity/network/update.rs | 136 ++++++++++--- .../src/wallet/identity/state/key_disable.rs | 192 ++++++++++++++++++ .../src/wallet/identity/state/mod.rs | 3 + 7 files changed, 403 insertions(+), 55 deletions(-) create mode 100644 packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs diff --git a/packages/rs-platform-wallet-ffi/src/error.rs b/packages/rs-platform-wallet-ffi/src/error.rs index 647a35ea910..ba0045d01ca 100644 --- a/packages/rs-platform-wallet-ffi/src/error.rs +++ b/packages/rs-platform-wallet-ffi/src/error.rs @@ -1085,6 +1085,11 @@ impl From for PlatformWalletFFIResult { PlatformWalletError::Sdk(dash_sdk::Error::Protocol( dpp::ProtocolError::LastAuthenticationKey(_), )) => PlatformWalletFFIResultCode::ErrorInvalidParameter, + // The wallet's refusal to disable the last enabled TRANSFER key: + // the same class as the DIP-11 refusal above. + PlatformWalletError::LastEnabledKey(_) => { + PlatformWalletFFIResultCode::ErrorInvalidParameter + } // A second producer of code 31 (the arm above is the first), // reached without any marker inspection at this layer: message // signing found no signable account for the address, or the signer @@ -2047,6 +2052,22 @@ mod tests { assert!(msg.contains("cannot disable authentication key 1"), "{msg}"); } + /// The wallet's last-transfer-key refusal is a caller-argument error too. + #[test] + fn last_enabled_key_refusal_maps_to_invalid_parameter() { + let err = PlatformWalletError::LastEnabledKey( + platform_wallet::wallet::identity::LastEnabledKeyError { + key_id: 3, + purpose: dpp::identity::Purpose::TRANSFER, + }, + ); + let result: PlatformWalletFFIResult = err.into(); + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + /// The four DPNS-marketplace trade rejections each map to their own /// dedicated code rather than flattening to `ErrorUnknown`, and the /// not-found case rides the existing `NotFound`. Hosts branch on these diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 1cd0117ff4b..babccfbc384 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -12,7 +12,6 @@ use std::slice; use dpp::identity::identity_public_key::contract_bounds::ContractBounds; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; -use dpp::identity::key_disable_rule::LastAuthenticationKeyError; use dpp::identity::KeyID; use dpp::identity::{IdentityPublicKey, KeyType, Purpose, SecurityLevel}; use dpp::platform_value::BinaryData; @@ -27,6 +26,7 @@ use dpp::state_transition::{StateTransition, StateTransitionType}; use platform_wallet::wallet::identity::network::{ check_document_type_name, decode_state_transition, }; +use platform_wallet::wallet::identity::KeyDisableRefusal; use rs_sdk_ffi::{SignerHandle, VTableSigner}; use crate::check_ptr; @@ -389,39 +389,50 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( #[repr(C)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct KeyDisableCheckFFI { - /// Whether every listed key may be disabled. - pub allowed: bool, - /// When not `allowed`: the key that cannot be disabled. + /// 0 = every listed key may be disabled; 1 = refused, the last valid + /// authentication key of a security level (DIP-11); 2 = refused, the + /// last enabled key of a purpose the wallet keeps one of (TRANSFER). + pub refusal: u8, + /// When refused: the key that cannot be disabled. pub refused_key_id: u32, - /// When not `allowed`: the security level (DPP discriminant, 0 = MASTER) - /// that would be left without a valid authentication key. + /// When `refusal` is 1: the security level (DPP discriminant, 0 = + /// MASTER) that would be left without a valid authentication key. pub refused_security_level: u8, + /// When `refusal` is 2: the purpose (DPP discriminant, 3 = TRANSFER) + /// that would be left without an enabled key. + pub refused_purpose: u8, } -impl From> for KeyDisableCheckFFI { - fn from(verdict: Result<(), LastAuthenticationKeyError>) -> Self { +impl From> for KeyDisableCheckFFI { + fn from(verdict: Result<(), KeyDisableRefusal>) -> Self { match verdict { - Ok(()) => Self { - allowed: true, + Ok(()) => Self::default(), + Err(KeyDisableRefusal::LastAuthenticationKey(refusal)) => Self { + refusal: 1, + refused_key_id: refusal.key_id, + refused_security_level: refusal.security_level as u8, ..Self::default() }, - Err(refusal) => Self { - allowed: false, + Err(KeyDisableRefusal::LastEnabledKey(refusal)) => Self { + refusal: 2, refused_key_id: refusal.key_id, - refused_security_level: refusal.security_level as u8, + refused_purpose: refusal.purpose as u8, + ..Self::default() }, } } } -/// Whether disabling `disable_public_key_ids` of the wallet's identity -/// `identity_id` follows DIP-11: every security level they touch keeps a -/// valid authentication key. `now_ms` is the host's wall clock in Unix -/// milliseconds, so a key with an expiry counts until it expires. +/// Whether the wallet lets an identity update disable +/// `disable_public_key_ids` of its identity `identity_id`: no security level +/// loses its last valid authentication key (DIP-11) and the identity keeps an +/// enabled TRANSFER key. `now_ms` is the host's wall clock in Unix +/// milliseconds, so an authentication key with an expiry counts until it +/// expires. /// /// Marshals `IdentityWallet::check_keys_can_be_disabled`, which judges the /// identity [`platform_wallet_update_identity_with_signer`] builds the update -/// from. A refusal is a verdict, written to `out_check` with a success result; +/// from with the policy that entry point refuses with. A refusal is a verdict, written to `out_check` with a success result; /// an error result means the wallet handle or the identity is unknown. #[no_mangle] pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( @@ -811,17 +822,18 @@ mod key_disable_check_tests { const IDENTITY_ID: [u8; 32] = [1; 32]; const NOW_MS: u64 = 1_800_000_000_000; - /// MASTER 0, CRITICAL 1, HIGH 2: one authentication key per level. + /// MASTER 0, CRITICAL 1, HIGH 2 authentication keys and TRANSFER 3. fn identity() -> Identity { let keys = [ - (0, SecurityLevel::MASTER), - (1, SecurityLevel::CRITICAL), - (2, SecurityLevel::HIGH), + (0, Purpose::AUTHENTICATION, SecurityLevel::MASTER), + (1, Purpose::AUTHENTICATION, SecurityLevel::CRITICAL), + (2, Purpose::AUTHENTICATION, SecurityLevel::HIGH), + (3, Purpose::TRANSFER, SecurityLevel::CRITICAL), ] - .map(|(id, security_level)| { + .map(|(id, purpose, security_level)| { IdentityPublicKey::V0(IdentityPublicKeyV0 { id, - purpose: Purpose::AUTHENTICATION, + purpose, security_level, contract_bounds: None, key_type: KeyType::ECDSA_SECP256K1, @@ -860,7 +872,7 @@ mod key_disable_check_tests { } #[test] - fn should_report_the_dip11_verdict_for_the_wallets_identity() { + fn should_report_the_wallets_verdict_for_its_identity() { // Setup only inside the runtime: the FFI entry point blocks on its own // worker and must not run nested in another runtime. let (manager, handle) = runtime().block_on(async { @@ -883,16 +895,29 @@ mod key_disable_check_tests { assert_eq!( refused, KeyDisableCheckFFI { - allowed: false, + refusal: 1, refused_key_id: 1, refused_security_level: SecurityLevel::CRITICAL as u8, + refused_purpose: 0, + } + ); + + let (result, refused) = unsafe { check(handle, &IDENTITY_ID, &[3]) }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!( + refused, + KeyDisableCheckFFI { + refusal: 2, + refused_key_id: 3, + refused_security_level: 0, + refused_purpose: Purpose::TRANSFER as u8, } ); // CRITICAL 1 stands in for HIGH 2. let (result, allowed) = unsafe { check(handle, &IDENTITY_ID, &[2]) }; assert_eq!(result.code, PlatformWalletFFIResultCode::Success); - assert!(allowed.allowed, "{allowed:?}"); + assert_eq!(allowed, KeyDisableCheckFFI::default()); // An identity the wallet does not hold is an error, not a verdict. let (result, _) = unsafe { check(handle, &[9; 32], &[1]) }; diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 5696d681d2b..682d02384fd 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -704,6 +704,15 @@ pub enum PlatformWalletError { #[error("Invalid parameter: {0}")] InvalidParameter(String), + /// An identity update would disable the last enabled key of a purpose + /// the wallet keeps one of (TRANSFER), refused before anything is + /// fetched or signed. Wallet policy, not consensus; the DIP-11 + /// last-authentication-key refusal travels as + /// `Sdk(Protocol(LastAuthenticationKey))` instead. FFI maps it to the + /// invalid-parameter code. + #[error(transparent)] + LastEnabledKey(#[from] crate::wallet::identity::LastEnabledKeyError), + /// The SPV masternode list has not been synced yet, so a /// masternode-list-dependent action (locating an entry, re-asserting its /// service values) cannot proceed. FFI maps it to the same @@ -1055,6 +1064,21 @@ impl From for PlatformWalletError { } } +impl From for PlatformWalletError { + /// The DIP-11 refusal keeps the shape dpp's identity update builder gives + /// it, `Sdk(Protocol(LastAuthenticationKey))`, so callers see one error + /// whichever of the two refuses first. + fn from(refusal: crate::wallet::identity::KeyDisableRefusal) -> Self { + use crate::wallet::identity::KeyDisableRefusal; + match refusal { + KeyDisableRefusal::LastAuthenticationKey(refusal) => Self::Sdk( + dash_sdk::Error::Protocol(dpp::ProtocolError::LastAuthenticationKey(refusal)), + ), + KeyDisableRefusal::LastEnabledKey(refusal) => Self::LastEnabledKey(refusal), + } + } +} + impl PlatformWalletError { /// A persister `load` failed. /// diff --git a/packages/rs-platform-wallet/src/wallet/identity/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/mod.rs index 01079577e1b..27dbb1bae1e 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/mod.rs @@ -31,7 +31,8 @@ pub use crypto::{ }; pub use network::{DashPayView, IdentityWallet}; pub use state::{ - BlockTime, DashPayState, IdentityLocation, IdentityManager, ManagedIdentity, RegistrationIndex, + BlockTime, DashPayState, IdentityLocation, IdentityManager, KeyDisableRefusal, + LastEnabledKeyError, ManagedIdentity, RegistrationIndex, }; pub use types::dashpay::profile::{calculate_avatar_hash, calculate_dhash_fingerprint}; pub use types::{ 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 7aefa072409..512b419d5a2 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -6,7 +6,6 @@ use dpp::identity::accessors::IdentityGettersV0; use dpp::identity::accessors::IdentitySettersV0; use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; -use dpp::identity::key_disable_rule::{check_keys_can_be_disabled, LastAuthenticationKeyError}; use dpp::identity::Identity; use dpp::identity::IdentityPublicKey; use dpp::identity::KeyID; @@ -22,6 +21,7 @@ use dpp::identity::signer::Signer; use dash_sdk::platform::transition::put_settings::PutSettings; use crate::error::PlatformWalletError; +use crate::wallet::identity::state::{check_keys_can_be_disabled, KeyDisableRefusal}; use super::signing_key::AvailableSigningKey; use super::*; @@ -63,18 +63,19 @@ where // --------------------------------------------------------------------------- impl IdentityWallet { - /// Whether disabling `disable_public_keys` of the managed identity - /// `identity_id` keeps a valid authentication key at every security level - /// it touches (DIP-11): the inner `Err` names the key that cannot be - /// disabled and its level. + /// Whether this wallet lets an identity update disable + /// `disable_public_keys` of the managed identity `identity_id`: the inner + /// `Err` is the [`KeyDisableRefusal`] — the last valid authentication key + /// of a security level (DIP-11), or the last enabled TRANSFER key. /// /// Judges the identity this wallet holds, which is the one /// [`Self::update_identity_with_external_signer`] builds the update from, - /// with dpp's [`check_keys_can_be_disabled`] at `now_ms`, the caller's - /// wall clock in Unix milliseconds. The builder has no clock and counts no - /// key with an expiry, so where the only stand-in key has an expiry this - /// allows a disable the builder then refuses; it never refuses one the - /// builder would let through. + /// with the same [`check_keys_can_be_disabled`] the update entry points + /// refuse with, at `now_ms`, the caller's wall clock in Unix + /// milliseconds. The entry points and dpp's builder have no clock and + /// count no authentication key with an expiry, so where the only stand-in + /// key has an expiry this allows a disable the update then refuses; it + /// never refuses one the update would let through. /// /// The outer `Err` is a wallet failure: the wallet or the identity is not /// loaded. @@ -83,7 +84,7 @@ impl IdentityWallet { identity_id: &Identifier, disable_public_keys: &[KeyID], now_ms: TimestampMillis, - ) -> Result, PlatformWalletError> { + ) -> Result, PlatformWalletError> { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { PlatformWalletError::WalletNotFound( @@ -112,11 +113,13 @@ impl IdentityWallet { /// identity-update state transition requires (DPP gates this on /// MASTER specifically — HIGH/CRITICAL aren't accepted). /// - /// An update that would disable the last valid authentication key of a - /// security level is refused by the transition builder before anything is - /// signed or broadcast, with + /// An update [`check_keys_can_be_disabled`] refuses is refused before a + /// nonce is fetched or anything is signed: disabling the last valid + /// authentication key of a security level with /// `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(_)))` - /// (DIP-11); keys added in the same update count as valid. + /// (DIP-11), the last enabled TRANSFER key with + /// `PlatformWalletError::LastEnabledKey(_)`. Keys added in the same update + /// count as remaining. /// /// NOTE: callers that ADD keys via `add_public_keys` are /// responsible for pre-persisting the new keys' private material @@ -177,6 +180,10 @@ impl IdentityWallet { (identity, index) }; + // Refuse a disable this wallet does not allow before fetching a nonce + // or signing anything. No clock, like the builder. + check_keys_can_be_disabled(&identity, &add_public_keys, &disable_public_keys, None)?; + // Increment revision for the update transition. let original_revision = identity.revision(); identity.set_revision(original_revision + 1); @@ -335,11 +342,9 @@ impl IdentityWallet { /// caller receives the chain's error. See /// `docs/protocol/authentication-key-limits.md`. /// - /// An update that would disable the last valid authentication key of a - /// security level is refused by the transition builder before anything is - /// signed or broadcast, with - /// `dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(_))` - /// (DIP-11). + /// An update [`check_keys_can_be_disabled`] refuses is refused before a + /// nonce is fetched or anything is signed, with the same errors as + /// [`Self::update_identity_with_external_signer`]. /// /// Returns the [`StateTransitionProofResult`] from the broadcast so callers /// can inspect proof-verified outcomes (e.g. updated keys, balance). @@ -351,12 +356,14 @@ impl IdentityWallet { disable_public_keys: Vec, signer: &S, settings: Option, - ) -> Result + ) -> Result { use dash_sdk::platform::transition::broadcast::BroadcastStateTransition; use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; + check_keys_can_be_disabled(identity, &add_public_keys, &disable_public_keys, None)?; + // Get identity nonce from Platform. let identity_nonce = self .sdk @@ -380,7 +387,7 @@ impl IdentityWallet { None, ) .await - .map_err(dash_sdk::Error::Protocol)?; + .map_err(|e| PlatformWalletError::Sdk(dash_sdk::Error::Protocol(e)))?; // Broadcast and wait for confirmation. let result = state_transition @@ -395,10 +402,12 @@ impl IdentityWallet { mod tests { use super::*; use crate::test_support::{test_platform_wallet_manager_with_sdk, NoopTestPersister}; + use crate::wallet::identity::LastEnabledKeyError; use crate::wallet::persister::{NoPlatformPersistence, WalletPersister}; use crate::wallet::platform_wallet::WalletId; use dash_sdk::query_types::IdentityNonceFetcher; use dpp::identity::identity_public_key::v0::IdentityPublicKeyV0; + use dpp::identity::key_disable_rule::LastAuthenticationKeyError; use dpp::identity::v0::IdentityV0; use std::collections::BTreeMap; use std::sync::Arc; @@ -554,10 +563,12 @@ mod tests { .expect("the identity is loaded"); assert_eq!( refused, - Err(LastAuthenticationKeyError { - key_id: 1, - security_level: SecurityLevel::CRITICAL, - }) + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) ); // CRITICAL 1 stands in for HIGH 2. assert_eq!( @@ -632,10 +643,81 @@ mod tests { assert!( matches!( result, - Err(dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(ref refusal))) + Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol( + ProtocolError::LastAuthenticationKey(ref refusal) + ))) if refusal.key_id == 1 && refusal.security_level == SecurityLevel::CRITICAL ), "{result:?}" ); } + + fn transfer_key(id: u32) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::TRANSFER, + security_level: SecurityLevel::CRITICAL, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + } + + #[tokio::test] + async fn should_judge_the_last_transfer_key_as_wallet_policy() { + let mut identity = identity(); + identity.add_public_key(transfer_key(3)); + let (_manager, wallet) = manager_holding(identity).await; + assert_eq!( + wallet + .identity() + .check_keys_can_be_disabled(&Identifier::from([1; 32]), &[3], NOW_MS) + .await + .expect("the identity is loaded"), + Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })) + ); + } + + /// Both update entry points refuse the last transfer key before any + /// network call: the mock SDK has no expectations, so a nonce fetch would + /// fail with a different error. + #[tokio::test] + async fn should_refuse_disabling_the_last_transfer_key_before_fetching_a_nonce() { + let mut identity = identity(); + identity.add_public_key(transfer_key(3)); + let (_manager, wallet) = manager_holding(identity.clone()).await; + + let result = wallet + .identity() + .update_identity_with_external_signer( + &Identifier::from([1; 32]), + vec![], + vec![3], + &HoldsEveryKey, + None, + ) + .await; + assert!( + matches!( + result, + Err(PlatformWalletError::LastEnabledKey(ref refusal)) + if refusal.key_id == 3 && refusal.purpose == Purpose::TRANSFER + ), + "{result:?}" + ); + + let result = wallet + .identity() + .update_identity_with_signer(&identity, &0, vec![], vec![3], &HoldsEveryKey, None) + .await; + assert!( + matches!(result, Err(PlatformWalletError::LastEnabledKey(_))), + "{result:?}" + ); + } } diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs new file mode 100644 index 00000000000..0a76a8590f1 --- /dev/null +++ b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs @@ -0,0 +1,192 @@ +//! Which of an identity's keys this wallet lets an identity update disable. +//! +//! Two refusals, both client policy that Platform does not enforce: +//! +//! - **DIP-11** — the last valid authentication key of a security level, +//! judged by dpp's [`dpp_check_keys_can_be_disabled`]. +//! - **Last enabled TRANSFER key** — wallet policy, not a DIP rule: without +//! one the identity can no longer withdraw or transfer credits until a new +//! transfer key is added. +//! +//! [`check_keys_can_be_disabled`] is the single place both are decided. The +//! identity-update entry points refuse with it before fetching a nonce, and +//! hosts ask it through `IdentityWallet::check_keys_can_be_disabled` to offer +//! only the keys it allows. + +use dpp::identity::accessors::IdentityGettersV0; +use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV0; +use dpp::identity::key_disable_rule::{ + check_keys_can_be_disabled as dpp_check_keys_can_be_disabled, LastAuthenticationKeyError, +}; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose}; +use dpp::prelude::TimestampMillis; + +/// Disabling `key_id` would leave the identity without an enabled key of +/// `purpose`. Wallet policy, not a DIP rule or consensus: Platform accepts +/// the update. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("cannot disable {purpose} key {key_id}: no other enabled {purpose} key would remain")] +pub struct LastEnabledKeyError { + /// The key the update would disable. + pub key_id: KeyID, + /// The purpose that would be left without an enabled key. + pub purpose: Purpose, +} + +/// Why this wallet refuses an identity update's key disables. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum KeyDisableRefusal { + /// DIP-11: the last valid authentication key of a security level. + #[error(transparent)] + LastAuthenticationKey(#[from] LastAuthenticationKeyError), + /// Wallet policy: the last enabled key of a purpose that must keep one. + #[error(transparent)] + LastEnabledKey(#[from] LastEnabledKeyError), +} + +/// Purposes an identity must keep an enabled key of. TRANSFER only: it +/// authorizes credit withdrawals and transfers. +const PURPOSES_KEPT_ENABLED: [Purpose; 1] = [Purpose::TRANSFER]; + +/// Whether an identity update that adds `add_public_keys` and disables +/// `disable_public_keys` is one this wallet lets through: DIP-11 first (see +/// [`dpp_check_keys_can_be_disabled`] for `now_ms` and what counts as a valid +/// authentication key), then the last enabled TRANSFER key. A key added in +/// the same update counts as remaining for both. +pub fn check_keys_can_be_disabled( + identity: &Identity, + add_public_keys: &[IdentityPublicKey], + disable_public_keys: &[KeyID], + now_ms: Option, +) -> Result<(), KeyDisableRefusal> { + dpp_check_keys_can_be_disabled(identity, add_public_keys, disable_public_keys, now_ms)?; + + for key_id in disable_public_keys { + let Some(key) = identity.public_keys().get(key_id) else { + continue; + }; + let purpose = key.purpose(); + if !PURPOSES_KEPT_ENABLED.contains(&purpose) || key.disabled_at().is_some() { + continue; + } + let another_remains = identity + .public_keys() + .values() + .filter(|remaining| !disable_public_keys.contains(&remaining.id())) + .chain(add_public_keys) + .any(|remaining| remaining.purpose() == purpose && remaining.disabled_at().is_none()); + if !another_remains { + return Err(LastEnabledKeyError { + key_id: *key_id, + purpose, + } + .into()); + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + 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::{KeyType, SecurityLevel}; + use dpp::platform_value::{BinaryData, Identifier}; + use std::collections::BTreeMap; + + fn key(id: KeyID, purpose: Purpose, security_level: SecurityLevel) -> IdentityPublicKey { + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose, + security_level, + contract_bounds: None, + key_type: KeyType::ECDSA_SECP256K1, + read_only: false, + data: BinaryData::new(vec![id as u8 + 2; 33]), + disabled_at: None, + }) + } + + /// MASTER 0, CRITICAL 1 authentication keys and TRANSFER 3. + fn identity_with(extra: Vec) -> Identity { + let keys = [ + key(0, Purpose::AUTHENTICATION, SecurityLevel::MASTER), + key(1, Purpose::AUTHENTICATION, SecurityLevel::CRITICAL), + key(3, Purpose::TRANSFER, SecurityLevel::CRITICAL), + ] + .into_iter() + .chain(extra); + IdentityV0 { + id: Identifier::from([1; 32]), + public_keys: keys.map(|key| (key.id(), key)).collect::>(), + balance: 0, + revision: 0, + } + .into() + } + + #[test] + fn should_refuse_disabling_the_last_enabled_transfer_key() { + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[3], None), + Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })) + ); + } + + #[test] + fn should_allow_disabling_a_transfer_key_another_enabled_one_replaces() { + let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![second.clone()]), &[], &[3], None), + Ok(()) + ); + // A transfer key added in the same update counts. + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[second], &[3], None), + Ok(()) + ); + } + + #[test] + fn should_not_count_a_disabled_transfer_key_as_remaining() { + let mut disabled = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + disabled.set_disabled_at(1_700_000_000_000); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![disabled]), &[], &[3], None), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + // Disabling both transfer keys at once leaves none. + let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![second]), &[], &[3, 4], None), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + #[test] + fn should_report_the_dip11_refusal_for_authentication_keys() { + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[1], None), + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) + ); + } + + #[test] + fn should_allow_disabling_keys_of_other_purposes() { + let encryption = key(5, Purpose::ENCRYPTION, SecurityLevel::MEDIUM); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![encryption]), &[], &[5], None), + Ok(()) + ); + } +} diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs index affa70d0156..896da298df9 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/mod.rs @@ -4,10 +4,13 @@ //! mutation impls that maintain its invariants (identity-side + //! DashPay-side both live here today). //! - `manager/`: the per-wallet collection of managed identities. +//! - `key_disable`: which keys an identity update may disable. +pub mod key_disable; pub mod managed_identity; pub mod manager; +pub use key_disable::{check_keys_can_be_disabled, KeyDisableRefusal, LastEnabledKeyError}; pub use managed_identity::{BlockTime, DashPayState, ManagedIdentity}; pub use manager::IdentityLocation; pub use manager::IdentityManager; From efdb2dd6184bcc6efda921be0ec21521e613b9a9 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:14:34 +0000 Subject: [PATCH 09/21] feat(kotlin-sdk): carry the refusal kind and purpose through checkKeysCanBeDisabled Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../rs-unified-sdk-jni/src/transactions.rs | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 98bec10bb7b..4ec600fd1f4 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -264,15 +264,17 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up }) } -/// Whether disabling `keyIds` of the wallet's identity follows DIP-11 — -/// every security level they touch keeps a valid authentication key — judged -/// at `nowMs`, the host's wall clock in Unix milliseconds. Thin marshaler over +/// Whether the wallet lets an identity update disable `keyIds` of its +/// identity — no security level loses its last valid authentication key +/// (DIP-11) and an enabled TRANSFER key remains — judged at `nowMs`, the +/// host's wall clock in Unix milliseconds. Thin marshaler over /// `platform_wallet_check_keys_can_be_disabled`. /// -/// Returns a `long[3]`: `[allowed (0/1), refusedKeyId, refusedSecurityLevel]`, -/// the last two meaningful only when not allowed (security level as the DPP -/// discriminant, 0 = MASTER). Throws when the wallet or the identity is not -/// loaded. +/// Returns a `long[4]`: `[refusal, refusedKeyId, refusedSecurityLevel, +/// refusedPurpose]`. `refusal` is 0 when allowed, 1 for the last valid +/// authentication key of a security level, 2 for the last enabled key of a +/// purpose; the level (1) and purpose (2) are DPP discriminants. Throws when +/// the wallet or the identity is not loaded. #[no_mangle] pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_checkKeysCanBeDisabled( mut env: JNIEnv, @@ -312,11 +314,12 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ch return ptr::null_mut(); } let out = [ - jlong::from(check.allowed), + jlong::from(check.refusal), jlong::from(check.refused_key_id), jlong::from(check.refused_security_level), + jlong::from(check.refused_purpose), ]; - let Ok(arr) = env.new_long_array(3) else { + let Ok(arr) = env.new_long_array(4) else { return ptr::null_mut(); }; if env.set_long_array_region(&arr, 0, &out).is_err() { From b98b803a772955d4d04ce1ea7625159ba928e1d5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:16:27 +0000 Subject: [PATCH 10/21] feat(swift-sdk): report which wallet rule refuses a key disable KeyDisableRefusal becomes an enum: lastAuthenticationKey(keyId, securityLevel) for DIP-11 and lastEnabledKey(keyId, purpose) for the wallet's last-transfer-key policy, decoded from the FFI verdict's refusal kind. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../ManagedPlatformWallet.swift | 56 +++++++++++++------ 1 file changed, 38 insertions(+), 18 deletions(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 4e16c73e95f..3df37792345 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3782,23 +3782,30 @@ extension ManagedPlatformWallet { }.value } - /// Why Rust refused disabling a set of keys under DIP-11: `keyId` is - /// the key whose `securityLevel` would be left without a valid - /// authentication key. - public struct KeyDisableRefusal: Equatable, Sendable { - public let keyId: UInt32 - public let securityLevel: SecurityLevel - - public init(keyId: UInt32, securityLevel: SecurityLevel) { - self.keyId = keyId - self.securityLevel = securityLevel + /// Why the wallet refuses disabling a set of keys — mirror of + /// platform-wallet's `KeyDisableRefusal`. + public enum KeyDisableRefusal: Equatable, Sendable { + /// DIP-11: `securityLevel` would be left without a valid + /// authentication key. + case lastAuthenticationKey(keyId: UInt32, securityLevel: SecurityLevel) + /// Wallet policy: `purpose` (TRANSFER) would be left without an + /// enabled key. + case lastEnabledKey(keyId: UInt32, purpose: KeyPurpose) + + /// The key that cannot be disabled. + public var keyId: UInt32 { + switch self { + case .lastAuthenticationKey(let keyId, _), .lastEnabledKey(let keyId, _): + return keyId + } } } - /// Whether `keyIds` of `identityId` may be disabled under DIP-11 — - /// every security level they touch keeps a valid authentication key — + /// Whether the wallet lets an identity update disable `keyIds` of + /// `identityId` — no security level loses its last valid + /// authentication key (DIP-11) and an enabled TRANSFER key remains — /// judged by Rust against this wallet's identity at `now`: `nil` when - /// they may, otherwise the refusal. Marshals + /// it does, otherwise the refusal. Marshals /// `platform_wallet_check_keys_can_be_disabled`; throws when the /// identity is not loaded in this wallet. public func checkKeysCanBeDisabled( @@ -3827,15 +3834,28 @@ extension ManagedPlatformWallet { } } try result.check() - if check.allowed { + switch check.refusal { + case 0: return nil - } - guard let level = SecurityLevel(rawValue: check.refused_security_level) else { + case 1: + guard let level = SecurityLevel(rawValue: check.refused_security_level) else { + throw PlatformWalletError.invalidParameter( + "unknown security level \(check.refused_security_level) in a key-disable refusal" + ) + } + return .lastAuthenticationKey(keyId: check.refused_key_id, securityLevel: level) + case 2: + guard let purpose = KeyPurpose(rawValue: check.refused_purpose) else { + throw PlatformWalletError.invalidParameter( + "unknown key purpose \(check.refused_purpose) in a key-disable refusal" + ) + } + return .lastEnabledKey(keyId: check.refused_key_id, purpose: purpose) + default: throw PlatformWalletError.invalidParameter( - "unknown security level \(check.refused_security_level) in a key-disable refusal" + "unknown key-disable refusal kind \(check.refusal)" ) } - return KeyDisableRefusal(keyId: check.refused_key_id, securityLevel: level) }.value } From 5e797764603988cb8f7b5a4c74898e6d94b828c4 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:16:27 +0000 Subject: [PATCH 11/21] refactor(swift-example-app): drop the local transfer-key guard from KeyDisableGate The wallet now refuses disabling the last enabled transfer key itself, so the gate keeps only the already-disabled display state and renders the wallet's refusal, whichever rule it names. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../Services/KeyDisableGate.swift | 80 ++++------- .../SwiftExampleApp/Views/KeyDetailView.swift | 7 +- .../SwiftExampleApp/Views/KeysListView.swift | 4 +- .../KeyDisableGateTests.swift | 124 ++++-------------- 4 files changed, 57 insertions(+), 158 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift index 8379bc92cb9..15ac2a47570 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift @@ -3,21 +3,13 @@ import SwiftDashSDK /// Client-side safety gate for the **Disable Key** action. /// -/// This is a pre-flight UI guard. It decides nothing about DIP-11 itself: -/// -/// - **Last valid key of a security level (DIP-11):** Rust judges it -/// (`ManagedPlatformWallet.checkKeysCanBeDisabled`, dpp's -/// `check_keys_can_be_disabled` at the device clock) and the caller -/// hands the verdict in as a `RuleVerdict`. The identity update builder -/// refuses the same updates before signing, so the gate only spares the -/// user a doomed attempt. -/// - **Last transfer key (client-side self-brick guard):** not a protocol -/// or DIP rule, and not in Rust — drive-abci would accept it — but -/// disabling the last enabled transfer key would break credit -/// withdrawals, so we refuse it locally. -/// -/// Callers pass `allKeys` (the identity's `identityPublicKeys`) alongside -/// the `target` key being disabled, for the transfer guard. +/// A pre-flight UI guard that decides nothing itself. Which keys may be +/// disabled is the wallet's call (`ManagedPlatformWallet.checkKeysCanBeDisabled`: +/// the last valid authentication key of a security level under DIP-11, +/// and the last enabled transfer key), judged in Rust at the device clock +/// and handed in as a `RuleVerdict`. The wallet's identity-update entry +/// points refuse the same updates, so the gate only spares the user a +/// doomed attempt and turns the structured refusal into copy. enum KeyDisableGate { /// Result of evaluating whether `target` may be disabled. enum Evaluation: Equatable { @@ -29,20 +21,19 @@ enum KeyDisableGate { case forbidden(reason: String) } - /// Rust's DIP-11 verdict for disabling the target key alone. + /// Rust's verdict for disabling the target key alone. enum RuleVerdict: Equatable { - /// DIP-11 allows disabling the key. + /// The wallet allows disabling the key. case allowed - /// DIP-11 forbids it: the refusal names the level left without a - /// valid key. + /// The wallet refuses it, for the reason the refusal names. case refused(ManagedPlatformWallet.KeyDisableRefusal) /// No verdict: the wallet is not loaded, or the check failed or is /// still running. `reason` is user-facing copy. case unavailable(reason: String) } - /// Ask Rust for the DIP-11 verdict on disabling `keyId` of - /// `identityId` at the device clock. Without a loaded wallet, or when + /// Ask Rust whether the wallet lets `keyId` of `identityId` be + /// disabled, at the device clock. Without a loaded wallet, or when /// the check fails, there is no verdict and the gate forbids the action. static func ruleVerdict( wallet: ManagedPlatformWallet?, @@ -67,14 +58,10 @@ enum KeyDisableGate { } } - /// Evaluate the disable gate for `target` within `allKeys`, given - /// Rust's `ruleVerdict` for disabling `target` alone. - /// - /// `allKeys` should be the identity's current `identityPublicKeys` - /// (the DPP projection). `target` must be one of them. + /// Evaluate the disable gate for `target`, given Rust's `ruleVerdict` + /// for disabling it alone. static func evaluate( target: IdentityPublicKey, - allKeys: [IdentityPublicKey], ruleVerdict: RuleVerdict ) -> Evaluation { // Already disabled → nothing to do. @@ -83,36 +70,23 @@ enum KeyDisableGate { } switch ruleVerdict { - case .refused(let refusal): - return .forbidden( - reason: "No other valid authentication key at the \(refusal.securityLevel.name) level would remain. Disabling this key would leave the identity unable to sign what needs that level — add another key first (Add Key)." - ) + case .allowed: + return .allowed case .unavailable(let reason): return .forbidden(reason: reason) - case .allowed: - break - } - - // Client-side self-brick guard (not a consensus or DIP rule): - // drive-abci would accept this, but disabling the last enabled - // TRANSFER key would break credit withdrawals / transfers - // (ID-10), and re-adding a transfer key currently requires the - // Add Key flow (ID-07). We refuse it locally as a UX safeguard. - if target.purpose == .transfer - && enabledCount(of: .transfer, in: allKeys) <= 1 { - return .forbidden( - reason: "This is the only enabled transfer key. Disabling it would break credit withdrawals — add another transfer key first (Add Key)." - ) + case .refused(let refusal): + return .forbidden(reason: reason(for: refusal)) } - - return .allowed } - /// Count of enabled (non-disabled) keys with the given purpose. - private static func enabledCount( - of purpose: KeyPurpose, - in keys: [IdentityPublicKey] - ) -> Int { - keys.filter { $0.purpose == purpose && $0.disabledAt == nil }.count + /// User-facing copy for a refusal. + private static func reason(for refusal: ManagedPlatformWallet.KeyDisableRefusal) -> String { + switch refusal { + case .lastAuthenticationKey(_, let securityLevel): + return "No other valid authentication key at the \(securityLevel.name) level would remain. Disabling this key would leave the identity unable to sign what needs that level — add another key first (Add Key)." + case .lastEnabledKey(_, let purpose): + let name = purpose.name.lowercased() + return "This is the only enabled \(name) key. Disabling it would break credit withdrawals — add another \(name) key first (Add Key)." + } } } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index 9ee9cc6257f..8a6dcd899a0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -13,7 +13,7 @@ struct KeyDetailView: View { @State private var showDisableConfirm = false @State private var isDisabling = false @State private var disableError: String? - /// Rust's DIP-11 verdict for disabling this key, refreshed whenever the + /// The wallet's verdict for disabling this key, refreshed whenever the /// identity's key set changes. @State private var ruleVerdict: KeyDisableGate.RuleVerdict = .unavailable(reason: "Checking whether this key may be disabled…") @@ -33,12 +33,11 @@ struct KeyDetailView: View { return result } - /// Pre-flight gate for disabling this key: Rust's DIP-11 verdict plus - /// the last-transfer-key guard over the identity's current key set. + /// Pre-flight gate for disabling this key: the wallet's verdict from + /// Rust (DIP-11 and the last transfer key). private var disableEvaluation: KeyDisableGate.Evaluation { KeyDisableGate.evaluate( target: publicKey, - allKeys: identity.identityPublicKeys, ruleVerdict: ruleVerdict ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index ab234590b32..72d830b5ac5 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -20,7 +20,7 @@ struct KeysListView: View { @State private var pendingDisableKey: IdentityPublicKey? @State private var isDisabling = false @State private var disableError: String? - /// Rust's DIP-11 verdict for disabling each enabled key alone, refreshed + /// The wallet's verdict for disabling each enabled key alone, refreshed /// whenever the key set changes. Absent while loading. @State private var ruleVerdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] /// What Platform says is left of each budgeted key, fetched once for the @@ -211,7 +211,6 @@ struct KeysListView: View { private func disableSwipeButton(for publicKey: IdentityPublicKey) -> some View { if case .allowed = KeyDisableGate.evaluate( target: publicKey, - allKeys: publicKeys, ruleVerdict: ruleVerdict(for: publicKey) ) { Button(role: .destructive) { @@ -242,7 +241,6 @@ struct KeysListView: View { ruleVerdicts[publicKey.id] = verdict guard case .allowed = KeyDisableGate.evaluate( target: publicKey, - allKeys: publicKeys, ruleVerdict: verdict ) else { return } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift index a66a9d1ca2d..16a2689401d 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleAppTests/KeyDisableGateTests.swift @@ -2,11 +2,10 @@ // KeyDisableGateTests.swift // SwiftExampleAppTests // -// Unit coverage for `KeyDisableGate.evaluate` — the single place that -// decides whether a Disable Key submit is attempted (used in -// KeyDetailView, KeysListView, and the pre-submit re-check). The DIP-11 -// verdict itself comes from Rust and is covered there; these tests pin -// how the gate renders it and the last-transfer-key guard. +// Unit coverage for `KeyDisableGate.evaluate` — what a Disable Key +// action shows for the wallet's verdict (used in KeyDetailView, +// KeysListView, and the pre-submit re-check). The verdict itself comes +// from Rust (DIP-11 and the last transfer key) and is covered there. // import XCTest @@ -18,8 +17,7 @@ final class KeyDisableGateTests: XCTestCase { // MARK: - Fixtures /// Build an `IdentityPublicKey` test value with the fields the gate - /// actually reads (`id`, `purpose`, `disabledAt`). The rest are - /// irrelevant to the gate, so they get trivial defaults. + /// actually reads (`disabledAt`). The rest get trivial defaults. private func makeKey( id: KeyID, purpose: KeyPurpose, @@ -41,10 +39,6 @@ final class KeyDisableGateTests: XCTestCase { /// A fixed, non-nil disabled timestamp for "already disabled" keys. private let disabledTimestamp: TimestampMillis = 1_700_000_000_000 - private func refusal(_ keyId: KeyID, _ level: SecurityLevel) -> KeyDisableGate.RuleVerdict { - .refused(ManagedPlatformWallet.KeyDisableRefusal(keyId: keyId, securityLevel: level)) - } - // MARK: - 1. Already disabled func testAlreadyDisabledTarget_returnsAlreadyDisabled() { @@ -53,124 +47,58 @@ final class KeyDisableGateTests: XCTestCase { // Whatever Rust says, a disabled key has nothing left to do. let result = KeyDisableGate.evaluate( target: target, - allKeys: [target], - ruleVerdict: refusal(5, .high) + ruleVerdict: .refused(.lastAuthenticationKey(keyId: 5, securityLevel: .high)) ) XCTAssertEqual(result, .alreadyDisabled) } - // MARK: - 2. DIP-11 verdict from Rust - - func testRefusedVerdict_isForbiddenNamingTheLevel() { - let target = makeKey(id: 1, purpose: .authentication, securityLevel: .critical) - - let result = KeyDisableGate.evaluate( - target: target, - allKeys: [target], - ruleVerdict: refusal(1, .critical) - ) - - guard case .forbidden(let reason) = result else { - return XCTFail("expected .forbidden for a refused verdict, got \(result)") - } - XCTAssertTrue(reason.contains(SecurityLevel.critical.name), reason) - } - - func testUnavailableVerdict_isForbiddenWithItsReason() { - let target = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate( - target: target, - allKeys: [target], - ruleVerdict: .unavailable(reason: "Wallet not loaded") - ) - - XCTAssertEqual(result, .forbidden(reason: "Wallet not loaded")) - } + // MARK: - 2. The wallet's verdict - func testAllowedVerdict_isAllowedEvenForAMasterKey() { - // Which authentication keys may go is Rust's call: with another - // MASTER key, DIP-11 lets this one be disabled. + func testAllowedVerdict_isAllowed() { + // Which keys may go is Rust's call: with another MASTER key, even a + // MASTER key may be disabled. let target = makeKey(id: 0, purpose: .authentication, securityLevel: .master) - let other = makeKey(id: 1, purpose: .authentication, securityLevel: .master) - let result = KeyDisableGate.evaluate( - target: target, - allKeys: [target, other], - ruleVerdict: .allowed - ) - - XCTAssertEqual(result, .allowed) + XCTAssertEqual(KeyDisableGate.evaluate(target: target, ruleVerdict: .allowed), .allowed) } - // MARK: - 3. Last transfer key - - func testLastEnabledTransferKey_isForbidden() { - let target = makeKey(id: 3, purpose: .transfer) - let auth = makeKey(id: 1, purpose: .authentication) + func testLastAuthenticationKeyRefusal_isForbiddenNamingTheLevel() { + let target = makeKey(id: 1, purpose: .authentication, securityLevel: .critical) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, auth], - ruleVerdict: .allowed + ruleVerdict: .refused(.lastAuthenticationKey(keyId: 1, securityLevel: .critical)) ) - guard case .forbidden = result else { - return XCTFail("expected .forbidden for the only transfer key, got \(result)") + guard case .forbidden(let reason) = result else { + return XCTFail("expected .forbidden for a DIP-11 refusal, got \(result)") } + XCTAssertTrue(reason.contains(SecurityLevel.critical.name), reason) } - func testTransferKey_withSecondEnabledTransfer_isAllowed() { - let target = makeKey(id: 3, purpose: .transfer) - let secondTransfer = makeKey(id: 4, purpose: .transfer) - let auth = makeKey(id: 1, purpose: .authentication) - - let result = KeyDisableGate.evaluate( - target: target, - allKeys: [target, secondTransfer, auth], - ruleVerdict: .allowed - ) - - XCTAssertEqual(result, .allowed) - } - - /// A disabled sibling transfer key doesn't keep the last enabled - /// transfer key disableable. - func testAlreadyDisabledSiblingTransferKeyExcludedFromCount() { + func testLastEnabledKeyRefusal_isForbiddenNamingThePurpose() { let target = makeKey(id: 3, purpose: .transfer) - let disabledTransfer = makeKey( - id: 4, - purpose: .transfer, - disabledAt: disabledTimestamp - ) - let auth = makeKey(id: 1, purpose: .authentication) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, disabledTransfer, auth], - ruleVerdict: .allowed + ruleVerdict: .refused(.lastEnabledKey(keyId: 3, purpose: .transfer)) ) - guard case .forbidden = result else { - return XCTFail( - "expected .forbidden — a disabled sibling transfer key is not an enabled fallback, got \(result)" - ) + guard case .forbidden(let reason) = result else { + return XCTFail("expected .forbidden for the last transfer key, got \(result)") } + XCTAssertTrue(reason.contains("transfer"), reason) } - // MARK: - 4. Normal key - - func testNormalKey_isAllowed() { - let target = makeKey(id: 7, purpose: .encryption) - let auth = makeKey(id: 1, purpose: .authentication) + func testUnavailableVerdict_isForbiddenWithItsReason() { + let target = makeKey(id: 1, purpose: .authentication) let result = KeyDisableGate.evaluate( target: target, - allKeys: [target, auth], - ruleVerdict: .allowed + ruleVerdict: .unavailable(reason: "Wallet not loaded") ) - XCTAssertEqual(result, .allowed) + XCTAssertEqual(result, .forbidden(reason: "Wallet not loaded")) } } From 061f1cc7ef7fda22fe314bde35a082b80f749a2c Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:16:27 +0000 Subject: [PATCH 12/21] feat(kotlin-sdk): report which wallet rule refuses a key disable KeyDisableRefusal becomes a sealed interface: LastAuthenticationKey (keyId, securityLevel) for DIP-11 and LastEnabledKey(keyId, purpose) for the wallet's last-transfer-key policy. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../dashsdk/ffi/TransactionsNative.kt | 16 ++++--- .../dashsdk/identity/IdentityUpdates.kt | 44 ++++++++++++++----- 2 files changed, 42 insertions(+), 18 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 f25c0baa4c9..4eb36e70732 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 @@ -54,15 +54,17 @@ internal object TransactionsNative { ) /** - * Whether disabling [keyIds] of [identityId] follows DIP-11 — every - * security level they touch keeps a valid authentication key — judged - * at [nowMs], the device's wall clock in Unix milliseconds. Bridges + * Whether the wallet lets an identity update disable [keyIds] of + * [identityId] — no security level loses its last valid authentication + * key (DIP-11) and an enabled TRANSFER key remains — judged at [nowMs], + * the device's wall clock in Unix milliseconds. Bridges * `platform_wallet_check_keys_can_be_disabled`. * - * @return `[allowed (0/1), refusedKeyId, refusedSecurityLevel]`; the last - * two are meaningful only when not allowed (DPP discriminant, 0 = - * MASTER). Throws [DashSDKException] when the wallet or the identity is - * not loaded. + * @return `[refusal, refusedKeyId, refusedSecurityLevel, refusedPurpose]`: + * `refusal` 0 = allowed, 1 = last valid authentication key of a + * security level, 2 = last enabled key of a purpose; level (1) and + * purpose (2) are DPP discriminants. Throws [DashSDKException] when the + * wallet or the identity is not loaded. */ external fun checkKeysCanBeDisabled( walletHandle: 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 81a88593606..81bfc3946c0 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 @@ -174,10 +174,26 @@ data class IdentityPubkey( } /** - * Why Rust refused disabling a set of keys under DIP-11: [keyId] is the key - * whose [securityLevel] would be left without a valid authentication key. + * Why the wallet refuses disabling a set of keys — Kotlin mirror of + * platform-wallet's `KeyDisableRefusal` and Swift's + * `ManagedPlatformWallet.KeyDisableRefusal`. */ -data class KeyDisableRefusal(val keyId: Int, val securityLevel: SecurityLevel) +sealed interface KeyDisableRefusal { + /** The key that cannot be disabled. */ + val keyId: Int + + /** DIP-11: [securityLevel] would be left without a valid authentication key. */ + data class LastAuthenticationKey( + override val keyId: Int, + val securityLevel: SecurityLevel, + ) : KeyDisableRefusal + + /** Wallet policy: [purpose] (TRANSFER) would be left without an enabled key. */ + data class LastEnabledKey( + override val keyId: Int, + val purpose: KeyPurpose, + ) : KeyDisableRefusal +} /** * Identity add/disable-keys bridge — port of the identity-update slice of @@ -245,9 +261,10 @@ class IdentityUpdates internal constructor( } /** - * Whether [keyIds] of [identityId] may be disabled under DIP-11, judged by - * Rust against the wallet's identity at [nowMs] (the device clock by - * default): null when they may, otherwise the [KeyDisableRefusal]. + * Whether the wallet lets an identity update disable [keyIds] of + * [identityId], judged by Rust against the wallet's identity at [nowMs] + * (the device clock by default): null when it does, otherwise the + * [KeyDisableRefusal]. * Bridges `platform_wallet_check_keys_can_be_disabled`; throws when the * wallet or the identity is not loaded. */ @@ -271,13 +288,18 @@ class IdentityUpdates internal constructor( nowMs, ) } - if (verdict[0] != 0L) { - null - } else { - KeyDisableRefusal( - keyId = verdict[1].toInt(), + val keyId = verdict[1].toInt() + when (verdict[0]) { + 0L -> null + 1L -> KeyDisableRefusal.LastAuthenticationKey( + keyId = keyId, securityLevel = SecurityLevel.entries.first { it.ffiValue.toLong() == verdict[2] }, ) + 2L -> KeyDisableRefusal.LastEnabledKey( + keyId = keyId, + purpose = KeyPurpose.entries.first { it.ffiValue.toLong() == verdict[3] }, + ) + else -> error("unknown key-disable refusal kind ${verdict[0]}") } } From 50bf8f77b4f3dee5b1a627140e66734e3f4086ed Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:16:27 +0000 Subject: [PATCH 13/21] refactor(kotlin-example-app): drop the local transfer-key guard from KeyDisableGate Port of the Swift change: the gate keeps only the already-disabled display state and renders the wallet's refusal. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../example/services/KeyDisableGate.kt | 78 ++++++++----------- .../example/ui/identity/KeyDetailScreen.kt | 22 ++---- packages/kotlin-sdk/PARITY.md | 2 +- 3 files changed, 37 insertions(+), 65 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt index ce236f8c3b7..7fe7cbe6757 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt @@ -9,20 +9,16 @@ import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity * Client-side safety gate for the Disable Key action — 1:1 port of * `KeyDisableGate.swift`. * - * A pre-flight UI guard. It decides nothing about DIP-11 itself: - * - **Last valid key of a security level (DIP-11):** Rust judges it - * (`IdentityUpdates.checkKeysCanBeDisabled`, dpp's - * `check_keys_can_be_disabled` at the device clock) and the caller hands - * the verdict in as [RuleVerdict]. The identity update builder refuses the - * same updates before signing, so the gate only spares the user a doomed - * attempt. - * - **Last transfer key (client-side self-brick guard):** not a protocol or - * DIP rule, and not in Rust — drive-abci would accept it — but disabling - * the last enabled transfer key would break credit withdrawals, so we - * refuse locally. + * A pre-flight UI guard that decides nothing itself. Which keys may be + * disabled is the wallet's call (`IdentityUpdates.checkKeysCanBeDisabled`: + * the last valid authentication key of a security level under DIP-11, and + * the last enabled transfer key), judged in Rust at the device clock and + * handed in as a [RuleVerdict]. The wallet's identity-update entry points + * refuse the same updates, so the gate only spares the user a doomed attempt + * and turns the structured refusal into copy. * - * Operates on Room [PublicKeyEntity] rows (the string-valued `purpose` and - * nullable `disabledAt` the persister writes). + * Operates on Room [PublicKeyEntity] rows (the nullable `disabledAt` the + * persister writes). */ object KeyDisableGate { @@ -38,12 +34,12 @@ object KeyDisableGate { data class Forbidden(val reason: String) : Evaluation } - /** Rust's DIP-11 verdict for disabling the target key alone. ← Swift `RuleVerdict`. */ + /** Rust's verdict for disabling the target key alone. ← Swift `RuleVerdict`. */ sealed interface RuleVerdict { - /** DIP-11 allows disabling the key. */ + /** The wallet allows disabling the key. */ data object Allowed : RuleVerdict - /** DIP-11 forbids it: [refusal] names the level left without a valid key. */ + /** The wallet refuses it, for the reason [refusal] names. */ data class Refused(val refusal: KeyDisableRefusal) : RuleVerdict /** @@ -54,7 +50,7 @@ object KeyDisableGate { } /** - * Ask Rust whether [keyId] of [identityId] may be disabled under DIP-11 + * Ask Rust whether the wallet lets [keyId] of [identityId] be disabled, * at the device clock. Without a loaded wallet, or when the check fails, * there is no verdict and the gate forbids the action. ← Swift * `ruleVerdict(wallet:identityId:keyId:)`. @@ -85,41 +81,29 @@ object KeyDisableGate { } /** - * Evaluate the disable gate for [target] within [allKeys] (the - * identity's current public keys), given Rust's [ruleVerdict] for - * disabling [target] alone. [target] must be one of [allKeys]. + * Evaluate the disable gate for [target], given Rust's [ruleVerdict] for + * disabling it alone. */ - fun evaluate( - target: PublicKeyEntity, - allKeys: List, - ruleVerdict: RuleVerdict, - ): Evaluation { + fun evaluate(target: PublicKeyEntity, ruleVerdict: RuleVerdict): Evaluation { if (target.disabledAt != null) return Evaluation.AlreadyDisabled - when (ruleVerdict) { - is RuleVerdict.Refused -> return Evaluation.Forbidden( - "No other valid authentication key at the " + - "${ruleVerdict.refusal.securityLevel.name} level would remain. Disabling " + - "this key would leave the identity unable to sign what needs that level — " + - "add another key first (Add Key).", - ) - is RuleVerdict.Unavailable -> return Evaluation.Forbidden(ruleVerdict.reason) - RuleVerdict.Allowed -> Unit + return when (ruleVerdict) { + RuleVerdict.Allowed -> Evaluation.Allowed + is RuleVerdict.Unavailable -> Evaluation.Forbidden(ruleVerdict.reason) + is RuleVerdict.Refused -> Evaluation.Forbidden(reason(ruleVerdict.refusal)) } + } - if (target.purpose.equals("TRANSFER", ignoreCase = true) && - enabledCount("TRANSFER", allKeys) <= 1 - ) { - return Evaluation.Forbidden( - "This is the only enabled transfer key. Disabling it would break credit " + - "withdrawals — add another transfer key first (Add Key).", - ) + /** User-facing copy for a [refusal]. */ + private fun reason(refusal: KeyDisableRefusal): String = when (refusal) { + is KeyDisableRefusal.LastAuthenticationKey -> + "No other valid authentication key at the ${refusal.securityLevel.name} level " + + "would remain. Disabling this key would leave the identity unable to sign " + + "what needs that level — add another key first (Add Key)." + is KeyDisableRefusal.LastEnabledKey -> { + val purpose = refusal.purpose.name.lowercase() + "This is the only enabled $purpose key. Disabling it would break credit " + + "withdrawals — add another $purpose key first (Add Key)." } - - return Evaluation.Allowed } - - /** Count of enabled (non-disabled) keys with the given [purpose]. */ - private fun enabledCount(purpose: String, keys: List): Int = - keys.count { it.purpose.equals(purpose, ignoreCase = true) && it.disabledAt == null } } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt index 6f6b96f044e..da832ae963a 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/KeyDetailScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController import kotlinx.coroutines.launch -import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity import org.dashfoundation.dashsdk.security.BiometricGate import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.services.IdentityKeys @@ -53,8 +52,8 @@ import org.dashfoundation.example.util.toHex * metadata + public key bytes, a biometric-gated private-key reveal (read * from [org.dashfoundation.dashsdk.security.WalletStorage]), and the Key * Status section with the gated destructive Disable Key action: the - * [KeyDisableGate] pre-flight (Rust's DIP-11 verdict, plus the last-transfer - * refusal) in front of `PlatformWalletManager.identityUpdates.disableKeys` — the + * [KeyDisableGate] pre-flight (the wallet's verdict from Rust: DIP-11 and the + * last transfer key) in front of `PlatformWalletManager.identityUpdates.disableKeys` — the * same `platform_wallet_update_identity_with_signer` call Swift's * `KeyDetailView.disableKey()` makes. */ @@ -91,7 +90,7 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon var showDisableConfirm by remember { mutableStateOf(false) } var isDisabling by remember { mutableStateOf(false) } - // Rust's DIP-11 verdict for disabling this key, re-asked whenever the key + // The wallet's verdict for disabling this key, re-asked whenever the key // set or the wallet changes. val ruleVerdict by produceState( initialValue = KeyDisableGate.RuleVerdict.Unavailable(CHECKING_DISABLE_RULE), @@ -208,8 +207,7 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon FormSection(title = "Key Status") { when (val evaluation = KeyDisableGate.evaluate( - target = k.normalizedForGate(), - allKeys = allKeys.map { it.normalizedForGate() }, + target = k, ruleVerdict = ruleVerdict, )) { KeyDisableGate.Evaluation.AlreadyDisabled -> { @@ -281,8 +279,7 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon // the view rendered. Never broadcast a doomed // transition (← Swift disableKey()). val gate = KeyDisableGate.evaluate( - target.normalizedForGate(), - allKeys.map { it.normalizedForGate() }, + target, KeyDisableGate.ruleVerdict( mgr.identityUpdates, w.handle, @@ -328,12 +325,3 @@ fun KeyDetailScreen(identityIdHex: String, keyId: Int, navController: NavHostCon } private const val CHECKING_DISABLE_RULE = "Checking whether this key may be disabled…" - -/** - * [KeyDisableGate] compares DPP enum *names*; Room rows may store the - * stringified rawValue. Normalize before evaluating. - */ -private fun PublicKeyEntity.normalizedForGate(): PublicKeyEntity = copy( - purpose = IdentityKeys.purposeName(purpose), - securityLevel = IdentityKeys.securityLevelName(securityLevel), -) diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index a21fe1a7d3e..c4196a8d67d 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -48,7 +48,7 @@ Status legend: | IdentitiesView.swift | ui/identity/IdentitiesHomeScreen.kt · `IdentitiesHome` | ported | | IdentityDetailView.swift | ui/identity/IdentityDetailScreen.kt · `IdentityDetail` | partial — Android reaches the compiled shared one-fetch `platform_wallet_sync_contested_dpns_names` + full cached `managed_identity_get_contested_dpns_names` JNI path, removes the eight-label probe cap, preserves loading/error/unavailable state with retry, and frees cached arrays through an RAII guard. Android identity persistence still drops contested snapshots on restore with no schema slot before reserved v8 invitations; allocation-failure/device and restart gates remain | | IdentityKeyAddition.swift | services/IdentityKeyAdditionFlow.kt | ported — derive → Keystore-persist → `IdentityPubkey` flow with the real keypair deriver injected (`PlatformWalletManager.deriveIdentityKeyPair`); slot assignment, Drive-combination validation, scalar scrubbing | -| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog (Rust's DIP-11 verdict via `IdentityUpdates.checkKeysCanBeDisabled` → `platform_wallet_check_keys_can_be_disabled`, plus the last-transfer-key guard), submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) | +| KeyDetailView.swift | ui/identity/KeyDetailScreen.kt · `KeyDetail` | ported — incl. the Key Status section: `KeyDisableGate`-guarded destructive Disable Key with confirm dialog (the wallet's verdict from Rust via `IdentityUpdates.checkKeysCanBeDisabled` → `platform_wallet_check_keys_can_be_disabled`: DIP-11 and the last transfer key), submitting through the bridged `IdentityUpdates.disableKeys` (`platform_wallet_update_identity_with_signer`) | | KeychainExplorerView.swift | ui/diagnostics/KeystoreExplorerScreen.kt · `KeystoreExplorer` | ported (adapted: WalletStorage entries masked + AndroidKeyStore aliases; adds biometric-gated mnemonic reveal) | | KeysListView.swift | ui/identity/KeysListScreen.kt · `KeysList` | ported — toolbar Add action opens AddIdentityKeyScreen (← the AddIdentityKeyView sheet) | | LoadIdentityView.swift | ui/identity/LoadIdentityScreen.kt · `LoadIdentity` | ported | From 75ebbaab471417daeae0633bd5d429a76d828a9d Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 10:22:53 +0000 Subject: [PATCH 14/21] refactor: judge key disables without a clock so the gate and the update always agree The host verdict took the device clock and counted an expiring authentication key until it expired, while the update paths and dpp's builder read no clock and count no key with an expiry: the button could offer a disable the update then refused. The wallet policy now takes no time at all and always calls the DIP-11 predicate with None; now_ms is gone from IdentityWallet::check_keys_can_be_disabled, the FFI, JNI and the Swift and Kotlin SDKs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../example/services/KeyDisableGate.kt | 10 +-- .../dashsdk/ffi/TransactionsNative.kt | 5 +- .../dashsdk/identity/IdentityUpdates.kt | 8 +-- .../src/identity_update.rs | 16 ++--- .../src/wallet/identity/network/update.rs | 63 ++++++++++--------- .../src/wallet/identity/state/key_disable.rs | 24 +++---- .../rs-unified-sdk-jni/src/transactions.rs | 10 +-- .../ManagedPlatformWallet.swift | 8 +-- .../Services/KeyDisableGate.swift | 8 +-- 9 files changed, 73 insertions(+), 79 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt index 7fe7cbe6757..7558306aa5e 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt @@ -12,8 +12,8 @@ import org.dashfoundation.dashsdk.persistence.entities.PublicKeyEntity * A pre-flight UI guard that decides nothing itself. Which keys may be * disabled is the wallet's call (`IdentityUpdates.checkKeysCanBeDisabled`: * the last valid authentication key of a security level under DIP-11, and - * the last enabled transfer key), judged in Rust at the device clock and - * handed in as a [RuleVerdict]. The wallet's identity-update entry points + * the last enabled transfer key), judged in Rust and handed in as a + * [RuleVerdict]. The wallet's identity-update entry points * refuse the same updates, so the gate only spares the user a doomed attempt * and turns the structured refusal into copy. * @@ -50,9 +50,9 @@ object KeyDisableGate { } /** - * Ask Rust whether the wallet lets [keyId] of [identityId] be disabled, - * at the device clock. Without a loaded wallet, or when the check fails, - * there is no verdict and the gate forbids the action. ← Swift + * Ask Rust whether the wallet lets [keyId] of [identityId] be disabled. + * Without a loaded wallet, or when the check fails, there is no verdict + * and the gate forbids the action. ← Swift * `ruleVerdict(wallet:identityId:keyId:)`. */ suspend fun ruleVerdict( 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 4eb36e70732..166d3a4bcb2 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 @@ -56,8 +56,8 @@ internal object TransactionsNative { /** * Whether the wallet lets an identity update disable [keyIds] of * [identityId] — no security level loses its last valid authentication - * key (DIP-11) and an enabled TRANSFER key remains — judged at [nowMs], - * the device's wall clock in Unix milliseconds. Bridges + * key (DIP-11) and an enabled TRANSFER key remains — with the same + * policy the identity update refuses with. Bridges * `platform_wallet_check_keys_can_be_disabled`. * * @return `[refusal, refusedKeyId, refusedSecurityLevel, refusedPurpose]`: @@ -70,7 +70,6 @@ internal object TransactionsNative { walletHandle: Long, identityId: ByteArray, keyIds: IntArray, - nowMs: Long, ): LongArray /** 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 81bfc3946c0..5ae6379932e 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 @@ -262,9 +262,9 @@ class IdentityUpdates internal constructor( /** * Whether the wallet lets an identity update disable [keyIds] of - * [identityId], judged by Rust against the wallet's identity at [nowMs] - * (the device clock by default): null when it does, otherwise the - * [KeyDisableRefusal]. + * [identityId], judged by Rust against the wallet's identity with the + * policy [update] refuses with, so the two always agree: null when it + * does, otherwise the [KeyDisableRefusal]. * Bridges `platform_wallet_check_keys_can_be_disabled`; throws when the * wallet or the identity is not loaded. */ @@ -272,7 +272,6 @@ class IdentityUpdates internal constructor( walletHandle: Long, identityId: ByteArray, keyIds: List, - nowMs: Long = System.currentTimeMillis(), ): KeyDisableRefusal? = gate.op { require(identityId.size == 32) { "identityId must be 32 bytes, got ${identityId.size}" @@ -285,7 +284,6 @@ class IdentityUpdates internal constructor( walletHandle, identityId, keyIds.toIntArray(), - nowMs, ) } val keyId = verdict[1].toInt() diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index babccfbc384..273ce972ef5 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -426,21 +426,20 @@ impl From> for KeyDisableCheckFFI { /// Whether the wallet lets an identity update disable /// `disable_public_key_ids` of its identity `identity_id`: no security level /// loses its last valid authentication key (DIP-11) and the identity keeps an -/// enabled TRANSFER key. `now_ms` is the host's wall clock in Unix -/// milliseconds, so an authentication key with an expiry counts until it -/// expires. +/// enabled TRANSFER key. /// /// Marshals `IdentityWallet::check_keys_can_be_disabled`, which judges the /// identity [`platform_wallet_update_identity_with_signer`] builds the update -/// from with the policy that entry point refuses with. A refusal is a verdict, written to `out_check` with a success result; -/// an error result means the wallet handle or the identity is unknown. +/// from with the policy that entry point refuses with, so the two always +/// agree. A refusal is a verdict, written to `out_check` with a success +/// result; an error result means the wallet handle or the identity is +/// unknown. #[no_mangle] pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( wallet_handle: Handle, identity_id: *const u8, disable_public_key_ids: *const u32, disable_public_key_ids_count: usize, - now_ms: u64, out_check: *mut KeyDisableCheckFFI, ) -> PlatformWalletFFIResult { check_ptr!(out_check); @@ -457,7 +456,7 @@ pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( let identity_wallet = wallet.identity().clone(); block_on_worker(async move { identity_wallet - .check_keys_can_be_disabled(&id, &disable_ids, now_ms) + .check_keys_can_be_disabled(&id, &disable_ids) .await }) }); @@ -820,7 +819,6 @@ mod key_disable_check_tests { use std::sync::Arc; const IDENTITY_ID: [u8; 32] = [1; 32]; - const NOW_MS: u64 = 1_800_000_000_000; /// MASTER 0, CRITICAL 1, HIGH 2 authentication keys and TRANSFER 3. fn identity() -> Identity { @@ -865,7 +863,6 @@ mod key_disable_check_tests { identity_id.as_ptr(), key_ids.as_ptr(), key_ids.len(), - NOW_MS, &mut out, ); (result, out) @@ -935,7 +932,6 @@ mod key_disable_check_tests { IDENTITY_ID.as_ptr(), ptr::null(), 0, - NOW_MS, ptr::null_mut(), ) }; 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 512b419d5a2..a6ef8ced376 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -13,7 +13,7 @@ use dpp::identity::KeyType; use dpp::identity::Purpose; use dpp::identity::SecurityLevel; use dpp::platform_value::BinaryData; -use dpp::prelude::{Identifier, TimestampMillis}; +use dpp::prelude::Identifier; use dpp::ProtocolError; use dpp::identity::signer::Signer; @@ -71,11 +71,8 @@ impl IdentityWallet { /// Judges the identity this wallet holds, which is the one /// [`Self::update_identity_with_external_signer`] builds the update from, /// with the same [`check_keys_can_be_disabled`] the update entry points - /// refuse with, at `now_ms`, the caller's wall clock in Unix - /// milliseconds. The entry points and dpp's builder have no clock and - /// count no authentication key with an expiry, so where the only stand-in - /// key has an expiry this allows a disable the update then refuses; it - /// never refuses one the update would let through. + /// refuse with, so the two always agree. Like them it reads no clock: an + /// authentication key with any expiry does not count as remaining. /// /// The outer `Err` is a wallet failure: the wallet or the identity is not /// loaded. @@ -83,7 +80,6 @@ impl IdentityWallet { &self, identity_id: &Identifier, disable_public_keys: &[KeyID], - now_ms: TimestampMillis, ) -> Result, PlatformWalletError> { let wm = self.wallet_manager.read().await; let info = wm.get_wallet_info(&self.wallet_id).ok_or_else(|| { @@ -99,7 +95,6 @@ impl IdentityWallet { &managed.identity, &[], disable_public_keys, - Some(now_ms), )) } @@ -181,8 +176,8 @@ impl IdentityWallet { }; // Refuse a disable this wallet does not allow before fetching a nonce - // or signing anything. No clock, like the builder. - check_keys_can_be_disabled(&identity, &add_public_keys, &disable_public_keys, None)?; + // or signing anything. + check_keys_can_be_disabled(&identity, &add_public_keys, &disable_public_keys)?; // Increment revision for the update transition. let original_revision = identity.revision(); @@ -362,7 +357,7 @@ impl IdentityWallet { use dpp::state_transition::identity_update_transition::methods::IdentityUpdateTransitionMethodsV0; use dpp::state_transition::identity_update_transition::IdentityUpdateTransition; - check_keys_can_be_disabled(identity, &add_public_keys, &disable_public_keys, None)?; + check_keys_can_be_disabled(identity, &add_public_keys, &disable_public_keys)?; // Get identity nonce from Platform. let identity_nonce = self @@ -549,8 +544,6 @@ mod tests { (manager, wallet) } - const NOW_MS: TimestampMillis = 1_800_000_000_000; - #[tokio::test] async fn should_judge_a_disable_against_the_managed_identity() { let (_manager, wallet) = manager_holding(identity()).await; @@ -558,7 +551,7 @@ mod tests { let refused = wallet .identity() - .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS) + .check_keys_can_be_disabled(&identity_id, &[1]) .await .expect("the identity is loaded"); assert_eq!( @@ -574,7 +567,7 @@ mod tests { assert_eq!( wallet .identity() - .check_keys_can_be_disabled(&identity_id, &[2], NOW_MS) + .check_keys_can_be_disabled(&identity_id, &[2]) .await .expect("the identity is loaded"), Ok(()) @@ -582,8 +575,9 @@ mod tests { } #[tokio::test] - async fn should_count_an_unexpired_key_with_the_callers_clock() { - // The builder counts no key with an expiry; a caller with a clock does. + async fn should_not_count_an_expiring_key_like_the_builder() { + // The builder reads no clock and counts no key with an expiry, so + // the verdict does not either: the two always agree. let mut identity = identity(); identity.add_public_key( IdentityPublicKey::V0(IdentityPublicKeyV0 { @@ -596,24 +590,37 @@ mod tests { data: BinaryData::new(vec![5; 33]), disabled_at: None, }) - .with_limits(None, Some(NOW_MS + 1)), + .with_limits(None, Some(u64::MAX)), ); - let (_manager, wallet) = manager_holding(identity).await; + let (_manager, wallet) = manager_holding(identity.clone()).await; let identity_id = Identifier::from([1; 32]); assert_eq!( wallet .identity() - .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS) + .check_keys_can_be_disabled(&identity_id, &[1]) .await .expect("the identity is loaded"), - Ok(()) + Err(KeyDisableRefusal::LastAuthenticationKey( + LastAuthenticationKeyError { + key_id: 1, + security_level: SecurityLevel::CRITICAL, + } + )) ); - assert!(wallet + // The update path refuses the same disable. + let result = wallet .identity() - .check_keys_can_be_disabled(&identity_id, &[1], NOW_MS + 1) - .await - .expect("the identity is loaded") - .is_err()); + .update_identity_with_signer(&identity, &0, vec![], vec![1], &HoldsEveryKey, None) + .await; + assert!( + matches!( + result, + Err(PlatformWalletError::Sdk(dash_sdk::Error::Protocol( + ProtocolError::LastAuthenticationKey(_) + ))) + ), + "{result:?}" + ); } #[tokio::test] @@ -622,7 +629,7 @@ mod tests { let unknown = Identifier::from([9; 32]); let result = wallet .identity() - .check_keys_can_be_disabled(&unknown, &[1], NOW_MS) + .check_keys_can_be_disabled(&unknown, &[1]) .await; assert!( matches!(result, Err(PlatformWalletError::IdentityNotFound(id)) if id == unknown), @@ -673,7 +680,7 @@ mod tests { assert_eq!( wallet .identity() - .check_keys_can_be_disabled(&Identifier::from([1; 32]), &[3], NOW_MS) + .check_keys_can_be_disabled(&Identifier::from([1; 32]), &[3]) .await .expect("the identity is loaded"), Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs index 0a76a8590f1..8aa07ea847d 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs @@ -19,7 +19,6 @@ use dpp::identity::key_disable_rule::{ check_keys_can_be_disabled as dpp_check_keys_can_be_disabled, LastAuthenticationKeyError, }; use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose}; -use dpp::prelude::TimestampMillis; /// Disabling `key_id` would leave the identity without an enabled key of /// `purpose`. Wallet policy, not a DIP rule or consensus: Platform accepts @@ -50,16 +49,19 @@ const PURPOSES_KEPT_ENABLED: [Purpose; 1] = [Purpose::TRANSFER]; /// Whether an identity update that adds `add_public_keys` and disables /// `disable_public_keys` is one this wallet lets through: DIP-11 first (see -/// [`dpp_check_keys_can_be_disabled`] for `now_ms` and what counts as a valid +/// [`dpp_check_keys_can_be_disabled`] for what counts as a valid /// authentication key), then the last enabled TRANSFER key. A key added in /// the same update counts as remaining for both. +/// +/// No clock, exactly like dpp's identity update builder: an authentication +/// key with any expiry does not count as remaining. So the answer hosts are +/// shown and the one the update gets are always the same. pub fn check_keys_can_be_disabled( identity: &Identity, add_public_keys: &[IdentityPublicKey], disable_public_keys: &[KeyID], - now_ms: Option, ) -> Result<(), KeyDisableRefusal> { - dpp_check_keys_can_be_disabled(identity, add_public_keys, disable_public_keys, now_ms)?; + dpp_check_keys_can_be_disabled(identity, add_public_keys, disable_public_keys, None)?; for key_id in disable_public_keys { let Some(key) = identity.public_keys().get(key_id) else { @@ -130,7 +132,7 @@ mod tests { #[test] fn should_refuse_disabling_the_last_enabled_transfer_key() { assert_eq!( - check_keys_can_be_disabled(&identity_with(vec![]), &[], &[3], None), + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[3]), Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { key_id: 3, purpose: Purpose::TRANSFER, @@ -142,12 +144,12 @@ mod tests { fn should_allow_disabling_a_transfer_key_another_enabled_one_replaces() { let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); assert_eq!( - check_keys_can_be_disabled(&identity_with(vec![second.clone()]), &[], &[3], None), + check_keys_can_be_disabled(&identity_with(vec![second.clone()]), &[], &[3]), Ok(()) ); // A transfer key added in the same update counts. assert_eq!( - check_keys_can_be_disabled(&identity_with(vec![]), &[second], &[3], None), + check_keys_can_be_disabled(&identity_with(vec![]), &[second], &[3]), Ok(()) ); } @@ -157,13 +159,13 @@ mod tests { let mut disabled = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); disabled.set_disabled_at(1_700_000_000_000); assert!(matches!( - check_keys_can_be_disabled(&identity_with(vec![disabled]), &[], &[3], None), + check_keys_can_be_disabled(&identity_with(vec![disabled]), &[], &[3]), Err(KeyDisableRefusal::LastEnabledKey(_)) )); // Disabling both transfer keys at once leaves none. let second = key(4, Purpose::TRANSFER, SecurityLevel::CRITICAL); assert!(matches!( - check_keys_can_be_disabled(&identity_with(vec![second]), &[], &[3, 4], None), + check_keys_can_be_disabled(&identity_with(vec![second]), &[], &[3, 4]), Err(KeyDisableRefusal::LastEnabledKey(_)) )); } @@ -171,7 +173,7 @@ mod tests { #[test] fn should_report_the_dip11_refusal_for_authentication_keys() { assert_eq!( - check_keys_can_be_disabled(&identity_with(vec![]), &[], &[1], None), + check_keys_can_be_disabled(&identity_with(vec![]), &[], &[1]), Err(KeyDisableRefusal::LastAuthenticationKey( LastAuthenticationKeyError { key_id: 1, @@ -185,7 +187,7 @@ mod tests { fn should_allow_disabling_keys_of_other_purposes() { let encryption = key(5, Purpose::ENCRYPTION, SecurityLevel::MEDIUM); assert_eq!( - check_keys_can_be_disabled(&identity_with(vec![encryption]), &[], &[5], None), + check_keys_can_be_disabled(&identity_with(vec![encryption]), &[], &[5]), Ok(()) ); } diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 4ec600fd1f4..14262f3e840 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -266,8 +266,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up /// Whether the wallet lets an identity update disable `keyIds` of its /// identity — no security level loses its last valid authentication key -/// (DIP-11) and an enabled TRANSFER key remains — judged at `nowMs`, the -/// host's wall clock in Unix milliseconds. Thin marshaler over +/// (DIP-11) and an enabled TRANSFER key remains — with the same policy the +/// identity update refuses with. Thin marshaler over /// `platform_wallet_check_keys_can_be_disabled`. /// /// Returns a `long[4]`: `[refusal, refusedKeyId, refusedSecurityLevel, @@ -282,7 +282,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ch wallet_handle: jlong, identity_id: JByteArray, key_ids: JIntArray, - now_ms: jlong, ) -> jlongArray { guard(&mut env, ptr::null_mut(), |env| { let Some(id) = read_id32(env, &identity_id, "identityId") else { @@ -291,10 +290,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ch let Some(key_ids) = read_key_ids(env, &key_ids) else { return ptr::null_mut(); }; - if now_ms < 0 { - throw_sdk_exception(env, 1, "nowMs must be non-negative"); - return ptr::null_mut(); - } let mut check = KeyDisableCheckFFI::default(); let result = unsafe { platform_wallet_ffi::platform_wallet_check_keys_can_be_disabled( @@ -306,7 +301,6 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ch key_ids.as_ptr() }, key_ids.len(), - now_ms as u64, &mut check, ) }; diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 3df37792345..73b057c66e5 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3804,21 +3804,20 @@ extension ManagedPlatformWallet { /// Whether the wallet lets an identity update disable `keyIds` of /// `identityId` — no security level loses its last valid /// authentication key (DIP-11) and an enabled TRANSFER key remains — - /// judged by Rust against this wallet's identity at `now`: `nil` when + /// judged by Rust against this wallet's identity with the policy + /// `updateIdentity` refuses with, so the two always agree: `nil` when /// it does, otherwise the refusal. Marshals /// `platform_wallet_check_keys_can_be_disabled`; throws when the /// identity is not loaded in this wallet. public func checkKeysCanBeDisabled( identityId: Identifier, - keyIds: [UInt32], - now: Date = Date() + keyIds: [UInt32] ) async throws -> KeyDisableRefusal? { let handle = self.handle let idBytes: [UInt8] = identityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) } let ids = keyIds - let nowMs = UInt64(max(0, now.timeIntervalSince1970 * 1000)) return try await Task.detached(priority: .userInitiated) { var check = KeyDisableCheckFFI() let result = idBytes.withUnsafeBufferPointer { idBp in @@ -3828,7 +3827,6 @@ extension ManagedPlatformWallet { idBp.baseAddress!, ids.isEmpty ? nil : idsBp.baseAddress, UInt(ids.count), - nowMs, &check ) } diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift index 15ac2a47570..94f89284ca0 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift @@ -6,8 +6,8 @@ import SwiftDashSDK /// A pre-flight UI guard that decides nothing itself. Which keys may be /// disabled is the wallet's call (`ManagedPlatformWallet.checkKeysCanBeDisabled`: /// the last valid authentication key of a security level under DIP-11, -/// and the last enabled transfer key), judged in Rust at the device clock -/// and handed in as a `RuleVerdict`. The wallet's identity-update entry +/// and the last enabled transfer key), judged in Rust and handed in as a +/// `RuleVerdict`. The wallet's identity-update entry /// points refuse the same updates, so the gate only spares the user a /// doomed attempt and turns the structured refusal into copy. enum KeyDisableGate { @@ -33,8 +33,8 @@ enum KeyDisableGate { } /// Ask Rust whether the wallet lets `keyId` of `identityId` be - /// disabled, at the device clock. Without a loaded wallet, or when - /// the check fails, there is no verdict and the gate forbids the action. + /// disabled. Without a loaded wallet, or when the check fails, there is + /// no verdict and the gate forbids the action. static func ruleVerdict( wallet: ManagedPlatformWallet?, identityId: Identifier, From 2346e3c28eab72f69c8e28e69634cb48a81576ce Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:13:21 +0000 Subject: [PATCH 15/21] fix(swift-example-app): unwrap the optional wallet id before the wallet lookup `identity.wallet?.walletId.flatMap` called Data's collection flatMap over bytes inside the optional chain and did not type-check. Parenthesise the optional chain so flatMap applies to Optional. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift | 2 +- .../SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index 8a6dcd899a0..ba86f84a47f 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -44,7 +44,7 @@ struct KeyDetailView: View { /// The loaded wallet this identity belongs to, if any. private var identityWallet: ManagedPlatformWallet? { - identity.wallet?.walletId.flatMap { walletManager.wallet(for: $0) } + (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } } /// Changes whenever a key is added or disabled, so the verdict is diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index 72d830b5ac5..a4faab7e21c 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -33,7 +33,7 @@ struct KeysListView: View { /// The loaded wallet this identity belongs to, if any. private var identityWallet: ManagedPlatformWallet? { - identity.wallet?.walletId.flatMap { walletManager.wallet(for: $0) } + (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } } /// Changes whenever a key is added or disabled, so the verdicts are From 18cfb774b2a2a471f1642269fd7a088a45407f3b Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:28 +0000 Subject: [PATCH 16/21] fix(platform-wallet): count only TRANSFER keys able to sign in the last-key guard A TRANSFER key of a type identity-signed validation refuses, or below CRITICAL, cannot sign a withdrawal or transfer, so it no longer stands in for the last one that can, and disabling it is no longer refused. Key types come from dpp's can_sign_state_transitions, so HASH160 keys keep counting. The refusal text and the verdict docs now say so, and state that the verdict covers a disable-only update: it counts no added key. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../example/services/KeyDisableGate.kt | 5 +- .../dashsdk/ffi/TransactionsNative.kt | 2 +- packages/rs-platform-wallet/src/error.rs | 4 +- .../src/wallet/identity/network/update.rs | 21 +-- .../src/wallet/identity/state/key_disable.rs | 133 ++++++++++++++++-- .../ManagedPlatformWallet.swift | 19 +-- .../Services/KeyDisableGate.swift | 2 +- 7 files changed, 148 insertions(+), 38 deletions(-) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt index 7558306aa5e..df8f60a10dc 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/services/KeyDisableGate.kt @@ -102,8 +102,9 @@ object KeyDisableGate { "what needs that level — add another key first (Add Key)." is KeyDisableRefusal.LastEnabledKey -> { val purpose = refusal.purpose.name.lowercase() - "This is the only enabled $purpose key. Disabling it would break credit " + - "withdrawals — add another $purpose key first (Add Key)." + "No other enabled $purpose key able to sign would remain. Disabling this key " + + "would break credit withdrawals and transfers — add another $purpose key " + + "first (Add Key)." } } } 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 166d3a4bcb2..d800738d5fb 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 @@ -56,7 +56,7 @@ internal object TransactionsNative { /** * Whether the wallet lets an identity update disable [keyIds] of * [identityId] — no security level loses its last valid authentication - * key (DIP-11) and an enabled TRANSFER key remains — with the same + * key (DIP-11) and an enabled TRANSFER key able to sign remains — with the same * policy the identity update refuses with. Bridges * `platform_wallet_check_keys_can_be_disabled`. * diff --git a/packages/rs-platform-wallet/src/error.rs b/packages/rs-platform-wallet/src/error.rs index 682d02384fd..4a30692b04f 100644 --- a/packages/rs-platform-wallet/src/error.rs +++ b/packages/rs-platform-wallet/src/error.rs @@ -704,8 +704,8 @@ pub enum PlatformWalletError { #[error("Invalid parameter: {0}")] InvalidParameter(String), - /// An identity update would disable the last enabled key of a purpose - /// the wallet keeps one of (TRANSFER), refused before anything is + /// An identity update would disable the last enabled key able to sign of + /// a purpose the wallet keeps one of (TRANSFER), refused before anything is /// fetched or signed. Wallet policy, not consensus; the DIP-11 /// last-authentication-key refusal travels as /// `Sdk(Protocol(LastAuthenticationKey))` instead. FFI maps it to 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 a6ef8ced376..00e3cb4fc10 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/network/update.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/network/update.rs @@ -63,16 +63,19 @@ where // --------------------------------------------------------------------------- impl IdentityWallet { - /// Whether this wallet lets an identity update disable - /// `disable_public_keys` of the managed identity `identity_id`: the inner - /// `Err` is the [`KeyDisableRefusal`] — the last valid authentication key - /// of a security level (DIP-11), or the last enabled TRANSFER key. + /// Whether this wallet lets an identity update that only disables + /// `disable_public_keys` of the managed identity `identity_id` through: + /// the inner `Err` is the [`KeyDisableRefusal`] — the last valid + /// authentication key of a security level (DIP-11), or the last enabled + /// TRANSFER key able to sign. /// /// Judges the identity this wallet holds, which is the one /// [`Self::update_identity_with_external_signer`] builds the update from, /// with the same [`check_keys_can_be_disabled`] the update entry points - /// refuse with, so the two always agree. Like them it reads no clock: an - /// authentication key with any expiry does not count as remaining. + /// refuse with. No added key counts here, so an update can go through + /// where this refuses when it also adds a replacement key. Like the entry + /// points it reads no clock: an authentication key with any expiry does + /// not count as remaining. /// /// The outer `Err` is a wallet failure: the wallet or the identity is not /// loaded. @@ -112,7 +115,7 @@ impl IdentityWallet { /// nonce is fetched or anything is signed: disabling the last valid /// authentication key of a security level with /// `PlatformWalletError::Sdk(dash_sdk::Error::Protocol(ProtocolError::LastAuthenticationKey(_)))` - /// (DIP-11), the last enabled TRANSFER key with + /// (DIP-11), the last enabled TRANSFER key able to sign with /// `PlatformWalletError::LastEnabledKey(_)`. Keys added in the same update /// count as remaining. /// @@ -576,8 +579,8 @@ mod tests { #[tokio::test] async fn should_not_count_an_expiring_key_like_the_builder() { - // The builder reads no clock and counts no key with an expiry, so - // the verdict does not either: the two always agree. + // The builder reads no clock and counts no key with an expiry, and + // neither does the verdict: for a disable-only update the two agree. let mut identity = identity(); identity.add_public_key( IdentityPublicKey::V0(IdentityPublicKeyV0 { diff --git a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs index 8aa07ea847d..8df057fb582 100644 --- a/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs +++ b/packages/rs-platform-wallet/src/wallet/identity/state/key_disable.rs @@ -4,9 +4,9 @@ //! //! - **DIP-11** — the last valid authentication key of a security level, //! judged by dpp's [`dpp_check_keys_can_be_disabled`]. -//! - **Last enabled TRANSFER key** — wallet policy, not a DIP rule: without -//! one the identity can no longer withdraw or transfer credits until a new -//! transfer key is added. +//! - **Last enabled TRANSFER key able to sign** — wallet policy, not a DIP +//! rule: without one the identity can no longer withdraw or transfer credits +//! until a new transfer key is added. //! //! [`check_keys_can_be_disabled`] is the single place both are decided. The //! identity-update entry points refuse with it before fetching a nonce, and @@ -18,17 +18,20 @@ use dpp::identity::identity_public_key::accessors::v0::IdentityPublicKeyGettersV use dpp::identity::key_disable_rule::{ check_keys_can_be_disabled as dpp_check_keys_can_be_disabled, LastAuthenticationKeyError, }; -use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose}; +use dpp::identity::{Identity, IdentityPublicKey, KeyID, Purpose, SecurityLevel}; /// Disabling `key_id` would leave the identity without an enabled key of -/// `purpose`. Wallet policy, not a DIP rule or consensus: Platform accepts -/// the update. +/// `purpose` able to sign. Wallet policy, not a DIP rule or consensus: +/// Platform accepts the update. #[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] -#[error("cannot disable {purpose} key {key_id}: no other enabled {purpose} key would remain")] +#[error( + "cannot disable {purpose} key {key_id}: no other enabled {purpose} key able to sign \ + withdrawals and transfers would remain" +)] pub struct LastEnabledKeyError { /// The key the update would disable. pub key_id: KeyID, - /// The purpose that would be left without an enabled key. + /// The purpose that would be left without an enabled key able to sign. pub purpose: Purpose, } @@ -47,15 +50,24 @@ pub enum KeyDisableRefusal { /// authorizes credit withdrawals and transfers. const PURPOSES_KEPT_ENABLED: [Purpose; 1] = [Purpose::TRANSFER]; +/// Whether identity-signed validation lets `key` sign a credit withdrawal or +/// transfer, both of which require a CRITICAL key. `ECDSA_HASH160` keys, which +/// identities created by older Dash Evo Tool versions carry, can. +fn can_sign_withdrawals_and_transfers(key: &IdentityPublicKey) -> bool { + key.security_level() == SecurityLevel::CRITICAL && key.key_type().can_sign_state_transitions() +} + /// Whether an identity update that adds `add_public_keys` and disables /// `disable_public_keys` is one this wallet lets through: DIP-11 first (see /// [`dpp_check_keys_can_be_disabled`] for what counts as a valid -/// authentication key), then the last enabled TRANSFER key. A key added in -/// the same update counts as remaining for both. +/// authentication key), then the last enabled TRANSFER key able to sign. A +/// TRANSFER key that cannot sign neither counts as remaining nor is protected: +/// disabling it costs no ability. A key added in the same update counts as +/// remaining for both. /// /// No clock, exactly like dpp's identity update builder: an authentication -/// key with any expiry does not count as remaining. So the answer hosts are -/// shown and the one the update gets are always the same. +/// key with any expiry does not count as remaining. So for the same keys, the +/// answer hosts are shown and the one the update gets are the same. pub fn check_keys_can_be_disabled( identity: &Identity, add_public_keys: &[IdentityPublicKey], @@ -68,7 +80,10 @@ pub fn check_keys_can_be_disabled( continue; }; let purpose = key.purpose(); - if !PURPOSES_KEPT_ENABLED.contains(&purpose) || key.disabled_at().is_some() { + if !PURPOSES_KEPT_ENABLED.contains(&purpose) + || key.disabled_at().is_some() + || !can_sign_withdrawals_and_transfers(key) + { continue; } let another_remains = identity @@ -76,7 +91,11 @@ pub fn check_keys_can_be_disabled( .values() .filter(|remaining| !disable_public_keys.contains(&remaining.id())) .chain(add_public_keys) - .any(|remaining| remaining.purpose() == purpose && remaining.disabled_at().is_none()); + .any(|remaining| { + remaining.purpose() == purpose + && remaining.disabled_at().is_none() + && can_sign_withdrawals_and_transfers(remaining) + }); if !another_remains { return Err(LastEnabledKeyError { key_id: *key_id, @@ -170,6 +189,92 @@ mod tests { )); } + fn transfer_key(id: KeyID, key_type: KeyType, level: SecurityLevel) -> IdentityPublicKey { + let data = match key_type { + KeyType::ECDSA_SECP256K1 => vec![id as u8 + 2; 33], + KeyType::BLS12_381 => vec![id as u8 + 2; 48], + _ => vec![id as u8 + 2; 20], + }; + IdentityPublicKey::V0(IdentityPublicKeyV0 { + id, + purpose: Purpose::TRANSFER, + security_level: level, + contract_bounds: None, + key_type, + read_only: false, + data: BinaryData::new(data), + disabled_at: None, + }) + } + + #[test] + fn should_not_count_a_transfer_key_that_cannot_sign_as_remaining() { + for key_type in [KeyType::BIP13_SCRIPT_HASH, KeyType::EDDSA_25519_HASH160] { + let unusable = transfer_key(4, key_type, SecurityLevel::CRITICAL); + let refused = Err(KeyDisableRefusal::LastEnabledKey(LastEnabledKeyError { + key_id: 3, + purpose: Purpose::TRANSFER, + })); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![unusable.clone()]), &[], &[3]), + refused, + "{key_type:?} held by the identity" + ); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![]), &[unusable], &[3]), + refused, + "{key_type:?} added in the same update" + ); + } + } + + #[test] + fn should_not_count_a_transfer_key_below_critical_as_remaining() { + let high = transfer_key(4, KeyType::ECDSA_SECP256K1, SecurityLevel::HIGH); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![high]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + #[test] + fn should_count_a_hash160_transfer_key_as_remaining() { + let hash160 = transfer_key(4, KeyType::ECDSA_HASH160, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![hash160.clone()]), &[], &[3]), + Ok(()) + ); + // And the last one is protected like any other that can sign. + let only_hash160 = transfer_key(3, KeyType::ECDSA_HASH160, SecurityLevel::CRITICAL); + assert!(matches!( + check_keys_can_be_disabled(&identity_with(vec![only_hash160]), &[], &[3]), + Err(KeyDisableRefusal::LastEnabledKey(_)) + )); + } + + #[test] + fn should_allow_disabling_a_transfer_key_that_cannot_sign() { + // Key id 3 replaces the signing TRANSFER key of `identity_with`, so this + // is the identity's only TRANSFER key and disabling it costs no ability. + let only_unusable = transfer_key(3, KeyType::BIP13_SCRIPT_HASH, SecurityLevel::CRITICAL); + assert_eq!( + check_keys_can_be_disabled(&identity_with(vec![only_unusable]), &[], &[3]), + Ok(()) + ); + } + + #[test] + fn should_say_the_remaining_transfer_key_must_be_able_to_sign() { + let unusable = transfer_key(4, KeyType::BIP13_SCRIPT_HASH, SecurityLevel::CRITICAL); + let refusal = check_keys_can_be_disabled(&identity_with(vec![unusable]), &[], &[3]) + .expect_err("an unusable key does not replace the last one that can sign"); + assert_eq!( + refusal.to_string(), + "cannot disable TRANSFER key 3: no other enabled TRANSFER key able to sign \ + withdrawals and transfers would remain" + ); + } + #[test] fn should_report_the_dip11_refusal_for_authentication_keys() { assert_eq!( diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 73b057c66e5..65f4d6d1f00 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3789,7 +3789,7 @@ extension ManagedPlatformWallet { /// authentication key. case lastAuthenticationKey(keyId: UInt32, securityLevel: SecurityLevel) /// Wallet policy: `purpose` (TRANSFER) would be left without an - /// enabled key. + /// enabled key able to sign. case lastEnabledKey(keyId: UInt32, purpose: KeyPurpose) /// The key that cannot be disabled. @@ -3801,14 +3801,15 @@ extension ManagedPlatformWallet { } } - /// Whether the wallet lets an identity update disable `keyIds` of - /// `identityId` — no security level loses its last valid - /// authentication key (DIP-11) and an enabled TRANSFER key remains — - /// judged by Rust against this wallet's identity with the policy - /// `updateIdentity` refuses with, so the two always agree: `nil` when - /// it does, otherwise the refusal. Marshals - /// `platform_wallet_check_keys_can_be_disabled`; throws when the - /// identity is not loaded in this wallet. + /// Whether the wallet lets an identity update that only disables + /// `keyIds` of `identityId` through — no security level loses its last + /// valid authentication key (DIP-11) and an enabled TRANSFER key able to + /// sign remains — judged by Rust against this wallet's identity with the + /// policy `updateIdentity` refuses with: `nil` when it does, otherwise + /// the refusal. No added key counts here, so `updateIdentity` can accept + /// a disable this refuses when the same update adds a replacement key. + /// Marshals `platform_wallet_check_keys_can_be_disabled`; throws when + /// the identity is not loaded in this wallet. public func checkKeysCanBeDisabled( identityId: Identifier, keyIds: [UInt32] diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift index 94f89284ca0..26e497fbb5e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Services/KeyDisableGate.swift @@ -86,7 +86,7 @@ enum KeyDisableGate { return "No other valid authentication key at the \(securityLevel.name) level would remain. Disabling this key would leave the identity unable to sign what needs that level — add another key first (Add Key)." case .lastEnabledKey(_, let purpose): let name = purpose.name.lowercased() - return "This is the only enabled \(name) key. Disabling it would break credit withdrawals — add another \(name) key first (Add Key)." + return "No other enabled \(name) key able to sign would remain. Disabling this key would break credit withdrawals and transfers — add another \(name) key first (Add Key)." } } } From 1141b707025baf040a2131d69de66ef8b96279e3 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:28 +0000 Subject: [PATCH 17/21] fix(unified-sdk-jni): throw when the key ids of a disable cannot be read A failed read used to become an empty list, so the gate could answer allowed and updateIdentity could drop its disables. Only a successful zero-length read now means no keys. Covered through both exports with a scripted JNI table, since no test host has a JVM. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../rs-unified-sdk-jni/src/transactions.rs | 335 ++++++++++++++++-- 1 file changed, 309 insertions(+), 26 deletions(-) diff --git a/packages/rs-unified-sdk-jni/src/transactions.rs b/packages/rs-unified-sdk-jni/src/transactions.rs index 14262f3e840..6c09bb79dad 100644 --- a/packages/rs-unified-sdk-jni/src/transactions.rs +++ b/packages/rs-unified-sdk-jni/src/transactions.rs @@ -135,32 +135,28 @@ fn read_cstring(env: &mut JNIEnv, s: &JString, field: &str) -> Option { // ── Identity update (add / disable keys) ────────────────────────────── -/// Read key ids from a JVM `int[]` into a `Vec`. A null or empty array -/// is a legitimate "no keys"; jni 0.21 has no `convert_int_array`, so read the -/// length then the region into an owned buffer. Throws + returns `None` on a -/// negative id, which would otherwise bit-cast to a bogus huge `u32`. +/// Read key ids from a JVM `int[]` into a `Vec`. Only a successful read +/// of an empty array means "no keys"; jni 0.21 has no `convert_int_array`, so +/// read the length then the region into an owned buffer. Throws + returns +/// `None` on a null array or a failed read, and on a negative id, which would +/// otherwise bit-cast to a bogus huge `u32`. fn read_key_ids(env: &mut JNIEnv, key_ids: &JIntArray) -> Option> { - if key_ids.is_null() { - return Some(Vec::new()); - } - match env.get_array_length(key_ids) { - Ok(len) if len > 0 => { - let mut buf = vec![0i32; len as usize]; - if env.get_int_array_region(key_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()) - } + let read = (|| { + let len = usize::try_from(env.get_array_length(key_ids).ok()?).ok()?; + let mut buf = vec![0i32; len]; + env.get_int_array_region(key_ids, 0, &mut buf).ok()?; + Some(buf) + })(); + let Some(buf) = read else { + let _ = env.exception_clear(); + throw_sdk_exception(env, 1, "keyIds int[] could not be read"); + return None; + }; + 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()) } /// Add public keys and/or disable existing key ids on an identity, signing @@ -266,8 +262,8 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_up /// Whether the wallet lets an identity update disable `keyIds` of its /// identity — no security level loses its last valid authentication key -/// (DIP-11) and an enabled TRANSFER key remains — with the same policy the -/// identity update refuses with. Thin marshaler over +/// (DIP-11) and an enabled TRANSFER key able to sign remains — with the same +/// policy the identity update refuses with. Thin marshaler over /// `platform_wallet_check_keys_can_be_disabled`. /// /// Returns a `long[4]`: `[refusal, refusedKeyId, refusedSecurityLevel, @@ -1013,3 +1009,290 @@ pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_ca // `success(null)`), so nothing else to release here. }) } + +#[cfg(test)] +mod read_key_ids_tests { + //! No JVM on the test hosts: these drive the real exports through a JNI + //! function table that scripts the array reads and records what is thrown. + + use super::*; + use jni::sys::{ + jarray, jboolean, jbyte, jbyteArray, jclass, jint, jintArray, jmethodID, jobject, jsize, + jstring, jthrowable, jvalue, JNIEnv as RawEnv, JNINativeInterface_, JNI_FALSE, JNI_TRUE, + }; + use std::cell::RefCell; + + const ID_ARRAY: usize = 0x10; + const BLOB_ARRAY: usize = 0x20; + const KEY_IDS_ARRAY: usize = 0x30; + const READ_FAILED: &str = "keyIds int[] could not be read"; + + /// What the fake JVM holds, and what the code under test did to it. + #[derive(Default)] + struct Fake { + blob: Vec, + key_ids: Vec, + fail_key_ids_read: bool, + exception_pending: bool, + last_string: String, + thrown: Vec, + } + + thread_local! { + static FAKE: RefCell = RefCell::new(Fake::default()); + } + + fn fake(f: impl FnOnce(&mut Fake) -> T) -> T { + FAKE.with(|fake| f(&mut fake.borrow_mut())) + } + + unsafe extern "system" fn get_array_length(_: *mut RawEnv, array: jarray) -> jsize { + match array as usize { + ID_ARRAY => 32, + BLOB_ARRAY => fake(|f| f.blob.len()) as jsize, + _ => fake(|f| f.key_ids.len()) as jsize, + } + } + + unsafe extern "system" fn get_byte_array_region( + _: *mut RawEnv, + array: jbyteArray, + start: jsize, + len: jsize, + buf: *mut jbyte, + ) { + if array as usize == BLOB_ARRAY { + let blob = fake(|f| f.blob.clone()); + ptr::copy_nonoverlapping( + blob[start as usize..].as_ptr().cast::(), + buf, + len as usize, + ); + } else { + ptr::write_bytes(buf, 0, len as usize); + } + } + + unsafe extern "system" fn get_int_array_region( + _: *mut RawEnv, + _: jintArray, + start: jsize, + len: jsize, + buf: *mut jint, + ) { + fake(|f| { + if f.fail_key_ids_read { + f.exception_pending = true; + } else { + ptr::copy_nonoverlapping(f.key_ids[start as usize..].as_ptr(), buf, len as usize); + } + }); + } + + unsafe extern "system" fn exception_check(_: *mut RawEnv) -> jboolean { + if fake(|f| f.exception_pending) { + JNI_TRUE + } else { + JNI_FALSE + } + } + + unsafe extern "system" fn exception_clear(_: *mut RawEnv) { + fake(|f| f.exception_pending = false); + } + + unsafe extern "system" fn find_class(_: *mut RawEnv, _: *const c_char) -> jclass { + 0x40 as jclass + } + + unsafe extern "system" fn get_method_id( + _: *mut RawEnv, + _: jclass, + _: *const c_char, + _: *const c_char, + ) -> jmethodID { + 0x50 as jmethodID + } + + unsafe extern "system" fn new_object_a( + _: *mut RawEnv, + _: jclass, + _: jmethodID, + _: *const jvalue, + ) -> jobject { + 0x60 as jobject + } + + unsafe extern "system" fn new_string_utf(_: *mut RawEnv, utf: *const c_char) -> jstring { + let message = CStr::from_ptr(utf).to_string_lossy().into_owned(); + fake(|f| f.last_string = message); + 0x70 as jstring + } + + unsafe extern "system" fn throw(_: *mut RawEnv, _: jthrowable) -> jint { + fake(|f| { + f.exception_pending = true; + let message = f.last_string.clone(); + f.thrown.push(message); + }); + 0 + } + + unsafe extern "system" fn delete_local_ref(_: *mut RawEnv, _: jobject) {} + + /// Run `body` against a fresh fake JVM. + fn with_fake_jvm(setup: Fake, body: impl FnOnce(JNIEnv<'static>) -> T) -> (T, Vec) { + fake(|f| *f = setup); + // SAFETY: an all-null table is valid, every entry is an `Option` or a + // raw pointer; a call to an entry left null is a jni `Err`, not UB. + let mut table: JNINativeInterface_ = unsafe { std::mem::zeroed() }; + table.GetArrayLength = Some(get_array_length); + table.GetByteArrayRegion = Some(get_byte_array_region); + table.GetIntArrayRegion = Some(get_int_array_region); + table.ExceptionCheck = Some(exception_check); + table.ExceptionClear = Some(exception_clear); + table.FindClass = Some(find_class); + table.GetMethodID = Some(get_method_id); + table.NewObjectA = Some(new_object_a); + table.NewStringUTF = Some(new_string_utf); + table.Throw = Some(throw); + table.DeleteLocalRef = Some(delete_local_ref); + let table_ptr: *const JNINativeInterface_ = &table; + let raw_env = &table_ptr as *const *const JNINativeInterface_ as *mut RawEnv; + // SAFETY: `table` and `table_ptr` outlive `body`, which is the only + // user of the env; the `'static` is the signature of the exports. + let env = unsafe { JNIEnv::from_raw(raw_env) }.expect("the env pointer is not null"); + let out = body(env); + (out, fake(|f| std::mem::take(&mut f.thrown))) + } + + fn key_ids_array() -> JIntArray<'static> { + // SAFETY: a fake object only the fake table interprets. + unsafe { JIntArray::from_raw(KEY_IDS_ARRAY as jintArray) } + } + + fn read(setup: Fake, array: JIntArray<'static>) -> (Option>, Vec) { + with_fake_jvm(setup, |mut env| read_key_ids(&mut env, &array)) + } + + /// One AUTHENTICATION key row, so an update that drops its disables would + /// still have something to submit. + fn one_key_blob() -> Vec { + let mut blob = 1u32.to_be_bytes().to_vec(); + blob.extend_from_slice(&9u32.to_be_bytes()); + blob.extend_from_slice(&[0, 0, 2, 0, 0]); + blob.extend_from_slice(&33u16.to_be_bytes()); + blob.extend_from_slice(&[2; 33]); + blob.push(0); + blob + } + + #[test] + fn should_read_the_key_ids() { + let setup = Fake { + key_ids: vec![1, 2], + ..Fake::default() + }; + assert_eq!(read(setup, key_ids_array()), (Some(vec![1, 2]), Vec::new())); + } + + #[test] + fn should_read_a_zero_length_array_as_no_keys() { + assert_eq!( + read(Fake::default(), key_ids_array()), + (Some(Vec::new()), Vec::new()) + ); + } + + #[test] + fn should_throw_on_a_negative_key_id() { + let setup = Fake { + key_ids: vec![-1], + ..Fake::default() + }; + assert_eq!( + read(setup, key_ids_array()), + (None, vec!["keyIds must be non-negative".to_string()]) + ); + } + + #[test] + fn should_throw_when_the_key_ids_cannot_be_read() { + let setup = Fake { + key_ids: vec![1, 2], + fail_key_ids_read: true, + ..Fake::default() + }; + assert_eq!( + read(setup, key_ids_array()), + (None, vec![READ_FAILED.to_string()]) + ); + } + + #[test] + fn should_throw_on_a_null_key_ids_array() { + // SAFETY: a null array is what a JNI caller can pass. + let null = unsafe { JIntArray::from_raw(ptr::null_mut()) }; + assert_eq!( + read(Fake::default(), null), + (None, vec![READ_FAILED.to_string()]) + ); + } + + #[test] + fn should_not_give_a_verdict_when_the_key_ids_cannot_be_read() { + let setup = Fake { + key_ids: vec![1], + fail_key_ids_read: true, + ..Fake::default() + }; + let (verdict, thrown) = with_fake_jvm(setup, |env| { + // SAFETY: fake objects only the fake table interprets. + let (class, id) = unsafe { + ( + JClass::from_raw(ptr::null_mut()), + JByteArray::from_raw(ID_ARRAY as jbyteArray), + ) + }; + Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_checkKeysCanBeDisabled( + env, + class, + 0, + id, + key_ids_array(), + ) + }); + assert!(verdict.is_null()); + assert_eq!(thrown, vec![READ_FAILED.to_string()]); + } + + #[test] + fn should_not_submit_an_update_when_its_disables_cannot_be_read() { + let setup = Fake { + blob: one_key_blob(), + key_ids: vec![1], + fail_key_ids_read: true, + ..Fake::default() + }; + let (_, thrown) = with_fake_jvm(setup, |env| { + // SAFETY: fake objects only the fake table interprets. + let (class, id, blob) = unsafe { + ( + JClass::from_raw(ptr::null_mut()), + JByteArray::from_raw(ID_ARRAY as jbyteArray), + JByteArray::from_raw(BLOB_ARRAY as jbyteArray), + ) + }; + Java_org_dashfoundation_dashsdk_ffi_TransactionsNative_updateIdentity( + env, + class, + 0, + id, + blob, + key_ids_array(), + 0, + ) + }); + assert_eq!(thrown, vec![READ_FAILED.to_string()]); + } +} From c8d4d4323828b89f007ce99ac050595c6ba1d54e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:28 +0000 Subject: [PATCH 18/21] fix(platform-wallet-ffi): refuse a null key list with a nonzero count platform_wallet_check_keys_can_be_disabled judged such a call as no keys and reported allowed. It now returns the invalid-parameter error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../src/identity_update.rs | 73 +++++++++++++++---- 1 file changed, 57 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/identity_update.rs b/packages/rs-platform-wallet-ffi/src/identity_update.rs index 273ce972ef5..4f65efb16d0 100644 --- a/packages/rs-platform-wallet-ffi/src/identity_update.rs +++ b/packages/rs-platform-wallet-ffi/src/identity_update.rs @@ -391,7 +391,8 @@ pub unsafe extern "C" fn platform_wallet_update_identity_with_signer( pub struct KeyDisableCheckFFI { /// 0 = every listed key may be disabled; 1 = refused, the last valid /// authentication key of a security level (DIP-11); 2 = refused, the - /// last enabled key of a purpose the wallet keeps one of (TRANSFER). + /// last enabled key able to sign of a purpose the wallet keeps one of + /// (TRANSFER). pub refusal: u8, /// When refused: the key that cannot be disabled. pub refused_key_id: u32, @@ -399,7 +400,7 @@ pub struct KeyDisableCheckFFI { /// MASTER) that would be left without a valid authentication key. pub refused_security_level: u8, /// When `refusal` is 2: the purpose (DPP discriminant, 3 = TRANSFER) - /// that would be left without an enabled key. + /// that would be left without an enabled key able to sign. pub refused_purpose: u8, } @@ -423,17 +424,18 @@ impl From> for KeyDisableCheckFFI { } } -/// Whether the wallet lets an identity update disable -/// `disable_public_key_ids` of its identity `identity_id`: no security level -/// loses its last valid authentication key (DIP-11) and the identity keeps an -/// enabled TRANSFER key. +/// Whether the wallet lets an identity update that only disables +/// `disable_public_key_ids` of its identity `identity_id` through: no security +/// level loses its last valid authentication key (DIP-11) and the identity +/// keeps an enabled TRANSFER key able to sign. /// /// Marshals `IdentityWallet::check_keys_can_be_disabled`, which judges the /// identity [`platform_wallet_update_identity_with_signer`] builds the update -/// from with the policy that entry point refuses with, so the two always -/// agree. A refusal is a verdict, written to `out_check` with a success -/// result; an error result means the wallet handle or the identity is -/// unknown. +/// from with the policy that entry point refuses with. No added key counts +/// here, so that entry point can accept a disable this refuses when the same +/// update adds a replacement key. A refusal is a verdict, written to +/// `out_check` with a success result; an error result means a null key list +/// with a nonzero count, or an unknown wallet handle or identity. #[no_mangle] pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( wallet_handle: Handle, @@ -445,12 +447,19 @@ pub unsafe extern "C" fn platform_wallet_check_keys_can_be_disabled( check_ptr!(out_check); let id = unwrap_result_or_return!(read_identifier(identity_id)); - 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() - }; + if disable_public_key_ids.is_null() && disable_public_key_ids_count != 0 { + return PlatformWalletFFIResult::err( + PlatformWalletFFIResultCode::ErrorInvalidParameter, + format!( + "disable_public_key_ids is null with a count of {disable_public_key_ids_count}" + ), + ); + } + let disable_ids: Vec = if 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 option = PLATFORM_WALLET_STORAGE.with_item(wallet_handle, |wallet| { let identity_wallet = wallet.identity().clone(); @@ -916,6 +925,20 @@ mod key_disable_check_tests { assert_eq!(result.code, PlatformWalletFFIResultCode::Success); assert_eq!(allowed, KeyDisableCheckFFI::default()); + // A null list of no keys is a legitimate "disable nothing". + let mut none_refused = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + handle, + IDENTITY_ID.as_ptr(), + ptr::null(), + 0, + &mut none_refused, + ) + }; + assert_eq!(result.code, PlatformWalletFFIResultCode::Success); + assert_eq!(none_refused, KeyDisableCheckFFI::default()); + // An identity the wallet does not hold is an error, not a verdict. let (result, _) = unsafe { check(handle, &[9; 32], &[1]) }; assert_ne!(result.code, PlatformWalletFFIResultCode::Success); @@ -924,6 +947,24 @@ mod key_disable_check_tests { drop(manager); } + #[test] + fn should_refuse_a_null_key_list_with_a_nonzero_count() { + let mut out = KeyDisableCheckFFI::default(); + let result = unsafe { + platform_wallet_check_keys_can_be_disabled( + 0, + IDENTITY_ID.as_ptr(), + ptr::null(), + 1, + &mut out, + ) + }; + assert_eq!( + result.code, + PlatformWalletFFIResultCode::ErrorInvalidParameter + ); + } + #[test] fn should_refuse_a_null_out_pointer() { let result = unsafe { From c6cb8218b4d2a3b71820897f689dc8aa1046158e Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:28 +0000 Subject: [PATCH 19/21] fix(kotlin-sdk): report unknown key-disable verdict values as a typed error They raised NoSuchElementException or IllegalStateException outside mapNativeErrors. They now raise the DashSdkError a native invalid parameter maps to, as the Swift wrapper does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../dashsdk/identity/IdentityUpdates.kt | 77 +++++++++++++------ .../dashsdk/identity/KeyDisableVerdictTest.kt | 41 ++++++++++ 2 files changed, 93 insertions(+), 25 deletions(-) create mode 100644 packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt 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 5ae6379932e..67efde9ae21 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 @@ -4,6 +4,7 @@ import org.dashfoundation.dashsdk.wallet.op import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.errors.DashSdkError import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.TransactionsNative @@ -188,7 +189,7 @@ sealed interface KeyDisableRefusal { val securityLevel: SecurityLevel, ) : KeyDisableRefusal - /** Wallet policy: [purpose] (TRANSFER) would be left without an enabled key. */ + /** Wallet policy: [purpose] (TRANSFER) would be left without an enabled key able to sign. */ data class LastEnabledKey( override val keyId: Int, val purpose: KeyPurpose, @@ -261,10 +262,11 @@ class IdentityUpdates internal constructor( } /** - * Whether the wallet lets an identity update disable [keyIds] of - * [identityId], judged by Rust against the wallet's identity with the - * policy [update] refuses with, so the two always agree: null when it - * does, otherwise the [KeyDisableRefusal]. + * Whether the wallet lets an identity update that only disables [keyIds] + * of [identityId] through, judged by Rust against the wallet's identity + * with the policy [update] refuses with: null when it does, otherwise the + * [KeyDisableRefusal]. No added key counts here, so [update] can accept a + * disable this refuses when the same update adds a replacement key. * Bridges `platform_wallet_check_keys_can_be_disabled`; throws when the * wallet or the identity is not loaded. */ @@ -279,26 +281,15 @@ class IdentityUpdates internal constructor( require(keyIds.all { it >= 0 }) { "every key id must be non-negative, got $keyIds" } - val verdict = mapNativeErrors { - TransactionsNative.checkKeysCanBeDisabled( - walletHandle, - identityId, - keyIds.toIntArray(), - ) - } - val keyId = verdict[1].toInt() - when (verdict[0]) { - 0L -> null - 1L -> KeyDisableRefusal.LastAuthenticationKey( - keyId = keyId, - securityLevel = SecurityLevel.entries.first { it.ffiValue.toLong() == verdict[2] }, - ) - 2L -> KeyDisableRefusal.LastEnabledKey( - keyId = keyId, - purpose = KeyPurpose.entries.first { it.ffiValue.toLong() == verdict[3] }, - ) - else -> error("unknown key-disable refusal kind ${verdict[0]}") - } + decodeKeyDisableVerdict( + mapNativeErrors { + TransactionsNative.checkKeysCanBeDisabled( + walletHandle, + identityId, + keyIds.toIntArray(), + ) + }, + ) } /** @@ -348,3 +339,39 @@ class IdentityUpdates internal constructor( } } } + +/** + * `PlatformWalletFFIResultCode::ErrorInvalidParameter`, which reaches Kotlin as + * [DashSdkError.PlatformWallet.Generic]. + */ +private const val PLATFORM_WALLET_INVALID_PARAMETER_CODE = 2 + +/** + * Decode the `[refusal, refusedKeyId, refusedSecurityLevel, refusedPurpose]` + * verdict of [TransactionsNative.checkKeysCanBeDisabled]. A value this SDK does + * not know is the typed error a native invalid parameter is, not an allowance. + */ +internal fun decodeKeyDisableVerdict(verdict: LongArray): KeyDisableRefusal? { + val keyId = verdict[1].toInt() + return when (verdict[0]) { + 0L -> null + 1L -> KeyDisableRefusal.LastAuthenticationKey( + keyId = keyId, + securityLevel = SecurityLevel.entries.firstOrNull { it.ffiValue.toLong() == verdict[2] } + ?: throw invalidKeyDisableVerdict( + "unknown security level ${verdict[2]} in a key-disable refusal", + ), + ) + 2L -> KeyDisableRefusal.LastEnabledKey( + keyId = keyId, + purpose = KeyPurpose.entries.firstOrNull { it.ffiValue.toLong() == verdict[3] } + ?: throw invalidKeyDisableVerdict( + "unknown key purpose ${verdict[3]} in a key-disable refusal", + ), + ) + else -> throw invalidKeyDisableVerdict("unknown key-disable refusal kind ${verdict[0]}") + } +} + +private fun invalidKeyDisableVerdict(message: String): DashSdkError = + DashSdkError.PlatformWallet.Generic(PLATFORM_WALLET_INVALID_PARAMETER_CODE, message) diff --git a/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt new file mode 100644 index 00000000000..aec9ee6d4c0 --- /dev/null +++ b/packages/kotlin-sdk/sdk/src/test/kotlin/org/dashfoundation/dashsdk/identity/KeyDisableVerdictTest.kt @@ -0,0 +1,41 @@ +package org.dashfoundation.dashsdk.identity + +import org.dashfoundation.dashsdk.errors.DashSdkError +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Test + +class KeyDisableVerdictTest { + + @Test + fun `should decode an allowed verdict`() { + assertNull(decodeKeyDisableVerdict(longArrayOf(0, 0, 0, 0))) + } + + @Test + fun `should decode both refusals`() { + assertEquals( + KeyDisableRefusal.LastAuthenticationKey(1, SecurityLevel.CRITICAL), + decodeKeyDisableVerdict(longArrayOf(1, 1, 1, 0)), + ) + assertEquals( + KeyDisableRefusal.LastEnabledKey(3, KeyPurpose.TRANSFER), + decodeKeyDisableVerdict(longArrayOf(2, 3, 0, 3)), + ) + } + + @Test + fun `should raise the typed SDK error for values it does not know`() { + listOf( + longArrayOf(9, 1, 0, 0), + longArrayOf(1, 1, 99, 0), + longArrayOf(2, 1, 0, 99), + ).forEach { verdict -> + val error = assertThrows(DashSdkError.PlatformWallet.Generic::class.java) { + decodeKeyDisableVerdict(verdict) + } + assertEquals(2, error.nativeCode) + } + } +} From 36fb5bb299edd031432aed21a5a81f1f2e8ce3c6 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:28 +0000 Subject: [PATCH 20/21] fix(swift-example-app): re-ask the disable verdict once the wallet loads The verdict task keyed on the key set only, so Disable Key stayed unavailable after the wallet finished loading. The keys list also returned silently when the submit-time recheck refused; it now shows the reason. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../SwiftExampleApp/Views/KeyDetailView.swift | 11 ++++---- .../SwiftExampleApp/Views/KeysListView.swift | 25 ++++++++++++------- 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index ba86f84a47f..25724441b7e 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -14,7 +14,7 @@ struct KeyDetailView: View { @State private var isDisabling = false @State private var disableError: String? /// The wallet's verdict for disabling this key, refreshed whenever the - /// identity's key set changes. + /// identity's key set or the wallet's presence changes. @State private var ruleVerdict: KeyDisableGate.RuleVerdict = .unavailable(reason: "Checking whether this key may be disabled…") /// What Platform says is left of this key's budget. `.some(nil)` means @@ -47,10 +47,11 @@ struct KeyDetailView: View { (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } } - /// Changes whenever a key is added or disabled, so the verdict is - /// re-asked. - private var keySetVersion: [String] { + /// Changes whenever a key is added or disabled or the wallet finishes + /// loading, so the verdict is re-asked. + private var ruleVerdictInputs: [String] { identity.identityPublicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + + ["walletLoaded:\(identityWallet != nil)"] } var body: some View { @@ -154,7 +155,7 @@ struct KeyDetailView: View { } .navigationTitle("Key #\(publicKey.id)") .navigationBarTitleDisplayMode(.inline) - .task(id: keySetVersion) { + .task(id: ruleVerdictInputs) { ruleVerdict = await KeyDisableGate.ruleVerdict( wallet: identityWallet, identityId: identity.identityId, diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index a4faab7e21c..9547f8c8326 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -21,7 +21,8 @@ struct KeysListView: View { @State private var isDisabling = false @State private var disableError: String? /// The wallet's verdict for disabling each enabled key alone, refreshed - /// whenever the key set changes. Absent while loading. + /// whenever the key set or the wallet's presence changes. Absent while + /// loading. @State private var ruleVerdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] /// What Platform says is left of each budgeted key, fetched once for the /// whole list. Absent while loading or when the query failed. @@ -36,10 +37,11 @@ struct KeysListView: View { (identity.wallet?.walletId).flatMap { walletManager.wallet(for: $0) } } - /// Changes whenever a key is added or disabled, so the verdicts are - /// re-asked. - private var keySetVersion: [String] { + /// Changes whenever a key is added or disabled or the wallet finishes + /// loading, so the verdicts are re-asked. + private var ruleVerdictInputs: [String] { publicKeys.map { "\($0.id):\(String(describing: $0.disabledAt))" } + + ["walletLoaded:\(identityWallet != nil)"] } private func ruleVerdict(for publicKey: IdentityPublicKey) -> KeyDisableGate.RuleVerdict { @@ -129,7 +131,7 @@ struct KeysListView: View { .task(id: publicKeys.compactMap { $0.totalBudget != nil ? $0.id : nil }) { await loadRemainingBudgets() } - .task(id: keySetVersion) { + .task(id: ruleVerdictInputs) { var verdicts: [UInt32: KeyDisableGate.RuleVerdict] = [:] for publicKey in publicKeys where publicKey.disabledAt == nil { verdicts[publicKey.id] = await KeyDisableGate.ruleVerdict( @@ -239,10 +241,15 @@ struct KeysListView: View { keyId: publicKey.id ) ruleVerdicts[publicKey.id] = verdict - guard case .allowed = KeyDisableGate.evaluate( - target: publicKey, - ruleVerdict: verdict - ) else { return } + switch KeyDisableGate.evaluate(target: publicKey, ruleVerdict: verdict) { + case .allowed: + break + case .alreadyDisabled: + return + case .forbidden(let reason): + disableError = reason + return + } guard let walletId = identity.wallet?.walletId else { disableError = "Identity has no wallet linkage; cannot sign the disable transition." From 279bda71abe0af1f13dc533470297a17dffc6729 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Fri, 30 Oct 2026 12:09:29 +0000 Subject: [PATCH 21/21] fix(swift-sdk): refuse a malformed identity id and drop stale disable verdicts checkKeysCanBeDisabled now throws the typed invalid-parameter error for an identity id that is not 32 bytes, instead of trapping in withFFIBytes. The key views no longer publish the verdict of a cancelled check, which could overwrite the answer of the newer check that replaced it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WunYYjpfLux1ByK7fGF7SG --- .../SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift | 3 +++ .../SwiftExampleApp/Views/KeyDetailView.swift | 6 +++++- .../SwiftExampleApp/Views/KeysListView.swift | 3 +++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift index 65f4d6d1f00..c5033888d47 100644 --- a/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift +++ b/packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/ManagedPlatformWallet.swift @@ -3814,6 +3814,9 @@ extension ManagedPlatformWallet { identityId: Identifier, keyIds: [UInt32] ) async throws -> KeyDisableRefusal? { + guard identityId.count == 32 else { + throw PlatformWalletError.invalidParameter("identityId must be 32 bytes") + } let handle = self.handle let idBytes: [UInt8] = identityId.withFFIBytes { ptr in Array(UnsafeBufferPointer(start: ptr, count: 32)) diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift index 25724441b7e..24e13a20273 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeyDetailView.swift @@ -156,11 +156,15 @@ struct KeyDetailView: View { .navigationTitle("Key #\(publicKey.id)") .navigationBarTitleDisplayMode(.inline) .task(id: ruleVerdictInputs) { - ruleVerdict = await KeyDisableGate.ruleVerdict( + let verdict = await KeyDisableGate.ruleVerdict( wallet: identityWallet, identityId: identity.identityId, keyId: publicKey.id ) + // A newer key set or wallet superseded this check; don't let its + // answer clobber the fresher one. + guard !Task.isCancelled else { return } + ruleVerdict = verdict } .alert("Success", isPresented: $showSuccessAlert) { Button("OK") { diff --git a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift index 9547f8c8326..a0b7b061721 100644 --- a/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift +++ b/packages/swift-sdk/SwiftExampleApp/SwiftExampleApp/Views/KeysListView.swift @@ -140,6 +140,9 @@ struct KeysListView: View { keyId: publicKey.id ) } + // A newer key set or wallet superseded this check; don't let its answer + // clobber the fresher one. + guard !Task.isCancelled else { return } ruleVerdicts = verdicts } .navigationBarTitleDisplayMode(.inline)