From 6d4f7e9b731ae3202902021b298c300a4377502a Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:19:50 -0400 Subject: [PATCH 01/20] perf(dpp): prove a shield-from-asset-lock bundle before its lock proof exists Add `ProvedShieldFromAssetLockBundle`, which splits the external-signer `ShieldFromAssetLock` builder into a proving step and an assembly step. `prove` builds and proves the outputs-only Orchard bundle from the locked outpoint alone. The bundle's sighash binds the outpoint, not the asset lock proof: `shield_from_asset_lock_extra_sighash_data` hashes the outpoint for InstantSend and ChainLock proofs alike. So the proof can run before the InstantSend lock exists. `build_transition_with_signer(&self, ..)` recomputes the binding from the real proof and refuses a mismatch. It then signs the proved bundle afresh and has the external signer sign the transition. It does not consume the bundle, so one proof can be wrapped around an InstantSend proof and, after a rejection, around a ChainLock proof of the same outpoint. The binding and padding spend-auth signatures are randomized RedPallas signatures over the sighash fixed at proving time. Two assemblies therefore never produce the same transition bytes, which is what a retry needs to get past Tenderdash's cache of rejected transition hashes. `build_shield_from_asset_lock_transition_with_signer` now goes through the two steps; its output is unchanged. The raw-key builder is untouched. The new API is gated on `core_key_wallet`, like the signer builder. Tests check that: - re-authorized bundles verify with `BatchValidator` and carry distinct binding signatures; - the assembled transitions share one proof around InstantSend and ChainLock proofs, and each assembly is signed afresh; - a different outpoint is refused; - the binding ignores the proof kind and chain-lock height at every protocol version. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-dpp/src/shielded/builder/mod.rs | 148 ++++++++++++- .../builder/shield_from_asset_lock.rs | 195 ++++++++++++++++-- .../signing_tests.rs | 131 ++++++++++++ 3 files changed, 454 insertions(+), 20 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 3651ecbf12b..c87af7c8f41 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -73,6 +73,11 @@ pub use token_shielded_transfer::build_token_shielded_transfer_transition; pub use token_unshield::build_token_unshield_transition; pub use unshield::build_unshield_transition; +// Prove-then-assemble form of the external-signer `ShieldFromAssetLock` builder, for proving +// while the asset lock's InstantSend lock is still awaited. +#[cfg(feature = "core_key_wallet")] +pub use shield_from_asset_lock::ProvedShieldFromAssetLockBundle; + use grovedb_commitment_tree::{ Anchor, Authorized, Builder, Bundle, BundleType, DashMemo, Flags as OrchardFlags, FullViewingKey, MerklePath, Note, NoteValue, OutgoingViewingKey, PaymentAddress, ProvingKey, @@ -222,6 +227,93 @@ pub(crate) fn build_output_only_bundle( extra_sighash_data: &[u8], prover: &P, ) -> Result, ProtocolError> { + let builder = output_only_bundle_builder(recipient, amount, memo, sender_ovk, dummy_outputs)?; + prove_and_sign_bundle(builder, prover, &[], extra_sighash_data) +} + +#[cfg(feature = "core_key_wallet")] +type AuthorizedBundle = Bundle; + +/// An output-only Orchard bundle whose Halo 2 proof has been created but that carries no +/// signatures yet. Produced by [`prove_output_only_bundle`]. +/// +/// [`authorize`](Self::authorize) applies the binding signature and the padding spends' +/// spend-authorization signatures over the sighash fixed when the bundle was proved. Those are +/// randomized RedPallas signatures, so every call returns a bundle with the same actions, +/// commitment and proof but different signature bytes. +#[cfg(feature = "core_key_wallet")] +pub(crate) struct ProvedOutputOnlyBundle { + /// Signs a clone of the proved bundle. The proved-but-unsigned bundle type + /// (`orchard::builder::InProgress`) is not re-exported by + /// `grovedb-commitment-tree`, so it lives inside this closure. + sign: Box Result + Send + Sync>, +} + +#[cfg(feature = "core_key_wallet")] +impl ProvedOutputOnlyBundle { + /// Signs the proved bundle with fresh randomness. + pub(crate) fn authorize(&self) -> Result { + (self.sign)() + } +} + +/// [`build_output_only_bundle`] without the final signing step: builds and proves the bundle and +/// binds the platform sighash (`extra_sighash_data`), but leaves the signatures to +/// [`ProvedOutputOnlyBundle::authorize`]. Signing an output-only bundle needs no spending key +/// (its spends are padding), so one proof can be authorized any number of times. +#[cfg(feature = "core_key_wallet")] +pub(crate) fn prove_output_only_bundle( + recipient: &OrchardAddress, + amount: u64, + memo: [u8; 36], + sender_ovk: Option, + dummy_outputs: usize, + extra_sighash_data: &[u8], + prover: &P, +) -> Result { + let builder = output_only_bundle_builder(recipient, amount, memo, sender_ovk, dummy_outputs)?; + let mut rng = OsRng; + + let (unauthorized, _) = builder + .build::(&mut rng) + .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to build bundle: {:?}", e)))? + .ok_or_else(|| { + ProtocolError::ShieldedBuildError("bundle was empty after build".to_string()) + })?; + + let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); + let sighash = compute_platform_sighash(&bundle_commitment, extra_sighash_data); + + let proven = unauthorized + .create_proof(prover.proving_key(), &mut rng) + .map_err(|e| { + ProtocolError::ShieldedBuildError(format!("failed to create proof: {:?}", e)) + })?; + + Ok(ProvedOutputOnlyBundle { + sign: Box::new(move || { + proven + .clone() + .apply_signatures(OsRng, sighash, &[]) + .map_err(|e| { + ProtocolError::ShieldedBuildError(format!( + "failed to apply signatures: {:?}", + e + )) + }) + }), + }) +} + +/// The unproved builder of an output-only bundle: the real output, then `dummy_outputs` +/// zero-value fillers (see [`build_output_only_bundle`]). +fn output_only_bundle_builder( + recipient: &OrchardAddress, + amount: u64, + memo: [u8; 36], + sender_ovk: Option, + dummy_outputs: usize, +) -> Result, ProtocolError> { let payment_address = PaymentAddress::from(recipient); let anchor = Anchor::empty_tree(); let mut builder = Builder::::new( @@ -252,7 +344,7 @@ pub(crate) fn build_output_only_bundle( })?; } - prove_and_sign_bundle(builder, prover, &[], extra_sighash_data) + Ok(builder) } /// Builds a spend+output Orchard bundle. @@ -549,6 +641,60 @@ mod mod_tests { ); } + // ------------------------------------------------------------------ + // `prove_output_only_bundle` — one proof, authorized any number of + // times. Each authorization must verify on its own (proof + binding + // + padding spend-auth signatures, exactly as consensus checks them) + // and must not repeat the previous one's signature bytes. + // ------------------------------------------------------------------ + + #[cfg(feature = "core_key_wallet")] + #[test] + fn proved_output_only_bundle_reauthorizes_into_distinct_valid_bundles() { + use grovedb_commitment_tree::{BatchValidator, VerifyingKey}; + + let extra_sighash_data = [0x86u8; 33]; + let proved = prove_output_only_bundle( + &test_orchard_address(), + 10_000, + [0u8; 36], + None, + 0, + &extra_sighash_data, + &TestProver, + ) + .expect("bundle should prove"); + let first = proved.authorize().expect("first authorization"); + let second = proved.authorize().expect("second authorization"); + + let commitment: [u8; 32] = first.commitment().into(); + assert_eq!(commitment, <[u8; 32]>::from(second.commitment())); + assert_eq!( + first.authorization().proof().as_ref(), + second.authorization().proof().as_ref(), + "authorizing must not re-prove" + ); + let first_sb = serialize_authorized_bundle(&first); + let second_sb = serialize_authorized_bundle(&second); + assert_ne!(first_sb.binding_signature, second_sb.binding_signature); + assert_eq!(*first.value_balance(), -10_000i64); + + let sighash = compute_platform_sighash(&commitment, &extra_sighash_data); + let vk = VerifyingKey::build(); + for bundle in [&first, &second] { + let mut batch = BatchValidator::new(); + batch.add_bundle(bundle, sighash); + assert!( + batch.validate(&vk, OsRng), + "re-authorized bundle must verify" + ); + } + // The signatures are bound to the sighash fixed at proving time. + let mut batch = BatchValidator::new(); + batch.add_bundle(&first, compute_platform_sighash(&commitment, &[0x86u8; 1])); + assert!(!batch.validate(&vk, OsRng)); + } + // ------------------------------------------------------------------ // `build_output_only_bundle` dummy-output padding — the on-wire // action count is `max(1 + dummy_outputs, 2)` (Orchard pads an diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index bb5dbe3190c..bddcc0cd57c 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -1,4 +1,9 @@ +#[cfg(feature = "core_key_wallet")] +use dashcore::OutPoint; + use crate::address_funds::{OrchardAddress, PlatformAddress}; +#[cfg(feature = "core_key_wallet")] +use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use crate::prelude::AssetLockProof; use crate::shielded::shield_from_asset_lock_extra_sighash_data; use crate::state_transition::shield_from_asset_lock_transition::methods::ShieldFromAssetLockTransitionMethodsV0; @@ -10,6 +15,157 @@ use platform_version::version::PlatformVersion; use super::{ build_output_only_bundle, serialize_authorized_bundle, OrchardProver, SerializedBundle, }; +#[cfg(feature = "core_key_wallet")] +use super::{prove_output_only_bundle, ProvedOutputOnlyBundle}; + +/// A `ShieldFromAssetLock` Orchard bundle that has been proved and bound to its funding asset +/// lock but not yet wrapped into a signed transition. Proved by [`Self::prove`]; wrapped (any +/// number of times) by [`Self::build_transition_with_signer`]. +/// +/// What each part of the finished transition commits to: +/// +/// - The Halo 2 proof covers the circuit's public inputs: each action's nullifier, `rk`, `cmx` +/// and `cv_net`, the anchor (the empty tree) and the flags. +/// - The binding signature (which also enforces the value balance against the `cv_net`s) and +/// the padding spends' spend-authorization signatures are over the platform sighash: the +/// bundle commitment — the actions including their note ciphertexts (so the recipient note and +/// the sender OVK's out-ciphertext), the flags, the value balance and the anchor, without +/// signatures or proof — plus [`shield_from_asset_lock_extra_sighash_data`]: a kind tag and the +/// double SHA-256 of the locked **outpoint**. They do not cover how the lock is proven +/// (InstantSend or ChainLock), the surplus output, or anything else about the transition. +/// - The outer ECDSA signature by the asset-lock key covers the whole transition except itself: +/// the asset-lock proof, the bundle fields (including the signatures above) and the surplus +/// output. `ShieldFromAssetLock` has no `user_fee_increase`. +/// +/// The sighash is fixed when the bundle is proved, so everything up to the bundle signatures +/// depends only on the locked outpoint and value, the recipient and the sender OVK. The +/// expensive part — the proof — can be produced as soon as the asset-lock transaction exists, +/// before its InstantSend lock arrives, and stays valid when the transition is later wrapped +/// around a ChainLock proof of the same outpoint. Everything that depends on the lock proof is +/// signed at assembly time. +/// +/// Every assembly signs the proved bundle afresh. RedPallas signatures are randomized, so two +/// transitions assembled from one proved bundle never hash alike — a client resubmitting after a +/// rejection relies on that, as Tenderdash caches the hashes of rejected transitions and drops an +/// identical resubmission. +#[cfg(feature = "core_key_wallet")] +pub struct ProvedShieldFromAssetLockBundle { + bundle: ProvedOutputOnlyBundle, + /// The asset-lock binding the bundle's sighash committed to (see + /// [`shield_from_asset_lock_extra_sighash_data`]); empty at protocol versions that bind + /// nothing. + extra_sighash_data: Vec, +} + +#[cfg(feature = "core_key_wallet")] +impl ProvedShieldFromAssetLockBundle { + /// Proves the outputs-only bundle of a `ShieldFromAssetLock` that will be funded by the asset + /// lock at `asset_lock_out_point`. + /// + /// The CPU-heavy half of [`build_shield_from_asset_lock_transition`] (Halo 2 + /// proof generation, seconds of work); it does no I/O and no signing, so it can run on a + /// blocking thread while the caller waits for the lock's InstantSend lock. Parameters are as + /// for [`build_shield_from_asset_lock_transition`]. + #[allow(clippy::too_many_arguments)] + pub fn prove( + recipient: &OrchardAddress, + shield_amount: u64, + asset_lock_out_point: OutPoint, + prover: &P, + memo: [u8; 36], + sender_ovk: Option, + dummy_outputs: usize, + platform_version: &PlatformVersion, + ) -> Result { + // The binding is a function of the locked outpoint alone: `AssetLockProof::create_identifier` + // is the double SHA-256 of the outpoint for an InstantSend and a ChainLock proof alike + // (pinned by `sighash`'s + // `should_bind_the_whole_outpoint_of_the_asset_lock_not_only_its_transaction`). A chain + // proof of the outpoint therefore stands in for the proof that does not exist yet; its + // height is not part of the binding. Assembly re-derives the binding from the real proof + // and refuses a mismatch. + let lock_placeholder = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 0, + out_point: asset_lock_out_point, + }); + let extra_sighash_data = + shield_from_asset_lock_extra_sighash_data(&lock_placeholder, platform_version)?; + let bundle = prove_output_only_bundle( + recipient, + shield_amount, + memo, + sender_ovk, + dummy_outputs, + &extra_sighash_data, + prover, + )?; + Ok(Self { + bundle, + extra_sighash_data, + }) + } + + /// Whether this bundle binds exactly what consensus will re-derive for a transition carrying + /// `asset_lock_proof` at `platform_version`: the same locked outpoint under the same binding + /// rules. The kind of proof (InstantSend or ChainLock) does not matter. A bundle proved for + /// another outpoint, or at a protocol version with a different binding, must be proved again + /// — except across protocol versions that bind nothing (`credit_pool_bundle_binding` unset), + /// where any lock matches, as it does for consensus. + pub fn is_bound_for( + &self, + asset_lock_proof: &AssetLockProof, + platform_version: &PlatformVersion, + ) -> Result { + Ok( + shield_from_asset_lock_extra_sighash_data(asset_lock_proof, platform_version)? + == self.extra_sighash_data, + ) + } + + /// Wraps the proved bundle into a `ShieldFromAssetLock` transition funded by + /// `asset_lock_proof`: signs the bundle afresh (see the type docs) and has + /// `asset_lock_signer` produce the outer ECDSA signature over the transition. Does not consume + /// the proved bundle, so a rejected transition can be re-assembled (with a new asset-lock + /// proof, or the same one) without proving again. + /// + /// Errors instead of producing a transition consensus would reject when the bundle is not + /// bound to `asset_lock_proof` at `platform_version` (see [`Self::is_bound_for`]). + pub async fn build_transition_with_signer( + &self, + asset_lock_proof: AssetLockProof, + asset_lock_proof_path: &::key_wallet::bip32::DerivationPath, + asset_lock_signer: &AS, + surplus_output: Option, + platform_version: &PlatformVersion, + ) -> Result + where + AS: ::key_wallet::signer::Signer, + { + if !self.is_bound_for(&asset_lock_proof, platform_version)? { + return Err(ProtocolError::ShieldedBuildError( + "shield_from_asset_lock: the asset lock proof does not match the binding the \ + bundle was proved with (different outpoint or protocol version)" + .to_string(), + )); + } + let sb = serialize_authorized_bundle(&self.bundle.authorize()?); + let value_balance = pool_inflow(&sb)?; + + ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + asset_lock_proof, + asset_lock_proof_path, + asset_lock_signer, + sb.actions, + value_balance, + sb.anchor, + sb.proof, + sb.binding_signature, + surplus_output, + platform_version, + ) + .await + } +} /// Builds a ShieldFromAssetLock state transition (core asset lock -> shielded pool). /// @@ -119,35 +275,34 @@ where P: OrchardProver, AS: ::key_wallet::signer::Signer, { - let (sb, value_balance) = build_bound_bundle( + // The same proof-then-assembly a client proving ahead of the lock proof goes through. + let asset_lock_out_point = asset_lock_proof.out_point().ok_or_else(|| { + ProtocolError::IdentifierError(String::from("No output at a given index")) + })?; + ProvedShieldFromAssetLockBundle::prove( recipient, shield_amount, - &asset_lock_proof, + asset_lock_out_point, prover, memo, sender_ovk, dummy_outputs, platform_version, - )?; - - ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( + )? + .build_transition_with_signer( asset_lock_proof, asset_lock_proof_path, asset_lock_signer, - sb.actions, - value_balance, - sb.anchor, - sb.proof, - sb.binding_signature, surplus_output, platform_version, ) .await } -/// Proves the outputs-only bundle of a `ShieldFromAssetLock` funded by `asset_lock_proof` and -/// returns it with the amount it moves into the pool. Both builders go through here, so a client -/// signing with a raw key and one signing through an external signer bind the same preimage. +/// Proves and signs the outputs-only bundle of a `ShieldFromAssetLock` funded by +/// `asset_lock_proof` and returns it with the amount it moves into the pool, for the raw-key +/// builder. It binds the same preimage as [`ProvedShieldFromAssetLockBundle::prove`], which the +/// external-signer builder goes through. #[allow(clippy::too_many_arguments)] fn build_bound_bundle( recipient: &OrchardAddress, @@ -173,19 +328,21 @@ fn build_bound_bundle( prover, )?; let sb = serialize_authorized_bundle(&bundle); + let value_balance = pool_inflow(&sb)?; + Ok((sb, value_balance)) +} - // For output-only bundles, Orchard value_balance is negative (value flowing in). - // Convert to u64 (absolute amount entering the pool). - let value_balance = sb - .value_balance +/// The amount an outputs-only bundle moves into the pool. Its Orchard value_balance is negative +/// (value flowing in); this is the absolute amount, as a `u64`. +fn pool_inflow(sb: &SerializedBundle) -> Result { + sb.value_balance .checked_neg() .and_then(|v| u64::try_from(v).ok()) .ok_or_else(|| { ProtocolError::ShieldedBuildError( "shield_from_asset_lock: bundle value_balance is not negative".to_string(), ) - })?; - Ok((sb, value_balance)) + }) } #[cfg(test)] diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 9ba80c940a9..621abbb0d6f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -96,6 +96,36 @@ fn make_chain_asset_lock_proof() -> AssetLockProof { }) } +/// An InstantSend proof of a one-output asset-lock transaction. Its InstantSend lock is not +/// valid; nothing here verifies it. +fn make_instant_asset_lock_proof() -> AssetLockProof { + use crate::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::transaction::special_transaction::TransactionPayload; + use dashcore::{InstantLock, ScriptBuf, Transaction, TxOut}; + + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 0, + credit_outputs: vec![TxOut { + value: 100_000, + script_pubkey: ScriptBuf::new(), + }], + }, + )), + }; + AssetLockProof::Instant(InstantAssetLockProof::new( + InstantLock::default(), + transaction, + 0, + )) +} + fn extract_v0(state_transition: StateTransition) -> ShieldFromAssetLockTransitionV0 { let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = state_transition @@ -350,3 +380,104 @@ async fn seed_pool_batch_fits_max_state_transition_size() { "dummy outputs are zero-value: value_balance must equal the real amount", ); } + +#[tokio::test] +async fn proved_bundle_assembles_around_instant_and_chain_proofs_of_its_outpoint() { + // The bundle is proved before the InstantSend lock exists, from the outpoint alone, then + // wrapped around the InstantSend proof and — after a rejection — around a ChainLock proof of + // the same outpoint. Both assemblies must carry the one proof, and each must be signed afresh + // so a resubmission never repeats a rejected transition's hash. + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + use crate::shielded::builder::ProvedShieldFromAssetLockBundle; + + let platform_version = PlatformVersion::latest(); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + let shield_amount = 50_000u64; + + let instant = make_instant_asset_lock_proof(); + let out_point = instant.out_point().expect("fixture has a credit output"); + let chain = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point, + }); + let other_lock = make_chain_asset_lock_proof(); + + let proved = ProvedShieldFromAssetLockBundle::prove( + &test_orchard_address(), + shield_amount, + out_point, + &TestProver, + [0u8; 36], + None, + 0, + platform_version, + ) + .expect("proving should succeed"); + assert!(proved.is_bound_for(&instant, platform_version).unwrap()); + assert!(proved.is_bound_for(&chain, platform_version).unwrap()); + assert!(!proved.is_bound_for(&other_lock, platform_version).unwrap()); + + let assemble = |proof: AssetLockProof| { + proved.build_transition_with_signer(proof, &path, &signer, None, platform_version) + }; + let over_instant = extract_v1(assemble(instant.clone()).await.expect("instant assembly")); + let resubmitted = extract_v1(assemble(instant).await.expect("instant re-assembly")); + let over_chain = extract_v1(assemble(chain).await.expect("chain assembly")); + + for v1 in [&over_instant, &resubmitted, &over_chain] { + assert_eq!(v1.value_balance, shield_amount); + assert_eq!(v1.proof, over_instant.proof, "one proof, never re-proved"); + assert_eq!(v1.anchor, over_instant.anchor); + assert_eq!(v1.actions.len(), over_instant.actions.len()); + for (action, first) in v1.actions.iter().zip(&over_instant.actions) { + assert_eq!(action.nullifier, first.nullifier); + assert_eq!(action.cmx, first.cmx); + assert_eq!(action.cv_net, first.cv_net); + assert_eq!(action.encrypted_note, first.encrypted_note); + } + } + assert_ne!( + over_instant.binding_signature, resubmitted.binding_signature, + "every assembly signs afresh, so the same proof never yields the same transition twice" + ); + assert_ne!(over_instant.signature, resubmitted.signature); + + let err = assemble(other_lock) + .await + .expect_err("a bundle bound to another outpoint must not be assembled"); + assert!( + matches!(&err, crate::ProtocolError::ShieldedBuildError(msg) + if msg.contains("does not match the binding")), + "unexpected error: {err:?}" + ); +} + +#[test] +fn proved_bundle_binding_is_the_outpoint_binding_at_every_version() { + // `ProvedShieldFromAssetLockBundle::prove` derives the binding from a stand-in chain proof + // of the outpoint. That is only sound because the binding never depends on the proof kind + // or the chain-lock height: pin it at every version, binding or not. + use crate::shielded::shield_from_asset_lock_extra_sighash_data; + + let instant = make_instant_asset_lock_proof(); + let out_point = instant.out_point().expect("fixture has a credit output"); + let versions: Vec<&PlatformVersion> = (1..=PlatformVersion::latest().protocol_version) + .filter_map(|version| PlatformVersion::get(version).ok()) + .collect(); + assert!(versions.len() > 1); + for platform_version in versions { + let bound = |proof: &AssetLockProof| { + shield_from_asset_lock_extra_sighash_data(proof, platform_version).unwrap() + }; + for height in [0, 1, u32::MAX] { + assert_eq!( + bound(&instant), + bound(&AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: height, + out_point, + })), + ); + } + } +} From 9ff4a13d40eda17f98f2857640aa1e47c2e01434 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:20:01 -0400 Subject: [PATCH 02/20] refactor(platform-wallet): report an asset lock's outpoint before its proof wait Add `resolve_funding_observing_out_point` to the asset lock manager. It is `resolve_funding_with_is_timeout_fallback` plus a callback that receives the lock's outpoint as soon as the lock is committed to the operation: - for a new lock, right after the asset-lock transaction is broadcast; - for a resumed lock, once it has passed the consumed and role checks. Either way the callback runs before the InstantSend wait. Two crate-internal wrappers, `create_funded_asset_lock_proof_observed` and `create_funded_asset_lock_proof_with_funding_observed`, carry the hook into the build-broadcast-wait pipeline. The existing entry points pass a no-op, so their behaviour is unchanged. This prepares the shielded flow to start proving while the lock proof is still pending. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/wallet/asset_lock/build.rs | 60 +++++++++++++++++++ .../src/wallet/asset_lock/orchestration.rs | 41 ++++++++++++- 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 8feba80262c..3c172ce8ff8 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -812,6 +812,31 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + self.create_funded_asset_lock_proof_observed( + amount_duffs, + account_index, + funding_type, + identity_index, + signer, + |_| {}, + ) + .await + } + + /// [`Self::create_funded_asset_lock_proof`], calling `on_broadcast` with + /// the lock's outpoint as soon as the transaction is broadcast — before + /// the proof wait — so a caller can start work that needs only the + /// outpoint while the InstantSend lock is awaited. Not called when the + /// build or the broadcast fails. + pub(crate) async fn create_funded_asset_lock_proof_observed( + &self, + amount_duffs: u64, + account_index: u32, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { self.create_funded_asset_lock_proof_pooled( AssetLockBuildAmount::Exact(amount_duffs), @@ -820,6 +845,7 @@ impl AssetLockManager { funding_type, identity_index, signer, + on_broadcast, ) .await } @@ -837,6 +863,30 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { + self.create_funded_asset_lock_proof_with_funding_observed( + amount, + funding_account, + funding_type, + identity_index, + signer, + |_| {}, + ) + .await + } + + /// [`Self::create_funded_asset_lock_proof_with_funding`] with the + /// `on_broadcast` hook of [`Self::create_funded_asset_lock_proof_observed`]. + pub(crate) async fn create_funded_asset_lock_proof_with_funding_observed< + S: ExtendedPubKeySigner, + >( + &self, + amount: AssetLockBuildAmount, + funding_account: AssetLockFundingAccount, + funding_type: AssetLockFundingType, + identity_index: u32, + signer: &S, + on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { self.create_funded_asset_lock_proof_pooled( amount, @@ -845,6 +895,7 @@ impl AssetLockManager { funding_type, identity_index, signer, + on_broadcast, ) .await } @@ -852,6 +903,7 @@ impl AssetLockManager { /// Source-list form of [`Self::create_funded_asset_lock_proof`] — same /// build → broadcast → proof pipeline with the pooled funding and amount /// semantics of [`Self::build_asset_lock_transaction_with_funding`]. + #[allow(clippy::too_many_arguments)] async fn create_funded_asset_lock_proof_pooled( &self, amount: AssetLockBuildAmount, @@ -860,6 +912,7 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, + on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { let (path, out_point) = self .broadcast_funded_asset_lock_with_funding( @@ -871,6 +924,7 @@ impl AssetLockManager { signer, ) .await?; + on_broadcast(out_point); let proof = self .wait_for_funded_asset_lock_proof(&out_point, source_index) .await?; @@ -928,6 +982,10 @@ impl AssetLockManager { .await } + /// Wallet-drain form of [`Self::create_funded_asset_lock_proof_observed`]: + /// `on_broadcast` gets the lock's outpoint once the transaction is + /// broadcast, before the proof wait. + #[allow(clippy::too_many_arguments)] pub(crate) async fn create_wallet_drain_asset_lock_proof( &self, account_index: u32, @@ -936,6 +994,7 @@ impl AssetLockManager { identity_index: u32, signer: &S, operation: &AssetLockOperationContext, + on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { self.require_wallet_drain_persistence()?; let (path, out_point) = self @@ -949,6 +1008,7 @@ impl AssetLockManager { Some(operation), ) .await?; + on_broadcast(out_point); let proof = self .wait_for_funded_asset_lock_proof(&out_point, account_index) .await?; diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 2f209c2a500..b5194a5dbe2 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -509,6 +509,39 @@ impl AssetLockManager { destination_index: u32, asset_lock_signer: &AS, ) -> Result + where + AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, + { + self.resolve_funding_observing_out_point( + funding, + funding_type, + destination_index, + asset_lock_signer, + |_| {}, + ) + .await + } + + /// [`Self::resolve_funding_with_is_timeout_fallback`], calling + /// `on_out_point` with the lock's outpoint as soon as it is known and the + /// lock is committed to this operation — before the proof wait: + /// + /// - `FromWalletBalance` / `DrainWalletBalance` / `DrainAccountBalance`: + /// once the new asset-lock transaction has been broadcast; + /// - `FromExistingAssetLock`: once the tracked lock has passed the + /// consumed / role checks, before it is resumed. + /// + /// Not called when the resolution fails before that point. A caller uses + /// it to start work that depends only on the outpoint (the shielded flow + /// proves its Orchard bundle) while the InstantSend lock is awaited. + pub(crate) async fn resolve_funding_observing_out_point( + &self, + funding: AssetLockFunding, + funding_type: AssetLockFundingType, + destination_index: u32, + asset_lock_signer: &AS, + on_out_point: impl FnOnce(OutPoint) + Send, + ) -> Result where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, { @@ -518,12 +551,13 @@ impl AssetLockManager { account_index, } => { match self - .create_funded_asset_lock_proof( + .create_funded_asset_lock_proof_observed( amount_duffs, account_index, funding_type, destination_index, asset_lock_signer, + on_out_point, ) .await { @@ -562,6 +596,7 @@ impl AssetLockManager { destination_index, asset_lock_signer, &operation, + on_out_point, ) .await { @@ -585,12 +620,13 @@ impl AssetLockManager { // Same pipeline as `FromWalletBalance`, with drain amount // semantics and the caller-picked funding account family. match self - .create_funded_asset_lock_proof_with_funding( + .create_funded_asset_lock_proof_with_funding_observed( super::build::AssetLockBuildAmount::DrainAll { minimum_lock_duffs }, account, funding_type, destination_index, asset_lock_signer, + on_out_point, ) .await { @@ -638,6 +674,7 @@ impl AssetLockManager { actual_identity_index, )?; } + on_out_point(out_point); // 300s is a window for the record or a mined-height lookup to // prove the lock, not a finality timeout: on expiry the caller // falls back to an unbounded ChainLock wait, so a resumed From 0a127ae311f7292300a91a15e3604eb7ec3021f3 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 00:20:13 -0400 Subject: [PATCH 03/20] perf(platform-wallet): prove the shield bundle while waiting for the InstantSend lock Core -> Shielded funding (`shielded_fund_from_asset_lock`) used to wait for the asset lock's InstantSend lock and only then build and prove the Orchard bundle, inline on an async worker. Each IS->ChainLock fallback and each CL-height retry proved a fresh bundle on top of that. The bundle commits to the locked outpoint and value, not to the lock proof, and both are known once the asset-lock transaction is broadcast. Now: - Start the proof on tokio's blocking pool from the broadcast on, while the resolver waits for the lock (`resolve_while_proving`). For a resumed lock, start it once the lock has passed the resolver's checks. - Reuse the proved bundle for every submission attempt: the first, each CL-height retry, and the IS->CL fallback (`BundleCache`). A bundle is reused only for an identical target: same outpoint, same amount, same protocol version. A speculative proof made for anything else is discarded and the bundle is proved afresh. Every attempt still signs the bundle afresh, so resubmissions keep distinct hashes for Tenderdash's rejected-transaction cache. - If the resolution fails, the speculative `ProofTask` is dropped: - a proof that has not started never runs (abort plus a cancellation flag); - a running proof's result is dropped when it finishes; - nothing is persisted. The sender OVK is now read once, before the resolution, so the speculative proof and any re-proof commit to the same key. Proving off the async runtime needs an owned prover, so `P` becomes `OrchardProver + Send + Sync + 'static`. The FFI entry points and the seed pool pass `&CachedOrchardProver` (`&'static`). Measured in the new tests, with the lock arriving one proof-length after the broadcast: - fake 400 ms proof and 400 ms lock wait: 810 ms sequential vs 409 ms overlapped; - real proof (test profile): 8.06 s vs 4.10 s. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/shielded_send.rs | 12 +- .../src/shielded_wallet_drain.rs | 2 +- .../wallet/shielded/fund_from_asset_lock.rs | 1010 +++++++++++++++-- .../src/wallet/shielded/seed_pool.rs | 4 +- .../src/wallet/shielded/wallet_drain_tests.rs | 13 +- 5 files changed, 918 insertions(+), 123 deletions(-) diff --git a/packages/rs-platform-wallet-ffi/src/shielded_send.rs b/packages/rs-platform-wallet-ffi/src/shielded_send.rs index 01f385d75b5..5192275be94 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_send.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_send.rs @@ -1643,7 +1643,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( network, ) }; - let prover = CachedOrchardProver::new(); + let prover: &'static CachedOrchardProver = &CachedOrchardProver; wallet .shielded_fund_from_asset_lock( &coordinator, @@ -1653,7 +1653,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_asset_lock( }, vec![(recipient, None)], &asset_lock_signer, - &prover, + prover, surplus_output, // Single real note, no anonymity-set fillers (the multi-note // pool-seeding path uses its own dedicated FFI entry point). @@ -1781,7 +1781,7 @@ unsafe fn shielded_fund_from_asset_lock_coinjoin_drain_inner( network, ) }; - let prover = CachedOrchardProver::new(); + let prover: &'static CachedOrchardProver = &CachedOrchardProver; wallet .shielded_fund_from_asset_lock( &coordinator, @@ -1796,7 +1796,7 @@ unsafe fn shielded_fund_from_asset_lock_coinjoin_drain_inner( }, vec![(recipient, None)], &asset_lock_signer, - &prover, + prover, // Single-recipient remainder flow: surplus is structurally // zero, so no surplus output. None, @@ -1936,7 +1936,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset network, ) }; - let prover = CachedOrchardProver::new(); + let prover: &'static CachedOrchardProver = &CachedOrchardProver; wallet .shielded_fund_from_asset_lock( &coordinator, @@ -1946,7 +1946,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_resume_fund_from_asset }, vec![(recipient, None)], &asset_lock_signer, - &prover, + prover, surplus_output, // Resuming a single-note fund (not a seeding batch). 0, diff --git a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs index 52463b4c307..fed6776d039 100644 --- a/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs +++ b/packages/rs-platform-wallet-ffi/src/shielded_wallet_drain.rs @@ -236,7 +236,7 @@ pub unsafe extern "C" fn platform_wallet_manager_shielded_fund_from_wallet_drain }, vec![(recipient, None)], &signer, - &CachedOrchardProver::new(), + &CachedOrchardProver, None, 0, None, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index f77bf13aab9..61eea68faed 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -12,15 +12,20 @@ //! bundles for Type 18; see [`validate_shielded_recipients`]). //! 2. **Resolve funding** — delegate to the shared //! [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]. +//! The Orchard bundle is proved concurrently, on the blocking pool: +//! it commits to the locked outpoint and value, not to the lock proof, +//! so the proof starts once the asset-lock transaction is broadcast +//! instead of after its InstantSend lock arrives. //! 3. **Submit** — wrap the build-and-broadcast in -//! `submit_with_cl_height_retry`; the build uses the new -//! [`build_shield_from_asset_lock_transition_with_signer`] so the -//! asset-lock-proof signature is routed through the external +//! `submit_with_cl_height_retry`; each attempt wraps the proved bundle +//! with [`ProvedShieldFromAssetLockBundle::build_transition_with_signer`] +//! so the asset-lock-proof signature is routed through the external //! `key_wallet::signer::Signer` (the host never sees the raw key). //! IS→CL fallback fires when `needs_chain_lock_proof` matches: //! Platform rejected the IS signature, or the InstantSend proof //! made the transition too large (the SDK refuses that before -//! broadcast with a typed error). +//! broadcast with a typed error). It and the CL-height retries reuse +//! the proved bundle rather than proving again. //! 4. **Finalize lock state** — a verified submit completes a wallet drain //! (`DrainCompleted`) or consumes a generic lock. A resumed drain that //! Platform rejects because its exact outpoint was already consumed is @@ -38,14 +43,17 @@ use dpp::asset_lock::StoredAssetLockInfo; use dpp::balances::credits::CREDITS_PER_DUFF; use dpp::fee::Credits; use dpp::prelude::AssetLockProof; -use dpp::shielded::builder::{build_shield_from_asset_lock_transition_with_signer, OrchardProver}; +use dpp::shielded::builder::{OrchardProver, ProvedShieldFromAssetLockBundle}; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::ProtocolError; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; use crate::wallet::asset_lock::tracked::TrackedAssetLock; use crate::wallet::asset_lock::AssetLockOperationState; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::time::Duration; use crate::broadcaster::TransactionBroadcaster; @@ -143,7 +151,11 @@ impl PlatformWallet { /// * `asset_lock_signer` — External signer for the outer ECDSA /// signature on the state transition. The raw key never crosses /// the FFI boundary. - /// * `prover` — Orchard prover (holds the Halo 2 proving key). + /// * `prover` — Orchard prover (holds the Halo 2 proving key). Owned + /// (`'static`) because the proof runs on tokio's blocking pool, and + /// starts as soon as the asset-lock transaction is broadcast — while + /// the InstantSend lock is awaited — rather than after it. Pass + /// `&CachedOrchardProver` (a `&'static` to the zero-sized handle). /// * `surplus_output` — Optional platform address that receives the /// asset-lock surplus (`lock_value − shield_amount − pool_fee`). /// @@ -163,7 +175,7 @@ impl PlatformWallet { /// resume commit to the same `shield_amount` (hence the same zero /// surplus) regardless of the resume call's `surplus_output` — so /// the surplus destination cannot desync the in-flight operation - /// even though each attempt re-signs a freshly-randomized bundle. + /// even though each attempt re-signs the bundle with fresh randomness. /// * `settings` — Optional `PutSettings`; `user_fee_increase` is /// bumped by the CL-height retry wrapper on consensus 10506. /// * `cl_wait` — ChainLock-fallback wait policy (`Option`). @@ -188,7 +200,7 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver, + P: OrchardProver + Send + Sync + 'static, { // The context is retained by the FFI caller across panic unwinding. // Register under the same guard as every shield-class operation, so a @@ -253,7 +265,7 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver, + P: OrchardProver + Send + Sync + 'static, { // On-wire Orchard action count = max(1 real + dummy_outputs, 2). Consensus // prices the flat shielded fee from this count, so the wallet's fee @@ -352,6 +364,47 @@ impl PlatformWallet { other => other, }; + let (recipient, _) = *recipients.first().expect("preflight enforces len() == 1"); + + // Encrypt the output under this wallet's OVK so the shielded sync can + // recover the funding (recipient, value, memo) from chain data alone. + // Prefer the bound account whose IVK recognizes the recipient address + // (the sent-note row then lands under that account); fall back to the + // lowest bound account, or `None` (unrecoverable out_ciphertext) if + // the shielded sub-wallet isn't bound. Read once, before the funding + // resolves, so the bundle proved while the InstantSend lock is + // awaited and any later re-proof commit to the same OVK. (It used to + // be read after the lock arrived: a shielded sub-wallet bound during + // the lock wait now takes effect from the next funding call.) + let sender_ovk = { + let guard = self.shielded_keys.read().await; + guard.as_ref().and_then(|keys| { + keys.values() + .find(|ks| { + ks.incoming_viewing_key + .diversifier_index(recipient.inner()) + .is_some() + }) + .or_else(|| keys.values().next()) + .map(|ks| ks.outgoing_viewing_key.clone()) + }) + }; + + // Proves the Orchard bundle for a target (outpoint, amount, protocol + // version) — on the blocking pool, see `ProofTask`. + let prove: ProveFn = Arc::new(move |target| { + ProvedShieldFromAssetLockBundle::prove( + &recipient, + target.shield_amount, + target.out_point, + &prover, + [0u8; 36], + sender_ovk.clone(), + dummy_outputs, + target.platform_version, + ) + }); + // Step 2: resolve funding. `AssetLockShieldedAddressTopUp` // selects the BIP44 funding family dedicated to shielded // top-ups (`accounts.asset_lock_shielded_address_topup` — @@ -361,20 +414,34 @@ impl PlatformWallet { // derivation, and `manager/accessors.rs` for the // persistence/UI tag (`fundingTypeRaw == 5`). // `destination_index = 0` is unused for this funding type. - let ResolvedFunding { - proof, - path, - tracked_out_point, - } = match self - .asset_locks - .resolve_funding_with_is_timeout_fallback( + // + // The Orchard bundle commits to the locked outpoint and value but not + // to the lock's proof (see `ProvedShieldFromAssetLockBundle`), and + // both are known once the asset-lock transaction is broadcast. So the + // proof — seconds of CPU — starts then, on the blocking pool, and runs + // while the resolver waits for the InstantSend lock; the submit below + // picks it up if the resolved lock is the one it was proved for. If + // the resolution fails, dropping the speculative task discards it. + let (out_point_tx, out_point_rx) = tokio::sync::oneshot::channel(); + let (resolution, speculative) = resolve_while_proving( + self.asset_locks.resolve_funding_observing_out_point( funding, AssetLockFundingType::AssetLockShieldedAddressTopUp, /* destination_index */ 0, asset_lock_signer, - ) - .await? - { + move |out_point| { + let _ = out_point_tx.send(out_point); + }, + ), + out_point_rx, + |out_point| self.start_speculative_shield_proof(out_point, num_actions, &prove), + ) + .await?; + let ResolvedFunding { + proof, + path, + tracked_out_point, + } = match resolution { FundingResolution::Resolved(rf) => rf, FundingResolution::IsTimeout { out_point } => { tracing::warn!( @@ -421,20 +488,7 @@ impl PlatformWallet { // lock_value − pool_fee` reserves room for the fee and pins the consensus surplus // (`lock_value − shield_amount − pool_fee`) to exactly zero. let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; - let shield_amount = asset_lock_value_credits - .checked_sub(pool_fee_credits) - .ok_or_else(|| { - PlatformWalletError::ShieldedBuildError(format!( - "asset lock value ({asset_lock_value_credits} credits) is below the \ - ShieldFromAssetLock pool fee ({pool_fee_credits} credits = shielded fee + \ - asset_lock_base_cost)" - )) - })?; - if shield_amount == 0 { - return Err(PlatformWalletError::ShieldedBuildError( - "shield amount after fee is zero".to_string(), - )); - } + let shield_amount = shield_amount_after_fee(asset_lock_value_credits, pool_fee_credits)?; // Surplus is structurally zero in this remainder flow (`shield_amount == lock_value − // pool_fee`), so `None` is always consensus-valid. Defensively assert the cap invariant @@ -461,27 +515,6 @@ impl PlatformWallet { ))); } } - let (recipient, _) = *recipients.first().expect("preflight enforces len() == 1"); - - // Encrypt the output under this wallet's OVK so the shielded sync can - // recover the funding (recipient, value, memo) from chain data alone. - // Prefer the bound account whose IVK recognizes the recipient address - // (the sent-note row then lands under that account); fall back to the - // lowest bound account, or `None` (unrecoverable out_ciphertext) if - // the shielded sub-wallet isn't bound. - let sender_ovk = { - let guard = self.shielded_keys.read().await; - guard.as_ref().and_then(|keys| { - keys.values() - .find(|ks| { - ks.incoming_viewing_key - .diversifier_index(recipient.inner()) - .is_some() - }) - .or_else(|| keys.values().next()) - .map(|ks| ks.outgoing_viewing_key.clone()) - }) - }; // Step 4: submit. Two Platform-side fallback layers — matching // the address-funding sibling: CL-height-too-low retries bump @@ -489,26 +522,34 @@ impl PlatformWallet { // cache) and an unusable IS proof (rejected, or too large) // triggers an IS→CL upgrade on the same outpoint. // + // Every attempt — the first, each CL-height retry, the IS→CL + // fallback — reuses the one proved bundle (`BundleCache`): none of + // them changes the outpoint, the amount or (barring a protocol + // upgrade mid-flight, which re-proves) the binding the proof commits + // to. + // // Subtle: `ShieldFromAssetLockTransition::set_user_fee_increase` // is a no-op (pinned at `state_transition::mod`'s // `test_shield_from_asset_lock_user_fee_increase_is_zero_and_setter_noop`), // so the wrapper's bump cannot directly diversify the ST hash // here the way it does for address-funding. Retries still avoid - // Tenderdash's invalid-tx cache because `build_output_only_bundle` - // draws fresh randomness from `OsRng` on every call - // (`packages/rs-dpp/src/shielded/builder/mod.rs`), so a re-built - // bundle has a different Halo 2 proof and therefore a different - // signable hash. If the prover is ever made deterministic for - // reproducibility, this orchestration would need an explicit - // diversifier (e.g. a memo-derived bump) to keep CL-height + // Tenderdash's invalid-tx cache because every assembly signs the + // proved bundle afresh: its binding and padding spend-auth + // signatures are randomized RedPallas signatures (drawn from + // `OsRng`), so each attempt carries different signature bytes and + // therefore a different signable hash (and outer ECDSA signature). + // If the bundle signing is ever made deterministic, this + // orchestration would need an explicit diversifier (e.g. a + // memo-derived bump, which would mean re-proving) to keep CL-height // retries from silently degrading into duplicate-hash submits. let proof_out_point = out_point_from_proof(&proof); let sdk = self.sdk.clone(); + let bundles = tokio::sync::Mutex::new(BundleCache::new(prove, speculative)); // Serialized actions of the bundle that actually landed — fed to - // the live activity recorder below. Each retry re-randomizes the - // bundle, so only the landed attempt's actions are the ones a - // later scan will recover; `build_and_broadcast_shielded` returns - // them on success, with the proof's verdict on the lock. + // the live activity recorder below. Each attempt re-signs the + // bundle, so only the landed attempt's actions (signatures + // included) are the ones on chain; `build_and_broadcast_shielded` + // returns them on success, with the proof's verdict on the lock. let (submit_result, effective_proof) = self .asset_locks .submit_with_chain_lock_fallback( @@ -519,20 +560,20 @@ impl PlatformWallet { |proof, s| { build_and_broadcast_shielded( sdk.clone(), - recipient, + &bundles, shield_amount, proof, path.clone(), asset_lock_signer, - &prover, - sender_ovk.clone(), surplus_output, - dummy_outputs, s, ) }, ) .await?; + // Release the proved bundle (and, if no attempt ever needed it, the + // speculative proof) before the reconciliation awaits below. + drop(bundles); // Platform's verdict settles the lock. For a wallet drain, an // exact-outpoint "already consumed" rejection means an earlier attempt @@ -641,6 +682,42 @@ impl PlatformWallet { Ok(()) } + /// Start proving the bundle for the asset lock at `out_point` while its + /// InstantSend lock is still awaited (see [`ProofTask`]). + /// + /// The amount is derived exactly as after the resolution — the lock's + /// value minus the pool fee — but from the tracked row, as the lock proof + /// does not exist yet. Returns `None`, starting nothing, when the value is + /// unknown or cannot fund the shield; the resolved path then reports that + /// as it always has. A proof started for a target the resolved lock does + /// not match is discarded, never used. + async fn start_speculative_shield_proof( + &self, + out_point: dashcore::OutPoint, + num_actions: usize, + prove: &ProveFn, + ) -> Option> { + let value_duffs = tracked_asset_lock_value_duffs(self, &out_point) + .await + .ok()?; + let value_credits = value_duffs.checked_mul(CREDITS_PER_DUFF)?; + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions).ok()?; + let shield_amount = shield_amount_after_fee(value_credits, pool_fee_credits).ok()?; + tracing::debug!( + %out_point, + shield_amount, + "proving the shield-from-asset-lock bundle while the asset lock proof is awaited" + ); + Some(ProofTask::spawn( + BundleTarget { + out_point, + shield_amount, + platform_version: self.sdk.version(), + }, + Arc::clone(prove), + )) + } + /// The flat pool fee for a `ShieldFromAssetLock` (Type 18) state /// transition, in credits. /// @@ -694,12 +771,13 @@ impl PlatformWallet { /// over the landed bundle's `actions`. /// /// Deliberately records ONLY the landed bundle (no Pending row before - /// broadcast, no Failed row after): `submit_with_cl_height_retry` - /// re-builds and re-randomizes the Orchard bundle on every attempt, - /// so each attempt has different output cmxs — and the activity id is - /// keyed to those cmxs. A pre-broadcast Pending row would orphan - /// (unconfirmable forever, its cmxs never on-chain) whenever a retry - /// is the attempt that lands. In-flight and failed Type 18s are + /// broadcast, no Failed row after): the attempts usually share one + /// proved bundle, but an attempt may need a fresh proof (a protocol + /// upgrade between attempts changes the binding), and a fresh proof has + /// different output cmxs — and the activity id is keyed to those cmxs. + /// A pre-broadcast Pending row would then orphan (unconfirmable + /// forever, its cmxs never on-chain) whenever the re-proved attempt is + /// the one that lands. In-flight and failed Type 18s are /// surfaced through the tracked asset-lock lifecycle instead /// (Built/Broadcast/Locked/Consumed + the resumable-funding UI), /// which tracks the L1 lock — the artifact that actually carries the @@ -810,17 +888,7 @@ async fn lookup_asset_lock_value_credits( "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), ) })?; - let locks: Vec = wallet.asset_locks.list_tracked_locks().await; - locks - .iter() - .find(|l| l.out_point == *op) - .map(|l| l.amount) - .ok_or_else(|| { - PlatformWalletError::AddressSync(format!( - "tracked asset lock {} not found in manager", - op - )) - })? + tracked_asset_lock_value_duffs(wallet, op).await? } }; duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { @@ -830,13 +898,29 @@ async fn lookup_asset_lock_value_credits( }) } +/// The value of the tracked asset lock at `out_point`, in duffs. +async fn tracked_asset_lock_value_duffs( + wallet: &PlatformWallet, + out_point: &dashcore::OutPoint, +) -> Result { + let locks: Vec = wallet.asset_locks.list_tracked_locks().await; + locks + .iter() + .find(|l| l.out_point == *out_point) + .map(|l| l.amount) + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} not found in manager", + out_point + )) + }) +} + /// What a landed Type 18 submit returns to the orchestrator. struct LandedShield { /// The **landed** bundle's serialized Orchard actions, so the /// orchestrator can record a live `ShieldFromAssetLock` activity entry - /// over the exact bundle that committed (each retry re-randomizes the - /// bundle, so only the landed attempt's cmxs are the ones a later scan - /// will recover). + /// over the exact bundle that committed. actions: Vec, /// Whether the verified proof result attests the asset lock was /// consumed in full (see [`proof_attests_full_consumption`]). @@ -866,44 +950,46 @@ fn proof_attests_full_consumption(result: &StateTransitionProofResult) -> bool { /// /// Extracted so `submit_with_cl_height_retry`'s closure stays compact /// and the IS→CL fallback path can re-call it with the upgraded proof. +/// The Orchard bundle comes from `bundles`: the one already proved for +/// this lock (reused across attempts), else a fresh proof. /// /// On success returns the landed bundle's actions together with what the /// verified proof result attests about the asset lock's consumption. #[allow(clippy::too_many_arguments)] -async fn build_and_broadcast_shielded( +async fn build_and_broadcast_shielded( sdk: std::sync::Arc, - recipient: OrchardAddress, + bundles: &tokio::sync::Mutex>, shield_amount: Credits, proof: AssetLockProof, path: ::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, - prover: &P, - sender_ovk: Option, surplus_output: Option, - dummy_outputs: usize, settings: Option, ) -> Result where AS: ::key_wallet::signer::Signer, - P: OrchardProver, { use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; use dpp::state_transition::StateTransition; - let st = build_shield_from_asset_lock_transition_with_signer( - &recipient, + // One version for the whole attempt: the bundle's binding is checked + // against the same rules the transition is assembled under. + let platform_version = sdk.version(); + let target = BundleTarget { + out_point: out_point_from_proof(&proof), shield_amount, - proof, - &path, - asset_lock_signer, - prover, - [0u8; 36], - sender_ovk, - surplus_output, - dummy_outputs, - sdk.version(), - ) - .await?; + platform_version, + }; + let bundle = bundles.lock().await.bundle_for(target).await?; + let st = bundle + .build_transition_with_signer( + proof, + &path, + asset_lock_signer, + surplus_output, + platform_version, + ) + .await?; let actions = match &st { StateTransition::ShieldFromAssetLock(transition) => transition.actions().to_vec(), @@ -928,6 +1014,207 @@ where }) } +/// `lock_value − pool_fee`, the amount a single-recipient +/// `ShieldFromAssetLock` moves into the pool; errors when the lock cannot +/// cover the fee with something left to shield. +fn shield_amount_after_fee( + asset_lock_value_credits: Credits, + pool_fee_credits: Credits, +) -> Result { + let shield_amount = asset_lock_value_credits + .checked_sub(pool_fee_credits) + .ok_or_else(|| { + PlatformWalletError::ShieldedBuildError(format!( + "asset lock value ({asset_lock_value_credits} credits) is below the \ + ShieldFromAssetLock pool fee ({pool_fee_credits} credits = shielded fee + \ + asset_lock_base_cost)" + )) + })?; + if shield_amount == 0 { + return Err(PlatformWalletError::ShieldedBuildError( + "shield amount after fee is zero".to_string(), + )); + } + Ok(shield_amount) +} + +// --------------------------------------------------------------------------- +// Proving the Orchard bundle off the async runtime, ahead of the lock proof +// --------------------------------------------------------------------------- + +/// Everything a `ShieldFromAssetLock` bundle is proved for that can differ +/// between the speculative proof and a submission attempt. The recipient, +/// sender OVK, memo and filler count are fixed for the whole call and live in +/// the [`ProveFn`]. +#[derive(Clone, Copy)] +struct BundleTarget { + /// The locked outpoint — the bundle's sighash binds it. + out_point: dashcore::OutPoint, + /// The value the bundle moves into the pool. + shield_amount: Credits, + /// The protocol version whose binding rules the bundle is proved under. + /// Compared by number: an SDK protocol-version change between the + /// speculative proof and an attempt re-proves even when the binding is + /// unchanged — conservative, and rare (it takes a protocol upgrade during + /// the call). + platform_version: &'static dpp::version::PlatformVersion, +} + +impl PartialEq for BundleTarget { + fn eq(&self, other: &Self) -> bool { + self.out_point == other.out_point + && self.shield_amount == other.shield_amount + && self.platform_version.protocol_version == other.platform_version.protocol_version + } +} + +/// Proves a bundle for a target. Blocking and CPU-heavy: only ever run on +/// tokio's blocking pool, through [`ProofTask`]. +type ProveFn = Arc Result + Send + Sync>; + +/// A bundle proof running on tokio's blocking pool, so it never occupies an +/// async worker. +/// +/// Dropping the task cancels it: a proof that has not started yet never runs +/// (the task is aborted, and the job checks a cancellation flag before +/// proving), and the result of one already running — Halo 2 proving cannot be +/// interrupted — is dropped, together with the inputs the job holds, as soon +/// as it finishes. Nothing waits for a cancelled proof and nothing is +/// persisted from it. +struct ProofTask { + target: BundleTarget, + cancelled: Arc, + /// `Some` until the proof has been joined. + handle: Option>>>, +} + +impl ProofTask { + fn spawn(target: BundleTarget, prove: ProveFn) -> Self { + let cancelled = Arc::new(AtomicBool::new(false)); + let job_cancelled = Arc::clone(&cancelled); + let handle = tokio::task::spawn_blocking(move || { + (!job_cancelled.load(Ordering::Acquire)).then(|| prove(target)) + }); + Self { + target, + cancelled, + handle: Some(handle), + } + } + + /// Wait for the proof. Cancellation-safe: dropping this future drops the + /// task, which cancels the proof. + async fn join(mut self) -> Result { + let joined = self + .handle + .as_mut() + .expect("the handle is present until the proof is joined") + .await; + self.handle = None; + match joined { + Ok(Some(result)) => result, + // Re-raise a proving panic exactly as if it had run inline. + Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()), + // The flag is only set on drop, so `Ok(None)` is unreachable + // while this task is alive; a cancelled join is the runtime + // shutting down. + Ok(None) | Err(_) => Err(ProtocolError::ShieldedBuildError( + "Orchard proof generation was cancelled".to_string(), + )), + } + } +} + +impl Drop for ProofTask { + fn drop(&mut self) { + if let Some(handle) = self.handle.take() { + self.cancelled.store(true, Ordering::Release); + handle.abort(); + } + } +} + +/// The proved bundle the submission attempts of one funding call share. +/// +/// Holds the speculative proof started while the lock proof was awaited +/// and, once joined or freshly proved, the bundle itself. Every attempt asks +/// for the bundle of its target; a bundle is reused only for an identical +/// target (same outpoint, amount and protocol version), so a reused bundle +/// commits to exactly what a fresh proof would. Anything else — a +/// speculative proof for another outpoint or amount, a protocol upgrade +/// between attempts — is discarded and proved afresh. +struct BundleCache { + prove: ProveFn, + speculative: Option>, + proved: Option<(BundleTarget, Arc)>, +} + +impl BundleCache { + fn new(prove: ProveFn, speculative: Option>) -> Self { + Self { + prove, + speculative, + proved: None, + } + } + + async fn bundle_for(&mut self, target: BundleTarget) -> Result, ProtocolError> { + if let Some((proved_for, bundle)) = &self.proved { + if *proved_for == target { + tracing::debug!( + out_point = %target.out_point, + "reusing the proved shield-from-asset-lock bundle for this attempt" + ); + return Ok(Arc::clone(bundle)); + } + } + let task = match self.speculative.take() { + Some(task) if task.target == target => task, + Some(stale) => { + // Rare (the lock's value or the protocol version changed under + // the call), but it forfeits the overlap: make it visible. + tracing::info!( + speculative_out_point = %stale.target.out_point, + speculative_amount = stale.target.shield_amount, + out_point = %target.out_point, + shield_amount = target.shield_amount, + "discarding a speculative shield-from-asset-lock proof made for other inputs" + ); + ProofTask::spawn(target, Arc::clone(&self.prove)) + } + None => ProofTask::spawn(target, Arc::clone(&self.prove)), + }; + let bundle = Arc::new(task.join().await?); + self.proved = Some((target, Arc::clone(&bundle))); + Ok(bundle) + } +} + +/// Drive `resolve` (funding resolution, whose proof wait is the slow part) to +/// completion while `speculate` starts a proof as soon as `out_point` delivers +/// the lock's outpoint. `speculate` must only *start* the proof (return its +/// [`ProofTask`]), not wait for it, so this returns as soon as `resolve` does. +/// +/// If `resolve` fails, the speculative task is dropped here — cancelling the +/// proof — and the error is returned. If `resolve` never reports an outpoint +/// (its sender is dropped), no proof is started. +async fn resolve_while_proving( + resolve: impl std::future::Future>, + out_point: tokio::sync::oneshot::Receiver, + speculate: S, +) -> Result<(R, Option>), E> +where + S: FnOnce(dashcore::OutPoint) -> SF, + SF: std::future::Future>>, +{ + let speculation = async move { + let out_point = out_point.await.ok()?; + speculate(out_point).await + }; + let (resolved, speculative) = tokio::join!(resolve, speculation); + Ok((resolved?, speculative)) +} + /// Pre-flight check for the recipient list. /// /// Today: non-empty, exactly one recipient whose `Credits` value is @@ -1835,4 +2122,511 @@ mod tests { .expect("tracked lock"); assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); } + + // -- Proving the bundle while the InstantSend lock is awaited --------- + // + // These drive the production pieces of `shielded_fund_from_asset_lock`'s + // proof pipeline (`resolve_while_proving`, `ProofTask`, `BundleCache`, + // `submit_with_cl_height_retry`) with a fake prover whose cost is a + // controllable sleep on the blocking pool, and a fake funding resolution + // that reports the outpoint at "broadcast" and then waits for the lock. + + mod proof_pipeline { + use std::sync::atomic::AtomicUsize; + use std::time::{Duration, Instant}; + + use dpp::version::PlatformVersion; + use tokio::sync::oneshot; + + use super::*; + use crate::error::is_instant_lock_proof_invalid; + use crate::wallet::asset_lock::orchestration::submit_with_cl_height_retry; + + const AMOUNT: Credits = 1_000_000; + + fn lock_out_point() -> OutPoint { + OutPoint::from([7u8; 36]) + } + + fn target(out_point: OutPoint, shield_amount: Credits) -> BundleTarget { + BundleTarget { + out_point, + shield_amount, + platform_version: PlatformVersion::latest(), + } + } + + /// Stands in for a proved bundle; counts its drops so a test can see + /// a discarded proof's result being released. + struct FakeBundle { + target_amount: Credits, + drops: Arc, + } + + impl Drop for FakeBundle { + fn drop(&mut self) { + self.drops.fetch_add(1, Ordering::SeqCst); + } + } + + /// A fake prover: blocks its blocking-pool thread for `cost`. + #[derive(Clone, Default)] + struct Probe { + calls: Arc, + drops: Arc, + started_at: Arc>>, + } + + impl Probe { + fn prover(&self, cost: Duration) -> ProveFn { + let probe = self.clone(); + Arc::new(move |target: BundleTarget| { + probe.calls.fetch_add(1, Ordering::SeqCst); + probe + .started_at + .lock() + .unwrap() + .get_or_insert_with(Instant::now); + std::thread::sleep(cost); + Ok(FakeBundle { + target_amount: target.shield_amount, + drops: Arc::clone(&probe.drops), + }) + }) + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + } + + fn cl_height_too_low() -> dash_sdk::Error { + use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; + use dpp::consensus::basic::BasicError; + use dpp::consensus::ConsensusError; + + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( + InvalidAssetLockProofCoreChainHeightError::new(100, 99), + )), + ))) + } + + fn instant_lock_rejected() -> dash_sdk::Error { + use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; + + dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( + InvalidInstantAssetLockProofSignatureError::new().into(), + ))) + } + + /// The old order (wait for the lock, then prove) against the new one + /// (prove from the broadcast on). Real sleeps on a multi-threaded + /// runtime: the proof costs `PROOF` of blocking-pool time, the lock + /// arrives `LOCK_WAIT` after the broadcast. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_prove_while_the_instant_lock_is_awaited() { + const PROOF: Duration = Duration::from_millis(400); + const LOCK_WAIT: Duration = Duration::from_millis(400); + + // Before: the proof starts once the lock has been delivered. + let sequential_probe = Probe::default(); + let start = Instant::now(); + tokio::time::sleep(LOCK_WAIT).await; + let mut cache = BundleCache::new(sequential_probe.prover(PROOF), None); + cache + .bundle_for(target(lock_out_point(), AMOUNT)) + .await + .expect("proof"); + let sequential = start.elapsed(); + + // After: the resolver reports the outpoint at broadcast, the proof + // starts, and the lock wait runs alongside it. + let probe = Probe::default(); + let prove = probe.prover(PROOF); + let lock_delivered_at = Arc::new(Mutex::new(None)); + let (out_point_tx, out_point_rx) = oneshot::channel(); + let start = Instant::now(); + let resolve = { + let lock_delivered_at = Arc::clone(&lock_delivered_at); + async move { + let _ = out_point_tx.send(lock_out_point()); + tokio::time::sleep(LOCK_WAIT).await; + *lock_delivered_at.lock().unwrap() = Some(Instant::now()); + Ok::<_, PlatformWalletError>("instant-lock proof") + } + }; + let (resolved, speculative) = resolve_while_proving(resolve, out_point_rx, |op| { + let prove = Arc::clone(&prove); + async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + }) + .await + .expect("resolution"); + assert_eq!(resolved, "instant-lock proof"); + let mut cache = BundleCache::new(prove, speculative); + let bundle = cache + .bundle_for(target(lock_out_point(), AMOUNT)) + .await + .expect("proof"); + let overlapped = start.elapsed(); + + assert_eq!(bundle.target_amount, AMOUNT); + assert_eq!(probe.calls(), 1, "the speculative proof is the one used"); + let proof_started = probe.started_at.lock().unwrap().expect("proof ran"); + let lock_delivered = lock_delivered_at.lock().unwrap().expect("lock delivered"); + assert!( + proof_started < lock_delivered, + "the proof must start before the InstantSend lock is delivered" + ); + eprintln!( + "shield-from-asset-lock: proof {PROOF:?}, lock wait {LOCK_WAIT:?}: \ + sequential {sequential:?}, overlapped {overlapped:?}" + ); + assert!(sequential >= PROOF + LOCK_WAIT); + // ≈ max(PROOF, LOCK_WAIT) against the sum. Only the ordering is + // asserted (wall-clock bounds flake on loaded runners); + // `should_overlap_the_proof_with_the_lock_wait` proves the overlap + // deterministically. + assert!( + overlapped < sequential, + "overlapped {overlapped:?} should beat sequential {sequential:?}" + ); + } + + /// Deterministic overlap: the InstantSend lock is only delivered once + /// the proof is running, and the proof only finishes once the lock + /// has been delivered. Waiting for the lock before proving (the old + /// order) can never complete this; overlapping finishes at once. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_overlap_the_proof_with_the_lock_wait() { + let probe = Probe::default(); + let (proof_started_tx, proof_started_rx) = oneshot::channel::<()>(); + let (lock_delivered_tx, lock_delivered_rx) = std::sync::mpsc::channel::<()>(); + let proof_started_tx = Mutex::new(Some(proof_started_tx)); + let lock_delivered_rx = Mutex::new(lock_delivered_rx); + let prove: ProveFn = { + let probe = probe.clone(); + Arc::new(move |target: BundleTarget| { + probe.calls.fetch_add(1, Ordering::SeqCst); + if let Some(tx) = proof_started_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + lock_delivered_rx + .lock() + .unwrap() + .recv() + .expect("lock delivered"); + Ok(FakeBundle { + target_amount: target.shield_amount, + drops: Arc::clone(&probe.drops), + }) + }) + }; + + let (out_point_tx, out_point_rx) = oneshot::channel(); + let resolve = async move { + let _ = out_point_tx.send(lock_out_point()); + proof_started_rx.await.expect("proof started"); + lock_delivered_tx.send(()).unwrap(); + Ok::<_, PlatformWalletError>("instant-lock proof") + }; + let bundle = tokio::time::timeout(Duration::from_secs(30), async { + let (_, speculative) = resolve_while_proving(resolve, out_point_rx, |op| { + let prove = Arc::clone(&prove); + async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + }) + .await + .expect("resolution"); + BundleCache::new(Arc::clone(&prove), speculative) + .bundle_for(target(lock_out_point(), AMOUNT)) + .await + .expect("proof") + }) + .await + .expect("the proof and the lock wait did not overlap"); + assert_eq!(bundle.target_amount, AMOUNT); + assert_eq!(probe.calls(), 1); + } + + /// One proof for the whole call: the first attempt (InstantSend + /// proof, rejected by Platform), the ChainLock fallback, and its two + /// CL-height-too-low retries all get the speculative bundle. + #[tokio::test(start_paused = true)] + async fn should_reuse_the_proved_bundle_across_fallback_and_retries() { + let probe = Probe::default(); + let prove = probe.prover(Duration::ZERO); + let speculative = + ProofTask::spawn(target(lock_out_point(), AMOUNT), Arc::clone(&prove)); + let bundles = tokio::sync::Mutex::new(BundleCache::new(prove, Some(speculative))); + let used = Mutex::new(Vec::<*const FakeBundle>::new()); + let attempts = AtomicUsize::new(0); + + let attempt = |reject: fn(usize) -> Option| { + let (bundles, used, attempts) = (&bundles, &used, &attempts); + move |_settings| async move { + let bundle = bundles + .lock() + .await + .bundle_for(target(lock_out_point(), AMOUNT)) + .await?; + used.lock().unwrap().push(Arc::as_ptr(&bundle)); + match reject(attempts.fetch_add(1, Ordering::SeqCst)) { + Some(error) => Err(error), + None => Ok(()), + } + } + }; + + // InstantSend proof rejected by Platform. + let rejected = + submit_with_cl_height_retry(None, attempt(|_| Some(instant_lock_rejected()))) + .await + .expect_err("Platform rejected the InstantSend proof"); + assert!(is_instant_lock_proof_invalid(&rejected)); + // ChainLock fallback: two CL-height-too-low rejections, then it lands. + submit_with_cl_height_retry(None, attempt(|n| (n < 3).then(cl_height_too_low))) + .await + .expect("lands on the third ChainLock attempt"); + + let used = used.into_inner().unwrap(); + assert_eq!( + used.len(), + 4, + "IS attempt + CL attempt + 2 CL-height retries" + ); + assert!(used.iter().all(|bundle| *bundle == used[0])); + assert_eq!(probe.calls(), 1, "proved once, never re-proved"); + + // Only an attempt whose committed inputs differ is proved afresh. + let mut cache = bundles.lock().await; + let other_amount = cache + .bundle_for(target(lock_out_point(), AMOUNT - 1)) + .await + .expect("proof"); + assert_eq!(other_amount.target_amount, AMOUNT - 1); + assert_eq!(probe.calls(), 2); + let mut other_version = target(lock_out_point(), AMOUNT - 1); + other_version.platform_version = PlatformVersion::first(); + cache.bundle_for(other_version).await.expect("proof"); + assert_eq!(probe.calls(), 3); + } + + /// A speculative proof made for inputs the resolved lock does not + /// match is discarded, not used. + #[tokio::test] + async fn should_not_use_a_speculative_proof_for_other_inputs() { + let probe = Probe::default(); + let prove = probe.prover(Duration::ZERO); + let speculative = + ProofTask::spawn(target(lock_out_point(), AMOUNT + 1), Arc::clone(&prove)); + // Let it run first: a speculative proof cancelled before it + // starts never runs at all, which is not what this test is about. + while probe.calls() == 0 { + tokio::time::sleep(Duration::from_millis(1)).await; + } + let mut cache = BundleCache::new(prove, Some(speculative)); + let bundle = cache + .bundle_for(target(lock_out_point(), AMOUNT)) + .await + .expect("proof"); + assert_eq!(bundle.target_amount, AMOUNT); + assert_eq!( + probe.calls(), + 2, + "the mismatched speculative proof is not used" + ); + } + + /// The lock wait fails while the speculative proof runs: the error is + /// returned at once, without waiting for the proof, and the proof's + /// result is dropped as soon as it finishes — nothing keeps it. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn should_discard_the_speculative_proof_when_the_lock_wait_fails() { + let probe = Probe::default(); + let (started_tx, started_rx) = oneshot::channel::<()>(); + let (finish_tx, finish_rx) = std::sync::mpsc::channel::<()>(); + let started_tx = Mutex::new(Some(started_tx)); + let finish_rx = Mutex::new(finish_rx); + let prove: ProveFn = { + let probe = probe.clone(); + Arc::new(move |target: BundleTarget| { + probe.calls.fetch_add(1, Ordering::SeqCst); + if let Some(tx) = started_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + finish_rx.lock().unwrap().recv().expect("released"); + Ok(FakeBundle { + target_amount: target.shield_amount, + drops: Arc::clone(&probe.drops), + }) + }) + }; + + let (out_point_tx, out_point_rx) = oneshot::channel(); + let resolve = async move { + let _ = out_point_tx.send(lock_out_point()); + // The lock wait gives up while the proof is running. + started_rx.await.expect("proof started"); + Err::<(), _>(PlatformWalletError::FinalityTimeout(lock_out_point())) + }; + let start = Instant::now(); + let result = tokio::time::timeout( + Duration::from_secs(10), + resolve_while_proving(resolve, out_point_rx, |op| { + let prove = Arc::clone(&prove); + async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + }), + ) + .await + .expect("the failure must not wait for the running proof"); + assert!(matches!( + result, + Err(PlatformWalletError::FinalityTimeout(_)) + )); + assert_eq!(probe.drops.load(Ordering::SeqCst), 0, "still proving"); + + // Let the proof finish: its result is dropped by the abandoned task. + finish_tx.send(()).unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while probe.drops.load(Ordering::SeqCst) == 0 { + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("the discarded proof's result is released"); + assert_eq!(probe.calls(), 1); + eprintln!("lock-wait failure returned after {:?}", start.elapsed()); + } + + /// No outpoint reported (the resolution failed before broadcast): no + /// proof is started at all. + #[tokio::test] + async fn should_not_prove_when_the_resolution_fails_before_broadcast() { + let probe = Probe::default(); + let prove = probe.prover(Duration::ZERO); + let (out_point_tx, out_point_rx) = oneshot::channel::(); + let resolve = async move { + drop(out_point_tx); + Err::<(), _>(PlatformWalletError::ShieldedBuildError("no funds".into())) + }; + let result = resolve_while_proving(resolve, out_point_rx, |op| { + let prove = Arc::clone(&prove); + async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + }) + .await; + assert!(result.is_err()); + assert_eq!(probe.calls(), 0); + } + + /// Real key, real proofs: the same before/after comparison as + /// `should_prove_while_the_instant_lock_is_awaited`, with the actual + /// `ShieldFromAssetLock` bundle proof and an InstantSend lock that + /// arrives as long after the broadcast as one proof takes. Ignored by + /// default (builds the proving key); run with + /// `cargo test -p platform-wallet --features shielded --lib -- --ignored real_proof --nocapture`. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + #[ignore = "builds the real Halo 2 proving key and proves real bundles"] + async fn real_proof_overlaps_a_simulated_instant_lock_wait() { + use crate::wallet::shielded::prover::CachedOrchardProver; + use grovedb_commitment_tree::{FullViewingKey, Scope, SpendingKey}; + + let prover: &'static CachedOrchardProver = &CachedOrchardProver; + let start = Instant::now(); + tokio::task::spawn_blocking(move || prover.warm_up()) + .await + .unwrap(); + eprintln!("proving key built in {:?}", start.elapsed()); + + let sk = SpendingKey::from_bytes([42u8; 32]).unwrap(); + let recipient = OrchardAddress::from_raw_bytes( + &FullViewingKey::from(&sk) + .address_at(0u32, Scope::External) + .to_raw_address_bytes(), + ) + .unwrap(); + let prove: ProveFn = Arc::new(move |target| { + ProvedShieldFromAssetLockBundle::prove( + &recipient, + target.shield_amount, + target.out_point, + &prover, + [0u8; 36], + None, + 0, + target.platform_version, + ) + }); + let lock_target = target(lock_out_point(), AMOUNT); + + let start = Instant::now(); + ProofTask::spawn(lock_target, Arc::clone(&prove)) + .join() + .await + .expect("proof"); + let proof_cost = start.elapsed(); + let lock_wait = proof_cost; + + let start = Instant::now(); + tokio::time::sleep(lock_wait).await; + BundleCache::new(Arc::clone(&prove), None) + .bundle_for(lock_target) + .await + .expect("proof"); + let sequential = start.elapsed(); + + let (out_point_tx, out_point_rx) = oneshot::channel(); + let start = Instant::now(); + let resolve = async move { + let _ = out_point_tx.send(lock_out_point()); + tokio::time::sleep(lock_wait).await; + Ok::<_, PlatformWalletError>(()) + }; + let ((), speculative) = resolve_while_proving(resolve, out_point_rx, |op| { + let prove = Arc::clone(&prove); + async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + }) + .await + .unwrap(); + BundleCache::new(prove, speculative) + .bundle_for(lock_target) + .await + .expect("proof"); + let overlapped = start.elapsed(); + + eprintln!( + "real ShieldFromAssetLock proof {proof_cost:?}, simulated lock wait \ + {lock_wait:?}: sequential {sequential:?}, overlapped {overlapped:?}" + ); + assert!(overlapped < sequential); + } + + /// A proof cancelled before a blocking thread picks it up never runs. + #[test] + fn should_never_run_a_proof_cancelled_before_it_starts() { + let runtime = tokio::runtime::Builder::new_current_thread() + .max_blocking_threads(1) + .enable_all() + .build() + .expect("runtime"); + runtime.block_on(async { + let probe = Probe::default(); + // Occupy the only blocking thread so the proof stays queued. + let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); + let blocker = tokio::task::spawn_blocking(move || { + let _ = release_rx.recv(); + }); + let task = ProofTask::spawn( + target(lock_out_point(), AMOUNT), + probe.prover(Duration::ZERO), + ); + drop(task); + release_tx.send(()).unwrap(); + blocker.await.unwrap(); + // The queue is FIFO: once this runs, the cancelled job has had + // its turn. + tokio::task::spawn_blocking(|| ()).await.unwrap(); + assert_eq!(probe.calls(), 0); + }); + } + } } diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs index bded4bc0c50..ba1dd2aa284 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -211,7 +211,7 @@ impl PlatformWallet { // One prover handle for the whole run (zero-sized; shares the // process-global cached proving key). - let prover = CachedOrchardProver::new(); + let prover: &'static CachedOrchardProver = &CachedOrchardProver; let mut pool_notes_now = start_notes; let mut batches_submitted = 0u64; @@ -281,7 +281,7 @@ impl PlatformWallet { funding, vec![(recipient, None)], asset_lock_signer, - &prover, + prover, None, dummy_outputs, settings, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs index 26d64b47140..ddc7c88c235 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain_tests.rs @@ -313,7 +313,7 @@ async fn should_release_registration_after_returned_prebuild_load_failure() { let (wallet, persistence, coordinator, _dir) = wallet_fixture(&lock, keys).await; let coordinator = Arc::new(coordinator); let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let prover = super::prover::CachedOrchardProver::new(); + let prover: &'static super::prover::CachedOrchardProver = &super::prover::CachedOrchardProver; let operation = Arc::new(AssetLockOperationContext::new([0x78; 16])); for failed_load in [true, false] { persistence.fail_load.store(failed_load, Ordering::SeqCst); @@ -327,7 +327,7 @@ async fn should_release_registration_after_returned_prebuild_load_failure() { }, vec![], &signer, - &prover, + prover, None, 0, None, @@ -364,7 +364,7 @@ async fn should_release_cancelled_drain_waiting_for_the_shield_guard() { let (wallet, _, coordinator, _dir) = wallet_fixture(&lock, keys).await; let coordinator = Arc::new(coordinator); let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let prover = super::prover::CachedOrchardProver::new(); + let prover: &'static super::prover::CachedOrchardProver = &super::prover::CachedOrchardProver; let operation = Arc::new(AssetLockOperationContext::new([0x77; 16])); let blocker = wallet.shield_guard.lock().await; let mut future = Box::pin(wallet.shielded_fund_from_asset_lock( @@ -376,7 +376,7 @@ async fn should_release_cancelled_drain_waiting_for_the_shield_guard() { }, vec![], &signer, - &prover, + prover, None, 0, None, @@ -424,7 +424,8 @@ async fn should_refuse_drain_without_viewing_keys_before_core_funding() { *wallet.shielded_keys.write().await = empty_binding.then(BTreeMap::new); let coordinator = Arc::new(coordinator); let (_, _, _, signer) = funded_wallet_manager(StandardAccountType::BIP44Account).await; - let prover = super::prover::CachedOrchardProver::new(); + let prover: &'static super::prover::CachedOrchardProver = + &super::prover::CachedOrchardProver; let operation = Arc::new(AssetLockOperationContext::new([0x97; 16])); let error = wallet .shielded_fund_from_asset_lock( @@ -436,7 +437,7 @@ async fn should_refuse_drain_without_viewing_keys_before_core_funding() { }, vec![(recipient, None)], &signer, - &prover, + prover, None, 0, None, From b2d588ad6f11a81f58635ad75a79442da267a697 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 01:12:28 -0400 Subject: [PATCH 04/20] docs(platform-wallet): note that a proved shield bundle retains its signing secrets A re-signable proved bundle (`ProvedOutputOnlyBundle`, `ProvedShieldFromAssetLockBundle`) keeps the binding signing key `bsk` and the padding spends' `dummy_ask` and `alpha` inside its signing closure. The funding flow holds the bundle for the rest of the call: possibly through the 300 s InstantSend window and an unbounded ChainLock wait. These secrets are fresh per bundle; no wallet key is among them. They are never serialized, persisted or logged, and are dropped with the `BundleCache`. Anyone holding them could re-bind the proof to another owner and open the per-action value commitments, so document where they live and when they go. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/rs-dpp/src/shielded/builder/mod.rs | 8 ++++++++ .../src/shielded/builder/shield_from_asset_lock.rs | 6 ++++++ .../src/wallet/shielded/fund_from_asset_lock.rs | 10 +++++++++- 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index c87af7c8f41..45b3d6704d5 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -241,6 +241,14 @@ type AuthorizedBundle = Bundle; /// spend-authorization signatures over the sighash fixed when the bundle was proved. Those are /// randomized RedPallas signatures, so every call returns a bundle with the same actions, /// commitment and proof but different signature bytes. +/// +/// It therefore retains signing secrets for as long as it lives: the binding signing key `bsk` +/// (the sum of the actions' value-commitment trapdoors) and each padding spend's spend +/// authorizing key and randomizer (`dummy_ask`, `alpha`). They are fresh per bundle — no wallet +/// key is among them — but whoever holds them can sign this proof over a *different* sighash, +/// i.e. re-bind it to another owner, and the trapdoors open the per-action value commitments. +/// They live only inside the signing closure: never serialized, persisted or logged (the type +/// has no `Debug`), and dropped with the value. #[cfg(feature = "core_key_wallet")] pub(crate) struct ProvedOutputOnlyBundle { /// Signs a clone of the proved bundle. The proved-but-unsigned bundle type diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index bddcc0cd57c..48ab776287d 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -48,6 +48,12 @@ use super::{prove_output_only_bundle, ProvedOutputOnlyBundle}; /// transitions assembled from one proved bundle never hash alike — a client resubmitting after a /// rejection relies on that, as Tenderdash caches the hashes of rejected transitions and drops an /// identical resubmission. +/// +/// Being re-signable, the value holds the bundle's signing secrets — the binding signing key and +/// the padding spends' spend authorizing keys and randomizers — until it is dropped (see +/// `ProvedOutputOnlyBundle`). They are never serialized, persisted or logged, but anyone who +/// obtains them can re-bind the proof to another asset lock, so drop the value as soon as the +/// transition has landed or been abandoned. #[cfg(feature = "core_key_wallet")] pub struct ProvedShieldFromAssetLockBundle { bundle: ProvedOutputOnlyBundle, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 61eea68faed..8cad172d977 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -572,7 +572,11 @@ impl PlatformWallet { ) .await?; // Release the proved bundle (and, if no attempt ever needed it, the - // speculative proof) before the reconciliation awaits below. + // speculative proof) before the reconciliation awaits below. The + // bundle holds its signing secrets (`ProvedShieldFromAssetLockBundle`) + // from the moment it is proved — possibly through the 300 s + // InstantSend window and an unbounded ChainLock wait — and this is + // where they go; every early return above drops them the same way. drop(bundles); // Platform's verdict settles the lock. For a wallet drain, an @@ -1136,6 +1140,10 @@ impl Drop for ProofTask { /// The proved bundle the submission attempts of one funding call share. /// +/// Holds the bundle's signing secrets (see `ProvedShieldFromAssetLockBundle`) +/// in memory only, for the rest of the funding call; they are dropped with +/// the cache. +/// /// Holds the speculative proof started while the lock proof was awaited /// and, once joined or freshly proved, the bundle itself. Every attempt asks /// for the bundle of its target; a bundle is reused only for an identical From d5a49f23e00b739d073721dd053a4607b27bae18 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 01:12:49 -0400 Subject: [PATCH 05/20] fix(platform-wallet): derive the speculative and resolved shield amounts alike The speculative proof took its amount from the tracked row's `amount`: the sum of all the lock transaction's credit outputs. The resolved amount on the InstantSend path is the proof's own output, `credit_outputs[output_index]`. The two agree for the single-output locks the wallet builds. But if they ever diverged, every funding would silently re-prove once the lock arrived, and the overlap would be lost. All paths now read the credit output at the outpoint's index through one helper, `credit_output_duffs`: - the speculative amount, from the tracked transaction; - the InstantSend path, from the proof's transaction; - the ChainLock path, from the tracked transaction. Tests: - a wallet-built lock's only credit output equals both its row amount and the InstantSend proof's output; - with several credit outputs, the indexed output is used, not the sum. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../wallet/shielded/fund_from_asset_lock.rs | 134 ++++++++++++++++-- 1 file changed, 121 insertions(+), 13 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 8cad172d977..06acf92a782 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -866,26 +866,27 @@ impl PlatformWallet { /// Look up the asset-lock value in credits. /// /// Preference order: -/// 1. If the proof is `Instant`, read directly from -/// `InstantAssetLockProof::output().value` — no manager lookup -/// needed. +/// 1. If the proof is `Instant`, read the credit output from the +/// transaction the proof carries — no manager lookup needed. /// 2. Otherwise (the IS-timeout-fallback path produced a CL proof /// that doesn't carry the tx output), look up the tracked -/// asset-lock row by outpoint. +/// asset-lock row by outpoint and read it from its transaction. +/// +/// Both go through [`credit_output_duffs`], as does the speculative +/// proof's amount (`start_speculative_shield_proof`), so a bundle proved +/// before the lock proof existed is proved for the amount computed here. async fn lookup_asset_lock_value_credits( wallet: &PlatformWallet, proof: &AssetLockProof, tracked_out_point: Option<&dashcore::OutPoint>, ) -> Result { let duffs = match proof { - AssetLockProof::Instant(is) => { - let out = is.output().ok_or_else(|| { + AssetLockProof::Instant(is) => credit_output_duffs(is.transaction(), is.output_index()) + .ok_or_else(|| { PlatformWalletError::AddressSync( "InstantAssetLockProof has no output at the indicated index".to_string(), ) - })?; - out.value - } + })?, AssetLockProof::Chain(_) => { let op = tracked_out_point.ok_or_else(|| { PlatformWalletError::AddressSync( @@ -902,22 +903,51 @@ async fn lookup_asset_lock_value_credits( }) } -/// The value of the tracked asset lock at `out_point`, in duffs. +/// The value of the tracked asset lock at `out_point`, in duffs: its +/// transaction's credit output at the outpoint's index (see +/// [`credit_output_duffs`]). async fn tracked_asset_lock_value_duffs( wallet: &PlatformWallet, out_point: &dashcore::OutPoint, ) -> Result { let locks: Vec = wallet.asset_locks.list_tracked_locks().await; - locks + let lock = locks .iter() .find(|l| l.out_point == *out_point) - .map(|l| l.amount) .ok_or_else(|| { PlatformWalletError::AddressSync(format!( "tracked asset lock {} not found in manager", out_point )) - }) + })?; + credit_output_duffs(&lock.transaction, out_point.vout).ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} has no credit output at index {}", + out_point, out_point.vout + )) + }) +} + +/// The value, in duffs, that an asset lock on `transaction` at `output_index` +/// locks: the payload's `credit_outputs[output_index]` — the output +/// `InstantAssetLockProof::output` names and consensus credits. `None` when +/// the transaction carries no such credit output. +/// +/// Not the tracked row's `amount`: that is the sum of all credit outputs. +/// The two agree for the single-output locks this wallet builds (pinned by +/// `wallet_built_lock_value_is_its_only_credit_output`), but reading the +/// output itself everywhere keeps the speculative and the resolved amounts +/// equal by construction. +fn credit_output_duffs(transaction: &dashcore::Transaction, output_index: u32) -> Option { + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + + match &transaction.special_transaction_payload { + Some(TransactionPayload::AssetLockPayloadType(payload)) => payload + .credit_outputs + .get(output_index as usize) + .map(|output| output.value), + _ => None, + } } /// What a landed Type 18 submit returns to the orchestrator. @@ -1835,6 +1865,84 @@ mod tests { ); } + /// The speculative proof's amount (tracked row) and the resolved amount + /// (InstantSend proof, or the tracked row on the ChainLock path) must be + /// the same number, or every funding silently re-proves after the lock + /// arrives and the overlap is lost. Both read `credit_output_duffs`; for + /// the single-output locks the wallet builds that is also the row's + /// `amount`. + #[tokio::test] + async fn wallet_built_lock_value_is_its_only_credit_output() { + use dashcore::InstantLock; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + + let ctx = consumption_report_context().await; + let lock = { + let wm = ctx.wallet_manager.read().await; + wm.get_wallet_info(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .get(&ctx.out_point) + .expect("tracked lock") + .clone() + }; + let from_row = credit_output_duffs(&lock.transaction, ctx.out_point.vout) + .expect("the lock's credit output"); + assert_eq!( + credit_output_duffs(&lock.transaction, ctx.out_point.vout + 1), + None, + "the wallet builds single-output locks" + ); + assert_eq!(from_row, 1_000_000, "the whole requested amount is locked"); + assert_eq!(from_row, lock.amount); + + let instant = + InstantAssetLockProof::new(InstantLock::default(), lock.transaction.clone(), 0); + assert_eq!( + out_point_from_proof(&AssetLockProof::Instant(instant.clone())), + ctx.out_point + ); + assert_eq!( + credit_output_duffs(instant.transaction(), instant.output_index()), + Some(from_row) + ); + assert_eq!(instant.output().map(|output| output.value), Some(from_row)); + } + + /// With several credit outputs, each lock is worth its own output — not + /// the sum the tracked row records. + #[test] + fn credit_output_value_is_the_indexed_output_not_the_sum() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::{ScriptBuf, Transaction, TxOut}; + + let output = |value| TxOut { + value, + script_pubkey: ScriptBuf::new(), + }; + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 0, + credit_outputs: vec![output(100_000), output(200_000)], + }, + )), + }; + assert_eq!(credit_output_duffs(&transaction, 0), Some(100_000)); + assert_eq!(credit_output_duffs(&transaction, 1), Some(200_000)); + assert_eq!(credit_output_duffs(&transaction, 2), None); + let plain = Transaction { + special_transaction_payload: None, + ..transaction + }; + assert_eq!(credit_output_duffs(&plain, 0), None); + } + #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; From 20655711bf650a2a7bfcb6bafcb9274836daeab3 Mon Sep 17 00:00:00 2001 From: pasta Date: Mon, 5 Oct 2026 01:12:49 -0400 Subject: [PATCH 06/20] test(dpp): verify re-assembled asset-lock shield bundles as consensus does Strengthen the test that wraps one proved bundle around an InstantSend proof (twice) and a ChainLock proof of the same outpoint. Each assembled transition's bundle is now rebuilt from its own fields and verified with `BatchValidator`. The sighash comes from that transition's own asset-lock proof, mirroring drive-abci's `shield_from_asset_lock` `transform_into_action` v1 and `reconstruct_and_verify_bundle`: outputs-only flags, `value_balance = -shield_amount`, strict proof size. The test also checks the converse: the same bundle fails under another lock's binding. Adds `nonempty` (already in the lockfile via orchard and drive-abci) as a dpp dev-dependency for `Bundle::try_from_parts`. Co-Authored-By: Claude Opus 5.5 (1M context) --- Cargo.lock | 1 + packages/rs-dpp/Cargo.toml | 3 + .../signing_tests.rs | 78 +++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 90ca9978ddb..c7994c825c4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2288,6 +2288,7 @@ dependencies = [ "libm", "log", "nohash-hasher", + "nonempty", "num-cmp", "num_enum 0.7.6", "once_cell", diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index 4b42108b46d..a3fd6c675e7 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -104,6 +104,9 @@ assert_matches = "1.5.0" once_cell = "1.7" env_logger = { version = "0.11.8" } log = { version = "0.4.27" } +# Rebuilds Orchard bundles from transition fields in the shielded builder tests (orchard's +# `Bundle::try_from_parts` takes a `NonEmpty`); same version orchard itself uses. +nonempty = "0.11" # secp256k1 0.33 pulls getrandom 0.3 (via rand 0.9) and key-wallet pulls # getrandom 0.4; on wasm32-unknown-unknown both refuse to build without their diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 621abbb0d6f..4d294aa739c 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -96,6 +96,66 @@ fn make_chain_asset_lock_proof() -> AssetLockProof { }) } +/// Rebuilds the Orchard bundle from a transition's fields and verifies its proof and signatures +/// against the sighash bound to `asset_lock_proof`, mirroring drive-abci's +/// `shield_from_asset_lock` `transform_into_action` (v1) → `reconstruct_and_verify_bundle` +/// (outputs-only flags, `value_balance = -shield_amount`). +fn bundle_verifies_like_consensus( + v1: &ShieldFromAssetLockTransitionV1, + asset_lock_proof: &AssetLockProof, + vk: &grovedb_commitment_tree::VerifyingKey, + platform_version: &PlatformVersion, +) -> bool { + use crate::shielded::{compute_platform_sighash, shield_from_asset_lock_extra_sighash_data}; + use grovedb_commitment_tree::{ + redpallas, Action, Anchor, Authorized, BatchValidator, Bundle, DashMemo, + ExtractedNoteCommitment, Flags, NoteBytesData, Nullifier, Proof, ProofSizeEnforcement, + TransmittedNoteCiphertext, ValueCommitment, + }; + + let actions: Vec<_> = v1 + .actions + .iter() + .map(|a| { + let note = &a.encrypted_note; + Action::from_parts( + Nullifier::from_bytes(&a.nullifier).unwrap(), + redpallas::VerificationKey::try_from(a.rk).unwrap(), + ExtractedNoteCommitment::from_bytes(&a.cmx).unwrap(), + TransmittedNoteCiphertext::::from_parts( + note[..32].try_into().unwrap(), + NoteBytesData(note[32..136].try_into().unwrap()), + note[136..].try_into().unwrap(), + ), + ValueCommitment::from_bytes(&a.cv_net).unwrap(), + redpallas::Signature::from(a.spend_auth_sig), + ) + .expect("well-formed action") + }) + .collect(); + let bundle: Bundle = Bundle::try_from_parts( + nonempty::NonEmpty::from_vec(actions).expect("actions"), + Flags::from_byte(0x02).expect("outputs-only flags"), + -(v1.value_balance as i64), + Anchor::from_bytes(v1.anchor).unwrap(), + Authorized::from_parts( + Proof::new(v1.proof.clone()), + redpallas::Signature::from(v1.binding_signature), + ), + ProofSizeEnforcement::Strict, + ) + .expect("well-formed bundle"); + + let extra_sighash_data = + shield_from_asset_lock_extra_sighash_data(asset_lock_proof, platform_version) + .expect("binding"); + let commitment: [u8; 32] = bundle.commitment().into(); + let sighash = compute_platform_sighash(&commitment, &extra_sighash_data); + let mut batch = BatchValidator::new(); + batch.add_bundle(&bundle, sighash); + batch.validate(vk, rand::rngs::OsRng) +} + /// An InstantSend proof of a one-output asset-lock transaction. Its InstantSend lock is not /// valid; nothing here verifies it. fn make_instant_asset_lock_proof() -> AssetLockProof { @@ -425,6 +485,24 @@ async fn proved_bundle_assembles_around_instant_and_chain_proofs_of_its_outpoint let resubmitted = extract_v1(assemble(instant).await.expect("instant re-assembly")); let over_chain = extract_v1(assemble(chain).await.expect("chain assembly")); + // Each transition's bundle verifies the way consensus checks it: rebuilt from the + // transition's own fields, against the sighash bound to the transition's OWN asset lock + // proof — not merely against the sighash it was proved with. + let vk = grovedb_commitment_tree::VerifyingKey::build(); + for v1 in [&over_instant, &resubmitted, &over_chain] { + assert!( + bundle_verifies_like_consensus(v1, &v1.asset_lock_proof, &vk, platform_version), + "an assembled transition's bundle must verify against its own asset lock proof" + ); + } + // And the check has teeth: under another lock's binding the same bundle is refused. + assert!(!bundle_verifies_like_consensus( + &over_chain, + &other_lock, + &vk, + platform_version + )); + for v1 in [&over_instant, &resubmitted, &over_chain] { assert_eq!(v1.value_balance, shield_amount); assert_eq!(v1.proof, over_instant.proof, "one proof, never re-proved"); From f7a124d68a98d1d3bc05be0557f2e79196467630 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 8 Oct 2026 18:15:54 +0000 Subject: [PATCH 07/20] test(platform-wallet): cut the shield-proof overlap tests to deterministic checks Drop the wall-clock overlap comparison (scheduler-dependent), the ignored real-prover benchmark, the lock-wait-failure test that polled drop counters, the retry-wrapper test that tracked raw pointers, and the tests of the trivial credit-output helper. What remains: a handshake test that only completes if the proof and the lock wait overlap, the bundle cache's one-proof-per-target rule, refusing a speculative proof made for other inputs, and a proof cancelled before it starts never running. The fake bundle is now just its amount. In dpp, replace the test that re-implemented drive-abci's bundle reconstruction (and the `nonempty` dev-dependency it needed) with a direct check: a bundle from `ProvedShieldFromAssetLockBundle::prove` verifies against the sighash derived from a real lock proof of its outpoint. Trim the long doc comments on the proved-bundle types; they now also say that the bundle signatures don't cover the surplus output. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 - packages/rs-dpp/Cargo.toml | 3 - packages/rs-dpp/src/shielded/builder/mod.rs | 29 +- .../builder/shield_from_asset_lock.rs | 101 +-- .../signing_tests.rs | 125 +--- .../wallet/shielded/fund_from_asset_lock.rs | 613 +++--------------- 6 files changed, 166 insertions(+), 706 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c7994c825c4..90ca9978ddb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2288,7 +2288,6 @@ dependencies = [ "libm", "log", "nohash-hasher", - "nonempty", "num-cmp", "num_enum 0.7.6", "once_cell", diff --git a/packages/rs-dpp/Cargo.toml b/packages/rs-dpp/Cargo.toml index a3fd6c675e7..4b42108b46d 100644 --- a/packages/rs-dpp/Cargo.toml +++ b/packages/rs-dpp/Cargo.toml @@ -104,9 +104,6 @@ assert_matches = "1.5.0" once_cell = "1.7" env_logger = { version = "0.11.8" } log = { version = "0.4.27" } -# Rebuilds Orchard bundles from transition fields in the shielded builder tests (orchard's -# `Bundle::try_from_parts` takes a `NonEmpty`); same version orchard itself uses. -nonempty = "0.11" # secp256k1 0.33 pulls getrandom 0.3 (via rand 0.9) and key-wallet pulls # getrandom 0.4; on wasm32-unknown-unknown both refuse to build without their diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 45b3d6704d5..d3a6affe588 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -234,21 +234,14 @@ pub(crate) fn build_output_only_bundle( #[cfg(feature = "core_key_wallet")] type AuthorizedBundle = Bundle; -/// An output-only Orchard bundle whose Halo 2 proof has been created but that carries no -/// signatures yet. Produced by [`prove_output_only_bundle`]. +/// An output-only Orchard bundle that is proved but not yet signed. Produced by +/// [`prove_output_only_bundle`]; [`authorize`](Self::authorize) signs it over the sighash fixed at +/// proving time, with fresh randomness on every call. /// -/// [`authorize`](Self::authorize) applies the binding signature and the padding spends' -/// spend-authorization signatures over the sighash fixed when the bundle was proved. Those are -/// randomized RedPallas signatures, so every call returns a bundle with the same actions, -/// commitment and proof but different signature bytes. -/// -/// It therefore retains signing secrets for as long as it lives: the binding signing key `bsk` -/// (the sum of the actions' value-commitment trapdoors) and each padding spend's spend -/// authorizing key and randomizer (`dummy_ask`, `alpha`). They are fresh per bundle — no wallet -/// key is among them — but whoever holds them can sign this proof over a *different* sighash, -/// i.e. re-bind it to another owner, and the trapdoors open the per-action value commitments. -/// They live only inside the signing closure: never serialized, persisted or logged (the type -/// has no `Debug`), and dropped with the value. +/// It holds the bundle's signing secrets — the binding signing key `bsk` and the padding spends' +/// `dummy_ask` and `alpha` — which could sign the proof over a different sighash. They are fresh +/// per bundle (no wallet key is among them), live only inside the signing closure (no `Debug`, +/// never serialized or logged) and are dropped with the value. #[cfg(feature = "core_key_wallet")] pub(crate) struct ProvedOutputOnlyBundle { /// Signs a clone of the proved bundle. The proved-but-unsigned bundle type @@ -649,12 +642,8 @@ mod mod_tests { ); } - // ------------------------------------------------------------------ - // `prove_output_only_bundle` — one proof, authorized any number of - // times. Each authorization must verify on its own (proof + binding - // + padding spend-auth signatures, exactly as consensus checks them) - // and must not repeat the previous one's signature bytes. - // ------------------------------------------------------------------ + // `prove_output_only_bundle`: each authorization of one proof verifies on its own and does + // not repeat the previous one's signature bytes. #[cfg(feature = "core_key_wallet")] #[test] diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index 48ab776287d..08959c1eda4 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -18,42 +18,20 @@ use super::{ #[cfg(feature = "core_key_wallet")] use super::{prove_output_only_bundle, ProvedOutputOnlyBundle}; -/// A `ShieldFromAssetLock` Orchard bundle that has been proved and bound to its funding asset -/// lock but not yet wrapped into a signed transition. Proved by [`Self::prove`]; wrapped (any -/// number of times) by [`Self::build_transition_with_signer`]. +/// A `ShieldFromAssetLock` Orchard bundle, proved for a locked outpoint but not yet wrapped into +/// a transition. Proved by [`Self::prove`]; wrapped any number of times by +/// [`Self::build_transition_with_signer`]. /// -/// What each part of the finished transition commits to: +/// The bundle's sighash binds the locked outpoint (see +/// [`shield_from_asset_lock_extra_sighash_data`]), not the proof that presents it, so the Halo 2 +/// proof can be made before the InstantSend lock arrives and reused under a ChainLock proof of the +/// same outpoint. Only the outer ECDSA signature covers the asset-lock proof and the surplus +/// output. /// -/// - The Halo 2 proof covers the circuit's public inputs: each action's nullifier, `rk`, `cmx` -/// and `cv_net`, the anchor (the empty tree) and the flags. -/// - The binding signature (which also enforces the value balance against the `cv_net`s) and -/// the padding spends' spend-authorization signatures are over the platform sighash: the -/// bundle commitment — the actions including their note ciphertexts (so the recipient note and -/// the sender OVK's out-ciphertext), the flags, the value balance and the anchor, without -/// signatures or proof — plus [`shield_from_asset_lock_extra_sighash_data`]: a kind tag and the -/// double SHA-256 of the locked **outpoint**. They do not cover how the lock is proven -/// (InstantSend or ChainLock), the surplus output, or anything else about the transition. -/// - The outer ECDSA signature by the asset-lock key covers the whole transition except itself: -/// the asset-lock proof, the bundle fields (including the signatures above) and the surplus -/// output. `ShieldFromAssetLock` has no `user_fee_increase`. -/// -/// The sighash is fixed when the bundle is proved, so everything up to the bundle signatures -/// depends only on the locked outpoint and value, the recipient and the sender OVK. The -/// expensive part — the proof — can be produced as soon as the asset-lock transaction exists, -/// before its InstantSend lock arrives, and stays valid when the transition is later wrapped -/// around a ChainLock proof of the same outpoint. Everything that depends on the lock proof is -/// signed at assembly time. -/// -/// Every assembly signs the proved bundle afresh. RedPallas signatures are randomized, so two -/// transitions assembled from one proved bundle never hash alike — a client resubmitting after a -/// rejection relies on that, as Tenderdash caches the hashes of rejected transitions and drops an -/// identical resubmission. -/// -/// Being re-signable, the value holds the bundle's signing secrets — the binding signing key and -/// the padding spends' spend authorizing keys and randomizers — until it is dropped (see -/// `ProvedOutputOnlyBundle`). They are never serialized, persisted or logged, but anyone who -/// obtains them can re-bind the proof to another asset lock, so drop the value as soon as the -/// transition has landed or been abandoned. +/// Each assembly re-signs the bundle with fresh randomness, so a resubmission never repeats a +/// rejected transition's hash (Tenderdash caches those and drops repeats). To allow that, the value +/// holds the bundle's signing secrets (see `ProvedOutputOnlyBundle`), which could re-bind the proof +/// to another asset lock: drop it once the transition has landed or been abandoned. #[cfg(feature = "core_key_wallet")] pub struct ProvedShieldFromAssetLockBundle { bundle: ProvedOutputOnlyBundle, @@ -83,13 +61,10 @@ impl ProvedShieldFromAssetLockBundle { dummy_outputs: usize, platform_version: &PlatformVersion, ) -> Result { - // The binding is a function of the locked outpoint alone: `AssetLockProof::create_identifier` - // is the double SHA-256 of the outpoint for an InstantSend and a ChainLock proof alike - // (pinned by `sighash`'s - // `should_bind_the_whole_outpoint_of_the_asset_lock_not_only_its_transaction`). A chain - // proof of the outpoint therefore stands in for the proof that does not exist yet; its - // height is not part of the binding. Assembly re-derives the binding from the real proof - // and refuses a mismatch. + // The binding depends on the locked outpoint alone, never on the proof kind or the + // chain-lock height, so a chain proof of the outpoint stands in for the proof that does + // not exist yet. Assembly re-derives the binding from the real proof and refuses a + // mismatch. let lock_placeholder = AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 0, out_point: asset_lock_out_point, @@ -448,4 +423,48 @@ mod tests { ); } } + + #[cfg(feature = "core_key_wallet")] + #[test] + fn proved_bundle_verifies_against_the_binding_of_a_real_lock_proof() { + // Consensus derives the sighash from the transition's own lock proof, not from what the + // builder stored: verify against that, for a proof `prove` never saw. + use super::ProvedShieldFromAssetLockBundle; + use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; + use crate::prelude::AssetLockProof; + use crate::shielded::{ + compute_platform_sighash, shield_from_asset_lock_extra_sighash_data, + }; + use dashcore::OutPoint; + use grovedb_commitment_tree::{BatchValidator, VerifyingKey}; + use platform_version::version::PlatformVersion; + use rand::rngs::OsRng; + + let platform_version = PlatformVersion::latest(); + let out_point = OutPoint::from([0x55; 36]); + let proved = ProvedShieldFromAssetLockBundle::prove( + &test_orchard_address(), + 50_000, + out_point, + &TestProver, + [0u8; 36], + None, + 0, + platform_version, + ) + .expect("bundle should prove"); + let bundle = proved.bundle.authorize().expect("bundle should sign"); + + let lock = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point, + }); + let extra = + shield_from_asset_lock_extra_sighash_data(&lock, platform_version).expect("binding"); + assert!(!extra.is_empty(), "the latest version binds the asset lock"); + let commitment: [u8; 32] = bundle.commitment().into(); + let mut batch = BatchValidator::new(); + batch.add_bundle(&bundle, compute_platform_sighash(&commitment, &extra)); + assert!(batch.validate(&VerifyingKey::build(), OsRng)); + } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 4d294aa739c..24354ba7d78 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -96,66 +96,6 @@ fn make_chain_asset_lock_proof() -> AssetLockProof { }) } -/// Rebuilds the Orchard bundle from a transition's fields and verifies its proof and signatures -/// against the sighash bound to `asset_lock_proof`, mirroring drive-abci's -/// `shield_from_asset_lock` `transform_into_action` (v1) → `reconstruct_and_verify_bundle` -/// (outputs-only flags, `value_balance = -shield_amount`). -fn bundle_verifies_like_consensus( - v1: &ShieldFromAssetLockTransitionV1, - asset_lock_proof: &AssetLockProof, - vk: &grovedb_commitment_tree::VerifyingKey, - platform_version: &PlatformVersion, -) -> bool { - use crate::shielded::{compute_platform_sighash, shield_from_asset_lock_extra_sighash_data}; - use grovedb_commitment_tree::{ - redpallas, Action, Anchor, Authorized, BatchValidator, Bundle, DashMemo, - ExtractedNoteCommitment, Flags, NoteBytesData, Nullifier, Proof, ProofSizeEnforcement, - TransmittedNoteCiphertext, ValueCommitment, - }; - - let actions: Vec<_> = v1 - .actions - .iter() - .map(|a| { - let note = &a.encrypted_note; - Action::from_parts( - Nullifier::from_bytes(&a.nullifier).unwrap(), - redpallas::VerificationKey::try_from(a.rk).unwrap(), - ExtractedNoteCommitment::from_bytes(&a.cmx).unwrap(), - TransmittedNoteCiphertext::::from_parts( - note[..32].try_into().unwrap(), - NoteBytesData(note[32..136].try_into().unwrap()), - note[136..].try_into().unwrap(), - ), - ValueCommitment::from_bytes(&a.cv_net).unwrap(), - redpallas::Signature::from(a.spend_auth_sig), - ) - .expect("well-formed action") - }) - .collect(); - let bundle: Bundle = Bundle::try_from_parts( - nonempty::NonEmpty::from_vec(actions).expect("actions"), - Flags::from_byte(0x02).expect("outputs-only flags"), - -(v1.value_balance as i64), - Anchor::from_bytes(v1.anchor).unwrap(), - Authorized::from_parts( - Proof::new(v1.proof.clone()), - redpallas::Signature::from(v1.binding_signature), - ), - ProofSizeEnforcement::Strict, - ) - .expect("well-formed bundle"); - - let extra_sighash_data = - shield_from_asset_lock_extra_sighash_data(asset_lock_proof, platform_version) - .expect("binding"); - let commitment: [u8; 32] = bundle.commitment().into(); - let sighash = compute_platform_sighash(&commitment, &extra_sighash_data); - let mut batch = BatchValidator::new(); - batch.add_bundle(&bundle, sighash); - batch.validate(vk, rand::rngs::OsRng) -} - /// An InstantSend proof of a one-output asset-lock transaction. Its InstantSend lock is not /// valid; nothing here verifies it. fn make_instant_asset_lock_proof() -> AssetLockProof { @@ -442,30 +382,28 @@ async fn seed_pool_batch_fits_max_state_transition_size() { } #[tokio::test] -async fn proved_bundle_assembles_around_instant_and_chain_proofs_of_its_outpoint() { - // The bundle is proved before the InstantSend lock exists, from the outpoint alone, then - // wrapped around the InstantSend proof and — after a rejection — around a ChainLock proof of - // the same outpoint. Both assemblies must carry the one proof, and each must be signed afresh - // so a resubmission never repeats a rejected transition's hash. +async fn proved_bundle_assembles_around_any_proof_of_its_outpoint() { + // Proved from the outpoint alone, one bundle wraps an InstantSend proof, the same proof again + // and a ChainLock proof of the outpoint. Each assembly signs afresh, so a resubmission never + // repeats a rejected transition's hash. That the bundle verifies under a lock proof's binding + // is `proved_bundle_verifies_against_the_binding_of_a_real_lock_proof`; that both proof kinds + // bind alike is `proved_bundle_binding_is_the_outpoint_binding_at_every_version`. use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; use crate::shielded::builder::ProvedShieldFromAssetLockBundle; let platform_version = PlatformVersion::latest(); let signer = FixedKeySigner::new([7u8; 32]); let path = DerivationPath::default(); - let shield_amount = 50_000u64; - let instant = make_instant_asset_lock_proof(); let out_point = instant.out_point().expect("fixture has a credit output"); let chain = AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 100, out_point, }); - let other_lock = make_chain_asset_lock_proof(); let proved = ProvedShieldFromAssetLockBundle::prove( &test_orchard_address(), - shield_amount, + 50_000, out_point, &TestProver, [0u8; 36], @@ -474,54 +412,21 @@ async fn proved_bundle_assembles_around_instant_and_chain_proofs_of_its_outpoint platform_version, ) .expect("proving should succeed"); - assert!(proved.is_bound_for(&instant, platform_version).unwrap()); - assert!(proved.is_bound_for(&chain, platform_version).unwrap()); - assert!(!proved.is_bound_for(&other_lock, platform_version).unwrap()); - let assemble = |proof: AssetLockProof| { proved.build_transition_with_signer(proof, &path, &signer, None, platform_version) }; - let over_instant = extract_v1(assemble(instant.clone()).await.expect("instant assembly")); - let resubmitted = extract_v1(assemble(instant).await.expect("instant re-assembly")); + let first = extract_v1(assemble(instant.clone()).await.expect("instant assembly")); + let again = extract_v1(assemble(instant).await.expect("instant re-assembly")); let over_chain = extract_v1(assemble(chain).await.expect("chain assembly")); - // Each transition's bundle verifies the way consensus checks it: rebuilt from the - // transition's own fields, against the sighash bound to the transition's OWN asset lock - // proof — not merely against the sighash it was proved with. - let vk = grovedb_commitment_tree::VerifyingKey::build(); - for v1 in [&over_instant, &resubmitted, &over_chain] { - assert!( - bundle_verifies_like_consensus(v1, &v1.asset_lock_proof, &vk, platform_version), - "an assembled transition's bundle must verify against its own asset lock proof" - ); + for v1 in [&again, &over_chain] { + assert_eq!(v1.proof, first.proof, "assembly must not re-prove"); + assert_eq!(v1.value_balance, 50_000); } - // And the check has teeth: under another lock's binding the same bundle is refused. - assert!(!bundle_verifies_like_consensus( - &over_chain, - &other_lock, - &vk, - platform_version - )); - - for v1 in [&over_instant, &resubmitted, &over_chain] { - assert_eq!(v1.value_balance, shield_amount); - assert_eq!(v1.proof, over_instant.proof, "one proof, never re-proved"); - assert_eq!(v1.anchor, over_instant.anchor); - assert_eq!(v1.actions.len(), over_instant.actions.len()); - for (action, first) in v1.actions.iter().zip(&over_instant.actions) { - assert_eq!(action.nullifier, first.nullifier); - assert_eq!(action.cmx, first.cmx); - assert_eq!(action.cv_net, first.cv_net); - assert_eq!(action.encrypted_note, first.encrypted_note); - } - } - assert_ne!( - over_instant.binding_signature, resubmitted.binding_signature, - "every assembly signs afresh, so the same proof never yields the same transition twice" - ); - assert_ne!(over_instant.signature, resubmitted.signature); + assert_ne!(first.binding_signature, again.binding_signature); + assert_ne!(first.signature, again.signature); - let err = assemble(other_lock) + let err = assemble(make_chain_asset_lock_proof()) .await .expect_err("a bundle bound to another outpoint must not be assembled"); assert!( diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 06acf92a782..51fab86d34a 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -372,10 +372,8 @@ impl PlatformWallet { // (the sent-note row then lands under that account); fall back to the // lowest bound account, or `None` (unrecoverable out_ciphertext) if // the shielded sub-wallet isn't bound. Read once, before the funding - // resolves, so the bundle proved while the InstantSend lock is - // awaited and any later re-proof commit to the same OVK. (It used to - // be read after the lock arrived: a shielded sub-wallet bound during - // the lock wait now takes effect from the next funding call.) + // resolves, so the speculative proof and any re-proof commit to the + // same OVK. let sender_ovk = { let guard = self.shielded_keys.read().await; guard.as_ref().and_then(|keys| { @@ -524,24 +522,14 @@ impl PlatformWallet { // // Every attempt — the first, each CL-height retry, the IS→CL // fallback — reuses the one proved bundle (`BundleCache`): none of - // them changes the outpoint, the amount or (barring a protocol - // upgrade mid-flight, which re-proves) the binding the proof commits - // to. + // them changes the outpoint or the amount the proof commits to. // // Subtle: `ShieldFromAssetLockTransition::set_user_fee_increase` - // is a no-op (pinned at `state_transition::mod`'s - // `test_shield_from_asset_lock_user_fee_increase_is_zero_and_setter_noop`), - // so the wrapper's bump cannot directly diversify the ST hash + // is a no-op, so the wrapper's bump cannot diversify the ST hash // here the way it does for address-funding. Retries still avoid - // Tenderdash's invalid-tx cache because every assembly signs the - // proved bundle afresh: its binding and padding spend-auth - // signatures are randomized RedPallas signatures (drawn from - // `OsRng`), so each attempt carries different signature bytes and - // therefore a different signable hash (and outer ECDSA signature). - // If the bundle signing is ever made deterministic, this - // orchestration would need an explicit diversifier (e.g. a - // memo-derived bump, which would mean re-proving) to keep CL-height - // retries from silently degrading into duplicate-hash submits. + // Tenderdash's invalid-tx cache because every assembly re-signs the + // bundle with randomized RedPallas signatures. If that signing is + // ever made deterministic, retries need another diversifier. let proof_out_point = out_point_from_proof(&proof); let sdk = self.sdk.clone(); let bundles = tokio::sync::Mutex::new(BundleCache::new(prove, speculative)); @@ -933,11 +921,9 @@ async fn tracked_asset_lock_value_duffs( /// `InstantAssetLockProof::output` names and consensus credits. `None` when /// the transaction carries no such credit output. /// -/// Not the tracked row's `amount`: that is the sum of all credit outputs. -/// The two agree for the single-output locks this wallet builds (pinned by -/// `wallet_built_lock_value_is_its_only_credit_output`), but reading the -/// output itself everywhere keeps the speculative and the resolved amounts -/// equal by construction. +/// Not the tracked row's `amount`, which sums all credit outputs (the same +/// number for the single-output locks this wallet builds). Reading the output +/// on both paths keeps the speculative and the resolved amounts equal. fn credit_output_duffs(transaction: &dashcore::Transaction, output_index: u32) -> Option { use dashcore::blockdata::transaction::special_transaction::TransactionPayload; @@ -1171,7 +1157,7 @@ impl Drop for ProofTask { /// The proved bundle the submission attempts of one funding call share. /// /// Holds the bundle's signing secrets (see `ProvedShieldFromAssetLockBundle`) -/// in memory only, for the rest of the funding call; they are dropped with +/// in memory only, until the submission attempts end; they are dropped with /// the cache. /// /// Holds the speculative proof started while the lock proof was awaited @@ -1865,84 +1851,6 @@ mod tests { ); } - /// The speculative proof's amount (tracked row) and the resolved amount - /// (InstantSend proof, or the tracked row on the ChainLock path) must be - /// the same number, or every funding silently re-proves after the lock - /// arrives and the overlap is lost. Both read `credit_output_duffs`; for - /// the single-output locks the wallet builds that is also the row's - /// `amount`. - #[tokio::test] - async fn wallet_built_lock_value_is_its_only_credit_output() { - use dashcore::InstantLock; - use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; - - let ctx = consumption_report_context().await; - let lock = { - let wm = ctx.wallet_manager.read().await; - wm.get_wallet_info(&ctx.wallet_id) - .expect("wallet") - .tracked_asset_locks - .get(&ctx.out_point) - .expect("tracked lock") - .clone() - }; - let from_row = credit_output_duffs(&lock.transaction, ctx.out_point.vout) - .expect("the lock's credit output"); - assert_eq!( - credit_output_duffs(&lock.transaction, ctx.out_point.vout + 1), - None, - "the wallet builds single-output locks" - ); - assert_eq!(from_row, 1_000_000, "the whole requested amount is locked"); - assert_eq!(from_row, lock.amount); - - let instant = - InstantAssetLockProof::new(InstantLock::default(), lock.transaction.clone(), 0); - assert_eq!( - out_point_from_proof(&AssetLockProof::Instant(instant.clone())), - ctx.out_point - ); - assert_eq!( - credit_output_duffs(instant.transaction(), instant.output_index()), - Some(from_row) - ); - assert_eq!(instant.output().map(|output| output.value), Some(from_row)); - } - - /// With several credit outputs, each lock is worth its own output — not - /// the sum the tracked row records. - #[test] - fn credit_output_value_is_the_indexed_output_not_the_sum() { - use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::blockdata::transaction::special_transaction::TransactionPayload; - use dashcore::{ScriptBuf, Transaction, TxOut}; - - let output = |value| TxOut { - value, - script_pubkey: ScriptBuf::new(), - }; - let transaction = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( - AssetLockPayload { - version: 0, - credit_outputs: vec![output(100_000), output(200_000)], - }, - )), - }; - assert_eq!(credit_output_duffs(&transaction, 0), Some(100_000)); - assert_eq!(credit_output_duffs(&transaction, 1), Some(200_000)); - assert_eq!(credit_output_duffs(&transaction, 2), None); - let plain = Transaction { - special_transaction_payload: None, - ..transaction - }; - assert_eq!(credit_output_duffs(&plain, 0), None); - } - #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; @@ -2241,479 +2149,126 @@ mod tests { // -- Proving the bundle while the InstantSend lock is awaited --------- // - // These drive the production pieces of `shielded_fund_from_asset_lock`'s - // proof pipeline (`resolve_while_proving`, `ProofTask`, `BundleCache`, - // `submit_with_cl_height_retry`) with a fake prover whose cost is a - // controllable sleep on the blocking pool, and a fake funding resolution - // that reports the outpoint at "broadcast" and then waits for the lock. + // A fake prover stands in for Halo 2: the "bundle" is the amount it was + // proved for. mod proof_pipeline { use std::sync::atomic::AtomicUsize; - use std::time::{Duration, Instant}; + use std::time::Duration; use dpp::version::PlatformVersion; use tokio::sync::oneshot; use super::*; - use crate::error::is_instant_lock_proof_invalid; - use crate::wallet::asset_lock::orchestration::submit_with_cl_height_retry; const AMOUNT: Credits = 1_000_000; - fn lock_out_point() -> OutPoint { - OutPoint::from([7u8; 36]) - } - - fn target(out_point: OutPoint, shield_amount: Credits) -> BundleTarget { + fn target(shield_amount: Credits) -> BundleTarget { BundleTarget { - out_point, + out_point: OutPoint::from([7u8; 36]), shield_amount, platform_version: PlatformVersion::latest(), } } - /// Stands in for a proved bundle; counts its drops so a test can see - /// a discarded proof's result being released. - struct FakeBundle { - target_amount: Credits, - drops: Arc, - } - - impl Drop for FakeBundle { - fn drop(&mut self) { - self.drops.fetch_add(1, Ordering::SeqCst); - } - } - - /// A fake prover: blocks its blocking-pool thread for `cost`. - #[derive(Clone, Default)] - struct Probe { - calls: Arc, - drops: Arc, - started_at: Arc>>, - } - - impl Probe { - fn prover(&self, cost: Duration) -> ProveFn { - let probe = self.clone(); - Arc::new(move |target: BundleTarget| { - probe.calls.fetch_add(1, Ordering::SeqCst); - probe - .started_at - .lock() - .unwrap() - .get_or_insert_with(Instant::now); - std::thread::sleep(cost); - Ok(FakeBundle { - target_amount: target.shield_amount, - drops: Arc::clone(&probe.drops), - }) - }) - } - - fn calls(&self) -> usize { - self.calls.load(Ordering::SeqCst) - } - } - - fn cl_height_too_low() -> dash_sdk::Error { - use dpp::consensus::basic::identity::InvalidAssetLockProofCoreChainHeightError; - use dpp::consensus::basic::BasicError; - use dpp::consensus::ConsensusError; - - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( - ConsensusError::BasicError(BasicError::InvalidAssetLockProofCoreChainHeightError( - InvalidAssetLockProofCoreChainHeightError::new(100, 99), - )), - ))) - } - - fn instant_lock_rejected() -> dash_sdk::Error { - use dpp::consensus::basic::identity::InvalidInstantAssetLockProofSignatureError; - - dash_sdk::Error::Protocol(dpp::ProtocolError::ConsensusError(Box::new( - InvalidInstantAssetLockProofSignatureError::new().into(), - ))) - } - - /// The old order (wait for the lock, then prove) against the new one - /// (prove from the broadcast on). Real sleeps on a multi-threaded - /// runtime: the proof costs `PROOF` of blocking-pool time, the lock - /// arrives `LOCK_WAIT` after the broadcast. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn should_prove_while_the_instant_lock_is_awaited() { - const PROOF: Duration = Duration::from_millis(400); - const LOCK_WAIT: Duration = Duration::from_millis(400); - - // Before: the proof starts once the lock has been delivered. - let sequential_probe = Probe::default(); - let start = Instant::now(); - tokio::time::sleep(LOCK_WAIT).await; - let mut cache = BundleCache::new(sequential_probe.prover(PROOF), None); - cache - .bundle_for(target(lock_out_point(), AMOUNT)) - .await - .expect("proof"); - let sequential = start.elapsed(); - - // After: the resolver reports the outpoint at broadcast, the proof - // starts, and the lock wait runs alongside it. - let probe = Probe::default(); - let prove = probe.prover(PROOF); - let lock_delivered_at = Arc::new(Mutex::new(None)); - let (out_point_tx, out_point_rx) = oneshot::channel(); - let start = Instant::now(); - let resolve = { - let lock_delivered_at = Arc::clone(&lock_delivered_at); - async move { - let _ = out_point_tx.send(lock_out_point()); - tokio::time::sleep(LOCK_WAIT).await; - *lock_delivered_at.lock().unwrap() = Some(Instant::now()); - Ok::<_, PlatformWalletError>("instant-lock proof") - } - }; - let (resolved, speculative) = resolve_while_proving(resolve, out_point_rx, |op| { - let prove = Arc::clone(&prove); - async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + fn counting_prover(calls: &Arc) -> ProveFn { + let calls = Arc::clone(calls); + Arc::new(move |target: BundleTarget| { + calls.fetch_add(1, Ordering::SeqCst); + Ok(target.shield_amount) }) - .await - .expect("resolution"); - assert_eq!(resolved, "instant-lock proof"); - let mut cache = BundleCache::new(prove, speculative); - let bundle = cache - .bundle_for(target(lock_out_point(), AMOUNT)) - .await - .expect("proof"); - let overlapped = start.elapsed(); - - assert_eq!(bundle.target_amount, AMOUNT); - assert_eq!(probe.calls(), 1, "the speculative proof is the one used"); - let proof_started = probe.started_at.lock().unwrap().expect("proof ran"); - let lock_delivered = lock_delivered_at.lock().unwrap().expect("lock delivered"); - assert!( - proof_started < lock_delivered, - "the proof must start before the InstantSend lock is delivered" - ); - eprintln!( - "shield-from-asset-lock: proof {PROOF:?}, lock wait {LOCK_WAIT:?}: \ - sequential {sequential:?}, overlapped {overlapped:?}" - ); - assert!(sequential >= PROOF + LOCK_WAIT); - // ≈ max(PROOF, LOCK_WAIT) against the sum. Only the ordering is - // asserted (wall-clock bounds flake on loaded runners); - // `should_overlap_the_proof_with_the_lock_wait` proves the overlap - // deterministically. - assert!( - overlapped < sequential, - "overlapped {overlapped:?} should beat sequential {sequential:?}" - ); } - /// Deterministic overlap: the InstantSend lock is only delivered once - /// the proof is running, and the proof only finishes once the lock - /// has been delivered. Waiting for the lock before proving (the old - /// order) can never complete this; overlapping finishes at once. + /// The InstantSend lock is only delivered once the proof is running, + /// and the proof only finishes once the lock has been delivered, so + /// this completes only if the two overlap. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn should_overlap_the_proof_with_the_lock_wait() { - let probe = Probe::default(); + async fn should_prove_while_the_lock_is_awaited() { let (proof_started_tx, proof_started_rx) = oneshot::channel::<()>(); let (lock_delivered_tx, lock_delivered_rx) = std::sync::mpsc::channel::<()>(); let proof_started_tx = Mutex::new(Some(proof_started_tx)); let lock_delivered_rx = Mutex::new(lock_delivered_rx); - let prove: ProveFn = { - let probe = probe.clone(); - Arc::new(move |target: BundleTarget| { - probe.calls.fetch_add(1, Ordering::SeqCst); - if let Some(tx) = proof_started_tx.lock().unwrap().take() { - let _ = tx.send(()); - } - lock_delivered_rx - .lock() - .unwrap() - .recv() - .expect("lock delivered"); - Ok(FakeBundle { - target_amount: target.shield_amount, - drops: Arc::clone(&probe.drops), - }) - }) - }; + let prove: ProveFn = Arc::new(move |target: BundleTarget| { + if let Some(tx) = proof_started_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + lock_delivered_rx + .lock() + .unwrap() + .recv() + .expect("lock delivered"); + Ok(target.shield_amount) + }); let (out_point_tx, out_point_rx) = oneshot::channel(); let resolve = async move { - let _ = out_point_tx.send(lock_out_point()); + let _ = out_point_tx.send(target(AMOUNT).out_point); proof_started_rx.await.expect("proof started"); lock_delivered_tx.send(()).unwrap(); - Ok::<_, PlatformWalletError>("instant-lock proof") + Ok::<_, PlatformWalletError>(()) }; let bundle = tokio::time::timeout(Duration::from_secs(30), async { - let (_, speculative) = resolve_while_proving(resolve, out_point_rx, |op| { - let prove = Arc::clone(&prove); - async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } + let ((), speculative) = resolve_while_proving(resolve, out_point_rx, |out_point| { + let task = ProofTask::spawn( + BundleTarget { + out_point, + ..target(AMOUNT) + }, + Arc::clone(&prove), + ); + std::future::ready(Some(task)) }) .await .expect("resolution"); + assert!(speculative.is_some()); BundleCache::new(Arc::clone(&prove), speculative) - .bundle_for(target(lock_out_point(), AMOUNT)) + .bundle_for(target(AMOUNT)) .await .expect("proof") }) .await .expect("the proof and the lock wait did not overlap"); - assert_eq!(bundle.target_amount, AMOUNT); - assert_eq!(probe.calls(), 1); + assert_eq!(*bundle, AMOUNT); } - /// One proof for the whole call: the first attempt (InstantSend - /// proof, rejected by Platform), the ChainLock fallback, and its two - /// CL-height-too-low retries all get the speculative bundle. - #[tokio::test(start_paused = true)] - async fn should_reuse_the_proved_bundle_across_fallback_and_retries() { - let probe = Probe::default(); - let prove = probe.prover(Duration::ZERO); - let speculative = - ProofTask::spawn(target(lock_out_point(), AMOUNT), Arc::clone(&prove)); - let bundles = tokio::sync::Mutex::new(BundleCache::new(prove, Some(speculative))); - let used = Mutex::new(Vec::<*const FakeBundle>::new()); - let attempts = AtomicUsize::new(0); - - let attempt = |reject: fn(usize) -> Option| { - let (bundles, used, attempts) = (&bundles, &used, &attempts); - move |_settings| async move { - let bundle = bundles - .lock() - .await - .bundle_for(target(lock_out_point(), AMOUNT)) - .await?; - used.lock().unwrap().push(Arc::as_ptr(&bundle)); - match reject(attempts.fetch_add(1, Ordering::SeqCst)) { - Some(error) => Err(error), - None => Ok(()), - } - } - }; - - // InstantSend proof rejected by Platform. - let rejected = - submit_with_cl_height_retry(None, attempt(|_| Some(instant_lock_rejected()))) - .await - .expect_err("Platform rejected the InstantSend proof"); - assert!(is_instant_lock_proof_invalid(&rejected)); - // ChainLock fallback: two CL-height-too-low rejections, then it lands. - submit_with_cl_height_retry(None, attempt(|n| (n < 3).then(cl_height_too_low))) - .await - .expect("lands on the third ChainLock attempt"); - - let used = used.into_inner().unwrap(); - assert_eq!( - used.len(), - 4, - "IS attempt + CL attempt + 2 CL-height retries" - ); - assert!(used.iter().all(|bundle| *bundle == used[0])); - assert_eq!(probe.calls(), 1, "proved once, never re-proved"); - - // Only an attempt whose committed inputs differ is proved afresh. - let mut cache = bundles.lock().await; - let other_amount = cache - .bundle_for(target(lock_out_point(), AMOUNT - 1)) - .await - .expect("proof"); - assert_eq!(other_amount.target_amount, AMOUNT - 1); - assert_eq!(probe.calls(), 2); - let mut other_version = target(lock_out_point(), AMOUNT - 1); - other_version.platform_version = PlatformVersion::first(); - cache.bundle_for(other_version).await.expect("proof"); - assert_eq!(probe.calls(), 3); - } - - /// A speculative proof made for inputs the resolved lock does not - /// match is discarded, not used. + /// Every submission attempt of one call — the first, the IS→CL + /// fallback, the CL-height retries — asks for the same target and gets + /// the speculative bundle. A changed amount or protocol version is + /// proved afresh. #[tokio::test] - async fn should_not_use_a_speculative_proof_for_other_inputs() { - let probe = Probe::default(); - let prove = probe.prover(Duration::ZERO); - let speculative = - ProofTask::spawn(target(lock_out_point(), AMOUNT + 1), Arc::clone(&prove)); - // Let it run first: a speculative proof cancelled before it - // starts never runs at all, which is not what this test is about. - while probe.calls() == 0 { - tokio::time::sleep(Duration::from_millis(1)).await; - } + async fn should_prove_once_per_target() { + let calls = Arc::new(AtomicUsize::new(0)); + let prove = counting_prover(&calls); + let speculative = ProofTask::spawn(target(AMOUNT), Arc::clone(&prove)); let mut cache = BundleCache::new(prove, Some(speculative)); - let bundle = cache - .bundle_for(target(lock_out_point(), AMOUNT)) - .await - .expect("proof"); - assert_eq!(bundle.target_amount, AMOUNT); - assert_eq!( - probe.calls(), - 2, - "the mismatched speculative proof is not used" - ); - } - - /// The lock wait fails while the speculative proof runs: the error is - /// returned at once, without waiting for the proof, and the proof's - /// result is dropped as soon as it finishes — nothing keeps it. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn should_discard_the_speculative_proof_when_the_lock_wait_fails() { - let probe = Probe::default(); - let (started_tx, started_rx) = oneshot::channel::<()>(); - let (finish_tx, finish_rx) = std::sync::mpsc::channel::<()>(); - let started_tx = Mutex::new(Some(started_tx)); - let finish_rx = Mutex::new(finish_rx); - let prove: ProveFn = { - let probe = probe.clone(); - Arc::new(move |target: BundleTarget| { - probe.calls.fetch_add(1, Ordering::SeqCst); - if let Some(tx) = started_tx.lock().unwrap().take() { - let _ = tx.send(()); - } - finish_rx.lock().unwrap().recv().expect("released"); - Ok(FakeBundle { - target_amount: target.shield_amount, - drops: Arc::clone(&probe.drops), - }) - }) - }; - let (out_point_tx, out_point_rx) = oneshot::channel(); - let resolve = async move { - let _ = out_point_tx.send(lock_out_point()); - // The lock wait gives up while the proof is running. - started_rx.await.expect("proof started"); - Err::<(), _>(PlatformWalletError::FinalityTimeout(lock_out_point())) + let first = cache.bundle_for(target(AMOUNT)).await.expect("proof"); + for _ in 0..3 { + let again = cache.bundle_for(target(AMOUNT)).await.expect("proof"); + assert!(Arc::ptr_eq(&first, &again)); + } + assert_eq!(calls.load(Ordering::SeqCst), 1); + + let other_amount = cache.bundle_for(target(AMOUNT - 1)).await.expect("proof"); + assert_eq!(*other_amount, AMOUNT - 1); + assert_eq!(calls.load(Ordering::SeqCst), 2); + let other_version = BundleTarget { + platform_version: PlatformVersion::first(), + ..target(AMOUNT - 1) }; - let start = Instant::now(); - let result = tokio::time::timeout( - Duration::from_secs(10), - resolve_while_proving(resolve, out_point_rx, |op| { - let prove = Arc::clone(&prove); - async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } - }), - ) - .await - .expect("the failure must not wait for the running proof"); - assert!(matches!( - result, - Err(PlatformWalletError::FinalityTimeout(_)) - )); - assert_eq!(probe.drops.load(Ordering::SeqCst), 0, "still proving"); - - // Let the proof finish: its result is dropped by the abandoned task. - finish_tx.send(()).unwrap(); - tokio::time::timeout(Duration::from_secs(10), async { - while probe.drops.load(Ordering::SeqCst) == 0 { - tokio::time::sleep(Duration::from_millis(5)).await; - } - }) - .await - .expect("the discarded proof's result is released"); - assert_eq!(probe.calls(), 1); - eprintln!("lock-wait failure returned after {:?}", start.elapsed()); + cache.bundle_for(other_version).await.expect("proof"); + assert_eq!(calls.load(Ordering::SeqCst), 3); } - /// No outpoint reported (the resolution failed before broadcast): no - /// proof is started at all. #[tokio::test] - async fn should_not_prove_when_the_resolution_fails_before_broadcast() { - let probe = Probe::default(); - let prove = probe.prover(Duration::ZERO); - let (out_point_tx, out_point_rx) = oneshot::channel::(); - let resolve = async move { - drop(out_point_tx); - Err::<(), _>(PlatformWalletError::ShieldedBuildError("no funds".into())) - }; - let result = resolve_while_proving(resolve, out_point_rx, |op| { - let prove = Arc::clone(&prove); - async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } - }) - .await; - assert!(result.is_err()); - assert_eq!(probe.calls(), 0); - } - - /// Real key, real proofs: the same before/after comparison as - /// `should_prove_while_the_instant_lock_is_awaited`, with the actual - /// `ShieldFromAssetLock` bundle proof and an InstantSend lock that - /// arrives as long after the broadcast as one proof takes. Ignored by - /// default (builds the proving key); run with - /// `cargo test -p platform-wallet --features shielded --lib -- --ignored real_proof --nocapture`. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - #[ignore = "builds the real Halo 2 proving key and proves real bundles"] - async fn real_proof_overlaps_a_simulated_instant_lock_wait() { - use crate::wallet::shielded::prover::CachedOrchardProver; - use grovedb_commitment_tree::{FullViewingKey, Scope, SpendingKey}; - - let prover: &'static CachedOrchardProver = &CachedOrchardProver; - let start = Instant::now(); - tokio::task::spawn_blocking(move || prover.warm_up()) - .await - .unwrap(); - eprintln!("proving key built in {:?}", start.elapsed()); - - let sk = SpendingKey::from_bytes([42u8; 32]).unwrap(); - let recipient = OrchardAddress::from_raw_bytes( - &FullViewingKey::from(&sk) - .address_at(0u32, Scope::External) - .to_raw_address_bytes(), - ) - .unwrap(); - let prove: ProveFn = Arc::new(move |target| { - ProvedShieldFromAssetLockBundle::prove( - &recipient, - target.shield_amount, - target.out_point, - &prover, - [0u8; 36], - None, - 0, - target.platform_version, - ) - }); - let lock_target = target(lock_out_point(), AMOUNT); - - let start = Instant::now(); - ProofTask::spawn(lock_target, Arc::clone(&prove)) - .join() - .await - .expect("proof"); - let proof_cost = start.elapsed(); - let lock_wait = proof_cost; - - let start = Instant::now(); - tokio::time::sleep(lock_wait).await; - BundleCache::new(Arc::clone(&prove), None) - .bundle_for(lock_target) - .await - .expect("proof"); - let sequential = start.elapsed(); - - let (out_point_tx, out_point_rx) = oneshot::channel(); - let start = Instant::now(); - let resolve = async move { - let _ = out_point_tx.send(lock_out_point()); - tokio::time::sleep(lock_wait).await; - Ok::<_, PlatformWalletError>(()) - }; - let ((), speculative) = resolve_while_proving(resolve, out_point_rx, |op| { - let prove = Arc::clone(&prove); - async move { Some(ProofTask::spawn(target(op, AMOUNT), prove)) } - }) - .await - .unwrap(); - BundleCache::new(prove, speculative) - .bundle_for(lock_target) + async fn should_not_use_a_speculative_proof_for_other_inputs() { + let prove = counting_prover(&Arc::new(AtomicUsize::new(0))); + let speculative = ProofTask::spawn(target(AMOUNT + 1), Arc::clone(&prove)); + let bundle = BundleCache::new(prove, Some(speculative)) + .bundle_for(target(AMOUNT)) .await .expect("proof"); - let overlapped = start.elapsed(); - - eprintln!( - "real ShieldFromAssetLock proof {proof_cost:?}, simulated lock wait \ - {lock_wait:?}: sequential {sequential:?}, overlapped {overlapped:?}" - ); - assert!(overlapped < sequential); + assert_eq!(*bundle, AMOUNT); } /// A proof cancelled before a blocking thread picks it up never runs. @@ -2725,23 +2280,19 @@ mod tests { .build() .expect("runtime"); runtime.block_on(async { - let probe = Probe::default(); + let calls = Arc::new(AtomicUsize::new(0)); // Occupy the only blocking thread so the proof stays queued. let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); let blocker = tokio::task::spawn_blocking(move || { let _ = release_rx.recv(); }); - let task = ProofTask::spawn( - target(lock_out_point(), AMOUNT), - probe.prover(Duration::ZERO), - ); - drop(task); + drop(ProofTask::spawn(target(AMOUNT), counting_prover(&calls))); release_tx.send(()).unwrap(); blocker.await.unwrap(); // The queue is FIFO: once this runs, the cancelled job has had // its turn. tokio::task::spawn_blocking(|| ()).await.unwrap(); - assert_eq!(probe.calls(), 0); + assert_eq!(calls.load(Ordering::SeqCst), 0); }); } } From 5b4ca2d2304668131fa3087d6d85183b2455cff9 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 8 Oct 2026 18:49:40 +0000 Subject: [PATCH 08/20] perf(platform-wallet): read one tracked asset lock's value without cloning the set The speculative shield proof looks up the lock's value on every funding call. It went through `list_tracked_locks`, which clones every tracked transaction (consumed ones included) under the wallet read lock. Read the one row in place with a keyed `read_tracked_lock` accessor. Pin that the tracked-row and InstantSend paths both take the credit output at the outpoint's index, not the row's summed amount, with a two-output lock. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/manager.rs | 15 +++ .../wallet/shielded/fund_from_asset_lock.rs | 96 +++++++++++++++---- 2 files changed, 95 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 52f473021a9..1023101f3aa 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -278,6 +278,21 @@ impl AssetLockManager { .map(|info| info.tracked_asset_locks.values().cloned().collect()) .unwrap_or_default() } + + /// Applies `read` to the tracked asset lock at `out_point` under the + /// wallet read lock, without cloning the tracked set. `None` when no such + /// lock is tracked. + #[cfg(feature = "shielded")] + pub(crate) async fn read_tracked_lock( + &self, + out_point: &OutPoint, + read: impl FnOnce(&TrackedAssetLock) -> T, + ) -> Option { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(out_point)) + .map(read) + } } impl std::fmt::Debug for AssetLockManager { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 51fab86d34a..5115d4a6ee5 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -49,7 +49,6 @@ use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::ProtocolError; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; -use crate::wallet::asset_lock::tracked::TrackedAssetLock; use crate::wallet::asset_lock::AssetLockOperationState; use std::sync::atomic::{AtomicBool, Ordering}; @@ -689,7 +688,7 @@ impl PlatformWallet { num_actions: usize, prove: &ProveFn, ) -> Option> { - let value_duffs = tracked_asset_lock_value_duffs(self, &out_point) + let value_duffs = tracked_asset_lock_value_duffs(&self.asset_locks, &out_point) .await .ok()?; let value_credits = value_duffs.checked_mul(CREDITS_PER_DUFF)?; @@ -881,7 +880,7 @@ async fn lookup_asset_lock_value_credits( "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), ) })?; - tracked_asset_lock_value_duffs(wallet, op).await? + tracked_asset_lock_value_duffs(&wallet.asset_locks, op).await? } }; duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { @@ -894,26 +893,27 @@ async fn lookup_asset_lock_value_credits( /// The value of the tracked asset lock at `out_point`, in duffs: its /// transaction's credit output at the outpoint's index (see /// [`credit_output_duffs`]). -async fn tracked_asset_lock_value_duffs( - wallet: &PlatformWallet, +async fn tracked_asset_lock_value_duffs( + asset_locks: &AssetLockManager, out_point: &dashcore::OutPoint, ) -> Result { - let locks: Vec = wallet.asset_locks.list_tracked_locks().await; - let lock = locks - .iter() - .find(|l| l.out_point == *out_point) + asset_locks + .read_tracked_lock(out_point, |lock| { + credit_output_duffs(&lock.transaction, out_point.vout) + }) + .await .ok_or_else(|| { PlatformWalletError::AddressSync(format!( "tracked asset lock {} not found in manager", out_point )) - })?; - credit_output_duffs(&lock.transaction, out_point.vout).ok_or_else(|| { - PlatformWalletError::AddressSync(format!( - "tracked asset lock {} has no credit output at index {}", - out_point, out_point.vout - )) - }) + })? + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} has no credit output at index {}", + out_point, out_point.vout + )) + }) } /// The value, in duffs, that an asset lock on `transaction` at `output_index` @@ -1851,6 +1851,70 @@ mod tests { ); } + /// The speculative and the ChainLock paths read a tracked lock's own + /// credit output at the outpoint's index, as the InstantSend path reads + /// it from the proof — not the row's `amount`, which sums all outputs. + #[tokio::test] + async fn tracked_lock_value_is_its_indexed_credit_output() { + use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; + use dashcore::blockdata::transaction::special_transaction::TransactionPayload; + use dashcore::{InstantLock, ScriptBuf, Transaction, TxOut}; + use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; + + let ctx = consumption_report_context().await; + let credit_output = |value| TxOut { + value, + script_pubkey: ScriptBuf::new(), + }; + let transaction = Transaction { + version: 3, + lock_time: 0, + input: vec![], + output: vec![], + special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( + AssetLockPayload { + version: 0, + credit_outputs: vec![credit_output(100_000), credit_output(200_000)], + }, + )), + }; + let out_point = OutPoint::new(transaction.txid(), 1); + ctx.wallet_manager + .write() + .await + .get_wallet_info_mut(&ctx.wallet_id) + .expect("wallet") + .tracked_asset_locks + .insert( + out_point, + TrackedAssetLock { + operation_id: None, + out_point, + transaction: transaction.clone(), + account_index: 0, + funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, + identity_index: 0, + amount: 300_000, + status: AssetLockStatus::ChainLocked, + proof: None, + }, + ); + + let tracked = tracked_asset_lock_value_duffs(&ctx.manager, &out_point) + .await + .expect("tracked lock value"); + assert_eq!(tracked, 200_000); + let instant = InstantAssetLockProof::new(InstantLock::default(), transaction, 1); + assert_eq!( + credit_output_duffs(instant.transaction(), instant.output_index()), + Some(tracked) + ); + let untracked = OutPoint::new(out_point.txid, 2); + assert!(tracked_asset_lock_value_duffs(&ctx.manager, &untracked) + .await + .is_err()); + } + #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; From ab4f5ad06cf62a05b9a63501a768877a4c91d9b2 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 8 Oct 2026 19:18:14 +0000 Subject: [PATCH 09/20] fix(platform-wallet): return a failed funding resolution without waiting for speculation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `resolve_while_proving` joined the resolution with the speculative proof startup, so an error waited for that startup — which reads the tracked lock under the wallet-manager read lock — while the shield-operation guard stayed held. Use `try_join!`, so the error returns at once and drops a startup still in flight or the proof it started. Co-Authored-By: Claude Opus 5.5 --- .../wallet/shielded/fund_from_asset_lock.rs | 28 +++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 5115d4a6ee5..d296f56d2d9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -1235,8 +1235,9 @@ where let out_point = out_point.await.ok()?; speculate(out_point).await }; - let (resolved, speculative) = tokio::join!(resolve, speculation); - Ok((resolved?, speculative)) + // `try_join!` so a failed resolution returns at once, dropping a + // speculative startup still in flight (or the proof it started). + tokio::try_join!(resolve, async { Ok(speculation.await) }) } /// Pre-flight check for the recipient list. @@ -2295,6 +2296,29 @@ mod tests { assert_eq!(*bundle, AMOUNT); } + /// A resolution that fails after reporting the outpoint returns its + /// error without waiting for a speculative startup still in flight. + #[tokio::test] + async fn should_return_a_resolution_error_without_waiting_for_speculation() { + let (out_point_tx, out_point_rx) = oneshot::channel(); + let resolve = async move { + let _ = out_point_tx.send(target(AMOUNT).out_point); + tokio::task::yield_now().await; + Err::<(), _>(PlatformWalletError::ShieldedBuildError( + "lock wait failed".into(), + )) + }; + let result = tokio::time::timeout( + Duration::from_secs(10), + resolve_while_proving(resolve, out_point_rx, |_| { + std::future::pending::>>() + }), + ) + .await + .expect("a failed resolution must not wait for speculative startup"); + assert!(result.is_err()); + } + /// Every submission attempt of one call — the first, the IS→CL /// fallback, the CL-height retries — asks for the same target and gets /// the speculative bundle. A changed amount or protocol version is From 79d5d049c6862bb49641bc4ff5980638ec53de11 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 12:12:02 -0500 Subject: [PATCH 10/20] refactor(dpp): prove asset-lock shield bundles through the shared builder path Replace the duplicated prove_output_only_bundle / ProvedOutputOnlyBundle pair with one prove_bundle_with step that prove_and_sign_bundle_with also uses: it proves once and returns a boxed closure that re-signs a copy of the proved bundle. ProvedShieldFromAssetLockBundle holds that closure and checks the real lock proof's binding inline, so is_bound_for is gone. Trim tests that duplicated shielded::sighash (IS/CL binding at every version) or each other. Co-Authored-By: Claude Opus 5.5 --- packages/rs-dpp/src/shielded/builder/mod.rs | 158 ++++-------------- .../builder/shield_from_asset_lock.rs | 108 ++++++------ .../signing_tests.rs | 89 ++-------- 3 files changed, 91 insertions(+), 264 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index d3a6affe588..11054f3846b 100644 --- a/packages/rs-dpp/src/shielded/builder/mod.rs +++ b/packages/rs-dpp/src/shielded/builder/mod.rs @@ -231,81 +231,6 @@ pub(crate) fn build_output_only_bundle( prove_and_sign_bundle(builder, prover, &[], extra_sighash_data) } -#[cfg(feature = "core_key_wallet")] -type AuthorizedBundle = Bundle; - -/// An output-only Orchard bundle that is proved but not yet signed. Produced by -/// [`prove_output_only_bundle`]; [`authorize`](Self::authorize) signs it over the sighash fixed at -/// proving time, with fresh randomness on every call. -/// -/// It holds the bundle's signing secrets — the binding signing key `bsk` and the padding spends' -/// `dummy_ask` and `alpha` — which could sign the proof over a different sighash. They are fresh -/// per bundle (no wallet key is among them), live only inside the signing closure (no `Debug`, -/// never serialized or logged) and are dropped with the value. -#[cfg(feature = "core_key_wallet")] -pub(crate) struct ProvedOutputOnlyBundle { - /// Signs a clone of the proved bundle. The proved-but-unsigned bundle type - /// (`orchard::builder::InProgress`) is not re-exported by - /// `grovedb-commitment-tree`, so it lives inside this closure. - sign: Box Result + Send + Sync>, -} - -#[cfg(feature = "core_key_wallet")] -impl ProvedOutputOnlyBundle { - /// Signs the proved bundle with fresh randomness. - pub(crate) fn authorize(&self) -> Result { - (self.sign)() - } -} - -/// [`build_output_only_bundle`] without the final signing step: builds and proves the bundle and -/// binds the platform sighash (`extra_sighash_data`), but leaves the signatures to -/// [`ProvedOutputOnlyBundle::authorize`]. Signing an output-only bundle needs no spending key -/// (its spends are padding), so one proof can be authorized any number of times. -#[cfg(feature = "core_key_wallet")] -pub(crate) fn prove_output_only_bundle( - recipient: &OrchardAddress, - amount: u64, - memo: [u8; 36], - sender_ovk: Option, - dummy_outputs: usize, - extra_sighash_data: &[u8], - prover: &P, -) -> Result { - let builder = output_only_bundle_builder(recipient, amount, memo, sender_ovk, dummy_outputs)?; - let mut rng = OsRng; - - let (unauthorized, _) = builder - .build::(&mut rng) - .map_err(|e| ProtocolError::ShieldedBuildError(format!("failed to build bundle: {:?}", e)))? - .ok_or_else(|| { - ProtocolError::ShieldedBuildError("bundle was empty after build".to_string()) - })?; - - let bundle_commitment: [u8; 32] = unauthorized.commitment().into(); - let sighash = compute_platform_sighash(&bundle_commitment, extra_sighash_data); - - let proven = unauthorized - .create_proof(prover.proving_key(), &mut rng) - .map_err(|e| { - ProtocolError::ShieldedBuildError(format!("failed to create proof: {:?}", e)) - })?; - - Ok(ProvedOutputOnlyBundle { - sign: Box::new(move || { - proven - .clone() - .apply_signatures(OsRng, sighash, &[]) - .map_err(|e| { - ProtocolError::ShieldedBuildError(format!( - "failed to apply signatures: {:?}", - e - )) - }) - }), - }) -} - /// The unproved builder of an output-only bundle: the real output, then `dummy_outputs` /// zero-value fillers (see [`build_output_only_bundle`]). fn output_only_bundle_builder( @@ -457,6 +382,26 @@ pub(crate) fn prove_and_sign_bundle_with( signing_keys: &[SpendAuthorizingKey], extra_sighash_data: F, ) -> Result, ProtocolError> +where + F: FnOnce(&[[u8; 32]]) -> Result, ProtocolError>, +{ + prove_bundle_with(builder, prover, extra_sighash_data)?(signing_keys) +} + +/// The signing step of a proved bundle (see [`prove_bundle_with`]). +pub(crate) type SignBundleFn = dyn Fn(&[SpendAuthorizingKey]) -> Result, ProtocolError> + + Send + + Sync; + +/// [`prove_and_sign_bundle_with`] up to, not including, the signatures: returns the signing step, +/// which signs a copy of the proved bundle over the sighash bound here, with fresh randomness on +/// every call. The closure holds the bundle's signing secrets (`bsk`, and the padding spends' +/// `dummy_ask` and `alpha`), fresh per bundle and dropped with it. +pub(crate) fn prove_bundle_with( + builder: Builder, + prover: &P, + extra_sighash_data: F, +) -> Result, ProtocolError> where F: FnOnce(&[[u8; 32]]) -> Result, ProtocolError>, { @@ -485,11 +430,14 @@ where ProtocolError::ShieldedBuildError(format!("failed to create proof: {:?}", e)) })?; - proven - .apply_signatures(rng, sighash, signing_keys) - .map_err(|e| { - ProtocolError::ShieldedBuildError(format!("failed to apply signatures: {:?}", e)) - }) + Ok(Box::new(move |signing_keys: &[SpendAuthorizingKey]| { + proven + .clone() + .apply_signatures(OsRng, sighash, signing_keys) + .map_err(|e| { + ProtocolError::ShieldedBuildError(format!("failed to apply signatures: {:?}", e)) + }) + })) } /// Shared test utilities for builder tests. @@ -642,56 +590,6 @@ mod mod_tests { ); } - // `prove_output_only_bundle`: each authorization of one proof verifies on its own and does - // not repeat the previous one's signature bytes. - - #[cfg(feature = "core_key_wallet")] - #[test] - fn proved_output_only_bundle_reauthorizes_into_distinct_valid_bundles() { - use grovedb_commitment_tree::{BatchValidator, VerifyingKey}; - - let extra_sighash_data = [0x86u8; 33]; - let proved = prove_output_only_bundle( - &test_orchard_address(), - 10_000, - [0u8; 36], - None, - 0, - &extra_sighash_data, - &TestProver, - ) - .expect("bundle should prove"); - let first = proved.authorize().expect("first authorization"); - let second = proved.authorize().expect("second authorization"); - - let commitment: [u8; 32] = first.commitment().into(); - assert_eq!(commitment, <[u8; 32]>::from(second.commitment())); - assert_eq!( - first.authorization().proof().as_ref(), - second.authorization().proof().as_ref(), - "authorizing must not re-prove" - ); - let first_sb = serialize_authorized_bundle(&first); - let second_sb = serialize_authorized_bundle(&second); - assert_ne!(first_sb.binding_signature, second_sb.binding_signature); - assert_eq!(*first.value_balance(), -10_000i64); - - let sighash = compute_platform_sighash(&commitment, &extra_sighash_data); - let vk = VerifyingKey::build(); - for bundle in [&first, &second] { - let mut batch = BatchValidator::new(); - batch.add_bundle(bundle, sighash); - assert!( - batch.validate(&vk, OsRng), - "re-authorized bundle must verify" - ); - } - // The signatures are bound to the sighash fixed at proving time. - let mut batch = BatchValidator::new(); - batch.add_bundle(&first, compute_platform_sighash(&commitment, &[0x86u8; 1])); - assert!(!batch.validate(&vk, OsRng)); - } - // ------------------------------------------------------------------ // `build_output_only_bundle` dummy-output padding — the on-wire // action count is `max(1 + dummy_outputs, 2)` (Orchard pads an diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index 08959c1eda4..849d17709db 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -16,7 +16,7 @@ use super::{ build_output_only_bundle, serialize_authorized_bundle, OrchardProver, SerializedBundle, }; #[cfg(feature = "core_key_wallet")] -use super::{prove_output_only_bundle, ProvedOutputOnlyBundle}; +use super::{output_only_bundle_builder, prove_bundle_with, SignBundleFn}; /// A `ShieldFromAssetLock` Orchard bundle, proved for a locked outpoint but not yet wrapped into /// a transition. Proved by [`Self::prove`]; wrapped any number of times by @@ -25,31 +25,29 @@ use super::{prove_output_only_bundle, ProvedOutputOnlyBundle}; /// The bundle's sighash binds the locked outpoint (see /// [`shield_from_asset_lock_extra_sighash_data`]), not the proof that presents it, so the Halo 2 /// proof can be made before the InstantSend lock arrives and reused under a ChainLock proof of the -/// same outpoint. Only the outer ECDSA signature covers the asset-lock proof and the surplus -/// output. +/// same outpoint. Only the outer ECDSA signature covers the asset-lock proof. /// /// Each assembly re-signs the bundle with fresh randomness, so a resubmission never repeats a /// rejected transition's hash (Tenderdash caches those and drops repeats). To allow that, the value -/// holds the bundle's signing secrets (see `ProvedOutputOnlyBundle`), which could re-bind the proof -/// to another asset lock: drop it once the transition has landed or been abandoned. +/// holds the bundle's signing secrets, with which its holder could re-bind the proof to another +/// asset lock and fund the same note twice: keep it in memory, and drop it once the transition has +/// landed or been abandoned. #[cfg(feature = "core_key_wallet")] pub struct ProvedShieldFromAssetLockBundle { - bundle: ProvedOutputOnlyBundle, - /// The asset-lock binding the bundle's sighash committed to (see - /// [`shield_from_asset_lock_extra_sighash_data`]); empty at protocol versions that bind - /// nothing. + /// Signs the proved bundle (see `prove_bundle_with`). The proved-but-unsigned bundle type is + /// not re-exported by `grovedb-commitment-tree`, so it lives inside this closure. + sign: Box, + /// The asset-lock binding the sighash committed to; empty at versions that bind nothing. extra_sighash_data: Vec, } #[cfg(feature = "core_key_wallet")] impl ProvedShieldFromAssetLockBundle { - /// Proves the outputs-only bundle of a `ShieldFromAssetLock` that will be funded by the asset - /// lock at `asset_lock_out_point`. - /// - /// The CPU-heavy half of [`build_shield_from_asset_lock_transition`] (Halo 2 - /// proof generation, seconds of work); it does no I/O and no signing, so it can run on a - /// blocking thread while the caller waits for the lock's InstantSend lock. Parameters are as - /// for [`build_shield_from_asset_lock_transition`]. + /// Proves the outputs-only bundle of a `ShieldFromAssetLock` that the asset lock at + /// `asset_lock_out_point` will fund: the CPU-heavy half (seconds of Halo 2 proving) of + /// [`build_shield_from_asset_lock_transition_with_signer`], whose parameters it shares. It does + /// no I/O and no signing, so it can run on a blocking thread while the lock's InstantSend lock + /// is awaited. #[allow(clippy::too_many_arguments)] pub fn prove( recipient: &OrchardAddress, @@ -63,54 +61,31 @@ impl ProvedShieldFromAssetLockBundle { ) -> Result { // The binding depends on the locked outpoint alone, never on the proof kind or the // chain-lock height, so a chain proof of the outpoint stands in for the proof that does - // not exist yet. Assembly re-derives the binding from the real proof and refuses a - // mismatch. + // not exist yet. Assembly re-derives the binding from the real proof. let lock_placeholder = AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 0, out_point: asset_lock_out_point, }); let extra_sighash_data = shield_from_asset_lock_extra_sighash_data(&lock_placeholder, platform_version)?; - let bundle = prove_output_only_bundle( - recipient, - shield_amount, - memo, - sender_ovk, - dummy_outputs, - &extra_sighash_data, - prover, - )?; + let builder = + output_only_bundle_builder(recipient, shield_amount, memo, sender_ovk, dummy_outputs)?; + let bound = extra_sighash_data.clone(); + let sign = prove_bundle_with(builder, prover, move |_| Ok(bound))?; Ok(Self { - bundle, + sign, extra_sighash_data, }) } - /// Whether this bundle binds exactly what consensus will re-derive for a transition carrying - /// `asset_lock_proof` at `platform_version`: the same locked outpoint under the same binding - /// rules. The kind of proof (InstantSend or ChainLock) does not matter. A bundle proved for - /// another outpoint, or at a protocol version with a different binding, must be proved again - /// — except across protocol versions that bind nothing (`credit_pool_bundle_binding` unset), - /// where any lock matches, as it does for consensus. - pub fn is_bound_for( - &self, - asset_lock_proof: &AssetLockProof, - platform_version: &PlatformVersion, - ) -> Result { - Ok( - shield_from_asset_lock_extra_sighash_data(asset_lock_proof, platform_version)? - == self.extra_sighash_data, - ) - } - /// Wraps the proved bundle into a `ShieldFromAssetLock` transition funded by - /// `asset_lock_proof`: signs the bundle afresh (see the type docs) and has - /// `asset_lock_signer` produce the outer ECDSA signature over the transition. Does not consume - /// the proved bundle, so a rejected transition can be re-assembled (with a new asset-lock - /// proof, or the same one) without proving again. + /// `asset_lock_proof`: signs the bundle afresh and has `asset_lock_signer` produce the outer + /// ECDSA signature. Does not consume the proved bundle, so a rejected transition can be + /// re-assembled, with a new asset-lock proof or the same one, without proving again. /// - /// Errors instead of producing a transition consensus would reject when the bundle is not - /// bound to `asset_lock_proof` at `platform_version` (see [`Self::is_bound_for`]). + /// Errors, rather than produce a transition consensus would reject, when consensus would + /// derive another binding from `asset_lock_proof` at `platform_version`: a proof of another + /// outpoint, or a protocol version that binds differently. pub async fn build_transition_with_signer( &self, asset_lock_proof: AssetLockProof, @@ -122,14 +97,16 @@ impl ProvedShieldFromAssetLockBundle { where AS: ::key_wallet::signer::Signer, { - if !self.is_bound_for(&asset_lock_proof, platform_version)? { + if shield_from_asset_lock_extra_sighash_data(&asset_lock_proof, platform_version)? + != self.extra_sighash_data + { return Err(ProtocolError::ShieldedBuildError( "shield_from_asset_lock: the asset lock proof does not match the binding the \ bundle was proved with (different outpoint or protocol version)" .to_string(), )); } - let sb = serialize_authorized_bundle(&self.bundle.authorize()?); + let sb = serialize_authorized_bundle(&(self.sign)(&[])?); let value_balance = pool_inflow(&sb)?; ShieldFromAssetLockTransition::try_from_asset_lock_with_bundle_and_signer( @@ -426,9 +403,10 @@ mod tests { #[cfg(feature = "core_key_wallet")] #[test] - fn proved_bundle_verifies_against_the_binding_of_a_real_lock_proof() { + fn proved_bundle_signs_afresh_and_verifies_against_a_real_lock_proof_binding() { // Consensus derives the sighash from the transition's own lock proof, not from what the - // builder stored: verify against that, for a proof `prove` never saw. + // builder stored: verify against that, for a proof `prove` never saw, after each of two + // signings (`signing_tests` checks they share the proof but not the signatures). use super::ProvedShieldFromAssetLockBundle; use crate::identity::state_transition::asset_lock_proof::chain::ChainAssetLockProof; use crate::prelude::AssetLockProof; @@ -453,7 +431,8 @@ mod tests { platform_version, ) .expect("bundle should prove"); - let bundle = proved.bundle.authorize().expect("bundle should sign"); + let first = (proved.sign)(&[]).expect("bundle should sign"); + let second = (proved.sign)(&[]).expect("bundle should sign again"); let lock = AssetLockProof::Chain(ChainAssetLockProof { core_chain_locked_height: 100, @@ -462,9 +441,18 @@ mod tests { let extra = shield_from_asset_lock_extra_sighash_data(&lock, platform_version).expect("binding"); assert!(!extra.is_empty(), "the latest version binds the asset lock"); - let commitment: [u8; 32] = bundle.commitment().into(); - let mut batch = BatchValidator::new(); - batch.add_bundle(&bundle, compute_platform_sighash(&commitment, &extra)); - assert!(batch.validate(&VerifyingKey::build(), OsRng)); + let commitment: [u8; 32] = first.commitment().into(); + let vk = VerifyingKey::build(); + let verifies = |bundle, extra: &[u8]| { + let mut batch = BatchValidator::new(); + batch.add_bundle(bundle, compute_platform_sighash(&commitment, extra)); + batch.validate(&vk, OsRng) + }; + assert!(verifies(&first, &extra)); + assert!(verifies(&second, &extra)); + assert!( + !verifies(&first, &extra[..1]), + "signatures bind the proving-time sighash" + ); } } diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index 24354ba7d78..c23f07c312f 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -96,36 +96,6 @@ fn make_chain_asset_lock_proof() -> AssetLockProof { }) } -/// An InstantSend proof of a one-output asset-lock transaction. Its InstantSend lock is not -/// valid; nothing here verifies it. -fn make_instant_asset_lock_proof() -> AssetLockProof { - use crate::identity::state_transition::asset_lock_proof::InstantAssetLockProof; - use dashcore::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::transaction::special_transaction::TransactionPayload; - use dashcore::{InstantLock, ScriptBuf, Transaction, TxOut}; - - let transaction = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( - AssetLockPayload { - version: 0, - credit_outputs: vec![TxOut { - value: 100_000, - script_pubkey: ScriptBuf::new(), - }], - }, - )), - }; - AssetLockProof::Instant(InstantAssetLockProof::new( - InstantLock::default(), - transaction, - 0, - )) -} - fn extract_v0(state_transition: StateTransition) -> ShieldFromAssetLockTransitionV0 { let StateTransition::ShieldFromAssetLock(ShieldFromAssetLockTransition::V0(v0)) = state_transition @@ -383,23 +353,23 @@ async fn seed_pool_batch_fits_max_state_transition_size() { #[tokio::test] async fn proved_bundle_assembles_around_any_proof_of_its_outpoint() { - // Proved from the outpoint alone, one bundle wraps an InstantSend proof, the same proof again - // and a ChainLock proof of the outpoint. Each assembly signs afresh, so a resubmission never - // repeats a rejected transition's hash. That the bundle verifies under a lock proof's binding - // is `proved_bundle_verifies_against_the_binding_of_a_real_lock_proof`; that both proof kinds - // bind alike is `proved_bundle_binding_is_the_outpoint_binding_at_every_version`. + // Proved from the outpoint alone, one bundle wraps a proof of that outpoint, the same proof + // again and another proof of it. Each assembly signs afresh, so a resubmission never repeats a + // rejected transition's hash. That an InstantSend and a ChainLock proof of one outpoint bind + // alike is pinned in `shielded::sighash`. use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; use crate::shielded::builder::ProvedShieldFromAssetLockBundle; let platform_version = PlatformVersion::latest(); let signer = FixedKeySigner::new([7u8; 32]); let path = DerivationPath::default(); - let instant = make_instant_asset_lock_proof(); - let out_point = instant.out_point().expect("fixture has a credit output"); - let chain = AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: 100, - out_point, - }); + let out_point = OutPoint::from([0x42; 36]); + let chain_proof_at = |core_chain_locked_height| { + AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height, + out_point, + }) + }; let proved = ProvedShieldFromAssetLockBundle::prove( &test_orchard_address(), @@ -415,11 +385,11 @@ async fn proved_bundle_assembles_around_any_proof_of_its_outpoint() { let assemble = |proof: AssetLockProof| { proved.build_transition_with_signer(proof, &path, &signer, None, platform_version) }; - let first = extract_v1(assemble(instant.clone()).await.expect("instant assembly")); - let again = extract_v1(assemble(instant).await.expect("instant re-assembly")); - let over_chain = extract_v1(assemble(chain).await.expect("chain assembly")); + let first = extract_v1(assemble(chain_proof_at(100)).await.expect("assembly")); + let again = extract_v1(assemble(chain_proof_at(100)).await.expect("re-assembly")); + let later = extract_v1(assemble(chain_proof_at(200)).await.expect("later proof")); - for v1 in [&again, &over_chain] { + for v1 in [&again, &later] { assert_eq!(v1.proof, first.proof, "assembly must not re-prove"); assert_eq!(v1.value_balance, 50_000); } @@ -435,32 +405,3 @@ async fn proved_bundle_assembles_around_any_proof_of_its_outpoint() { "unexpected error: {err:?}" ); } - -#[test] -fn proved_bundle_binding_is_the_outpoint_binding_at_every_version() { - // `ProvedShieldFromAssetLockBundle::prove` derives the binding from a stand-in chain proof - // of the outpoint. That is only sound because the binding never depends on the proof kind - // or the chain-lock height: pin it at every version, binding or not. - use crate::shielded::shield_from_asset_lock_extra_sighash_data; - - let instant = make_instant_asset_lock_proof(); - let out_point = instant.out_point().expect("fixture has a credit output"); - let versions: Vec<&PlatformVersion> = (1..=PlatformVersion::latest().protocol_version) - .filter_map(|version| PlatformVersion::get(version).ok()) - .collect(); - assert!(versions.len() > 1); - for platform_version in versions { - let bound = |proof: &AssetLockProof| { - shield_from_asset_lock_extra_sighash_data(proof, platform_version).unwrap() - }; - for height in [0, 1, u32::MAX] { - assert_eq!( - bound(&instant), - bound(&AssetLockProof::Chain(ChainAssetLockProof { - core_chain_locked_height: height, - out_point, - })), - ); - } - } -} From 2e39be06ccb3d74527c6f98101594a2cb5195802 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 12:12:02 -0500 Subject: [PATCH 11/20] refactor(platform-wallet): overlap the shield proof with a plain spawn and join Replace BundleCache / ProofTask / ProveFn / BundleTarget / resolve_while_proving and their tests with a oneshot for the outpoint and a speculative spawn_blocking joined with funding resolution. After resolution the speculative proof is used if it was made for the resolved outpoint and amount; otherwise it is detached and the bundle is proved afresh. Every submission attempt re-signs the one proved bundle. Also: - drop the _observed wrappers in build.rs; orchestration calls the pooled builder with an on_broadcast callback - revert credit_output_duffs: the Chain-path value lookup is unchanged from base, and a speculative amount mismatch simply re-proves - revert read_tracked_lock (fd2890a2801); the clone it avoided is the same one base already makes - take the prover as P: OrchardProver + Copy + Send + 'static, no Arc Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/build.rs | 61 +- .../src/wallet/asset_lock/manager.rs | 15 - .../src/wallet/asset_lock/orchestration.rs | 10 +- .../wallet/shielded/fund_from_asset_lock.rs | 702 +++--------------- 4 files changed, 129 insertions(+), 659 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 3c172ce8ff8..f208ae868c6 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -812,31 +812,6 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, - ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { - self.create_funded_asset_lock_proof_observed( - amount_duffs, - account_index, - funding_type, - identity_index, - signer, - |_| {}, - ) - .await - } - - /// [`Self::create_funded_asset_lock_proof`], calling `on_broadcast` with - /// the lock's outpoint as soon as the transaction is broadcast — before - /// the proof wait — so a caller can start work that needs only the - /// outpoint while the InstantSend lock is awaited. Not called when the - /// build or the broadcast fails. - pub(crate) async fn create_funded_asset_lock_proof_observed( - &self, - amount_duffs: u64, - account_index: u32, - funding_type: AssetLockFundingType, - identity_index: u32, - signer: &S, - on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { self.create_funded_asset_lock_proof_pooled( AssetLockBuildAmount::Exact(amount_duffs), @@ -845,7 +820,7 @@ impl AssetLockManager { funding_type, identity_index, signer, - on_broadcast, + |_| {}, ) .await } @@ -863,30 +838,6 @@ impl AssetLockManager { funding_type: AssetLockFundingType, identity_index: u32, signer: &S, - ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { - self.create_funded_asset_lock_proof_with_funding_observed( - amount, - funding_account, - funding_type, - identity_index, - signer, - |_| {}, - ) - .await - } - - /// [`Self::create_funded_asset_lock_proof_with_funding`] with the - /// `on_broadcast` hook of [`Self::create_funded_asset_lock_proof_observed`]. - pub(crate) async fn create_funded_asset_lock_proof_with_funding_observed< - S: ExtendedPubKeySigner, - >( - &self, - amount: AssetLockBuildAmount, - funding_account: AssetLockFundingAccount, - funding_type: AssetLockFundingType, - identity_index: u32, - signer: &S, - on_broadcast: impl FnOnce(OutPoint) + Send, ) -> Result<(dpp::prelude::AssetLockProof, DerivationPath, OutPoint), PlatformWalletError> { self.create_funded_asset_lock_proof_pooled( amount, @@ -895,7 +846,7 @@ impl AssetLockManager { funding_type, identity_index, signer, - on_broadcast, + |_| {}, ) .await } @@ -903,8 +854,12 @@ impl AssetLockManager { /// Source-list form of [`Self::create_funded_asset_lock_proof`] — same /// build → broadcast → proof pipeline with the pooled funding and amount /// semantics of [`Self::build_asset_lock_transaction_with_funding`]. + /// + /// `on_broadcast` gets the lock's outpoint once the transaction is + /// broadcast, before the proof wait, so a caller can start work that + /// needs only the outpoint while the InstantSend lock is awaited. #[allow(clippy::too_many_arguments)] - async fn create_funded_asset_lock_proof_pooled( + pub(crate) async fn create_funded_asset_lock_proof_pooled( &self, amount: AssetLockBuildAmount, funding_sources: &[AccountTypePreference], @@ -982,7 +937,7 @@ impl AssetLockManager { .await } - /// Wallet-drain form of [`Self::create_funded_asset_lock_proof_observed`]: + /// Wallet-drain form of [`Self::create_funded_asset_lock_proof_pooled`]: /// `on_broadcast` gets the lock's outpoint once the transaction is /// broadcast, before the proof wait. #[allow(clippy::too_many_arguments)] diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 1023101f3aa..52f473021a9 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -278,21 +278,6 @@ impl AssetLockManager { .map(|info| info.tracked_asset_locks.values().cloned().collect()) .unwrap_or_default() } - - /// Applies `read` to the tracked asset lock at `out_point` under the - /// wallet read lock, without cloning the tracked set. `None` when no such - /// lock is tracked. - #[cfg(feature = "shielded")] - pub(crate) async fn read_tracked_lock( - &self, - out_point: &OutPoint, - read: impl FnOnce(&TrackedAssetLock) -> T, - ) -> Option { - let wm = self.wallet_manager.read().await; - wm.get_wallet_info(&self.wallet_id) - .and_then(|info| info.tracked_asset_locks.get(out_point)) - .map(read) - } } impl std::fmt::Debug for AssetLockManager { diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index b5194a5dbe2..a48f3c9271c 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs @@ -551,8 +551,9 @@ impl AssetLockManager { account_index, } => { match self - .create_funded_asset_lock_proof_observed( - amount_duffs, + .create_funded_asset_lock_proof_pooled( + super::build::AssetLockBuildAmount::Exact(amount_duffs), + &crate::ASSET_LOCK_FUNDING_SOURCES, account_index, funding_type, destination_index, @@ -620,9 +621,10 @@ impl AssetLockManager { // Same pipeline as `FromWalletBalance`, with drain amount // semantics and the caller-picked funding account family. match self - .create_funded_asset_lock_proof_with_funding_observed( + .create_funded_asset_lock_proof_pooled( super::build::AssetLockBuildAmount::DrainAll { minimum_lock_duffs }, - account, + &[account.into()], + account.account_index(), funding_type, destination_index, asset_lock_signer, diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index d296f56d2d9..43970b7b6ae 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -11,11 +11,11 @@ //! signature doesn't change when DPP grows multi-output Orchard //! bundles for Type 18; see [`validate_shielded_recipients`]). //! 2. **Resolve funding** — delegate to the shared -//! [`AssetLockManager::resolve_funding_with_is_timeout_fallback`]. -//! The Orchard bundle is proved concurrently, on the blocking pool: -//! it commits to the locked outpoint and value, not to the lock proof, -//! so the proof starts once the asset-lock transaction is broadcast -//! instead of after its InstantSend lock arrives. +//! `AssetLockManager::resolve_funding_observing_out_point`. +//! The Orchard bundle commits to the locked outpoint, not to the lock +//! proof, so it is proved on the blocking pool as soon as the outpoint +//! is known (the lock is broadcast, or a resumed lock passes its +//! checks), while the InstantSend lock is awaited. //! 3. **Submit** — wrap the build-and-broadcast in //! `submit_with_cl_height_retry`; each attempt wraps the proved bundle //! with [`ProvedShieldFromAssetLockBundle::build_transition_with_signer`] @@ -24,8 +24,8 @@ //! IS→CL fallback fires when `needs_chain_lock_proof` matches: //! Platform rejected the IS signature, or the InstantSend proof //! made the transition too large (the SDK refuses that before -//! broadcast with a typed error). It and the CL-height retries reuse -//! the proved bundle rather than proving again. +//! broadcast with a typed error). Every attempt re-signs the one +//! proved bundle rather than proving again. //! 4. **Finalize lock state** — a verified submit completes a wallet drain //! (`DrainCompleted`) or consumes a generic lock. A resumed drain that //! Platform rejects because its exact outpoint was already consumed is @@ -49,10 +49,9 @@ use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::ProtocolError; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; +use crate::wallet::asset_lock::tracked::TrackedAssetLock; use crate::wallet::asset_lock::AssetLockOperationState; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; use std::time::Duration; use crate::broadcaster::TransactionBroadcaster; @@ -150,11 +149,9 @@ impl PlatformWallet { /// * `asset_lock_signer` — External signer for the outer ECDSA /// signature on the state transition. The raw key never crosses /// the FFI boundary. - /// * `prover` — Orchard prover (holds the Halo 2 proving key). Owned - /// (`'static`) because the proof runs on tokio's blocking pool, and - /// starts as soon as the asset-lock transaction is broadcast — while - /// the InstantSend lock is awaited — rather than after it. Pass - /// `&CachedOrchardProver` (a `&'static` to the zero-sized handle). + /// * `prover` — Orchard prover (holds the Halo 2 proving key). + /// `Copy + 'static` because the proof runs on tokio's blocking pool + /// while the InstantSend lock is awaited. Pass `&CachedOrchardProver`. /// * `surplus_output` — Optional platform address that receives the /// asset-lock surplus (`lock_value − shield_amount − pool_fee`). /// @@ -199,7 +196,7 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver + Send + Sync + 'static, + P: OrchardProver + Copy + Send + 'static, { // The context is retained by the FFI caller across panic unwinding. // Register under the same guard as every shield-class operation, so a @@ -264,12 +261,15 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver + Send + Sync + 'static, + P: OrchardProver + Copy + Send + 'static, { // On-wire Orchard action count = max(1 real + dummy_outputs, 2). Consensus // prices the flat shielded fee from this count, so the wallet's fee // reservation below is derived from the SAME value (any mismatch is rejected). let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); + // `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost` — the + // SAME flat fee consensus charges (`transform_into_action` Step 3b). + let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; // Step 1: pre-flight. Failing fast here avoids broadcasting // an unfundable asset-lock tx (or paying for an Orchard proof // build, ~30s, only to reject downstream). @@ -298,7 +298,6 @@ impl PlatformWallet { {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; if lock_credits <= pool_fee_credits { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ @@ -324,7 +323,6 @@ impl PlatformWallet { account, minimum_lock_duffs: _, } => { - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; let minimum_lock_duffs = pool_fee_credits / CREDITS_PER_DUFF + 1; AssetLockFunding::DrainAccountBalance { account, @@ -370,9 +368,8 @@ impl PlatformWallet { // Prefer the bound account whose IVK recognizes the recipient address // (the sent-note row then lands under that account); fall back to the // lowest bound account, or `None` (unrecoverable out_ciphertext) if - // the shielded sub-wallet isn't bound. Read once, before the funding - // resolves, so the speculative proof and any re-proof commit to the - // same OVK. + // the shielded sub-wallet isn't bound. Read before the funding + // resolves: the bundle may be proved before then. let sender_ovk = { let guard = self.shielded_keys.read().await; guard.as_ref().and_then(|keys| { @@ -387,20 +384,24 @@ impl PlatformWallet { }) }; - // Proves the Orchard bundle for a target (outpoint, amount, protocol - // version) — on the blocking pool, see `ProofTask`. - let prove: ProveFn = Arc::new(move |target| { - ProvedShieldFromAssetLockBundle::prove( - &recipient, - target.shield_amount, - target.out_point, - &prover, - [0u8; 36], - sender_ovk.clone(), - dummy_outputs, - target.platform_version, - ) - }); + // Proves the Orchard bundle on the blocking pool, so the seconds of + // Halo 2 work never occupy an async worker. + let platform_version = self.sdk.version(); + let spawn_proof = |out_point, shield_amount| { + let sender_ovk = sender_ovk.clone(); + tokio::task::spawn_blocking(move || { + ProvedShieldFromAssetLockBundle::prove( + &recipient, + shield_amount, + out_point, + &prover, + [0u8; 36], + sender_ovk, + dummy_outputs, + platform_version, + ) + }) + }; // Step 2: resolve funding. `AssetLockShieldedAddressTopUp` // selects the BIP44 funding family dedicated to shielded @@ -412,15 +413,28 @@ impl PlatformWallet { // persistence/UI tag (`fundingTypeRaw == 5`). // `destination_index = 0` is unused for this funding type. // - // The Orchard bundle commits to the locked outpoint and value but not - // to the lock's proof (see `ProvedShieldFromAssetLockBundle`), and - // both are known once the asset-lock transaction is broadcast. So the - // proof — seconds of CPU — starts then, on the blocking pool, and runs - // while the resolver waits for the InstantSend lock; the submit below - // picks it up if the resolved lock is the one it was proved for. If - // the resolution fails, dropping the speculative task discards it. + // The bundle commits to the locked outpoint and the shield amount + // but not to the lock's proof (see `ProvedShieldFromAssetLockBundle`), + // and both are known once the lock is committed to this call. So the proof starts + // then and runs while the resolver waits for the InstantSend lock. + // Its amount is read from the tracked row, as the lock proof does + // not exist yet; Step 3 uses the proof only if it was made for the + // resolved outpoint and amount. A proof nobody uses is detached: it + // cannot be interrupted, and its result is dropped when it finishes. let (out_point_tx, out_point_rx) = tokio::sync::oneshot::channel(); - let (resolution, speculative) = resolve_while_proving( + let speculate = async { + let out_point = out_point_rx.await.ok()?; + let value_duffs = tracked_lock_value_duffs(self, &out_point).await.ok()?; + let value_credits = value_duffs.checked_mul(CREDITS_PER_DUFF)?; + let shield_amount = shield_amount_after_fee(value_credits, pool_fee_credits).ok()?; + Some(( + out_point, + shield_amount, + spawn_proof(out_point, shield_amount), + )) + }; + // `try_join!` so a failed resolution returns at once. + let (resolution, speculative) = tokio::try_join!( self.asset_locks.resolve_funding_observing_out_point( funding, AssetLockFundingType::AssetLockShieldedAddressTopUp, @@ -430,10 +444,8 @@ impl PlatformWallet { let _ = out_point_tx.send(out_point); }, ), - out_point_rx, - |out_point| self.start_speculative_shield_proof(out_point, num_actions, &prove), - ) - .await?; + async { Ok(speculate.await) }, + )?; let ResolvedFunding { proof, path, @@ -470,7 +482,9 @@ impl PlatformWallet { // signing. The wallet is the right place: it already has the // lock value (from the IS proof's TxOut, or from the asset- // lock manager's tracked row for CL-only paths) and the - // protocol min-fee constant (from `PlatformVersion`). + // protocol min-fee constant (from `PlatformVersion`). Deriving + // `shield_amount = lock_value − pool_fee` reserves room for the + // fee and pins the consensus surplus to exactly zero. // // Single-recipient + `None` semantics today: the recipient // receives `lock_value - pool_fee`. Future multi-recipient @@ -480,11 +494,6 @@ impl PlatformWallet { // builder. let asset_lock_value_credits = lookup_asset_lock_value_credits(self, &proof, tracked_out_point.as_ref()).await?; - // `pool_fee = compute_minimum_shielded_fee(2) + asset_lock_base_cost` — the SAME flat fee - // consensus charges (`transform_into_action` Step 3b). Deriving `shield_amount = - // lock_value − pool_fee` reserves room for the fee and pins the consensus surplus - // (`lock_value − shield_amount − pool_fee`) to exactly zero. - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; let shield_amount = shield_amount_after_fee(asset_lock_value_credits, pool_fee_credits)?; // Surplus is structurally zero in this remainder flow (`shield_amount == lock_value − @@ -496,9 +505,7 @@ impl PlatformWallet { .and_then(|v| v.checked_sub(pool_fee_credits)) .unwrap_or(0); if surplus_output.is_none() { - let implicit_fee_cap = self - .sdk - .version() + let implicit_fee_cap = platform_version .drive_abci .validation_and_processing .event_constants @@ -520,8 +527,8 @@ impl PlatformWallet { // triggers an IS→CL upgrade on the same outpoint. // // Every attempt — the first, each CL-height retry, the IS→CL - // fallback — reuses the one proved bundle (`BundleCache`): none of - // them changes the outpoint or the amount the proof commits to. + // fallback — re-signs the one proved bundle: none of them changes + // the outpoint or the amount the proof commits to. // // Subtle: `ShieldFromAssetLockTransition::set_user_fee_increase` // is a no-op, so the wrapper's bump cannot diversify the ST hash @@ -530,8 +537,16 @@ impl PlatformWallet { // bundle with randomized RedPallas signatures. If that signing is // ever made deterministic, retries need another diversifier. let proof_out_point = out_point_from_proof(&proof); + let proving = match speculative { + Some((out_point, amount, proving)) + if out_point == proof_out_point && amount == shield_amount => + { + proving + } + _ => spawn_proof(proof_out_point, shield_amount), + }; + let bundle = join_proof(proving).await?; let sdk = self.sdk.clone(); - let bundles = tokio::sync::Mutex::new(BundleCache::new(prove, speculative)); // Serialized actions of the bundle that actually landed — fed to // the live activity recorder below. Each attempt re-signs the // bundle, so only the landed attempt's actions (signatures @@ -547,8 +562,7 @@ impl PlatformWallet { |proof, s| { build_and_broadcast_shielded( sdk.clone(), - &bundles, - shield_amount, + &bundle, proof, path.clone(), asset_lock_signer, @@ -558,13 +572,10 @@ impl PlatformWallet { }, ) .await?; - // Release the proved bundle (and, if no attempt ever needed it, the - // speculative proof) before the reconciliation awaits below. The - // bundle holds its signing secrets (`ProvedShieldFromAssetLockBundle`) - // from the moment it is proved — possibly through the 300 s - // InstantSend window and an unbounded ChainLock wait — and this is - // where they go; every early return above drops them the same way. - drop(bundles); + // Release the proved bundle's signing secrets + // (`ProvedShieldFromAssetLockBundle`) before the reconciliation + // awaits below. + drop(bundle); // Platform's verdict settles the lock. For a wallet drain, an // exact-outpoint "already consumed" rejection means an earlier attempt @@ -673,42 +684,6 @@ impl PlatformWallet { Ok(()) } - /// Start proving the bundle for the asset lock at `out_point` while its - /// InstantSend lock is still awaited (see [`ProofTask`]). - /// - /// The amount is derived exactly as after the resolution — the lock's - /// value minus the pool fee — but from the tracked row, as the lock proof - /// does not exist yet. Returns `None`, starting nothing, when the value is - /// unknown or cannot fund the shield; the resolved path then reports that - /// as it always has. A proof started for a target the resolved lock does - /// not match is discarded, never used. - async fn start_speculative_shield_proof( - &self, - out_point: dashcore::OutPoint, - num_actions: usize, - prove: &ProveFn, - ) -> Option> { - let value_duffs = tracked_asset_lock_value_duffs(&self.asset_locks, &out_point) - .await - .ok()?; - let value_credits = value_duffs.checked_mul(CREDITS_PER_DUFF)?; - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions).ok()?; - let shield_amount = shield_amount_after_fee(value_credits, pool_fee_credits).ok()?; - tracing::debug!( - %out_point, - shield_amount, - "proving the shield-from-asset-lock bundle while the asset lock proof is awaited" - ); - Some(ProofTask::spawn( - BundleTarget { - out_point, - shield_amount, - platform_version: self.sdk.version(), - }, - Arc::clone(prove), - )) - } - /// The flat pool fee for a `ShieldFromAssetLock` (Type 18) state /// transition, in credits. /// @@ -762,13 +737,11 @@ impl PlatformWallet { /// over the landed bundle's `actions`. /// /// Deliberately records ONLY the landed bundle (no Pending row before - /// broadcast, no Failed row after): the attempts usually share one - /// proved bundle, but an attempt may need a fresh proof (a protocol - /// upgrade between attempts changes the binding), and a fresh proof has - /// different output cmxs — and the activity id is keyed to those cmxs. - /// A pre-broadcast Pending row would then orphan (unconfirmable - /// forever, its cmxs never on-chain) whenever the re-proved attempt is - /// the one that lands. In-flight and failed Type 18s are + /// broadcast, no Failed row after): the activity id is keyed to the + /// bundle's output cmxs, and a call that fails leaves its lock to a + /// resume that proves a fresh bundle with different cmxs. A + /// pre-broadcast Pending row would then orphan (unconfirmable + /// forever, its cmxs never on-chain). In-flight and failed Type 18s are /// surfaced through the tracked asset-lock lifecycle instead /// (Built/Broadcast/Locked/Consumed + the resumable-funding UI), /// which tracks the L1 lock — the artifact that actually carries the @@ -853,34 +826,33 @@ impl PlatformWallet { /// Look up the asset-lock value in credits. /// /// Preference order: -/// 1. If the proof is `Instant`, read the credit output from the -/// transaction the proof carries — no manager lookup needed. +/// 1. If the proof is `Instant`, read directly from +/// `InstantAssetLockProof::output().value` — no manager lookup +/// needed. /// 2. Otherwise (the IS-timeout-fallback path produced a CL proof /// that doesn't carry the tx output), look up the tracked -/// asset-lock row by outpoint and read it from its transaction. -/// -/// Both go through [`credit_output_duffs`], as does the speculative -/// proof's amount (`start_speculative_shield_proof`), so a bundle proved -/// before the lock proof existed is proved for the amount computed here. +/// asset-lock row by outpoint. async fn lookup_asset_lock_value_credits( wallet: &PlatformWallet, proof: &AssetLockProof, tracked_out_point: Option<&dashcore::OutPoint>, ) -> Result { let duffs = match proof { - AssetLockProof::Instant(is) => credit_output_duffs(is.transaction(), is.output_index()) - .ok_or_else(|| { + AssetLockProof::Instant(is) => { + let out = is.output().ok_or_else(|| { PlatformWalletError::AddressSync( "InstantAssetLockProof has no output at the indicated index".to_string(), ) - })?, + })?; + out.value + } AssetLockProof::Chain(_) => { let op = tracked_out_point.ok_or_else(|| { PlatformWalletError::AddressSync( "ChainAssetLockProof but no tracked outpoint to look up value".to_string(), ) })?; - tracked_asset_lock_value_duffs(&wallet.asset_locks, op).await? + tracked_lock_value_duffs(wallet, op).await? } }; duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { @@ -890,49 +862,35 @@ async fn lookup_asset_lock_value_credits( }) } -/// The value of the tracked asset lock at `out_point`, in duffs: its -/// transaction's credit output at the outpoint's index (see -/// [`credit_output_duffs`]). -async fn tracked_asset_lock_value_duffs( - asset_locks: &AssetLockManager, +/// The value, in duffs, of the tracked asset lock at `out_point`. +async fn tracked_lock_value_duffs( + wallet: &PlatformWallet, out_point: &dashcore::OutPoint, ) -> Result { - asset_locks - .read_tracked_lock(out_point, |lock| { - credit_output_duffs(&lock.transaction, out_point.vout) - }) - .await + let locks: Vec = wallet.asset_locks.list_tracked_locks().await; + locks + .iter() + .find(|l| l.out_point == *out_point) + .map(|l| l.amount) .ok_or_else(|| { PlatformWalletError::AddressSync(format!( "tracked asset lock {} not found in manager", out_point )) - })? - .ok_or_else(|| { - PlatformWalletError::AddressSync(format!( - "tracked asset lock {} has no credit output at index {}", - out_point, out_point.vout - )) }) } -/// The value, in duffs, that an asset lock on `transaction` at `output_index` -/// locks: the payload's `credit_outputs[output_index]` — the output -/// `InstantAssetLockProof::output` names and consensus credits. `None` when -/// the transaction carries no such credit output. -/// -/// Not the tracked row's `amount`, which sums all credit outputs (the same -/// number for the single-output locks this wallet builds). Reading the output -/// on both paths keeps the speculative and the resolved amounts equal. -fn credit_output_duffs(transaction: &dashcore::Transaction, output_index: u32) -> Option { - use dashcore::blockdata::transaction::special_transaction::TransactionPayload; - - match &transaction.special_transaction_payload { - Some(TransactionPayload::AssetLockPayloadType(payload)) => payload - .credit_outputs - .get(output_index as usize) - .map(|output| output.value), - _ => None, +/// Wait for a bundle proof started with `spawn_blocking`. +async fn join_proof( + proving: tokio::task::JoinHandle>, +) -> Result { + match proving.await { + Ok(proved) => proved.map_err(|e| PlatformWalletError::Sdk(e.into())), + // Re-raise a proving panic exactly as if it had run inline. + Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()), + Err(_) => Err(PlatformWalletError::ShieldedBuildError( + "Orchard proof generation was cancelled".to_string(), + )), } } @@ -970,16 +928,13 @@ fn proof_attests_full_consumption(result: &StateTransitionProofResult) -> bool { /// /// Extracted so `submit_with_cl_height_retry`'s closure stays compact /// and the IS→CL fallback path can re-call it with the upgraded proof. -/// The Orchard bundle comes from `bundles`: the one already proved for -/// this lock (reused across attempts), else a fresh proof. +/// Each call re-signs the proved `bundle` around `proof`. /// /// On success returns the landed bundle's actions together with what the /// verified proof result attests about the asset lock's consumption. -#[allow(clippy::too_many_arguments)] async fn build_and_broadcast_shielded( sdk: std::sync::Arc, - bundles: &tokio::sync::Mutex>, - shield_amount: Credits, + bundle: &ProvedShieldFromAssetLockBundle, proof: AssetLockProof, path: ::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, @@ -992,15 +947,7 @@ where use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; use dpp::state_transition::StateTransition; - // One version for the whole attempt: the bundle's binding is checked - // against the same rules the transition is assembled under. let platform_version = sdk.version(); - let target = BundleTarget { - out_point: out_point_from_proof(&proof), - shield_amount, - platform_version, - }; - let bundle = bundles.lock().await.bundle_for(target).await?; let st = bundle .build_transition_with_signer( proof, @@ -1058,188 +1005,6 @@ fn shield_amount_after_fee( Ok(shield_amount) } -// --------------------------------------------------------------------------- -// Proving the Orchard bundle off the async runtime, ahead of the lock proof -// --------------------------------------------------------------------------- - -/// Everything a `ShieldFromAssetLock` bundle is proved for that can differ -/// between the speculative proof and a submission attempt. The recipient, -/// sender OVK, memo and filler count are fixed for the whole call and live in -/// the [`ProveFn`]. -#[derive(Clone, Copy)] -struct BundleTarget { - /// The locked outpoint — the bundle's sighash binds it. - out_point: dashcore::OutPoint, - /// The value the bundle moves into the pool. - shield_amount: Credits, - /// The protocol version whose binding rules the bundle is proved under. - /// Compared by number: an SDK protocol-version change between the - /// speculative proof and an attempt re-proves even when the binding is - /// unchanged — conservative, and rare (it takes a protocol upgrade during - /// the call). - platform_version: &'static dpp::version::PlatformVersion, -} - -impl PartialEq for BundleTarget { - fn eq(&self, other: &Self) -> bool { - self.out_point == other.out_point - && self.shield_amount == other.shield_amount - && self.platform_version.protocol_version == other.platform_version.protocol_version - } -} - -/// Proves a bundle for a target. Blocking and CPU-heavy: only ever run on -/// tokio's blocking pool, through [`ProofTask`]. -type ProveFn = Arc Result + Send + Sync>; - -/// A bundle proof running on tokio's blocking pool, so it never occupies an -/// async worker. -/// -/// Dropping the task cancels it: a proof that has not started yet never runs -/// (the task is aborted, and the job checks a cancellation flag before -/// proving), and the result of one already running — Halo 2 proving cannot be -/// interrupted — is dropped, together with the inputs the job holds, as soon -/// as it finishes. Nothing waits for a cancelled proof and nothing is -/// persisted from it. -struct ProofTask { - target: BundleTarget, - cancelled: Arc, - /// `Some` until the proof has been joined. - handle: Option>>>, -} - -impl ProofTask { - fn spawn(target: BundleTarget, prove: ProveFn) -> Self { - let cancelled = Arc::new(AtomicBool::new(false)); - let job_cancelled = Arc::clone(&cancelled); - let handle = tokio::task::spawn_blocking(move || { - (!job_cancelled.load(Ordering::Acquire)).then(|| prove(target)) - }); - Self { - target, - cancelled, - handle: Some(handle), - } - } - - /// Wait for the proof. Cancellation-safe: dropping this future drops the - /// task, which cancels the proof. - async fn join(mut self) -> Result { - let joined = self - .handle - .as_mut() - .expect("the handle is present until the proof is joined") - .await; - self.handle = None; - match joined { - Ok(Some(result)) => result, - // Re-raise a proving panic exactly as if it had run inline. - Err(e) if e.is_panic() => std::panic::resume_unwind(e.into_panic()), - // The flag is only set on drop, so `Ok(None)` is unreachable - // while this task is alive; a cancelled join is the runtime - // shutting down. - Ok(None) | Err(_) => Err(ProtocolError::ShieldedBuildError( - "Orchard proof generation was cancelled".to_string(), - )), - } - } -} - -impl Drop for ProofTask { - fn drop(&mut self) { - if let Some(handle) = self.handle.take() { - self.cancelled.store(true, Ordering::Release); - handle.abort(); - } - } -} - -/// The proved bundle the submission attempts of one funding call share. -/// -/// Holds the bundle's signing secrets (see `ProvedShieldFromAssetLockBundle`) -/// in memory only, until the submission attempts end; they are dropped with -/// the cache. -/// -/// Holds the speculative proof started while the lock proof was awaited -/// and, once joined or freshly proved, the bundle itself. Every attempt asks -/// for the bundle of its target; a bundle is reused only for an identical -/// target (same outpoint, amount and protocol version), so a reused bundle -/// commits to exactly what a fresh proof would. Anything else — a -/// speculative proof for another outpoint or amount, a protocol upgrade -/// between attempts — is discarded and proved afresh. -struct BundleCache { - prove: ProveFn, - speculative: Option>, - proved: Option<(BundleTarget, Arc)>, -} - -impl BundleCache { - fn new(prove: ProveFn, speculative: Option>) -> Self { - Self { - prove, - speculative, - proved: None, - } - } - - async fn bundle_for(&mut self, target: BundleTarget) -> Result, ProtocolError> { - if let Some((proved_for, bundle)) = &self.proved { - if *proved_for == target { - tracing::debug!( - out_point = %target.out_point, - "reusing the proved shield-from-asset-lock bundle for this attempt" - ); - return Ok(Arc::clone(bundle)); - } - } - let task = match self.speculative.take() { - Some(task) if task.target == target => task, - Some(stale) => { - // Rare (the lock's value or the protocol version changed under - // the call), but it forfeits the overlap: make it visible. - tracing::info!( - speculative_out_point = %stale.target.out_point, - speculative_amount = stale.target.shield_amount, - out_point = %target.out_point, - shield_amount = target.shield_amount, - "discarding a speculative shield-from-asset-lock proof made for other inputs" - ); - ProofTask::spawn(target, Arc::clone(&self.prove)) - } - None => ProofTask::spawn(target, Arc::clone(&self.prove)), - }; - let bundle = Arc::new(task.join().await?); - self.proved = Some((target, Arc::clone(&bundle))); - Ok(bundle) - } -} - -/// Drive `resolve` (funding resolution, whose proof wait is the slow part) to -/// completion while `speculate` starts a proof as soon as `out_point` delivers -/// the lock's outpoint. `speculate` must only *start* the proof (return its -/// [`ProofTask`]), not wait for it, so this returns as soon as `resolve` does. -/// -/// If `resolve` fails, the speculative task is dropped here — cancelling the -/// proof — and the error is returned. If `resolve` never reports an outpoint -/// (its sender is dropped), no proof is started. -async fn resolve_while_proving( - resolve: impl std::future::Future>, - out_point: tokio::sync::oneshot::Receiver, - speculate: S, -) -> Result<(R, Option>), E> -where - S: FnOnce(dashcore::OutPoint) -> SF, - SF: std::future::Future>>, -{ - let speculation = async move { - let out_point = out_point.await.ok()?; - speculate(out_point).await - }; - // `try_join!` so a failed resolution returns at once, dropping a - // speculative startup still in flight (or the proof it started). - tokio::try_join!(resolve, async { Ok(speculation.await) }) -} - /// Pre-flight check for the recipient list. /// /// Today: non-empty, exactly one recipient whose `Credits` value is @@ -1852,70 +1617,6 @@ mod tests { ); } - /// The speculative and the ChainLock paths read a tracked lock's own - /// credit output at the outpoint's index, as the InstantSend path reads - /// it from the proof — not the row's `amount`, which sums all outputs. - #[tokio::test] - async fn tracked_lock_value_is_its_indexed_credit_output() { - use dashcore::blockdata::transaction::special_transaction::asset_lock::AssetLockPayload; - use dashcore::blockdata::transaction::special_transaction::TransactionPayload; - use dashcore::{InstantLock, ScriptBuf, Transaction, TxOut}; - use dpp::identity::state_transition::asset_lock_proof::InstantAssetLockProof; - - let ctx = consumption_report_context().await; - let credit_output = |value| TxOut { - value, - script_pubkey: ScriptBuf::new(), - }; - let transaction = Transaction { - version: 3, - lock_time: 0, - input: vec![], - output: vec![], - special_transaction_payload: Some(TransactionPayload::AssetLockPayloadType( - AssetLockPayload { - version: 0, - credit_outputs: vec![credit_output(100_000), credit_output(200_000)], - }, - )), - }; - let out_point = OutPoint::new(transaction.txid(), 1); - ctx.wallet_manager - .write() - .await - .get_wallet_info_mut(&ctx.wallet_id) - .expect("wallet") - .tracked_asset_locks - .insert( - out_point, - TrackedAssetLock { - operation_id: None, - out_point, - transaction: transaction.clone(), - account_index: 0, - funding_type: AssetLockFundingType::AssetLockShieldedAddressTopUp, - identity_index: 0, - amount: 300_000, - status: AssetLockStatus::ChainLocked, - proof: None, - }, - ); - - let tracked = tracked_asset_lock_value_duffs(&ctx.manager, &out_point) - .await - .expect("tracked lock value"); - assert_eq!(tracked, 200_000); - let instant = InstantAssetLockProof::new(InstantLock::default(), transaction, 1); - assert_eq!( - credit_output_duffs(instant.transaction(), instant.output_index()), - Some(tracked) - ); - let untracked = OutPoint::new(out_point.txid, 2); - assert!(tracked_asset_lock_value_duffs(&ctx.manager, &untracked) - .await - .is_err()); - } - #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; @@ -2211,177 +1912,4 @@ mod tests { .expect("tracked lock"); assert_eq!(lock.status, AssetLockStatus::RecoveredFromChain); } - - // -- Proving the bundle while the InstantSend lock is awaited --------- - // - // A fake prover stands in for Halo 2: the "bundle" is the amount it was - // proved for. - - mod proof_pipeline { - use std::sync::atomic::AtomicUsize; - use std::time::Duration; - - use dpp::version::PlatformVersion; - use tokio::sync::oneshot; - - use super::*; - - const AMOUNT: Credits = 1_000_000; - - fn target(shield_amount: Credits) -> BundleTarget { - BundleTarget { - out_point: OutPoint::from([7u8; 36]), - shield_amount, - platform_version: PlatformVersion::latest(), - } - } - - fn counting_prover(calls: &Arc) -> ProveFn { - let calls = Arc::clone(calls); - Arc::new(move |target: BundleTarget| { - calls.fetch_add(1, Ordering::SeqCst); - Ok(target.shield_amount) - }) - } - - /// The InstantSend lock is only delivered once the proof is running, - /// and the proof only finishes once the lock has been delivered, so - /// this completes only if the two overlap. - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn should_prove_while_the_lock_is_awaited() { - let (proof_started_tx, proof_started_rx) = oneshot::channel::<()>(); - let (lock_delivered_tx, lock_delivered_rx) = std::sync::mpsc::channel::<()>(); - let proof_started_tx = Mutex::new(Some(proof_started_tx)); - let lock_delivered_rx = Mutex::new(lock_delivered_rx); - let prove: ProveFn = Arc::new(move |target: BundleTarget| { - if let Some(tx) = proof_started_tx.lock().unwrap().take() { - let _ = tx.send(()); - } - lock_delivered_rx - .lock() - .unwrap() - .recv() - .expect("lock delivered"); - Ok(target.shield_amount) - }); - - let (out_point_tx, out_point_rx) = oneshot::channel(); - let resolve = async move { - let _ = out_point_tx.send(target(AMOUNT).out_point); - proof_started_rx.await.expect("proof started"); - lock_delivered_tx.send(()).unwrap(); - Ok::<_, PlatformWalletError>(()) - }; - let bundle = tokio::time::timeout(Duration::from_secs(30), async { - let ((), speculative) = resolve_while_proving(resolve, out_point_rx, |out_point| { - let task = ProofTask::spawn( - BundleTarget { - out_point, - ..target(AMOUNT) - }, - Arc::clone(&prove), - ); - std::future::ready(Some(task)) - }) - .await - .expect("resolution"); - assert!(speculative.is_some()); - BundleCache::new(Arc::clone(&prove), speculative) - .bundle_for(target(AMOUNT)) - .await - .expect("proof") - }) - .await - .expect("the proof and the lock wait did not overlap"); - assert_eq!(*bundle, AMOUNT); - } - - /// A resolution that fails after reporting the outpoint returns its - /// error without waiting for a speculative startup still in flight. - #[tokio::test] - async fn should_return_a_resolution_error_without_waiting_for_speculation() { - let (out_point_tx, out_point_rx) = oneshot::channel(); - let resolve = async move { - let _ = out_point_tx.send(target(AMOUNT).out_point); - tokio::task::yield_now().await; - Err::<(), _>(PlatformWalletError::ShieldedBuildError( - "lock wait failed".into(), - )) - }; - let result = tokio::time::timeout( - Duration::from_secs(10), - resolve_while_proving(resolve, out_point_rx, |_| { - std::future::pending::>>() - }), - ) - .await - .expect("a failed resolution must not wait for speculative startup"); - assert!(result.is_err()); - } - - /// Every submission attempt of one call — the first, the IS→CL - /// fallback, the CL-height retries — asks for the same target and gets - /// the speculative bundle. A changed amount or protocol version is - /// proved afresh. - #[tokio::test] - async fn should_prove_once_per_target() { - let calls = Arc::new(AtomicUsize::new(0)); - let prove = counting_prover(&calls); - let speculative = ProofTask::spawn(target(AMOUNT), Arc::clone(&prove)); - let mut cache = BundleCache::new(prove, Some(speculative)); - - let first = cache.bundle_for(target(AMOUNT)).await.expect("proof"); - for _ in 0..3 { - let again = cache.bundle_for(target(AMOUNT)).await.expect("proof"); - assert!(Arc::ptr_eq(&first, &again)); - } - assert_eq!(calls.load(Ordering::SeqCst), 1); - - let other_amount = cache.bundle_for(target(AMOUNT - 1)).await.expect("proof"); - assert_eq!(*other_amount, AMOUNT - 1); - assert_eq!(calls.load(Ordering::SeqCst), 2); - let other_version = BundleTarget { - platform_version: PlatformVersion::first(), - ..target(AMOUNT - 1) - }; - cache.bundle_for(other_version).await.expect("proof"); - assert_eq!(calls.load(Ordering::SeqCst), 3); - } - - #[tokio::test] - async fn should_not_use_a_speculative_proof_for_other_inputs() { - let prove = counting_prover(&Arc::new(AtomicUsize::new(0))); - let speculative = ProofTask::spawn(target(AMOUNT + 1), Arc::clone(&prove)); - let bundle = BundleCache::new(prove, Some(speculative)) - .bundle_for(target(AMOUNT)) - .await - .expect("proof"); - assert_eq!(*bundle, AMOUNT); - } - - /// A proof cancelled before a blocking thread picks it up never runs. - #[test] - fn should_never_run_a_proof_cancelled_before_it_starts() { - let runtime = tokio::runtime::Builder::new_current_thread() - .max_blocking_threads(1) - .enable_all() - .build() - .expect("runtime"); - runtime.block_on(async { - let calls = Arc::new(AtomicUsize::new(0)); - // Occupy the only blocking thread so the proof stays queued. - let (release_tx, release_rx) = std::sync::mpsc::channel::<()>(); - let blocker = tokio::task::spawn_blocking(move || { - let _ = release_rx.recv(); - }); - drop(ProofTask::spawn(target(AMOUNT), counting_prover(&calls))); - release_tx.send(()).unwrap(); - blocker.await.unwrap(); - // The queue is FIFO: once this runs, the cancelled job has had - // its turn. - tokio::task::spawn_blocking(|| ()).await.unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 0); - }); - } - } } From 33b059f67e04dedb93592c4f31840667ddb8efc6 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 12:26:03 -0500 Subject: [PATCH 12/20] fix(platform-wallet): never hold a resolved funding for speculative proof startup try_join! let a successful resolution wait for the speculative proof's setup, which reads the tracked lock under the wallet-manager lock. Poll both with a biased select! instead and break as soon as resolution returns: a speculation that has not spawned its proof by then is dropped and Step 3 proves from the resolved lock. Co-Authored-By: Claude Opus 5.5 --- .../wallet/shielded/fund_from_asset_lock.rs | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 43970b7b6ae..fb2d10da54c 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -415,8 +415,9 @@ impl PlatformWallet { // // The bundle commits to the locked outpoint and the shield amount // but not to the lock's proof (see `ProvedShieldFromAssetLockBundle`), - // and both are known once the lock is committed to this call. So the proof starts - // then and runs while the resolver waits for the InstantSend lock. + // and both are known once the lock is committed to this call. So the + // proof starts then and runs while the resolver waits for the + // InstantSend lock. // Its amount is read from the tracked row, as the lock proof does // not exist yet; Step 3 uses the proof only if it was made for the // resolved outpoint and amount. A proof nobody uses is detached: it @@ -433,19 +434,29 @@ impl PlatformWallet { spawn_proof(out_point, shield_amount), )) }; - // `try_join!` so a failed resolution returns at once. - let (resolution, speculative) = tokio::try_join!( - self.asset_locks.resolve_funding_observing_out_point( - funding, - AssetLockFundingType::AssetLockShieldedAddressTopUp, - /* destination_index */ 0, - asset_lock_signer, - move |out_point| { - let _ = out_point_tx.send(out_point); - }, - ), - async { Ok(speculate.await) }, - )?; + let resolve = self.asset_locks.resolve_funding_observing_out_point( + funding, + AssetLockFundingType::AssetLockShieldedAddressTopUp, + /* destination_index */ 0, + asset_lock_signer, + move |out_point| { + let _ = out_point_tx.send(out_point); + }, + ); + tokio::pin!(resolve, speculate); + // Resolution never waits on speculation: whatever has not started + // proving by the time the lock resolves is dropped. + let (mut speculative, mut speculating) = (None, true); + let resolution = loop { + tokio::select! { + biased; + started = &mut speculate, if speculating => { + speculating = false; + speculative = started; + } + resolution = &mut resolve => break resolution?, + } + }; let ResolvedFunding { proof, path, From f734b65dd9f910d16bcd37bef8b5ce878af4bdf5 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 13:19:06 -0500 Subject: [PATCH 13/20] fix(platform-wallet): drop unstarted speculation as soon as funding resolves `tokio::pin!` kept the speculative-startup future alive in the caller's scope after the select loop broke. A speculation queued on the wallet read lock could then be granted a permit it is never polled to release, starving the later `consume_asset_lock` write. Run the select in `resolve_while_speculating`, which owns both futures and drops them on return, and pin it with a real `RwLock` regression (it fails against the old shape). Read the tracked lock's amount through a keyed `tracked_lock_amount` accessor instead of cloning every tracked lock via `list_tracked_locks`. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/asset_lock/manager.rs | 10 +++ .../wallet/shielded/fund_from_asset_lock.rs | 71 ++++++++++++++----- 2 files changed, 62 insertions(+), 19 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs index 52f473021a9..5dd79dd15ca 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/manager.rs @@ -278,6 +278,16 @@ impl AssetLockManager { .map(|info| info.tracked_asset_locks.values().cloned().collect()) .unwrap_or_default() } + + /// The amount, in duffs, of the tracked asset lock at `out_point`, read + /// in place without cloning the tracked set. + #[cfg(feature = "shielded")] + pub(crate) async fn tracked_lock_amount(&self, out_point: &OutPoint) -> Option { + let wm = self.wallet_manager.read().await; + wm.get_wallet_info(&self.wallet_id) + .and_then(|info| info.tracked_asset_locks.get(out_point)) + .map(|lock| lock.amount) + } } impl std::fmt::Debug for AssetLockManager { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index fb2d10da54c..47b0d36fec9 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -49,9 +49,9 @@ use dpp::state_transition::proof_result::StateTransitionProofResult; use dpp::ProtocolError; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; -use crate::wallet::asset_lock::tracked::TrackedAssetLock; use crate::wallet::asset_lock::AssetLockOperationState; +use std::future::Future; use std::time::Duration; use crate::broadcaster::TransactionBroadcaster; @@ -443,25 +443,14 @@ impl PlatformWallet { let _ = out_point_tx.send(out_point); }, ); - tokio::pin!(resolve, speculate); // Resolution never waits on speculation: whatever has not started // proving by the time the lock resolves is dropped. - let (mut speculative, mut speculating) = (None, true); - let resolution = loop { - tokio::select! { - biased; - started = &mut speculate, if speculating => { - speculating = false; - speculative = started; - } - resolution = &mut resolve => break resolution?, - } - }; + let (resolution, speculative) = resolve_while_speculating(resolve, speculate).await; let ResolvedFunding { proof, path, tracked_out_point, - } = match resolution { + } = match resolution? { FundingResolution::Resolved(rf) => rf, FundingResolution::IsTimeout { out_point } => { tracing::warn!( @@ -878,11 +867,10 @@ async fn tracked_lock_value_duffs( wallet: &PlatformWallet, out_point: &dashcore::OutPoint, ) -> Result { - let locks: Vec = wallet.asset_locks.list_tracked_locks().await; - locks - .iter() - .find(|l| l.out_point == *out_point) - .map(|l| l.amount) + wallet + .asset_locks + .tracked_lock_amount(out_point) + .await .ok_or_else(|| { PlatformWalletError::AddressSync(format!( "tracked asset lock {} not found in manager", @@ -891,6 +879,31 @@ async fn tracked_lock_value_duffs( }) } +/// Runs `resolve` to completion while polling `speculate` alongside it, and +/// returns the resolution with whatever speculation produced by then. +/// +/// Both futures are owned here, so an unfinished `speculate` is dropped on +/// return rather than kept alive for the rest of the caller. A speculation +/// parked on a lock must not outlive resolution: a lock can hand it a permit +/// it never polls to release, starving later writers. +async fn resolve_while_speculating( + resolve: impl Future, + speculate: impl Future>, +) -> (R, Option) { + tokio::pin!(resolve, speculate); + let (mut speculative, mut speculating) = (None, true); + loop { + tokio::select! { + biased; + started = &mut speculate, if speculating => { + speculating = false; + speculative = started; + } + resolution = &mut resolve => return (resolution, speculative), + } + } +} + /// Wait for a bundle proof started with `spawn_blocking`. async fn join_proof( proving: tokio::task::JoinHandle>, @@ -1628,6 +1641,26 @@ mod tests { ); } + #[tokio::test] + async fn should_release_a_speculative_read_queued_behind_resolution() { + let lock = RwLock::new(()); + let held = lock.write().await; + // Speculation queues a read behind the held write; resolution then + // releases the write, granting that read before it is polled again. + let speculate = async { + let _read = lock.read().await; + Some(()) + }; + let resolve = async move { drop(held) }; + + let ((), speculative) = resolve_while_speculating(resolve, speculate).await; + + assert!(speculative.is_none()); + let _write = tokio::time::timeout(Duration::from_secs(5), lock.write()) + .await + .expect("a later writer must not starve behind dropped speculation"); + } + #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; From 36d531283fcb089f79733ace2b72a5bf775ceaec Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 13:48:29 -0500 Subject: [PATCH 14/20] fix(platform-wallet): price, prove and assemble a shield bundle under one protocol version The definitive pool fee was read before awaiting the shield guard and keys, and the proving version after. A version ratchet in between would subtract one schedule's fee while consensus charges another, donating the difference to the pools. Take one `PlatformVersion` snapshot after those awaits for the fee, the proof and every assembly, keep the early fee as a preflight estimate only, and refuse to submit once the SDK has moved off that snapshot (the lock stays resumable). Cover `resolve_while_speculating` handing back a started proof without waiting for it, and finishing resolution after speculation gives up. Co-Authored-By: Claude Opus 5.5 --- .../wallet/shielded/fund_from_asset_lock.rs | 93 +++++++++++++++---- .../src/wallet/shielded/seed_pool.rs | 2 +- .../src/wallet/shielded/wallet_drain.rs | 6 +- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 47b0d36fec9..94567f77131 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -46,6 +46,7 @@ use dpp::prelude::AssetLockProof; use dpp::shielded::builder::{OrchardProver, ProvedShieldFromAssetLockBundle}; use dpp::shielded::compute_minimum_shielded_fee; use dpp::state_transition::proof_result::StateTransitionProofResult; +use dpp::version::PlatformVersion; use dpp::ProtocolError; use key_wallet::wallet::managed_wallet_info::asset_lock_builder::AssetLockFundingType; @@ -268,8 +269,11 @@ impl PlatformWallet { // reservation below is derived from the SAME value (any mismatch is rejected). let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); // `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost` — the - // SAME flat fee consensus charges (`transform_into_action` Step 3b). - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; + // SAME flat fee consensus charges (`transform_into_action` Step 3b). This + // estimate only sizes the pre-broadcast guards; the definitive fee is + // re-derived below from the version the bundle is proved under. + let preflight_fee_credits = + Self::shield_from_asset_lock_pool_fee(num_actions, self.sdk.version())?; // Step 1: pre-flight. Failing fast here avoids broadcasting // an unfundable asset-lock tx (or paying for an Orchard proof // build, ~30s, only to reject downstream). @@ -298,10 +302,10 @@ impl PlatformWallet { {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - if lock_credits <= pool_fee_credits { + if lock_credits <= preflight_fee_credits { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ - below the ShieldFromAssetLock pool fee ({pool_fee_credits} credits = \ + below the ShieldFromAssetLock pool fee ({preflight_fee_credits} credits = \ shielded fee + asset_lock_base_cost) — refusing to broadcast a single-use \ L1 outpoint that would be unrecoverable on resume" ))); @@ -323,7 +327,7 @@ impl PlatformWallet { account, minimum_lock_duffs: _, } => { - let minimum_lock_duffs = pool_fee_credits / CREDITS_PER_DUFF + 1; + let minimum_lock_duffs = preflight_fee_credits / CREDITS_PER_DUFF + 1; AssetLockFunding::DrainAccountBalance { account, minimum_lock_duffs: Some(minimum_lock_duffs), @@ -351,11 +355,10 @@ impl PlatformWallet { .into(), )); } - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; AssetLockFunding::DrainWalletBalance { account_index, operation, - minimum_lock_duffs: Some(pool_fee_credits / CREDITS_PER_DUFF + 1), + minimum_lock_duffs: Some(preflight_fee_credits / CREDITS_PER_DUFF + 1), } } other => other, @@ -384,9 +387,15 @@ impl PlatformWallet { }) }; + // One protocol-version snapshot prices the fee, proves the bundle and + // assembles every attempt, so the amount and the binding cannot come + // from different fee schedules. + let platform_version = self.sdk.version(); + let pool_fee_credits = + Self::shield_from_asset_lock_pool_fee(num_actions, platform_version)?; + // Proves the Orchard bundle on the blocking pool, so the seconds of // Halo 2 work never occupy an async worker. - let platform_version = self.sdk.version(); let spawn_proof = |out_point, shield_amount| { let sender_ovk = sender_ovk.clone(); tokio::task::spawn_blocking(move || { @@ -568,6 +577,7 @@ impl PlatformWallet { asset_lock_signer, surplus_output, s, + platform_version, ) }, ) @@ -704,11 +714,10 @@ impl PlatformWallet { /// funding) uses, read from `dpp.state_transitions.identities.asset_locks` /// and converted duffs→credits. pub(crate) fn shield_from_asset_lock_pool_fee( - &self, num_actions: usize, + platform_version: &PlatformVersion, ) -> Result { - let pv = self.sdk.version(); - let albc_duffs = pv + let albc_duffs = platform_version .dpp .state_transitions .identities @@ -720,11 +729,12 @@ impl PlatformWallet { ({albc_duffs} duffs * {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - let shielded_fee = compute_minimum_shielded_fee(num_actions, pv).map_err(|e| { - PlatformWalletError::ShieldedBuildError(format!( - "failed to compute minimum shielded fee for ShieldFromAssetLock: {e}" - )) - })?; + let shielded_fee = + compute_minimum_shielded_fee(num_actions, platform_version).map_err(|e| { + PlatformWalletError::ShieldedBuildError(format!( + "failed to compute minimum shielded fee for ShieldFromAssetLock: {e}" + )) + })?; shielded_fee.checked_add(albc).ok_or_else(|| { PlatformWalletError::ShieldedBuildError(format!( "ShieldFromAssetLock pool fee overflowed credits conversion \ @@ -952,10 +962,15 @@ fn proof_attests_full_consumption(result: &StateTransitionProofResult) -> bool { /// /// Extracted so `submit_with_cl_height_retry`'s closure stays compact /// and the IS→CL fallback path can re-call it with the upgraded proof. -/// Each call re-signs the proved `bundle` around `proof`. +/// Each call re-signs the proved `bundle` around `proof`, and refuses to +/// submit once the SDK has moved off the `platform_version` the bundle's +/// amount was priced under: consensus would charge another fee, and a lower +/// one would donate the difference to the pools. The lock stays resumable, +/// and a resume proves afresh. /// /// On success returns the landed bundle's actions together with what the /// verified proof result attests about the asset lock's consumption. +#[allow(clippy::too_many_arguments)] async fn build_and_broadcast_shielded( sdk: std::sync::Arc, bundle: &ProvedShieldFromAssetLockBundle, @@ -964,6 +979,7 @@ async fn build_and_broadcast_shielded( asset_lock_signer: &AS, surplus_output: Option, settings: Option, + platform_version: &PlatformVersion, ) -> Result where AS: ::key_wallet::signer::Signer, @@ -971,7 +987,14 @@ where use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; use dpp::state_transition::StateTransition; - let platform_version = sdk.version(); + let current_version = sdk.version().protocol_version; + if current_version != platform_version.protocol_version { + return Err(dash_sdk::Error::Generic(format!( + "protocol version moved from {} to {current_version} since the shield bundle \ + was priced; resume the asset lock to re-prove it", + platform_version.protocol_version + ))); + } let st = bundle .build_transition_with_signer( proof, @@ -1661,6 +1684,40 @@ mod tests { .expect("a later writer must not starve behind dropped speculation"); } + #[tokio::test] + async fn should_return_a_started_speculation_without_waiting_for_it() { + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + let (release_tx, release_rx) = tokio::sync::oneshot::channel::<()>(); + let speculate = async move { + let proving = tokio::spawn(async move { release_rx.await.map(|()| 7u8) }); + let _ = started_tx.send(()); + Some(proving) + }; + // Resolution only completes once the proof has started. + let resolve = async move { started_rx.await.is_ok() }; + + let (resolved, speculative) = resolve_while_speculating(resolve, speculate).await; + + assert!(resolved); + let proving = speculative.expect("the started proof is handed back"); + assert!(!proving.is_finished()); + release_tx.send(()).unwrap(); + assert_eq!(proving.await.unwrap(), Ok(7)); + } + + #[tokio::test] + async fn should_keep_resolving_after_speculation_gives_up() { + // Speculation finishes first, so the select runs again afterwards: + // polling the completed speculation there would panic. + let speculate = async { None::<()> }; + let resolve = async { 1 }; + + let (resolution, speculative) = resolve_while_speculating(resolve, speculate).await; + + assert_eq!(resolution, 1); + assert!(speculative.is_none()); + } + #[tokio::test] async fn successful_submit_result_passes_through_without_mutation() { let ctx = consumption_report_context().await; diff --git a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs index ba1dd2aa284..c1e7116bb2f 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/seed_pool.rs @@ -242,7 +242,7 @@ impl PlatformWallet { // real note lands at ~REAL_NOTE_VALUE_CREDITS and the surplus // is structurally zero. let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); - let pool_fee = self.shield_from_asset_lock_pool_fee(num_actions)?; + let pool_fee = Self::shield_from_asset_lock_pool_fee(num_actions, self.sdk.version())?; let lock_credits = pool_fee .checked_add(REAL_NOTE_VALUE_CREDITS) .ok_or_else(|| { diff --git a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs index 8c4f4f38a65..aff92d465a3 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/wallet_drain.rs @@ -72,8 +72,10 @@ impl PlatformWallet { account_index: u32, dummy_outputs: usize, ) -> Result { - let pool_fee_credits = self - .shield_from_asset_lock_pool_fee(shield_from_asset_lock_num_actions(dummy_outputs))?; + let pool_fee_credits = Self::shield_from_asset_lock_pool_fee( + shield_from_asset_lock_num_actions(dummy_outputs), + self.sdk.version(), + )?; let (candidates, height) = { let manager = self.wallet_manager.read().await; let (wallet, info) = From 7ae79dd4029e38a29d0696c2f3de7b16485cb174 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 14:20:55 -0500 Subject: [PATCH 15/20] fix(dpp): pin a proved shield bundle to its outpoint at every protocol version Before protocol 14 the ShieldFromAssetLock sighash binds nothing, so comparing bindings let one proved bundle be assembled around two different asset locks, funding the same note twice. The proved bundle now keeps the outpoint it was proved for and refuses any other, whatever the version binds. The wallet also aborts a discarded speculative proof, so one still queued on the blocking pool never runs. Co-Authored-By: Claude Opus 5.5 --- .../builder/shield_from_asset_lock.rs | 16 ++++--- .../signing_tests.rs | 43 +++++++++++++++++++ .../wallet/shielded/fund_from_asset_lock.rs | 9 +++- 3 files changed, 62 insertions(+), 6 deletions(-) diff --git a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs index 849d17709db..18697edf43c 100644 --- a/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs +++ b/packages/rs-dpp/src/shielded/builder/shield_from_asset_lock.rs @@ -37,6 +37,9 @@ pub struct ProvedShieldFromAssetLockBundle { /// Signs the proved bundle (see `prove_bundle_with`). The proved-but-unsigned bundle type is /// not re-exported by `grovedb-commitment-tree`, so it lives inside this closure. sign: Box, + /// The outpoint the bundle was proved for. Checked on its own, because at versions that bind + /// nothing the sighash would accept a proof of any outpoint. + asset_lock_out_point: OutPoint, /// The asset-lock binding the sighash committed to; empty at versions that bind nothing. extra_sighash_data: Vec, } @@ -74,6 +77,7 @@ impl ProvedShieldFromAssetLockBundle { let sign = prove_bundle_with(builder, prover, move |_| Ok(bound))?; Ok(Self { sign, + asset_lock_out_point, extra_sighash_data, }) } @@ -83,9 +87,10 @@ impl ProvedShieldFromAssetLockBundle { /// ECDSA signature. Does not consume the proved bundle, so a rejected transition can be /// re-assembled, with a new asset-lock proof or the same one, without proving again. /// - /// Errors, rather than produce a transition consensus would reject, when consensus would - /// derive another binding from `asset_lock_proof` at `platform_version`: a proof of another - /// outpoint, or a protocol version that binds differently. + /// Errors when `asset_lock_proof` is for another outpoint, even at versions whose sighash + /// binds nothing (one proved bundle around two locks would fund the same note twice), and, + /// rather than produce a transition consensus would reject, when `platform_version` binds + /// differently from the version the bundle was proved at. pub async fn build_transition_with_signer( &self, asset_lock_proof: AssetLockProof, @@ -97,8 +102,9 @@ impl ProvedShieldFromAssetLockBundle { where AS: ::key_wallet::signer::Signer, { - if shield_from_asset_lock_extra_sighash_data(&asset_lock_proof, platform_version)? - != self.extra_sighash_data + if asset_lock_proof.out_point() != Some(self.asset_lock_out_point) + || shield_from_asset_lock_extra_sighash_data(&asset_lock_proof, platform_version)? + != self.extra_sighash_data { return Err(ProtocolError::ShieldedBuildError( "shield_from_asset_lock: the asset lock proof does not match the binding the \ diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index c23f07c312f..e7fc722b24a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -405,3 +405,46 @@ async fn proved_bundle_assembles_around_any_proof_of_its_outpoint() { "unexpected error: {err:?}" ); } + +#[tokio::test] +async fn proved_bundle_refuses_another_outpoint_where_the_sighash_binds_nothing() { + // Protocol 13 predates `credit_pool_bundle_binding`, so the sighash is the same for every + // lock: only the stored outpoint stops one proved bundle from funding the note twice. + use crate::shielded::builder::test_helpers::{test_orchard_address, TestProver}; + use crate::shielded::builder::ProvedShieldFromAssetLockBundle; + + let platform_version = PlatformVersion::get(13).expect("protocol version 13"); + let signer = FixedKeySigner::new([7u8; 32]); + let path = DerivationPath::default(); + let out_point = OutPoint::from([0x42; 36]); + let proved = ProvedShieldFromAssetLockBundle::prove( + &test_orchard_address(), + 50_000, + out_point, + &TestProver, + [0u8; 36], + None, + 0, + platform_version, + ) + .expect("proving should succeed"); + let assemble = |proof: AssetLockProof| { + proved.build_transition_with_signer(proof, &path, &signer, None, platform_version) + }; + + let own = AssetLockProof::Chain(ChainAssetLockProof { + core_chain_locked_height: 100, + out_point, + }); + assemble(own) + .await + .expect("a proof of its own outpoint assembles"); + let err = assemble(make_chain_asset_lock_proof()) + .await + .expect_err("a bundle proved for another outpoint must not be assembled"); + assert!( + matches!(&err, crate::ProtocolError::ShieldedBuildError(msg) + if msg.contains("does not match the binding")), + "unexpected error: {err:?}" + ); +} diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 94567f77131..4b206ffd808 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -552,7 +552,14 @@ impl PlatformWallet { { proving } - _ => spawn_proof(proof_out_point, shield_amount), + stale => { + // Made for other inputs: a queued proof never starts; a + // running one can't be interrupted, and its result is dropped. + if let Some((_, _, proving)) = stale { + proving.abort(); + } + spawn_proof(proof_out_point, shield_amount) + } }; let bundle = join_proof(proving).await?; let sdk = self.sdk.clone(); From 77e2a7d736c98e704dd55549cc08eca5d71c707e Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 14:55:44 -0500 Subject: [PATCH 16/20] fix(platform-wallet): cancel a queued shield proof on every exit The speculative proof's handle detached on drop, so a funding that failed after speculation started (resolution error, ChainLock fallback, amount lookup) left its proof queued on the blocking pool. Proof handles now abort on drop, which also covers the discarded-speculation case the explicit abort handled. A running proof still cannot be interrupted. The protocol-13 proved-bundle test now also assembles around the bundle's own outpoint at the latest version and expects a binding mismatch, so the binding comparison is covered independently of the outpoint check. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + .../signing_tests.rs | 13 +++++++++++- packages/rs-platform-wallet/Cargo.toml | 2 +- .../wallet/shielded/fund_from_asset_lock.rs | 21 ++++++++----------- 4 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 90ca9978ddb..2bd52c07f92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7906,6 +7906,7 @@ dependencies = [ "futures-core", "futures-io", "futures-sink", + "futures-util", "pin-project-lite", "tokio", ] diff --git a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs index e7fc722b24a..a93787f363a 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/shielded/shield_from_asset_lock_transition/signing_tests.rs @@ -436,9 +436,20 @@ async fn proved_bundle_refuses_another_outpoint_where_the_sighash_binds_nothing( core_chain_locked_height: 100, out_point, }); - assemble(own) + assemble(own.clone()) .await .expect("a proof of its own outpoint assembles"); + // A version that binds the outpoint verifies against another sighash than the one the bundle + // was signed for, even around the bundle's own outpoint. + let err = proved + .build_transition_with_signer(own, &path, &signer, None, PlatformVersion::latest()) + .await + .expect_err("a bundle proved before the binding must not assemble after it"); + assert!( + matches!(&err, crate::ProtocolError::ShieldedBuildError(msg) + if msg.contains("does not match the binding")), + "unexpected error: {err:?}" + ); let err = assemble(make_chain_asset_lock_proof()) .await .expect_err("a bundle proved for another outpoint must not be assembled"); diff --git a/packages/rs-platform-wallet/Cargo.toml b/packages/rs-platform-wallet/Cargo.toml index 7da2bb5ce6c..38b2ab3d111 100644 --- a/packages/rs-platform-wallet/Cargo.toml +++ b/packages/rs-platform-wallet/Cargo.toml @@ -30,7 +30,7 @@ bimap = "0.6" # Async runtime tokio = { version = "1", features = ["sync", "rt", "time", "macros"] } -tokio-util = { version = "0.7.12" } +tokio-util = { version = "0.7.12", features = ["rt"] } dash-async = { path = "../rs-dash-async" } # Logging diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 4b206ffd808..3499f92aa5b 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -54,6 +54,7 @@ use crate::wallet::asset_lock::AssetLockOperationState; use std::future::Future; use std::time::Duration; +use tokio_util::task::AbortOnDropHandle; use crate::broadcaster::TransactionBroadcaster; use crate::error::is_asset_lock_already_consumed; @@ -395,10 +396,12 @@ impl PlatformWallet { Self::shield_from_asset_lock_pool_fee(num_actions, platform_version)?; // Proves the Orchard bundle on the blocking pool, so the seconds of - // Halo 2 work never occupy an async worker. + // Halo 2 work never occupy an async worker. Dropping the handle on + // any exit cancels a proof that is still queued; a running one can't + // be interrupted, and its result is dropped. let spawn_proof = |out_point, shield_amount| { let sender_ovk = sender_ovk.clone(); - tokio::task::spawn_blocking(move || { + AbortOnDropHandle::new(tokio::task::spawn_blocking(move || { ProvedShieldFromAssetLockBundle::prove( &recipient, shield_amount, @@ -409,7 +412,7 @@ impl PlatformWallet { dummy_outputs, platform_version, ) - }) + })) }; // Step 2: resolve funding. `AssetLockShieldedAddressTopUp` @@ -552,14 +555,8 @@ impl PlatformWallet { { proving } - stale => { - // Made for other inputs: a queued proof never starts; a - // running one can't be interrupted, and its result is dropped. - if let Some((_, _, proving)) = stale { - proving.abort(); - } - spawn_proof(proof_out_point, shield_amount) - } + // Made for other inputs: dropping it cancels it if still queued. + _ => spawn_proof(proof_out_point, shield_amount), }; let bundle = join_proof(proving).await?; let sdk = self.sdk.clone(); @@ -923,7 +920,7 @@ async fn resolve_while_speculating( /// Wait for a bundle proof started with `spawn_blocking`. async fn join_proof( - proving: tokio::task::JoinHandle>, + proving: AbortOnDropHandle>, ) -> Result { match proving.await { Ok(proved) => proved.map_err(|e| PlatformWalletError::Sdk(e.into())), From be97f1e9c754a586f2583e71cc63a72b74dde122 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 15:07:30 -0500 Subject: [PATCH 17/20] fix(platform-wallet): recheck the protocol version after signing a shield transition An external signer can take long enough for the SDK to move to another protocol version, so check right before broadcast instead of before signing. Co-Authored-By: Claude Opus 5.5 --- .../wallet/shielded/fund_from_asset_lock.rs | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 3499f92aa5b..0993be575d4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -991,14 +991,6 @@ where use dpp::state_transition::shield_from_asset_lock_transition::accessors::ShieldFromAssetLockTransitionAccessorsV0; use dpp::state_transition::StateTransition; - let current_version = sdk.version().protocol_version; - if current_version != platform_version.protocol_version { - return Err(dash_sdk::Error::Generic(format!( - "protocol version moved from {} to {current_version} since the shield bundle \ - was priced; resume the asset lock to re-prove it", - platform_version.protocol_version - ))); - } let st = bundle .build_transition_with_signer( proof, @@ -1014,6 +1006,17 @@ where _ => Vec::new(), }; + // Checked after signing, which an external signer can stretch out, and + // as close to the broadcast as the SDK allows. + let current_version = sdk.version().protocol_version; + if current_version != platform_version.protocol_version { + return Err(dash_sdk::Error::Generic(format!( + "protocol version moved from {} to {current_version} since the shield bundle \ + was priced; resume the asset lock to re-prove it", + platform_version.protocol_version + ))); + } + // Wait for the verified result rather than relay-ACK. Single-use // asset-lock proof: a false-positive on a transition Platform // later rejects would strand the L1 outpoint with no in-app From 83a9586d90cb1e53bd4ffb59978eef15668d0444 Mon Sep 17 00:00:00 2001 From: pasta Date: Fri, 9 Oct 2026 15:59:34 -0500 Subject: [PATCH 18/20] fix(platform-wallet): drop a stale speculative proof before re-proving A `_` arm does not move `speculative`, so a proof made for another outpoint or amount stayed owned until the funding call returned and could still start ahead of its replacement. Move it out and drop it first. Co-Authored-By: Claude Opus 5.5 --- .../src/wallet/shielded/fund_from_asset_lock.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 0993be575d4..2d0064bbfa4 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -556,7 +556,12 @@ impl PlatformWallet { proving } // Made for other inputs: dropping it cancels it if still queued. - _ => spawn_proof(proof_out_point, shield_amount), + // `_` would leave it owned by `speculative` until the end of the + // function, so move it out and drop it before the replacement. + stale => { + drop(stale); + spawn_proof(proof_out_point, shield_amount) + } }; let bundle = join_proof(proving).await?; let sdk = self.sdk.clone(); From bc58b5a6f19bfecf1473a54a1ed8ab6c443f5846 Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 04:06:19 -0500 Subject: [PATCH 19/20] fix(platform-wallet): size a fresh shield lock under the same protocol version that spends it The pre-broadcast lock-size guard and the drain floor priced the Type 18 pool fee from one SDK version read, and the fee that sets the shield amount, proves the bundle and assembles the transition came from a second read taken after the shielded-key reads. If the SDK moved to a protocol version with a higher fee in between, a fresh lock could pass the old floor, be broadcast, and then be too small for the fee it is spent under. Take the one protocol-version snapshot first (the outer call already holds shield_guard) and use it for the lock-size guard, the drain floor, pricing, proving and every assembly attempt. Co-Authored-By: Claude Opus 5.5 --- .../wallet/shielded/fund_from_asset_lock.rs | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs index 2d0064bbfa4..2afe3a0db44 100644 --- a/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs +++ b/packages/rs-platform-wallet/src/wallet/shielded/fund_from_asset_lock.rs @@ -270,11 +270,13 @@ impl PlatformWallet { // reservation below is derived from the SAME value (any mismatch is rejected). let num_actions = shield_from_asset_lock_num_actions(dummy_outputs); // `pool_fee = compute_minimum_shielded_fee(num_actions) + asset_lock_base_cost` — the - // SAME flat fee consensus charges (`transform_into_action` Step 3b). This - // estimate only sizes the pre-broadcast guards; the definitive fee is - // re-derived below from the version the bundle is proved under. - let preflight_fee_credits = - Self::shield_from_asset_lock_pool_fee(num_actions, self.sdk.version())?; + // SAME flat fee consensus charges (`transform_into_action` Step 3b). + // One protocol-version snapshot sizes a fresh lock, prices the fee, + // proves the bundle and assembles every attempt, so a lock cannot be + // admitted under one fee schedule and spent under another. + let platform_version = self.sdk.version(); + let pool_fee_credits = + Self::shield_from_asset_lock_pool_fee(num_actions, platform_version)?; // Step 1: pre-flight. Failing fast here avoids broadcasting // an unfundable asset-lock tx (or paying for an Orchard proof // build, ~30s, only to reject downstream). @@ -303,10 +305,10 @@ impl PlatformWallet { {CREDITS_PER_DUFF} credits/duff > u64::MAX)" )) })?; - if lock_credits <= preflight_fee_credits { + if lock_credits <= pool_fee_credits { return Err(PlatformWalletError::ShieldedBuildError(format!( "asset lock ({lock_credits} credits, from {amount_duffs} duffs) is at or \ - below the ShieldFromAssetLock pool fee ({preflight_fee_credits} credits = \ + below the ShieldFromAssetLock pool fee ({pool_fee_credits} credits = \ shielded fee + asset_lock_base_cost) — refusing to broadcast a single-use \ L1 outpoint that would be unrecoverable on resume" ))); @@ -328,7 +330,7 @@ impl PlatformWallet { account, minimum_lock_duffs: _, } => { - let minimum_lock_duffs = preflight_fee_credits / CREDITS_PER_DUFF + 1; + let minimum_lock_duffs = pool_fee_credits / CREDITS_PER_DUFF + 1; AssetLockFunding::DrainAccountBalance { account, minimum_lock_duffs: Some(minimum_lock_duffs), @@ -359,7 +361,7 @@ impl PlatformWallet { AssetLockFunding::DrainWalletBalance { account_index, operation, - minimum_lock_duffs: Some(preflight_fee_credits / CREDITS_PER_DUFF + 1), + minimum_lock_duffs: Some(pool_fee_credits / CREDITS_PER_DUFF + 1), } } other => other, @@ -388,13 +390,6 @@ impl PlatformWallet { }) }; - // One protocol-version snapshot prices the fee, proves the bundle and - // assembles every attempt, so the amount and the binding cannot come - // from different fee schedules. - let platform_version = self.sdk.version(); - let pool_fee_credits = - Self::shield_from_asset_lock_pool_fee(num_actions, platform_version)?; - // Proves the Orchard bundle on the blocking pool, so the seconds of // Halo 2 work never occupy an async worker. Dropping the handle on // any exit cancels a proof that is still queued; a running one can't From b833f35546e75c141f949def2320473d7a8c47bc Mon Sep 17 00:00:00 2001 From: pasta Date: Thu, 29 Oct 2026 05:27:57 -0500 Subject: [PATCH 20/20] docs(platform-wallet): record the shield-from-asset-lock prover bound in the changelog Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 18e71ea3d72..0d1b59c1073 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ ### Changed +- **platform-wallet (breaking):** Core → Shielded funding + (`PlatformWallet::shielded_fund_from_asset_lock`) proves its Orchard bundle + while the asset lock's InstantSend lock is awaited, on Tokio's blocking pool, + and reuses that proof for the ChainLock fallback and retries. The prover is + moved into that task, so the method now requires + `P: OrchardProver + Copy + Send + 'static`: pass `&CachedOrchardProver` + instead of a borrowed, non-`'static` prover. - **Rust dependencies:** pin rust-dashcore to `dev` at `280b3f2c`, which includes [rust-dashcore#1175](https://github.com/dashpay/rust-dashcore/pull/1175): every transaction payload keeps its stored serde variant index, so wallet rows