Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,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
Expand Down
6 changes: 6 additions & 0 deletions src/backend_task/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
99 changes: 11 additions & 88 deletions src/backend_task/wallet/fund_platform_address_from_asset_lock.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -124,95 +123,19 @@ impl AppContext {
out_point: OutPoint,
outputs: BTreeMap<PlatformAddress, Option<Credits>>,
) -> Result<BackendTaskSuccessResult, TaskError> {
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?;

Expand Down
86 changes: 86 additions & 0 deletions src/wallet_backend/identity_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<dash_sdk::dpp::balances::credits::Credits>,
>,
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.
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/wallet_backend/payments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions tests/backend-e2e/wallet_tasks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading