From 83235e13168e4cc55054f847c687353cb9c6c2bd Mon Sep 17 00:00:00 2001 From: Lukasz Klimek <842586+lklimek@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:54:00 +0000 Subject: [PATCH] fix(wallet): resolve saved asset-lock keys through platform-wallet Keep manual Platform funding signing inside the wallet backend secret session and reject invitation vouchers. Exercise credit-key lookup without the legacy address cache. Co-Authored-By: OpenAI Codex --- CHANGELOG.md | 3 + src/backend_task/error.rs | 6 ++ .../fund_platform_address_from_asset_lock.rs | 99 +++---------------- src/wallet_backend/identity_ops.rs | 86 ++++++++++++++++ src/wallet_backend/payments.rs | 2 +- tests/backend-e2e/wallet_tasks.rs | 7 ++ 6 files changed, 114 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ce4225a8..171aff3d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -152,6 +152,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Funding a Platform address from a saved asset lock now resolves its signing key + through platform-wallet, including locks absent from DET’s local address cache. + - Backend E2E funding waits for final, unreserved asset-lock inputs and recovers leftover test funds before checking the suite budget. Asset-lock tests select the newly broadcast transaction in duffs; shielded tests wait for balance diff --git a/src/backend_task/error.rs b/src/backend_task/error.rs index d9803d2a8..7e8c5b65b 100644 --- a/src/backend_task/error.rs +++ b/src/backend_task/error.rs @@ -365,6 +365,12 @@ pub enum TaskError { )] AssetLockNotEligibleForTopUp, + /// Invitation credit keys are bearer vouchers and must not be reclaimed by generic funding. + #[error( + "This funding transaction belongs to a DashPay invitation. Choose a different funding transaction." + )] + AssetLockReservedForInvitation, + /// A top-up of an identity outside this wallet was handed a funding mode /// this path cannot build — currently only the whole-account drain, which /// no flow here requests. Rejected before any funds move rather than diff --git a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs index 8b0f42552..1a23d5c1f 100644 --- a/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs +++ b/src/backend_task/wallet/fund_platform_address_from_asset_lock.rs @@ -5,7 +5,6 @@ use crate::model::wallet::WalletSeedHash; use dash_sdk::dpp::address_funds::PlatformAddress; use dash_sdk::dpp::balances::credits::Credits; use dash_sdk::dpp::dashcore::OutPoint; -use dash_sdk::dpp::dashcore::transaction::special_transaction::TransactionPayload; use std::collections::BTreeMap; use std::sync::Arc; @@ -124,95 +123,19 @@ impl AppContext { out_point: OutPoint, outputs: BTreeMap>, ) -> Result { - use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; - use dash_sdk::dpp::dashcore::Address; - use dash_sdk::platform::transition::top_up_address::TopUpAddress; - - let backend = self.wallet_backend()?; - let tracked = backend - .list_tracked_asset_locks(&seed_hash) - .await? - .into_iter() - .find(|t| t.out_point == out_point) - .ok_or(TaskError::AssetLockAddressNotFound)?; - - let asset_lock_proof = tracked - .proof - .clone() - .ok_or(TaskError::AssetLockAddressNotFound)?; - - // Recover the credit-output address from the asset-lock transaction - // payload — the first credit output is the funded address. - let payload = tracked - .transaction - .special_transaction_payload - .as_ref() - .ok_or(TaskError::AssetLockAddressNotFound)?; - let asset_lock_payload = match payload { - TransactionPayload::AssetLockPayloadType(p) => p, - _ => return Err(TaskError::AssetLockAddressNotFound), - }; - let credit_output = asset_lock_payload - .credit_outputs - .first() - .ok_or(TaskError::AssetLockAddressNotFound)?; - let asset_lock_address = Address::from_script(&credit_output.script_pubkey, self.network) - .map_err(|_| TaskError::AssetLockAddressNotFound)?; + use crate::wallet_backend::PlatformPathIndex; - let (wallet, sdk) = { - let wallet = self.wallet_arc(&seed_hash)?.read()?.clone(); - let sdk = backend.sdk().clone(); - (wallet, sdk) + let path_index = { + let wallet = self.wallet_arc(&seed_hash)?; + let wallet = wallet.read()?; + PlatformPathIndex::from_wallet(&wallet, self.network) }; - - // Resolve the HD seed once through the chokepoint and, inside that same - // scope, both derive the asset-lock address's private key AND build the - // JIT platform signer that authorises each funded-output witness. The - // seed is borrowed for the whole top-up and zeroizes when the closure - // returns — it never enters this layer by value. The pure path index is - // built before the scope. - use crate::wallet_backend::{DetPlatformSigner, PlatformPathIndex}; - let network = self.network; - let asset_lock_address_for_lookup = asset_lock_address.clone(); - let path_index = PlatformPathIndex::from_wallet(&wallet, network); - let fee_strategy = vec![AddressFundsFeeStrategyStep::ReduceOutput(0)]; - - let _result = backend - .secret_access() - .with_secret_session( - &crate::wallet_backend::SecretScope::HdSeed { seed_hash }, - async |session| { - let plaintext = session.plaintext(); - let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; - let asset_lock_private_key = wallet - .private_key_for_address_with_seed( - seed, - &asset_lock_address_for_lookup, - network, - ) - .map_err(|detail| { - tracing::warn!(error = %detail, "Asset-lock key derivation failed"); - TaskError::WalletKeyLookupFailed - })? - .ok_or(TaskError::AssetLockAddressNotFound)?; - let signer = DetPlatformSigner::from_held(seed, network, &path_index); - // Non-owned destination: the manual `TopUpAddress` submit - // has no orchestrated consume/retry. A failure propagates via - // `?`, so the flow never reports a false success; the - // orchestrated recovery is reserved for wallet-owned - // destinations (see the owned branch above). - outputs - .top_up( - &sdk, - asset_lock_proof, - asset_lock_private_key, - fee_strategy, - &signer, - None, - ) - .await - .map_err(TaskError::from) - }, + self.wallet_backend()? + .fund_platform_address_from_tracked_lock_manual( + &seed_hash, + out_point, + outputs, + &path_index, ) .await?; diff --git a/src/wallet_backend/identity_ops.rs b/src/wallet_backend/identity_ops.rs index 6ce0b967f..563a1d8a1 100644 --- a/src/wallet_backend/identity_ops.rs +++ b/src/wallet_backend/identity_ops.rs @@ -279,6 +279,15 @@ async fn persist_account_registrations( .await } +fn validate_manual_asset_lock_role( + funding_type: platform_wallet::AssetLockFundingType, +) -> Result<(), TaskError> { + if funding_type == platform_wallet::AssetLockFundingType::IdentityInvitation { + return Err(TaskError::AssetLockReservedForInvitation); + } + Ok(()) +} + impl WalletBackend { /// Register a new identity on Platform funded by an asset lock built and /// tracked-to-finality by the upstream `AssetLockManager`. Returns the @@ -821,6 +830,65 @@ impl WalletBackend { .await } + /// Fund destinations outside the upstream address pool using a tracked lock. + /// The credit key comes from the upstream funding account, independently of + /// DET's display address cache. Secrets stay inside one held session. + pub(crate) async fn fund_platform_address_from_tracked_lock_manual( + &self, + seed_hash: &WalletSeedHash, + out_point: dash_sdk::dpp::dashcore::OutPoint, + outputs: std::collections::BTreeMap< + dash_sdk::dpp::address_funds::PlatformAddress, + Option, + >, + path_index: &PlatformPathIndex, + ) -> Result<(), TaskError> { + use dash_sdk::dpp::address_funds::AddressFundsFeeStrategyStep; + use dash_sdk::platform::transition::top_up_address::TopUpAddress; + + let tracked = self + .list_tracked_asset_locks(seed_hash) + .await? + .into_iter() + .find(|lock| lock.out_point == out_point) + .ok_or(TaskError::AssetLockAddressNotFound)?; + validate_manual_asset_lock_role(tracked.funding_type)?; + + let scope = Self::hd_scope(seed_hash); + self.inner + .secret_access + .with_secret_session(&scope, async |session| { + let wallet = self.resolve_wallet(seed_hash).await?; + let (proof, credit_output_path) = wallet + .asset_locks() + .resume_asset_lock(&out_point, None) + .await + .map_err(|source| TaskError::WalletBackend { + source: Arc::new(source), + })?; + let private_key = + self.derive_private_key_from_held(session.plaintext(), &credit_output_path)?; + let plaintext = session.plaintext(); + let seed = plaintext.expose_hd_seed().ok_or(TaskError::WalletLocked)?; + let signer = DetPlatformSigner::from_held(seed, self.inner.network, path_index); + // Non-pool destinations use the SDK directly; upstream's + // orchestrated consume/retry requires pool-owned recipients. + outputs + .top_up( + self.sdk(), + proof, + private_key, + vec![AddressFundsFeeStrategyStep::ReduceOutput(0)], + &signer, + None, + ) + .await + .map(|_| ()) + .map_err(TaskError::from) + }) + .await + } + // UPSTREAM GAP: rs-platform-wallet has no identity-funding-account // registrar (sibling to register_contact_account). Contained exception — // key_wallet plumbing lives ONLY here, never leaks past WalletBackend. @@ -1214,6 +1282,24 @@ mod tests { use platform_wallet::changeset::ClientStartState; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn manual_asset_lock_funding_protects_invitation_vouchers() { + use platform_wallet::AssetLockFundingType::*; + assert!(matches!( + validate_manual_asset_lock_role(IdentityInvitation), + Err(TaskError::AssetLockReservedForInvitation) + )); + for funding_type in [ + IdentityRegistration, + IdentityTopUp, + IdentityTopUpNotBound, + AssetLockAddressTopUp, + AssetLockShieldedAddressTopUp, + ] { + validate_manual_asset_lock_role(funding_type).expect("non-voucher lock can be funded"); + } + } + /// Counts the writes [`persist_account_registrations`] issues, answering /// the inline-commit question however the test asks it to. struct CountingPersister { diff --git a/src/wallet_backend/payments.rs b/src/wallet_backend/payments.rs index ec57e73ab..fdbe28539 100644 --- a/src/wallet_backend/payments.rs +++ b/src/wallet_backend/payments.rs @@ -630,7 +630,7 @@ impl WalletBackend { /// transitions (Platform-address top-up, shielded deposit). The seed is /// the one already held open by the surrounding `with_secret_session` /// scope, so this never re-prompts. - fn derive_private_key_from_held( + pub(super) fn derive_private_key_from_held( &self, plaintext: SecretPlaintext<'_>, path: &dash_sdk::dpp::key_wallet::bip32::DerivationPath, diff --git a/tests/backend-e2e/wallet_tasks.rs b/tests/backend-e2e/wallet_tasks.rs index 5fd59c248..9335e02c5 100644 --- a/tests/backend-e2e/wallet_tasks.rs +++ b/tests/backend-e2e/wallet_tasks.rs @@ -774,6 +774,13 @@ async fn tc_018_fund_platform_address_from_asset_lock() { tracing::info!(%credit_address, known_locally, proof_present = tracked.proof.is_some(), "TC-018: credit-output signing lookup"); + // Exercise signing without the legacy DET credit-address cache entry. + wallet_arc + .write() + .unwrap() + .known_addresses + .remove(&credit_address); + // Step 3: Derive a fresh platform address for funding let platform_addr = crate::framework::funding::derive_platform_receive_address( &ctx.app_context,