diff --git a/src/backend_task/core/create_asset_lock.rs b/src/backend_task/core/create_asset_lock.rs index dc5a0e26e..027198e3d 100644 --- a/src/backend_task/core/create_asset_lock.rs +++ b/src/backend_task/core/create_asset_lock.rs @@ -16,10 +16,11 @@ impl AppContext { ) -> Result { let amount_duffs = amount / CREDITS_PER_DUFF; - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { + let (asset_lock_transaction, _private_key, _change_address, used_utxos, wallet_seed_hash) = { let mut wallet_guard = wallet.write()?; + let seed_hash = wallet_guard.seed_hash(); - wallet_guard + let (tx, key, addr, utxos) = wallet_guard .registration_asset_lock_transaction( self, self.network, @@ -27,29 +28,19 @@ impl AppContext { allow_take_fee_from_amount, identity_index, ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; + (tx, key, addr, utxos, seed_hash) }; - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); - } + let tx_id = self + .broadcast_and_commit_asset_lock( + &asset_lock_transaction, + amount_duffs, + &wallet_seed_hash, + &wallet, + &used_utxos, + ) + .await?; Ok(BackendTaskSuccessResult::Message(format!( "Asset lock transaction broadcast successfully. TX ID: {}", @@ -67,10 +58,11 @@ impl AppContext { ) -> Result { let amount_duffs = amount / CREDITS_PER_DUFF; - let (asset_lock_transaction, _private_key, _change_address, _used_utxos) = { + let (asset_lock_transaction, _private_key, _change_address, used_utxos, wallet_seed_hash) = { let mut wallet_guard = wallet.write()?; + let seed_hash = wallet_guard.seed_hash(); - wallet_guard + let (tx, key, addr, utxos) = wallet_guard .top_up_asset_lock_transaction( self, self.network, @@ -79,29 +71,19 @@ impl AppContext { identity_index, top_up_index, ) - .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })? + .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; + (tx, key, addr, utxos, seed_hash) }; - let tx_id = asset_lock_transaction.txid(); - - { - let mut proofs = self.transactions_waiting_for_finality.lock()?; - proofs.insert(tx_id, None); - } - - if let Err(e) = self - .broadcast_raw_transaction(&asset_lock_transaction) - .await - { - if let Ok(mut proofs) = self.transactions_waiting_for_finality.lock() { - proofs.remove(&tx_id); - } else { - tracing::warn!( - "Failed to clean up finality tracking for tx {tx_id}: Mutex poisoned" - ); - } - return Err(e); - } + let tx_id = self + .broadcast_and_commit_asset_lock( + &asset_lock_transaction, + amount_duffs, + &wallet_seed_hash, + &wallet, + &used_utxos, + ) + .await?; Ok(BackendTaskSuccessResult::Message(format!( "Asset lock transaction broadcast successfully. TX ID: {}", diff --git a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs index a84d969c9..566c9cb3f 100644 --- a/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs +++ b/src/backend_task/wallet/fund_platform_address_from_wallet_utxos.rs @@ -1,7 +1,7 @@ use crate::backend_task::BackendTaskSuccessResult; use crate::backend_task::error::TaskError; use crate::context::AppContext; -use crate::model::wallet::WalletSeedHash; +use crate::model::wallet::{AssetLockUsage, WalletSeedHash}; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::CREDITS_PER_DUFF; use std::sync::Arc; @@ -50,12 +50,17 @@ impl AppContext { let mut wallet = wallet_arc.write()?; + let usage = AssetLockUsage::PlatformAddressFunding; + let funding_index = wallet.next_generic_funding_index(self.network, usage); + // Try to create the asset lock transaction, reload UTXOs if needed match wallet.generic_asset_lock_transaction( self, self.network, asset_lock_amount, allow_take_fee_from_amount, + usage, + funding_index, ) { Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), Err(e) => { @@ -73,6 +78,8 @@ impl AppContext { self.network, asset_lock_amount, allow_take_fee_from_amount, + usage, + funding_index, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; (tx, private_key, address, utxos) diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index f9de609d2..b73b6ce5c 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -1,5 +1,5 @@ use crate::context::AppContext; -use crate::model::wallet::Wallet; +use crate::model::wallet::{AssetLockUsage, Wallet}; use dash_sdk::dashcore_rpc::dashcore::key::Secp256k1; use dash_sdk::dpp::dashcore::secp256k1::Message; use dash_sdk::dpp::dashcore::sighash::SighashCache; @@ -221,8 +221,12 @@ impl Wallet { ) } - /// Create an asset lock transaction with a randomly generated one-time key. + /// Create an asset lock transaction with a deterministic HD-derived key. /// This is used for generic platform address funding (not identity-specific). + /// + /// The key is derived from `m/9'/coin_type'/5'/sub_feature'/index'` where + /// the sub-feature is determined by the `AssetLockUsage` parameter per + /// DIP-9 Feature 5' assignments. #[allow(clippy::type_complexity)] pub fn generic_asset_lock_transaction( &mut self, @@ -230,6 +234,8 @@ impl Wallet { network: Network, amount: u64, allow_take_fee_from_amount: bool, + usage: AssetLockUsage, + funding_index: u32, ) -> Result< ( Transaction, @@ -240,15 +246,11 @@ impl Wallet { ), String, > { - use bip39::rand::rngs::OsRng; - - // Generate a random private key for the asset lock + let private_key = + self.generic_asset_lock_ecdsa_private_key(app_context, network, usage, funding_index)?; let secp = Secp256k1::new(); - let (secret_key, _) = secp.generate_keypair(&mut OsRng); - let private_key = PrivateKey::new(secret_key, network); let public_key = private_key.public_key(&secp); - // The asset lock address is where the proof will be tied to let asset_lock_address = Address::p2pkh(&public_key, network); let (tx, returned_private_key, change_address, used_utxos) = self diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index c186dd9ad..136f23b3f 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -147,6 +147,12 @@ pub trait DerivationPathHelpers { key_class: u32, index: u32, ) -> DerivationPath; + fn is_generic_asset_lock_funding(&self, network: Network) -> bool; + fn is_generic_asset_lock_funding_for_usage( + &self, + network: Network, + usage: AssetLockUsage, + ) -> bool; } pub(crate) fn is_bip44_path(path: &DerivationPath, network: Network) -> bool { @@ -248,6 +254,89 @@ impl DerivationPathHelpers for DerivationPath { ChildNumber::Normal { index }, ]) } + + fn is_generic_asset_lock_funding(&self, network: Network) -> bool { + let coin_type = match network { + Network::Mainnet => 5, + _ => 1, + }; + let components = self.as_ref(); + components.len() == 5 + && components[0] == ChildNumber::Hardened { index: 9 } + && components[1] == ChildNumber::Hardened { index: coin_type } + && components[2] == ChildNumber::Hardened { index: 5 } + && matches!( + components[3], + ChildNumber::Hardened { index: 4 } | ChildNumber::Hardened { index: 5 } + ) + } + + fn is_generic_asset_lock_funding_for_usage( + &self, + network: Network, + usage: AssetLockUsage, + ) -> bool { + let coin_type = match network { + Network::Mainnet => 5, + _ => 1, + }; + let components = self.as_ref(); + components.len() == 5 + && components[0] == ChildNumber::Hardened { index: 9 } + && components[1] == ChildNumber::Hardened { index: coin_type } + && components[2] == ChildNumber::Hardened { index: 5 } + && components[3] + == ChildNumber::Hardened { + index: usage.sub_feature_index(), + } + } +} + +/// DIP-9 Feature 5' sub-features for asset lock key derivation. +/// +/// Each variant maps to a sub-feature index under `m/9'/coin_type'/5'/`. +/// See +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum AssetLockUsage { + /// Sub-feature 1': Identity registration funding. + Registration, + /// Sub-feature 2': Identity top-up funding. + TopUp, + /// Sub-feature 3': Invitation funding. + Invitation, + /// Sub-feature 4': Generic platform address funding. + PlatformAddressFunding, + /// Sub-feature 5': Shielded (Orchard) address top-up. + ShieldedTopUp, +} + +impl AssetLockUsage { + /// DIP-9 sub-feature index under Feature 5'. + pub fn sub_feature_index(&self) -> u32 { + match self { + Self::Registration => 1, + Self::TopUp => 2, + Self::Invitation => 3, + Self::PlatformAddressFunding => 4, + Self::ShieldedTopUp => 5, + } + } +} + +/// Create a derivation path for asset lock funding: m/9'/coin_type'/5'/sub_feature'/index' +/// +/// Sub-feature is determined by `AssetLockUsage` per DIP-9 Feature 5' assignments. +fn asset_lock_funding_path(network: Network, usage: AssetLockUsage, index: u32) -> DerivationPath { + let coin_type = Wallet::coin_type(network); + DerivationPath::from(vec![ + ChildNumber::Hardened { index: 9 }, + ChildNumber::Hardened { index: coin_type }, + ChildNumber::Hardened { index: 5 }, + ChildNumber::Hardened { + index: usage.sub_feature_index(), + }, + ChildNumber::Hardened { index }, + ]) } use crate::context::AppContext; @@ -270,6 +359,8 @@ const BOOTSTRAP_IDENTITY_INVITATION_COUNT: u32 = 8; const BOOTSTRAP_IDENTITY_TOPUP_PER_REGISTRATION: u32 = 4; const BOOTSTRAP_IDENTITY_TOPUP_NOT_BOUND_COUNT: u32 = 8; const BOOTSTRAP_PROVIDER_ADDRESS_COUNT: u32 = 4; +/// Number of generic asset lock funding addresses to bootstrap +const BOOTSTRAP_GENERIC_FUNDING_ADDRESS_COUNT: u32 = 8; /// DIP-17: Number of Platform payment addresses to bootstrap per key class const BOOTSTRAP_PLATFORM_PAYMENT_ADDRESS_COUNT: u32 = 20; @@ -810,6 +901,10 @@ impl Wallet { if let Err(err) = self.bootstrap_platform_payment_addresses(network, app_context) { tracing::warn!("Failed to bootstrap Platform payment addresses: {}", err); } + + if let Err(err) = self.bootstrap_generic_funding_addresses(network, app_context) { + tracing::warn!("Failed to bootstrap generic funding addresses: {}", err); + } } pub fn set_transactions(&mut self, transactions: Vec) { @@ -1420,6 +1515,35 @@ impl Wallet { Ok(()) } + fn bootstrap_generic_funding_addresses( + &mut self, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + let usages = [ + AssetLockUsage::PlatformAddressFunding, + AssetLockUsage::ShieldedTopUp, + ]; + for usage in usages { + for index in 0..BOOTSTRAP_GENERIC_FUNDING_ADDRESS_COUNT { + let derivation_path = asset_lock_funding_path(network, usage, index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(&seed, network) + .map_err(|e| WalletError::KeyDerivation { source: e }.to_string())?; + let private_key = extended_private_key.to_priv(); + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + app_context, + )?; + } + } + Ok(()) + } + fn identity_registration_indices(&self) -> BTreeSet { let mut indices: BTreeSet = self.identities.keys().copied().collect(); let fallback_limit = BOOTSTRAP_IDENTITY_REGISTRATION_FALLBACK; @@ -1563,6 +1687,9 @@ impl Wallet { } } + // TODO(DIP-9): Top-up currently uses m/9'/ct'/5'/identity_index'/top_up_index' + // but DIP-9 assigns sub-feature 2' for top-up. Changing this would break + // existing wallets. Needs migration strategy. pub fn identity_top_up_ecdsa_private_key( &mut self, app_context: &AppContext, @@ -1587,6 +1714,9 @@ impl Wallet { Ok(private_key) } + // TODO(DIP-9): Registration currently uses m/9'/ct'/5'/0'/index' but DIP-9 + // assigns sub-feature 1' for registration. Changing this would break existing + // wallets that already derived keys on the old path. Needs migration strategy. /// Generate Core key for identity registration pub fn identity_registration_ecdsa_private_key( &mut self, @@ -1610,6 +1740,52 @@ impl Wallet { Ok(private_key) } + /// Generate a deterministic key for generic asset lock funding. + /// + /// Uses derivation path `m/9'/coin_type'/5'/sub_feature'/index'` to produce + /// a key that is always recoverable from the wallet seed, preventing fund + /// loss when Platform rejects a state transition after the asset lock is + /// broadcast. + pub fn generic_asset_lock_ecdsa_private_key( + &mut self, + app_context: &AppContext, + network: Network, + usage: AssetLockUsage, + funding_index: u32, + ) -> Result { + let derivation_path = asset_lock_funding_path(network, usage, funding_index); + let extended_private_key = derivation_path + .derive_priv_ecdsa_for_master_seed(self.seed_bytes()?, network) + .expect("derivation should not be able to fail"); + let private_key = extended_private_key.to_priv(); + + self.register_address_from_private_key( + &private_key, + &derivation_path, + DerivationPathType::CREDIT_FUNDING, + DerivationPathReference::BlockchainIdentityCreditRegistrationFunding, + app_context, + )?; + Ok(private_key) + } + + /// Find the next unused index for generic asset lock funding by scanning + /// `known_addresses` for paths matching the given `AssetLockUsage`. + pub fn next_generic_funding_index(&self, network: Network, usage: AssetLockUsage) -> u32 { + self.known_addresses + .values() + .filter(|path| path.is_generic_asset_lock_funding_for_usage(network, usage)) + .filter_map(|path| { + path.as_ref().last().and_then(|child| match child { + ChildNumber::Hardened { index } => Some(*index), + _ => None, + }) + }) + .max() + .map(|max_idx| max_idx + 1) + .unwrap_or(0) + } + pub fn receive_address( &mut self, network: Network, @@ -3342,6 +3518,72 @@ mod tests { assert!(!path.is_asset_lock_funding(Network::Mainnet)); } + #[test] + fn test_asset_lock_usage_sub_feature_indices() { + assert_eq!(AssetLockUsage::Registration.sub_feature_index(), 1); + assert_eq!(AssetLockUsage::TopUp.sub_feature_index(), 2); + assert_eq!(AssetLockUsage::Invitation.sub_feature_index(), 3); + assert_eq!( + AssetLockUsage::PlatformAddressFunding.sub_feature_index(), + 4 + ); + assert_eq!(AssetLockUsage::ShieldedTopUp.sub_feature_index(), 5); + } + + #[test] + fn test_asset_lock_funding_path_structure() { + let path = + asset_lock_funding_path(Network::Testnet, AssetLockUsage::PlatformAddressFunding, 7); + let components = path.as_ref(); + assert_eq!(components.len(), 5); + assert_eq!(components[0], ChildNumber::Hardened { index: 9 }); + assert_eq!(components[1], ChildNumber::Hardened { index: 1 }); // testnet + assert_eq!(components[2], ChildNumber::Hardened { index: 5 }); // Feature 5' + assert_eq!(components[3], ChildNumber::Hardened { index: 4 }); // PlatformAddressFunding + assert_eq!(components[4], ChildNumber::Hardened { index: 7 }); // index + } + + #[test] + fn test_asset_lock_funding_path_shielded() { + let path = asset_lock_funding_path(Network::Testnet, AssetLockUsage::ShieldedTopUp, 0); + let components = path.as_ref(); + assert_eq!(components.len(), 5); + assert_eq!(components[2], ChildNumber::Hardened { index: 5 }); + assert_eq!(components[3], ChildNumber::Hardened { index: 5 }); // ShieldedTopUp + } + + #[test] + fn test_is_generic_asset_lock_funding() { + // PlatformAddressFunding path (sub-feature 4') + let path_platform = + asset_lock_funding_path(Network::Testnet, AssetLockUsage::PlatformAddressFunding, 0); + assert!(path_platform.is_generic_asset_lock_funding(Network::Testnet)); + assert!(!path_platform.is_generic_asset_lock_funding(Network::Mainnet)); + + // ShieldedTopUp path (sub-feature 5') + let path_shielded = + asset_lock_funding_path(Network::Testnet, AssetLockUsage::ShieldedTopUp, 0); + assert!(path_shielded.is_generic_asset_lock_funding(Network::Testnet)); + + // Registration path (sub-feature 1') should NOT match generic + let path_reg = asset_lock_funding_path(Network::Testnet, AssetLockUsage::Registration, 0); + assert!(!path_reg.is_generic_asset_lock_funding(Network::Testnet)); + } + + #[test] + fn test_is_generic_asset_lock_funding_for_usage() { + let path = + asset_lock_funding_path(Network::Testnet, AssetLockUsage::PlatformAddressFunding, 3); + assert!(path.is_generic_asset_lock_funding_for_usage( + Network::Testnet, + AssetLockUsage::PlatformAddressFunding + )); + assert!(!path.is_generic_asset_lock_funding_for_usage( + Network::Testnet, + AssetLockUsage::ShieldedTopUp + )); + } + #[test] fn test_is_platform_payment() { let path = DerivationPath::from(vec![