diff --git a/key-wallet-ffi/src/account.rs b/key-wallet-ffi/src/account.rs index 0c933095b..3fbe33ef1 100644 --- a/key-wallet-ffi/src/account.rs +++ b/key-wallet-ffi/src/account.rs @@ -90,7 +90,19 @@ pub unsafe extern "C" fn wallet_get_account( } let wallet = &*wallet; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(err) => { + let code = err.code; + let message = if err.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(err.message).to_string_lossy().into_owned() + }; + // `err` dropped here; its original message is freed by `Drop`. + return FFIAccountResult::error(code, message); + } + }; match wallet.inner().accounts.account_of_type(account_type_rust) { Some(account) => { diff --git a/key-wallet-ffi/src/address_pool.rs b/key-wallet-ffi/src/address_pool.rs index ce2bc3225..3b4224ae5 100644 --- a/key-wallet-ffi/src/address_pool.rs +++ b/key-wallet-ffi/src/address_pool.rs @@ -46,6 +46,16 @@ fn get_managed_account_by_type<'a>( collection.identity_topup_not_bound.as_ref() } AccountType::IdentityInvitation => collection.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only + // and have no managed-side representation. + None + } AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_ref(), AccountType::AssetLockShieldedAddressTopUp => { collection.asset_lock_shielded_address_topup.as_ref() @@ -99,6 +109,16 @@ fn get_managed_account_by_type_mut<'a>( collection.identity_topup_not_bound.as_mut() } AccountType::IdentityInvitation => collection.identity_invitation.as_mut(), + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only + // and have no managed-side representation. + None + } AccountType::AssetLockAddressTopUp => collection.asset_lock_address_topup.as_mut(), AccountType::AssetLockShieldedAddressTopUp => { collection.asset_lock_shielded_address_topup.as_mut() @@ -295,7 +315,19 @@ pub unsafe extern "C" fn managed_wallet_get_address_pool_info( check_ptr!(info_out, error); let managed_wallet = wrapper.inner(); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + (*error).set(e.code, &msg); + // `e` dropped here; its original message is freed by `Drop`. + return false; + } + }; // Get the specific managed account let managed_account = @@ -384,7 +416,19 @@ pub unsafe extern "C" fn managed_wallet_set_gap_limit( ) -> bool { let managed_wallet = deref_ptr_mut!(managed_wallet, error).inner_mut(); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + (*error).set(e.code, &msg); + // `e` dropped here; its original message is freed by `Drop`. + return false; + } + }; // Get the specific managed account let managed_account = @@ -464,7 +508,19 @@ pub unsafe extern "C" fn managed_wallet_generate_addresses_to_index( let managed_wallet = deref_ptr_mut!(managed_wallet, error).inner_mut(); let wallet = deref_ptr!(wallet, error); - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let msg = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + (*error).set(e.code, &msg); + // `e` dropped here; its original message is freed by `Drop`. + return false; + } + }; let account_type_to_check = match account_type_rust.try_into() { Ok(check_type) => check_type, diff --git a/key-wallet-ffi/src/managed_account.rs b/key-wallet-ffi/src/managed_account.rs index 4321c4c52..56cec0432 100644 --- a/key-wallet-ffi/src/managed_account.rs +++ b/key-wallet-ffi/src/managed_account.rs @@ -221,7 +221,24 @@ pub unsafe extern "C" fn managed_wallet_get_account( } let managed_wallet = &*managed_wallet_ptr; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + // `e` dropped here; its original message is freed by `Drop`. + // `wallet_manager_get_managed_wallet_info` allocated `managed_wallet_ptr` + // above; the success path frees it via `managed_wallet_info_free` at the + // bottom of this function. Do the same here before bailing so the + // managed-wallet handle isn't leaked on invalid-account-type errors. + crate::managed_wallet::managed_wallet_info_free(managed_wallet_ptr); + return FFIManagedCoreAccountResult::error(code, message); + } + }; let result = { use key_wallet::account::StandardAccountType; @@ -250,6 +267,17 @@ pub unsafe extern "C" fn managed_wallet_get_account( managed_collection.identity_topup_not_bound.as_ref() } AccountType::IdentityInvitation => managed_collection.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only + // and have no managed-side representation. Signing keys are + // derived from the immutable `Account` directly. + None + } AccountType::AssetLockAddressTopUp => { managed_collection.asset_lock_address_topup.as_ref() } @@ -567,6 +595,12 @@ pub unsafe extern "C" fn managed_core_account_get_account_type( FFIAccountType::IdentityTopUpNotBoundToIdentity } AccountType::IdentityInvitation => FFIAccountType::IdentityInvitation, + AccountType::IdentityAuthenticationEcdsa { + .. + } => FFIAccountType::IdentityAuthenticationEcdsa, + AccountType::IdentityAuthenticationBls { + .. + } => FFIAccountType::IdentityAuthenticationBls, AccountType::AssetLockAddressTopUp => FFIAccountType::AssetLockAddressTopUp, AccountType::AssetLockShieldedAddressTopUp => FFIAccountType::AssetLockShieldedAddressTopUp, AccountType::ProviderVotingKeys => FFIAccountType::ProviderVotingKeys, diff --git a/key-wallet-ffi/src/types.rs b/key-wallet-ffi/src/types.rs index 58447e4f2..fdadfea9a 100644 --- a/key-wallet-ffi/src/types.rs +++ b/key-wallet-ffi/src/types.rs @@ -1,5 +1,6 @@ //! Common types for FFI interface +use crate::error::{FFIError, FFIErrorCode}; use dashcore::ephemerealdata::instant_lock::InstantLock; use dashcore::hashes::Hash; use key_wallet::account::{InputDetail, OutputDetail}; @@ -7,6 +8,7 @@ use key_wallet::managed_account::transaction_record::{OutputRole, TransactionDir use key_wallet::transaction_checking::transaction_router::TransactionType; use key_wallet::transaction_checking::{BlockInfo, TransactionContext}; use key_wallet::Wallet; +use std::ffi::CString; use std::os::raw::c_char; use std::sync::Arc; @@ -236,14 +238,24 @@ pub enum FFIAccountType { AssetLockAddressTopUp = 14, /// Asset lock shielded address top-up funding (subfeature 5) AssetLockShieldedAddressTopUp = 15, + /// Per-identity ECDSA authentication keys (DIP-13, sub-feature 0', key type 0'). + /// Path prefix: `m/9'/coin_type'/5'/0'/0'/identity_index'`. + IdentityAuthenticationEcdsa = 16, + /// Per-identity BLS authentication keys (DIP-13, sub-feature 0', key type 1'). + /// Path prefix: `m/9'/coin_type'/5'/0'/1'/identity_index'`. + IdentityAuthenticationBls = 17, } impl FFIAccountType { /// Convert to AccountType with the provided index (used where applicable). /// For types needing an index (e.g., IdentityTopUp.registration_index), the provided index is used. - pub fn to_account_type(self, index: u32) -> key_wallet::AccountType { + /// + /// Returns `Err` for variants that cannot be represented with a single `u32` index + /// (DashpayReceivingFunds, DashpayExternalAccount, PlatformPayment), since those + /// require additional data not carried by this conversion signature. + pub fn to_account_type(self, index: u32) -> Result { use key_wallet::account::account_type::StandardAccountType; - match self { + Ok(match self { FFIAccountType::StandardBIP44 => key_wallet::AccountType::Standard { index, standard_account_type: StandardAccountType::BIP44Account, @@ -274,42 +286,59 @@ impl FFIAccountType { FFIAccountType::ProviderOwnerKeys => key_wallet::AccountType::ProviderOwnerKeys, FFIAccountType::ProviderOperatorKeys => key_wallet::AccountType::ProviderOperatorKeys, FFIAccountType::ProviderPlatformKeys => key_wallet::AccountType::ProviderPlatformKeys, - // DashPay variants require additional identity IDs (user_identity_id and friend_identity_id) - // that are not part of the current FFI API. These types cannot be constructed via this - // conversion path. Attempting to use them is a programming error. - // - // TODO: Extend the FFI API to accept identity IDs for DashPay account creation: - // - Add new FFI functions like: - // * ffi_account_type_to_dashpay_receiving_funds(index, user_id[32], friend_id[32]) - // * ffi_account_type_to_dashpay_external_account(index, user_id[32], friend_id[32]) - // - Or extend to_account_type to accept optional identity ID parameters - // - // Until then, attempting to convert these variants will panic to prevent silent misrouting. + // DIP-13 authentication accounts use the provided `index` as + // `identity_index` (the hardened child at path level 6). + FFIAccountType::IdentityAuthenticationEcdsa => { + key_wallet::AccountType::IdentityAuthenticationEcdsa { + identity_index: index, + } + } + FFIAccountType::IdentityAuthenticationBls => { + key_wallet::AccountType::IdentityAuthenticationBls { + identity_index: index, + } + } + // DashPay and PlatformPayment variants require additional data (identity IDs + // or account/key_class indices) that are not part of this single-u32 + // conversion. Callers must use the dedicated FFI entry points (e.g. + // `wallet_add_dashpay_receiving_account`, `wallet_add_platform_payment_account`). FFIAccountType::DashpayReceivingFunds => { - panic!( - "FFIAccountType::DashpayReceivingFunds cannot be converted to AccountType \ - without user_identity_id and friend_identity_id. The FFI API does not yet \ - support passing these 32-byte identity IDs. This is a programming error - \ - DashPay account creation must use a different API path." - ); + return Err(FFIError { + code: FFIErrorCode::InvalidInput, + message: CString::new( + "FFIAccountType::DashpayReceivingFunds cannot be converted to \ + AccountType without user_identity_id and friend_identity_id. Use \ + wallet_add_dashpay_receiving_account() instead.", + ) + .unwrap_or_default() + .into_raw(), + }); } FFIAccountType::DashpayExternalAccount => { - panic!( - "FFIAccountType::DashpayExternalAccount cannot be converted to AccountType \ - without user_identity_id and friend_identity_id. The FFI API does not yet \ - support passing these 32-byte identity IDs. This is a programming error - \ - DashPay account creation must use a different API path." - ); + return Err(FFIError { + code: FFIErrorCode::InvalidInput, + message: CString::new( + "FFIAccountType::DashpayExternalAccount cannot be converted to \ + AccountType without user_identity_id and friend_identity_id. Use \ + wallet_add_dashpay_external_account_with_xpub_bytes() instead.", + ) + .unwrap_or_default() + .into_raw(), + }); } FFIAccountType::PlatformPayment => { - panic!( - "FFIAccountType::PlatformPayment cannot be converted to AccountType \ - without account and key_class indices. The FFI API does not yet \ - support passing these values. This is a programming error - \ - Platform Payment account creation must use a different API path." - ); + return Err(FFIError { + code: FFIErrorCode::InvalidInput, + message: CString::new( + "FFIAccountType::PlatformPayment cannot be converted to AccountType \ + without account and key_class indices. Use \ + wallet_add_platform_payment_account() instead.", + ) + .unwrap_or_default() + .into_raw(), + }); } - } + }) } /// Convert from AccountType to FFI representation @@ -367,6 +396,12 @@ impl FFIAccountType { key_wallet::AccountType::ProviderPlatformKeys => { (FFIAccountType::ProviderPlatformKeys, 0, None) } + key_wallet::AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => (FFIAccountType::IdentityAuthenticationEcdsa, *identity_index, None), + key_wallet::AccountType::IdentityAuthenticationBls { + identity_index, + } => (FFIAccountType::IdentityAuthenticationBls, *identity_index, None), key_wallet::AccountType::DashpayReceivingFunds { index, user_identity_id, @@ -642,7 +677,13 @@ impl FFIWalletAccountCreationOptions { } } - // Convert special account types if provided + // Convert special account types if provided. + // + // Variants that cannot be represented with a single `u32` index + // (DashpayReceivingFunds, DashpayExternalAccount, PlatformPayment) + // are silently skipped here because callers must use the dedicated + // entry points (e.g. `wallet_add_dashpay_receiving_account`). This + // conversion path has no error-return channel. let special_accounts = if !self.special_account_types.is_null() && self.special_account_types_count > 0 { @@ -652,7 +693,14 @@ impl FFIWalletAccountCreationOptions { ); let mut accounts = Vec::new(); for &ffi_type in slice { - accounts.push(ffi_type.to_account_type(0)); + // Errors are silently dropped — callers must use the + // dedicated entry points for account types that need + // more than a single u32 index, and this signature has + // no error-return channel. `FFIError::Drop` frees the + // allocated message. + if let Ok(account_type) = ffi_type.to_account_type(0) { + accounts.push(account_type); + } } Some(accounts) } else { @@ -953,24 +1001,30 @@ mod tests { } #[test] - #[should_panic(expected = "DashpayReceivingFunds cannot be converted to AccountType")] - fn test_dashpay_receiving_funds_to_account_type_panics() { - // This should panic because we cannot construct a DashPay account without identity IDs - let _ = FFIAccountType::DashpayReceivingFunds.to_account_type(0); + fn test_dashpay_receiving_funds_to_account_type_returns_err() { + // Cannot construct a DashPay account without identity IDs + let result = FFIAccountType::DashpayReceivingFunds.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + // FFIError's Drop impl frees the message. } #[test] - #[should_panic(expected = "DashpayExternalAccount cannot be converted to AccountType")] - fn test_dashpay_external_account_to_account_type_panics() { - // This should panic because we cannot construct a DashPay account without identity IDs - let _ = FFIAccountType::DashpayExternalAccount.to_account_type(0); + fn test_dashpay_external_account_to_account_type_returns_err() { + // Cannot construct a DashPay account without identity IDs + let result = FFIAccountType::DashpayExternalAccount.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + // FFIError's Drop impl frees the message. } #[test] - #[should_panic(expected = "PlatformPayment cannot be converted to AccountType")] - fn test_platform_payment_to_account_type_panics() { - // This should panic because we cannot construct a Platform Payment account without indices - let _ = FFIAccountType::PlatformPayment.to_account_type(0); + fn test_platform_payment_to_account_type_returns_err() { + // Cannot construct a Platform Payment account without account/key_class indices + let result = FFIAccountType::PlatformPayment.to_account_type(0); + let err = result.expect_err("should be an error"); + assert_eq!(err.code, FFIErrorCode::InvalidInput); + // FFIError's Drop impl frees the message. } #[test] @@ -1000,7 +1054,8 @@ mod tests { #[test] fn test_non_dashpay_conversions_work() { // Verify that non-DashPay types still convert correctly - let standard_bip44 = FFIAccountType::StandardBIP44.to_account_type(5); + let standard_bip44 = + FFIAccountType::StandardBIP44.to_account_type(5).expect("StandardBIP44 should convert"); assert!(matches!( standard_bip44, key_wallet::AccountType::Standard { @@ -1009,7 +1064,8 @@ mod tests { } )); - let coinjoin = FFIAccountType::CoinJoin.to_account_type(3); + let coinjoin = + FFIAccountType::CoinJoin.to_account_type(3).expect("CoinJoin should convert"); assert!(matches!( coinjoin, key_wallet::AccountType::CoinJoin { diff --git a/key-wallet-ffi/src/wallet.rs b/key-wallet-ffi/src/wallet.rs index f3567d55f..3d914c0aa 100644 --- a/key-wallet-ffi/src/wallet.rs +++ b/key-wallet-ffi/src/wallet.rs @@ -379,7 +379,19 @@ pub unsafe extern "C" fn wallet_add_account( let wallet = &mut *wallet; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + // `e` dropped here; its original message is freed by `Drop`. + return crate::types::FFIAccountResult::error(code, message); + } + }; match wallet.inner_mut() { Some(w) => { @@ -608,7 +620,19 @@ pub unsafe extern "C" fn wallet_add_account_with_xpub_bytes( use key_wallet::ExtendedPubKey; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + // `e` dropped here; its original message is freed by `Drop`. + return crate::types::FFIAccountResult::error(code, message); + } + }; // Parse the xpub from bytes (assuming it's a string representation) let xpub_slice = slice::from_raw_parts(xpub_bytes, xpub_len); @@ -731,7 +755,19 @@ pub unsafe extern "C" fn wallet_add_account_with_string_xpub( use key_wallet::ExtendedPubKey; - let account_type_rust = account_type.to_account_type(account_index); + let account_type_rust = match account_type.to_account_type(account_index) { + Ok(t) => t, + Err(e) => { + let code = e.code; + let message = if e.message.is_null() { + "Invalid account type".to_string() + } else { + std::ffi::CStr::from_ptr(e.message).to_string_lossy().into_owned() + }; + // `e` dropped here; its original message is freed by `Drop`. + return crate::types::FFIAccountResult::error(code, message); + } + }; // Parse the xpub from C string let xpub_str = match CStr::from_ptr(xpub_string).to_str() { diff --git a/key-wallet/src/account/account_collection.rs b/key-wallet/src/account/account_collection.rs index f430a13e8..eb0f13db2 100644 --- a/key-wallet/src/account/account_collection.rs +++ b/key-wallet/src/account/account_collection.rs @@ -58,6 +58,14 @@ pub struct AccountCollection { pub identity_topup_not_bound: Option, /// Identity invitation account (optional) pub identity_invitation: Option, + /// Per-identity ECDSA authentication accounts (DIP-13, sub-feature 0', + /// key type 0'), keyed by `identity_index`. Platform-only — these carry no + /// L1 UTXOs. + pub identity_authentication_ecdsa: BTreeMap, + /// Per-identity BLS authentication accounts (DIP-13, sub-feature 0', + /// key type 1'), keyed by `identity_index`. Platform-only. + #[cfg(feature = "bls")] + pub identity_authentication_bls: BTreeMap, /// Asset lock address top-up account (optional) pub asset_lock_address_topup: Option, /// Asset lock shielded address top-up account (optional) @@ -91,6 +99,9 @@ impl AccountCollection { identity_topup: BTreeMap::new(), identity_topup_not_bound: None, identity_invitation: None, + identity_authentication_ecdsa: BTreeMap::new(), + #[cfg(feature = "bls")] + identity_authentication_bls: BTreeMap::new(), asset_lock_address_topup: None, asset_lock_shielded_address_topup: None, provider_voting_keys: None, @@ -141,6 +152,18 @@ impl AccountCollection { AccountType::IdentityInvitation => { self.identity_invitation = Some(account); } + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => { + self.identity_authentication_ecdsa.insert(*identity_index, account); + } + AccountType::IdentityAuthenticationBls { + .. + } => { + return Err( + "IdentityAuthenticationBls requires BLSAccount, use insert_bls_account", + ); + } AccountType::AssetLockAddressTopUp => { self.asset_lock_address_topup = Some(account); } @@ -197,14 +220,29 @@ impl AccountCollection { Ok(()) } - /// Insert a BLS account for provider operator keys + /// Insert a BLS account for provider operator keys or identity + /// authentication. + /// + /// Accepts [`AccountType::ProviderOperatorKeys`] or + /// [`AccountType::IdentityAuthenticationBls`]. Rejects any other account + /// type. #[cfg(feature = "bls")] pub fn insert_bls_account(&mut self, account: BLSAccount) -> Result<(), &'static str> { - if !matches!(account.account_type, AccountType::ProviderOperatorKeys) { - return Err("BLS account must have ProviderOperatorKeys type"); + match account.account_type { + AccountType::ProviderOperatorKeys => { + self.provider_operator_keys = Some(account); + Ok(()) + } + AccountType::IdentityAuthenticationBls { + identity_index, + } => { + self.identity_authentication_bls.insert(identity_index, account); + Ok(()) + } + _ => { + Err("BLS account must have ProviderOperatorKeys or IdentityAuthenticationBls type") + } } - self.provider_operator_keys = Some(account); - Ok(()) } /// Insert an EdDSA account for provider platform keys @@ -242,6 +280,17 @@ impl AccountCollection { } => self.identity_topup.contains_key(registration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.is_some(), AccountType::IdentityInvitation => self.identity_invitation.is_some(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.contains_key(identity_index), + #[cfg(feature = "bls")] + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.contains_key(identity_index), + #[cfg(not(feature = "bls"))] + AccountType::IdentityAuthenticationBls { + .. + } => false, AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.is_some(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.is_some() @@ -315,6 +364,12 @@ impl AccountCollection { } => self.identity_topup.get(®istration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_ref(), AccountType::IdentityInvitation => self.identity_invitation.as_ref(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get(&identity_index), + AccountType::IdentityAuthenticationBls { + .. + } => None, // BLSAccount, use bls_account_of_type AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_ref(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.as_ref() @@ -382,6 +437,12 @@ impl AccountCollection { } => self.identity_topup.get_mut(®istration_index), AccountType::IdentityTopUpNotBoundToIdentity => self.identity_topup_not_bound.as_mut(), AccountType::IdentityInvitation => self.identity_invitation.as_mut(), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => self.identity_authentication_ecdsa.get_mut(&identity_index), + AccountType::IdentityAuthenticationBls { + .. + } => None, // BLSAccount, use bls_account_of_type_mut AccountType::AssetLockAddressTopUp => self.asset_lock_address_topup.as_mut(), AccountType::AssetLockShieldedAddressTopUp => { self.asset_lock_shielded_address_topup.as_mut() @@ -449,6 +510,8 @@ impl AccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values()); + if let Some(account) = &self.asset_lock_address_topup { accounts.push(account); } @@ -497,6 +560,8 @@ impl AccountCollection { accounts.push(account); } + accounts.extend(self.identity_authentication_ecdsa.values_mut()); + if let Some(account) = &mut self.asset_lock_address_topup { accounts.push(account); } @@ -523,16 +588,27 @@ impl AccountCollection { accounts } - /// Get the BLS account (provider operator keys) + /// Get a BLS account by type. + /// + /// Supports [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`] — returns `None` for other + /// types. #[cfg(feature = "bls")] pub fn bls_account_of_type(&self, account_type: AccountType) -> Option<&BLSAccount> { match account_type { AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_ref(), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get(&identity_index), _ => None, } } - /// Get the BLS account mutably (provider operator keys) + /// Get a BLS account by type mutably. + /// + /// Supports [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`] — returns `None` for other + /// types. #[cfg(feature = "bls")] pub fn bls_account_of_type_mut( &mut self, @@ -540,6 +616,9 @@ impl AccountCollection { ) -> Option<&mut BLSAccount> { match account_type { AccountType::ProviderOperatorKeys => self.provider_operator_keys.as_mut(), + AccountType::IdentityAuthenticationBls { + identity_index, + } => self.identity_authentication_bls.get_mut(&identity_index), _ => None, } } @@ -575,6 +654,11 @@ impl AccountCollection { count += 1; } + #[cfg(feature = "bls")] + { + count += self.identity_authentication_bls.len(); + } + #[cfg(feature = "eddsa")] if self.provider_platform_keys.is_some() { count += 1; @@ -605,6 +689,7 @@ impl AccountCollection { && self.identity_topup.is_empty() && self.identity_topup_not_bound.is_none() && self.identity_invitation.is_none() + && self.identity_authentication_ecdsa.is_empty() && self.asset_lock_address_topup.is_none() && self.asset_lock_shielded_address_topup.is_none() && self.provider_voting_keys.is_none() @@ -612,7 +697,9 @@ impl AccountCollection { #[cfg(feature = "bls")] { - is_empty = is_empty && self.provider_operator_keys.is_none(); + is_empty = is_empty + && self.provider_operator_keys.is_none() + && self.identity_authentication_bls.is_empty(); } #[cfg(feature = "eddsa")] @@ -632,6 +719,7 @@ impl AccountCollection { self.identity_topup.clear(); self.identity_topup_not_bound = None; self.identity_invitation = None; + self.identity_authentication_ecdsa.clear(); self.asset_lock_address_topup = None; self.asset_lock_shielded_address_topup = None; self.provider_voting_keys = None; @@ -639,6 +727,7 @@ impl AccountCollection { #[cfg(feature = "bls")] { self.provider_operator_keys = None; + self.identity_authentication_bls.clear(); } #[cfg(feature = "eddsa")] { diff --git a/key-wallet/src/account/account_collection_test.rs b/key-wallet/src/account/account_collection_test.rs index dead953e0..813dc8a82 100644 --- a/key-wallet/src/account/account_collection_test.rs +++ b/key-wallet/src/account/account_collection_test.rs @@ -125,7 +125,10 @@ mod tests { let result = collection.insert_bls_account(bls_account); assert!(result.is_err()); - assert_eq!(result.unwrap_err(), "BLS account must have ProviderOperatorKeys type"); + assert_eq!( + result.unwrap_err(), + "BLS account must have ProviderOperatorKeys or IdentityAuthenticationBls type" + ); } #[test] diff --git a/key-wallet/src/account/account_type.rs b/key-wallet/src/account/account_type.rs index ba1830e2d..712393e26 100644 --- a/key-wallet/src/account/account_type.rs +++ b/key-wallet/src/account/account_type.rs @@ -53,6 +53,32 @@ pub enum AccountType { IdentityTopUpNotBoundToIdentity, /// Identity invitation funding IdentityInvitation, + /// Per-identity authentication keys using ECDSA (DIP-13, sub-feature 0', key type 0'). + /// + /// Account-level path: `m/9'/coin_type'/5'/0'/0'/identity_index'`. Individual + /// keys live at `.../identity_index'/key_index'` and are managed sequentially + /// by the `AddressPool` below this account. These accounts carry no L1 + /// balance — they are pure signing-key chains the user employs to sign Dash + /// Platform state transitions. + IdentityAuthenticationEcdsa { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + }, + /// Per-identity authentication keys using BLS (DIP-13, sub-feature 0', key type 1'). + /// + /// Account-level path: `m/9'/coin_type'/5'/0'/1'/identity_index'`. When the + /// `bls` feature is enabled this is backed by + /// [`BLSAccount`](crate::account::BLSAccount); like + /// [`AccountType::IdentityAuthenticationEcdsa`] these carry no L1 balance. + /// + /// The variant itself is always present so that downstream pattern matches + /// remain exhaustive regardless of features; the BLS-typed storage it maps + /// into (see [`crate::account::AccountCollection::identity_authentication_bls`]) + /// is what is gated on the `bls` feature. + IdentityAuthenticationBls { + /// Which identity in this wallet these keys belong to (hardened). + identity_index: u32, + }, /// Asset lock address top-up funding (subfeature 4) /// Path: m/9'/coinType'/5'/4'/index' AssetLockAddressTopUp, @@ -125,6 +151,16 @@ impl TryFrom for AccountTypeToCheck { Ok(AccountTypeToCheck::IdentityTopUpNotBound) } AccountType::IdentityInvitation => Ok(AccountTypeToCheck::IdentityInvitation), + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only, + // operating on Dash Platform rather than the Core chain. + Err(PlatformAccountConversionError) + } AccountType::AssetLockAddressTopUp => Ok(AccountTypeToCheck::AssetLockAddressTopUp), AccountType::AssetLockShieldedAddressTopUp => { Ok(AccountTypeToCheck::AssetLockShieldedAddressTopUp) @@ -180,6 +216,12 @@ impl AccountType { } | Self::IdentityTopUpNotBoundToIdentity | Self::IdentityInvitation + | Self::IdentityAuthenticationEcdsa { + .. + } + | Self::IdentityAuthenticationBls { + .. + } | Self::AssetLockAddressTopUp | Self::AssetLockShieldedAddressTopUp | Self::ProviderVotingKeys @@ -225,6 +267,12 @@ impl AccountType { Self::IdentityInvitation { .. } => DerivationPathReference::BlockchainIdentityCreditInvitationFunding, + Self::IdentityAuthenticationEcdsa { + .. + } => DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + Self::IdentityAuthenticationBls { + .. + } => DerivationPathReference::BlockchainIdentityAuthenticationBls, Self::AssetLockAddressTopUp { .. } => DerivationPathReference::BlockchainAssetLockAddressTopupFunding, @@ -352,6 +400,44 @@ impl AccountType { _ => Err(crate::error::Error::InvalidNetwork), } } + Self::IdentityAuthenticationEcdsa { + identity_index, + } => { + // DIP-13: m/9'/coin_type'/5'/0'/0'/identity_index' + // The base const supplies the first 5 hardened levels; we append identity_index'. + let base_path = match network { + Network::Mainnet => crate::dip9::IDENTITY_AUTHENTICATION_ECDSA_PATH_MAINNET, + Network::Testnet | Network::Devnet | Network::Regtest => { + crate::dip9::IDENTITY_AUTHENTICATION_ECDSA_PATH_TESTNET + } + _ => return Err(crate::error::Error::InvalidNetwork), + }; + let mut path = DerivationPath::from(base_path); + path.push( + ChildNumber::from_hardened_idx(*identity_index) + .map_err(crate::error::Error::Bip32)?, + ); + Ok(path) + } + Self::IdentityAuthenticationBls { + identity_index, + } => { + // DIP-13: m/9'/coin_type'/5'/0'/1'/identity_index' + // The base const supplies the first 5 hardened levels; we append identity_index'. + let base_path = match network { + Network::Mainnet => crate::dip9::IDENTITY_AUTHENTICATION_BLS_PATH_MAINNET, + Network::Testnet | Network::Devnet | Network::Regtest => { + crate::dip9::IDENTITY_AUTHENTICATION_BLS_PATH_TESTNET + } + _ => return Err(crate::error::Error::InvalidNetwork), + }; + let mut path = DerivationPath::from(base_path); + path.push( + ChildNumber::from_hardened_idx(*identity_index) + .map_err(crate::error::Error::Bip32)?, + ); + Ok(path) + } Self::AssetLockAddressTopUp => { // Base path without index - actual key index added when deriving match network { @@ -493,3 +579,236 @@ impl AccountType { } } } + +#[cfg(test)] +mod identity_authentication_tests { + use super::*; + + /// Helper: build a `DerivationPath` of hardened children from the given + /// indices. Keeps tests readable. + fn hardened_path(indices: &[u32]) -> DerivationPath { + DerivationPath::from( + indices.iter().map(|i| ChildNumber::from_hardened_idx(*i).unwrap()).collect::>(), + ) + } + + #[test] + fn test_identity_authentication_ecdsa_mainnet_path() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 0, + }; + let path = account_type.derivation_path(Network::Mainnet).unwrap(); + // m/9'/5'/5'/0'/0'/0' + assert_eq!(path, hardened_path(&[9, 5, 5, 0, 0, 0])); + } + + #[test] + fn test_identity_authentication_ecdsa_testnet_path() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 7, + }; + let path = account_type.derivation_path(Network::Testnet).unwrap(); + // m/9'/1'/5'/0'/0'/7' + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 0, 7])); + } + + #[test] + fn test_identity_authentication_ecdsa_index_is_none() { + // `index()` is for BIP44-style account indices, not identity indices. + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 42, + }; + assert!(account_type.index().is_none()); + } + + #[test] + fn test_identity_authentication_ecdsa_derivation_path_reference() { + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 0, + }; + assert_eq!( + account_type.derivation_path_reference(), + crate::dip9::DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + ); + } + + #[test] + fn test_identity_authentication_ecdsa_to_account_type_to_check_errs() { + // DIP-13 identity-authentication accounts are Platform-only and must + // never be mapped onto a Core-chain [`AccountTypeToCheck`] variant. + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 3, + }; + let result: Result = account_type.try_into(); + assert_eq!(result, Err(PlatformAccountConversionError)); + } + + #[test] + fn test_identity_authentication_bls_mainnet_path() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 0, + }; + let path = account_type.derivation_path(Network::Mainnet).unwrap(); + // m/9'/5'/5'/0'/1'/0' + assert_eq!(path, hardened_path(&[9, 5, 5, 0, 1, 0])); + } + + #[test] + fn test_identity_authentication_bls_testnet_path() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 2, + }; + let path = account_type.derivation_path(Network::Testnet).unwrap(); + // m/9'/1'/5'/0'/1'/2' + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 1, 2])); + } + + #[test] + fn test_identity_authentication_bls_regtest_uses_testnet_coin_type() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 5, + }; + let path = account_type.derivation_path(Network::Regtest).unwrap(); + // Regtest/Devnet use the same coin_type (1') as Testnet. + assert_eq!(path, hardened_path(&[9, 1, 5, 0, 1, 5])); + } + + #[test] + fn test_identity_authentication_bls_index_is_none() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 99, + }; + assert!(account_type.index().is_none()); + } + + #[test] + fn test_identity_authentication_bls_derivation_path_reference() { + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 0, + }; + assert_eq!( + account_type.derivation_path_reference(), + crate::dip9::DerivationPathReference::BlockchainIdentityAuthenticationBls, + ); + } + + #[test] + fn test_identity_authentication_bls_to_account_type_to_check_errs() { + // DIP-13 identity-authentication accounts are Platform-only and must + // never be mapped onto a Core-chain [`AccountTypeToCheck`] variant. + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 3, + }; + let result: Result = account_type.try_into(); + assert_eq!(result, Err(PlatformAccountConversionError)); + } + + /// End-to-end: insert an ECDSA identity-authentication account into an + /// `AccountCollection` and round-trip through `contains_account_type` / + /// `account_of_type`. + #[test] + fn test_account_collection_round_trip_ecdsa() { + use crate::account::{Account, AccountCollection}; + use crate::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use crate::mnemonic::{Language, Mnemonic}; + use secp256k1::Secp256k1; + + let mut collection = AccountCollection::new(); + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon about", + Language::English, + ) + .unwrap(); + let seed = mnemonic.to_seed(""); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + + let account_type = AccountType::IdentityAuthenticationEcdsa { + identity_index: 4, + }; + let account = Account::new(None, account_type, xpub, Network::Testnet).unwrap(); + + assert!(!collection.contains_account_type(&account_type)); + assert!(collection.insert(account).is_ok()); + assert!(collection.contains_account_type(&account_type)); + assert!(collection.account_of_type(account_type).is_some()); + + // Sanity: the ECDSA variant is *not* routed to the BLS map, so the BLS + // lookup on an ECDSA variant returns None regardless of features. + #[cfg(feature = "bls")] + { + let bls_probe = AccountType::IdentityAuthenticationBls { + identity_index: 4, + }; + assert!(collection.bls_account_of_type(bls_probe).is_none()); + } + } + + /// End-to-end: insert a BLS identity-authentication account into an + /// `AccountCollection` via `insert_bls_account`, and round-trip through + /// `contains_account_type` / `bls_account_of_type`. + #[cfg(feature = "bls")] + #[test] + fn test_account_collection_round_trip_bls() { + use crate::account::{AccountCollection, BLSAccount}; + + let mut collection = AccountCollection::new(); + + let account_type = AccountType::IdentityAuthenticationBls { + identity_index: 11, + }; + let bls_account = + BLSAccount::from_seed(None, account_type, [7u8; 32], Network::Testnet).unwrap(); + + assert!(!collection.contains_account_type(&account_type)); + assert!(collection.insert_bls_account(bls_account).is_ok()); + assert!(collection.contains_account_type(&account_type)); + assert!(collection.bls_account_of_type(account_type).is_some()); + + // ECDSA `account_of_type` should refuse BLS lookups via the plain + // accessor. + assert!(collection.account_of_type(account_type).is_none()); + } + + /// Inserting a BLS account via the plain `insert` path must fail with a + /// pointer to `insert_bls_account`. + #[cfg(feature = "bls")] + #[test] + fn test_account_collection_rejects_bls_via_plain_insert() { + use crate::account::{Account, AccountCollection}; + use crate::bip32::{ExtendedPrivKey, ExtendedPubKey}; + use crate::mnemonic::{Language, Mnemonic}; + use secp256k1::Secp256k1; + + let mut collection = AccountCollection::new(); + + let mnemonic = Mnemonic::from_phrase( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon \ + abandon about", + Language::English, + ) + .unwrap(); + let seed = mnemonic.to_seed(""); + let master = ExtendedPrivKey::new_master(Network::Testnet, &seed).unwrap(); + let secp = Secp256k1::new(); + let xpub = ExtendedPubKey::from_priv(&secp, &master); + + // Using an Account (ECDSA) constructor but with a BLS auth type is a + // programming error — insert() routes it to the BLS error arm. + let account = Account::new( + None, + AccountType::IdentityAuthenticationBls { + identity_index: 0, + }, + xpub, + Network::Testnet, + ) + .unwrap(); + + let err = collection.insert(account).unwrap_err(); + assert!(err.contains("insert_bls_account")); + } +} diff --git a/key-wallet/src/account/bls_account.rs b/key-wallet/src/account/bls_account.rs index 12a1c031d..445d984e4 100644 --- a/key-wallet/src/account/bls_account.rs +++ b/key-wallet/src/account/bls_account.rs @@ -247,6 +247,11 @@ impl } => Some(ChildNumber::Hardened { index: registration_index, }), + AccountType::IdentityAuthenticationBls { + identity_index, + } => Some(ChildNumber::Hardened { + index: identity_index, + }), _ => None, } } diff --git a/key-wallet/src/account/mod.rs b/key-wallet/src/account/mod.rs index 177a42747..dd5d0cea6 100644 --- a/key-wallet/src/account/mod.rs +++ b/key-wallet/src/account/mod.rs @@ -179,6 +179,11 @@ impl AccountDerivation Some(ChildNumber::Hardened { index: registration_index, }), + AccountType::IdentityAuthenticationEcdsa { + identity_index, + } => Some(ChildNumber::Hardened { + index: identity_index, + }), _ => None, } } diff --git a/key-wallet/src/dip9.rs b/key-wallet/src/dip9.rs index 276972df7..4c56dee8c 100644 --- a/key-wallet/src/dip9.rs +++ b/key-wallet/src/dip9.rs @@ -30,6 +30,12 @@ pub enum DerivationPathReference { PlatformPayment = 16, BlockchainAssetLockAddressTopupFunding = 17, BlockchainAssetLockShieldedAddressTopupFunding = 18, + /// Per-identity authentication keys using ECDSA (DIP-13, sub-feature 0', key type 0'). + /// Path prefix: m/9'/coin_type'/5'/0'/0'/identity_index' + BlockchainIdentityAuthenticationEcdsa = 19, + /// Per-identity authentication keys using BLS (DIP-13, sub-feature 0', key type 1'). + /// Path prefix: m/9'/coin_type'/5'/0'/1'/identity_index' + BlockchainIdentityAuthenticationBls = 20, Root = 255, } @@ -423,6 +429,104 @@ pub const ASSET_LOCK_SHIELDED_ADDRESS_TOPUP_PATH_TESTNET: IndexConstPath<4> = In path_type: DerivationPathType::CREDIT_FUNDING, }; +// DIP-13 Identity Authentication (ECDSA) paths — 5 hardened levels: +// m/9'/coin_type'/5'/0'/0' (key_type = 0' = ECDSA). +// The account level adds identity_index' on top (see `AccountType::derivation_path`). +pub const IDENTITY_AUTHENTICATION_ECDSA_PATH_MAINNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 0' for ECDSA + index: 0, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const IDENTITY_AUTHENTICATION_ECDSA_PATH_TESTNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 0' for ECDSA + index: 0, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationEcdsa, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +// DIP-13 Identity Authentication (BLS) paths — 5 hardened levels: +// m/9'/coin_type'/5'/0'/1' (key_type = 1' = BLS). +// The account level adds identity_index' on top (see `AccountType::derivation_path`). +pub const IDENTITY_AUTHENTICATION_BLS_PATH_MAINNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 1' for BLS + index: 1, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationBls, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + +pub const IDENTITY_AUTHENTICATION_BLS_PATH_TESTNET: IndexConstPath<5> = IndexConstPath { + indexes: [ + ChildNumber::Hardened { + index: FEATURE_PURPOSE, + }, + ChildNumber::Hardened { + index: DASH_TESTNET_COIN_TYPE, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES, + }, + ChildNumber::Hardened { + index: FEATURE_PURPOSE_IDENTITIES_SUBFEATURE_AUTHENTICATION, + }, + ChildNumber::Hardened { + // key_type = 1' for BLS + index: 1, + }, + ], + reference: DerivationPathReference::BlockchainIdentityAuthenticationBls, + path_type: DerivationPathType::SINGLE_USER_AUTHENTICATION, +}; + // Authentication Keys Paths pub const IDENTITY_AUTHENTICATION_PATH_MAINNET: IndexConstPath<4> = IndexConstPath { indexes: [ diff --git a/key-wallet/src/managed_account/managed_account_collection.rs b/key-wallet/src/managed_account/managed_account_collection.rs index 10f5adcb4..b38d021ec 100644 --- a/key-wallet/src/managed_account/managed_account_collection.rs +++ b/key-wallet/src/managed_account/managed_account_collection.rs @@ -438,6 +438,11 @@ impl ManagedAccountCollection { } } + // DIP-13 per-identity authentication accounts are Platform-only and + // have no managed-side representation. Signing keys are derived on + // demand from the immutable `Account`'s xpub / derivation path, which + // lives on `account_collection` directly. + if let Some(account) = &account_collection.asset_lock_address_topup { if let Ok(managed_account) = Self::create_managed_account_from_account(account) { managed_collection.asset_lock_address_topup = Some(managed_account); @@ -665,6 +670,19 @@ impl ManagedAccountCollection { addresses, } } + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only + // and have no managed-side representation. + return Err(crate::error::Error::InvalidParameter( + "DIP-13 identity authentication accounts have no managed representation" + .to_string(), + )); + } AccountType::AssetLockAddressTopUp => { let addresses = AddressPool::new( base_path, diff --git a/key-wallet/src/managed_account/managed_account_type.rs b/key-wallet/src/managed_account/managed_account_type.rs index d86c6812f..8dfe44984 100644 --- a/key-wallet/src/managed_account/managed_account_type.rs +++ b/key-wallet/src/managed_account/managed_account_type.rs @@ -607,6 +607,21 @@ impl ManagedAccountType { addresses: pool, }) } + AccountType::IdentityAuthenticationEcdsa { + .. + } + | AccountType::IdentityAuthenticationBls { + .. + } => { + // DIP-13 per-identity authentication accounts are Platform-only + // — they hold no L1 UTXOs and have no managed-side + // representation. Signing keys are derived on demand from the + // immutable `Account`'s xpub / derivation path. + Err(crate::error::Error::InvalidParameter( + "DIP-13 identity authentication accounts have no managed representation" + .to_string(), + )) + } AccountType::AssetLockAddressTopUp => { let path = account_type .derivation_path(network) diff --git a/key-wallet/src/managed_account/mod.rs b/key-wallet/src/managed_account/mod.rs index c8064dae1..8ea308e7c 100644 --- a/key-wallet/src/managed_account/mod.rs +++ b/key-wallet/src/managed_account/mod.rs @@ -105,51 +105,63 @@ impl ManagedCoreAccount { self.spent_outpoints.contains(outpoint) } - /// Create a ManagedAccount from an Account - pub fn from_account(account: &super::Account) -> Self { + /// Create a ManagedAccount from an Account. + /// + /// Returns `Err` for account types that have no managed-side representation + /// (e.g. DIP-13 identity authentication accounts). Falls back to a + /// `NoKeySource` construction if the primary key-source path fails for any + /// other reason (e.g. hardened derivation from a public-only xpub). + pub fn from_account(account: &super::Account) -> Result { // Use the account's public key as the key source let key_source = address_pool::KeySource::Public(account.account_xpub); - let managed_type = ManagedAccountType::from_account_type( + let managed_type = match ManagedAccountType::from_account_type( account.account_type, account.network, &key_source, - ) - .unwrap_or_else(|_| { - // Fallback: create without pre-generated addresses - let no_key_source = address_pool::KeySource::NoKeySource; - ManagedAccountType::from_account_type( - account.account_type, - account.network, - &no_key_source, - ) - .expect("Should succeed with NoKeySource") - }); + ) { + Ok(managed) => managed, + Err(e @ crate::error::Error::InvalidParameter(_)) => return Err(e), + Err(_) => { + // Fallback: create without pre-generated addresses + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + )? + } + }; - Self::new(managed_type, account.network, account.is_watch_only) + Ok(Self::new(managed_type, account.network, account.is_watch_only)) } - /// Create a ManagedAccount from a BLS Account + /// Create a ManagedAccount from a BLS Account. + /// + /// Returns `Err` for account types that have no managed-side representation + /// (e.g. DIP-13 identity authentication accounts). #[cfg(feature = "bls")] - pub fn from_bls_account(account: &BLSAccount) -> Self { + pub fn from_bls_account(account: &BLSAccount) -> Result { // Use the BLS public key as the key source let key_source = address_pool::KeySource::BLSPublic(account.bls_public_key.clone()); - let managed_type = ManagedAccountType::from_account_type( + let managed_type = match ManagedAccountType::from_account_type( account.account_type, account.network, &key_source, - ) - .unwrap_or_else(|_| { - // Fallback: create without pre-generated addresses - let no_key_source = address_pool::KeySource::NoKeySource; - ManagedAccountType::from_account_type( - account.account_type, - account.network, - &no_key_source, - ) - .expect("Should succeed with NoKeySource") - }); + ) { + Ok(managed) => managed, + Err(e @ crate::error::Error::InvalidParameter(_)) => return Err(e), + Err(_) => { + // Fallback: create without pre-generated addresses + let no_key_source = address_pool::KeySource::NoKeySource; + ManagedAccountType::from_account_type( + account.account_type, + account.network, + &no_key_source, + )? + } + }; - Self::new(managed_type, account.network, account.is_watch_only) + Ok(Self::new(managed_type, account.network, account.is_watch_only)) } /// Create a ManagedAccount from an EdDSA Account @@ -959,6 +971,40 @@ impl ManagedCoreAccount { } } + /// Derive the next BLS public key from an address pool. + /// + /// Used by [`Self::next_bls_operator_key`]. + #[cfg(feature = "bls")] + fn next_bls_key_from_pool( + addresses: &mut address_pool::AddressPool, + account_xpub: Option, + add_to_state: bool, + ) -> Result, &'static str> { + let key_source = match account_xpub { + Some(xpub) => address_pool::KeySource::BLSPublic(xpub), + None => address_pool::KeySource::NoKeySource, + }; + + let info = addresses + .next_unused_with_info(&key_source, add_to_state) + .map_err(|_| "Failed to get next unused address")?; + + let Some(PublicKeyType::BLS(pub_key_bytes)) = info.public_key else { + return Err("Expected BLS public key but got different key type"); + }; + + use dashcore::blsful::{Bls12381G2Impl, PublicKey, SerializationFormat}; + let public_key = PublicKey::::from_bytes_with_mode( + &pub_key_bytes, + SerializationFormat::Modern, + ) + .map_err(|_| "Failed to deserialize BLS public key")?; + + addresses.mark_index_used(info.index); + + Ok(public_key) + } + /// Generate the next BLS operator key (only for ProviderOperatorKeys accounts) /// Returns the BLS public key at the next unused index #[cfg(feature = "bls")] @@ -971,36 +1017,7 @@ impl ManagedCoreAccount { ManagedAccountType::ProviderOperatorKeys { addresses, .. - } => { - // Create key source from the optional BLS public key - let key_source = match account_xpub { - Some(xpub) => address_pool::KeySource::BLSPublic(xpub), - None => address_pool::KeySource::NoKeySource, - }; - - // Use next_unused_with_info to get the next address (handles caching and derivation) - let info = addresses - .next_unused_with_info(&key_source, add_to_state) - .map_err(|_| "Failed to get next unused address")?; - - // Extract the BLS public key from the address info - let Some(PublicKeyType::BLS(pub_key_bytes)) = info.public_key else { - return Err("Expected BLS public key but got different key type"); - }; - - // Mark as used - addresses.mark_index_used(info.index); - - // Convert bytes to BLS public key - use dashcore::blsful::{Bls12381G2Impl, PublicKey, SerializationFormat}; - let public_key = PublicKey::::from_bytes_with_mode( - &pub_key_bytes, - SerializationFormat::Modern, - ) - .map_err(|_| "Failed to deserialize BLS public key")?; - - Ok(public_key) - } + } => Self::next_bls_key_from_pool(addresses, account_xpub, add_to_state), _ => Err("This method only works for ProviderOperatorKeys accounts"), } } diff --git a/key-wallet/src/transaction_checking/transaction_router/mod.rs b/key-wallet/src/transaction_checking/transaction_router/mod.rs index e26152648..6b15d3d73 100644 --- a/key-wallet/src/transaction_checking/transaction_router/mod.rs +++ b/key-wallet/src/transaction_checking/transaction_router/mod.rs @@ -167,10 +167,24 @@ impl TransactionRouter { } } -/// Core account types that can be checked for transactions +/// Core account types that can be checked for transactions. /// -/// Note: Platform Payment accounts (DIP-17) are NOT included here as they -/// operate on Dash Platform, not the Core chain. +/// Platform-only account types are NOT represented here because they operate +/// on Dash Platform, not the Core chain. The two Platform-only cases differ +/// in where they live and how the conversion rejects them: +/// +/// - **Platform Payment (DIP-17)** exists on both sides: +/// [`ManagedAccountType::PlatformPayment`] and +/// [`crate::AccountType::PlatformPayment`]. `TryFrom` on either produces +/// [`PlatformAccountConversionError`]. +/// - **DIP-13 identity authentication** exists ONLY on the immutable side +/// as [`crate::AccountType::IdentityAuthenticationEcdsa`] / +/// [`crate::AccountType::IdentityAuthenticationBls`]. It intentionally +/// has no [`ManagedAccountType`] variant — these keys carry no L1 state, +/// so there is nothing to manage. Consequently +/// `TryFrom` rejects only `PlatformPayment`, while +/// `TryFrom` rejects both `PlatformPayment` and the +/// two DIP-13 identity authentication variants. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AccountTypeToCheck { StandardBIP44, @@ -190,13 +204,14 @@ pub enum AccountTypeToCheck { DashpayExternalAccount, } -/// Error returned when trying to convert a Platform Payment account to AccountTypeToCheck +/// Error returned when trying to convert a Platform-only account (Platform +/// Payment or DIP-13 identity authentication) to [`AccountTypeToCheck`]. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct PlatformAccountConversionError; impl core::fmt::Display for PlatformAccountConversionError { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - write!(f, "PlatformPayment accounts cannot be converted to AccountTypeToCheck") + write!(f, "Platform-only accounts cannot be converted to AccountTypeToCheck") } } diff --git a/key-wallet/src/wallet/accounts.rs b/key-wallet/src/wallet/accounts.rs index f6c4041f1..7f479ce7c 100644 --- a/key-wallet/src/wallet/accounts.rs +++ b/key-wallet/src/wallet/accounts.rs @@ -124,10 +124,13 @@ impl Wallet { /// Add a new BLS account to the wallet /// - /// BLS accounts are used for Platform/masternode operations. + /// BLS accounts are used for Platform/masternode operations. Accepts + /// [`AccountType::ProviderOperatorKeys`] and + /// [`AccountType::IdentityAuthenticationBls`]. /// /// # Arguments - /// * `account_type` - The type of account (must be ProviderOperatorKeys) + /// * `account_type` - The type of account (must be ProviderOperatorKeys + /// or IdentityAuthenticationBls) /// * `bls_seed` - Optional 32-byte seed for BLS key generation. If not provided, /// the account will be derived from the wallet's private key. /// @@ -140,9 +143,13 @@ impl Wallet { bls_seed: Option<[u8; 32]>, ) -> Result<()> { // Validate account type - if !matches!(account_type, AccountType::ProviderOperatorKeys) { + if !matches!( + account_type, + AccountType::ProviderOperatorKeys | AccountType::IdentityAuthenticationBls { .. } + ) { return Err(Error::InvalidParameter( - "BLS accounts can only be ProviderOperatorKeys".to_string(), + "BLS accounts can only be ProviderOperatorKeys or IdentityAuthenticationBls" + .to_string(), )); } @@ -200,9 +207,13 @@ impl Wallet { passphrase: &str, ) -> Result<()> { // Validate account type - if !matches!(account_type, AccountType::ProviderOperatorKeys) { + if !matches!( + account_type, + AccountType::ProviderOperatorKeys | AccountType::IdentityAuthenticationBls { .. } + ) { return Err(Error::InvalidParameter( - "BLS accounts can only be ProviderOperatorKeys".to_string(), + "BLS accounts can only be ProviderOperatorKeys or IdentityAuthenticationBls" + .to_string(), )); } diff --git a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs index f21713c1b..61409f82d 100644 --- a/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs +++ b/key-wallet/src/wallet/managed_wallet_info/managed_accounts.rs @@ -42,7 +42,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreAccount::from_account(account); + let managed_account = ManagedCoreAccount::from_account(account)?; // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -117,7 +117,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { let account = Account::new(None, account_type, account_xpub, self.network)?; // Create the ManagedAccount from the Account - let managed_account = ManagedCoreAccount::from_account(&account); + let managed_account = ManagedCoreAccount::from_account(&account)?; // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -162,7 +162,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { })?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreAccount::from_bls_account(bls_account); + let managed_account = ManagedCoreAccount::from_bls_account(bls_account)?; // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) { @@ -234,7 +234,7 @@ impl ManagedAccountOperations for ManagedWalletInfo { BLSAccount::from_public_key_bytes(None, account_type, bls_public_key, self.network)?; // Create the ManagedAccount from the BLS Account - let managed_account = ManagedCoreAccount::from_bls_account(&bls_account); + let managed_account = ManagedCoreAccount::from_bls_account(&bls_account)?; // Check if managed account already exists if self.accounts.contains_managed_account_type(managed_account.managed_type()) {