From 77f2f55d77fda2195bbe34a7a935202ff0ca21a1 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:09:46 +0100 Subject: [PATCH 1/3] fix(wallet): use deterministic HD key for generic asset lock transactions Replace randomly generated OsRng key in generic_asset_lock_transaction() with a deterministic HD-derived key at m/9'/coin_type'/15'/index'. This ensures asset lock keys are always recoverable from the wallet seed, preventing permanent fund loss when Platform rejects a state transition after Core has already broadcast the asset lock transaction. Co-Authored-By: Claude Opus 4.6 --- ...fund_platform_address_from_wallet_utxos.rs | 4 + src/model/wallet/asset_lock_transaction.rs | 16 +-- src/model/wallet/mod.rs | 101 ++++++++++++++++++ 3 files changed, 114 insertions(+), 7 deletions(-) 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..cc339b10e 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 @@ -50,12 +50,15 @@ impl AppContext { let mut wallet = wallet_arc.write()?; + let funding_index = wallet.next_generic_funding_index(self.network); + // 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, + funding_index, ) { Ok((tx, private_key, address, _change, utxos)) => (tx, private_key, address, utxos), Err(e) => { @@ -73,6 +76,7 @@ impl AppContext { self.network, asset_lock_amount, allow_take_fee_from_amount, + 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..1658fa78e 100644 --- a/src/model/wallet/asset_lock_transaction.rs +++ b/src/model/wallet/asset_lock_transaction.rs @@ -221,8 +221,13 @@ 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'/15'/funding_index'`, making it + /// always recoverable from the wallet seed. This prevents permanent fund + /// loss when Platform rejects a state transition after the asset lock + /// transaction has been broadcast. #[allow(clippy::type_complexity)] pub fn generic_asset_lock_transaction( &mut self, @@ -230,6 +235,7 @@ impl Wallet { network: Network, amount: u64, allow_take_fee_from_amount: bool, + 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, 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..f1d4c6219 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -147,6 +147,7 @@ pub trait DerivationPathHelpers { key_class: u32, index: u32, ) -> DerivationPath; + fn is_generic_asset_lock_funding(&self, network: Network) -> bool; } pub(crate) fn is_bip44_path(path: &DerivationPath, network: Network) -> bool { @@ -248,6 +249,33 @@ 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() == 4 + && components[0] == ChildNumber::Hardened { index: 9 } + && components[1] == ChildNumber::Hardened { index: coin_type } + && components[2] == ChildNumber::Hardened { index: 15 } + } +} + +/// Create a derivation path for generic asset lock funding: m/9'/coin_type'/15'/index' +/// +/// This path is used for asset lock transactions that are not tied to a specific +/// identity (e.g., platform address funding). Sub-feature index 15 is chosen to +/// avoid collision with existing sub-features (5 = identity, 17 = platform payment). +fn asset_lock_funding_path(network: Network, 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: 15 }, + ChildNumber::Hardened { index }, + ]) } use crate::context::AppContext; @@ -270,6 +298,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 +840,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 +1454,29 @@ impl Wallet { Ok(()) } + fn bootstrap_generic_funding_addresses( + &mut self, + network: Network, + app_context: &AppContext, + ) -> Result<(), String> { + let seed = *self.seed_bytes()?; + for index in 0..BOOTSTRAP_GENERIC_FUNDING_ADDRESS_COUNT { + let derivation_path = asset_lock_funding_path(network, 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; @@ -1610,6 +1667,50 @@ impl Wallet { Ok(private_key) } + /// Generate a deterministic key for generic asset lock funding. + /// + /// Uses derivation path `m/9'/coin_type'/15'/index'` to produce a key that + /// is always recoverable from the wallet seed, preventing fund loss when a + /// Platform state transition is rejected after the asset lock is broadcast. + pub fn generic_asset_lock_ecdsa_private_key( + &mut self, + app_context: &AppContext, + network: Network, + funding_index: u32, + ) -> Result { + let derivation_path = asset_lock_funding_path(network, 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 existing paths under `m/9'/coin_type'/15'/*'`. + pub fn next_generic_funding_index(&self, network: Network) -> u32 { + self.known_addresses + .values() + .filter(|path| path.is_generic_asset_lock_funding(network)) + .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, From 2444aca3965f6e1f6cac9e9db3db21dd6a54a0c5 Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:47:09 +0100 Subject: [PATCH 2/3] fix(wallet): use DIP-9 Feature 5' paths for asset lock derivation Replace incorrect feature 15' with DIP-9-compliant Feature 5' sub-feature paths for asset lock key derivation. Introduce AssetLockUsage enum that maps each usage to its DIP-9 sub-feature index (1'=Registration, 2'=TopUp, 3'=Invitation, 4'=PlatformAddressFunding, 5'=ShieldedTopUp). Path structure changes from m/9'/ct'/15'/index' (4 components) to m/9'/ct'/5'/sub_feature'/index' (5 components). Bootstrap now pre-derives addresses for both PlatformAddressFunding and ShieldedTopUp usages. Add TODOs for pre-existing registration/top-up path mismatches that would require a migration strategy to fix. Co-Authored-By: Claude Opus 4.6 --- ...fund_platform_address_from_wallet_utxos.rs | 7 +- src/model/wallet/asset_lock_transaction.rs | 12 +- src/model/wallet/mod.rs | 197 +++++++++++++++--- 3 files changed, 180 insertions(+), 36 deletions(-) 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 cc339b10e..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,7 +50,8 @@ impl AppContext { let mut wallet = wallet_arc.write()?; - let funding_index = wallet.next_generic_funding_index(self.network); + 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( @@ -58,6 +59,7 @@ impl AppContext { 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), @@ -76,6 +78,7 @@ impl AppContext { self.network, asset_lock_amount, allow_take_fee_from_amount, + usage, funding_index, ) .map_err(|e| TaskError::AssetLockTransactionBuildFailed { detail: e })?; diff --git a/src/model/wallet/asset_lock_transaction.rs b/src/model/wallet/asset_lock_transaction.rs index 1658fa78e..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; @@ -224,10 +224,9 @@ impl Wallet { /// 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'/15'/funding_index'`, making it - /// always recoverable from the wallet seed. This prevents permanent fund - /// loss when Platform rejects a state transition after the asset lock - /// transaction has been broadcast. + /// 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, @@ -235,6 +234,7 @@ impl Wallet { network: Network, amount: u64, allow_take_fee_from_amount: bool, + usage: AssetLockUsage, funding_index: u32, ) -> Result< ( @@ -247,7 +247,7 @@ impl Wallet { String, > { let private_key = - self.generic_asset_lock_ecdsa_private_key(app_context, network, funding_index)?; + self.generic_asset_lock_ecdsa_private_key(app_context, network, usage, funding_index)?; let secp = Secp256k1::new(); let public_key = private_key.public_key(&secp); diff --git a/src/model/wallet/mod.rs b/src/model/wallet/mod.rs index f1d4c6219..136f23b3f 100644 --- a/src/model/wallet/mod.rs +++ b/src/model/wallet/mod.rs @@ -148,6 +148,11 @@ pub trait DerivationPathHelpers { 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 { @@ -256,24 +261,80 @@ impl DerivationPathHelpers for DerivationPath { _ => 1, }; let components = self.as_ref(); - components.len() == 4 + components.len() == 5 && components[0] == ChildNumber::Hardened { index: 9 } && components[1] == ChildNumber::Hardened { index: coin_type } - && components[2] == ChildNumber::Hardened { index: 15 } + && 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(), + } } } -/// Create a derivation path for generic asset lock funding: m/9'/coin_type'/15'/index' +/// DIP-9 Feature 5' sub-features for asset lock key derivation. /// -/// This path is used for asset lock transactions that are not tied to a specific -/// identity (e.g., platform address funding). Sub-feature index 15 is chosen to -/// avoid collision with existing sub-features (5 = identity, 17 = platform payment). -fn asset_lock_funding_path(network: Network, index: u32) -> DerivationPath { +/// 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: 15 }, + ChildNumber::Hardened { index: 5 }, + ChildNumber::Hardened { + index: usage.sub_feature_index(), + }, ChildNumber::Hardened { index }, ]) } @@ -1460,19 +1521,25 @@ impl Wallet { app_context: &AppContext, ) -> Result<(), String> { let seed = *self.seed_bytes()?; - for index in 0..BOOTSTRAP_GENERIC_FUNDING_ADDRESS_COUNT { - let derivation_path = asset_lock_funding_path(network, 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, - )?; + 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(()) } @@ -1620,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, @@ -1644,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, @@ -1669,16 +1742,18 @@ impl Wallet { /// Generate a deterministic key for generic asset lock funding. /// - /// Uses derivation path `m/9'/coin_type'/15'/index'` to produce a key that - /// is always recoverable from the wallet seed, preventing fund loss when a - /// Platform state transition is rejected after the asset lock is broadcast. + /// 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, funding_index); + 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"); @@ -1695,11 +1770,11 @@ impl Wallet { } /// Find the next unused index for generic asset lock funding by scanning - /// `known_addresses` for existing paths under `m/9'/coin_type'/15'/*'`. - pub fn next_generic_funding_index(&self, network: Network) -> u32 { + /// `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(network)) + .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), @@ -3443,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![ From 7067e6bfa6f106b725d53b937dcba5e875f6cefc Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:46:23 +0100 Subject: [PATCH 3/3] fix: unify asset lock persistence for create_asset_lock paths Replace direct broadcast_raw_transaction() calls in create_registration_asset_lock and create_top_up_asset_lock with broadcast_and_commit_asset_lock() to ensure DB persistence before broadcast and UTXO cleanup after. Prevents recovery gaps if app crashes after Core broadcast but before state transition completes. All asset lock broadcast paths now go through the single centralized broadcast_and_commit_asset_lock() function. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/backend_task/core/create_asset_lock.rs | 74 ++++++++-------------- 1 file changed, 28 insertions(+), 46 deletions(-) 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: {}",