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 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/shielded/builder/mod.rs b/packages/rs-dpp/src/shielded/builder/mod.rs index 3651ecbf12b..11054f3846b 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,19 @@ 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) +} + +/// 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 +270,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. @@ -364,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>, { @@ -392,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. 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..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 @@ -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,121 @@ use platform_version::version::PlatformVersion; use super::{ build_output_only_bundle, serialize_authorized_bundle, OrchardProver, SerializedBundle, }; +#[cfg(feature = "core_key_wallet")] +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 +/// [`Self::build_transition_with_signer`]. +/// +/// 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. +/// +/// 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, 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 { + /// 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, +} + +#[cfg(feature = "core_key_wallet")] +impl ProvedShieldFromAssetLockBundle { + /// 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, + 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 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. + 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 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 { + sign, + asset_lock_out_point, + extra_sighash_data, + }) + } + + /// Wraps the proved bundle into a `ShieldFromAssetLock` transition funded by + /// `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 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, + 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 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 \ + bundle was proved with (different outpoint or protocol version)" + .to_string(), + )); + } + let sb = serialize_authorized_bundle(&(self.sign)(&[])?); + 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 +239,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 +292,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)] @@ -285,4 +406,59 @@ mod tests { ); } } + + #[cfg(feature = "core_key_wallet")] + #[test] + 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, 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; + 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 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, + 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] = 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 9ba80c940a9..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 @@ -350,3 +350,112 @@ 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_any_proof_of_its_outpoint() { + // 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 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(), + 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 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, &later] { + assert_eq!(v1.proof, first.proof, "assembly must not re-prove"); + assert_eq!(v1.value_balance, 50_000); + } + assert_ne!(first.binding_signature, again.binding_signature); + assert_ne!(first.signature, again.signature); + + let err = assemble(make_chain_asset_lock_proof()) + .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:?}" + ); +} + +#[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.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"); + 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-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/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/asset_lock/build.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs index 8feba80262c..f208ae868c6 100644 --- a/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs +++ b/packages/rs-platform-wallet/src/wallet/asset_lock/build.rs @@ -820,6 +820,7 @@ impl AssetLockManager { funding_type, identity_index, signer, + |_| {}, ) .await } @@ -845,6 +846,7 @@ impl AssetLockManager { funding_type, identity_index, signer, + |_| {}, ) .await } @@ -852,7 +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`]. - async fn create_funded_asset_lock_proof_pooled( + /// + /// `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)] + pub(crate) async fn create_funded_asset_lock_proof_pooled( &self, amount: AssetLockBuildAmount, funding_sources: &[AccountTypePreference], @@ -860,6 +867,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 +879,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 +937,10 @@ impl AssetLockManager { .await } + /// 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)] pub(crate) async fn create_wallet_drain_asset_lock_proof( &self, account_index: u32, @@ -936,6 +949,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 +963,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/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/asset_lock/orchestration.rs b/packages/rs-platform-wallet/src/wallet/asset_lock/orchestration.rs index 2f209c2a500..a48f3c9271c 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,14 @@ impl AssetLockManager { account_index, } => { match self - .create_funded_asset_lock_proof( - 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, asset_lock_signer, + on_out_point, ) .await { @@ -562,6 +597,7 @@ impl AssetLockManager { destination_index, asset_lock_signer, &operation, + on_out_point, ) .await { @@ -585,12 +621,14 @@ 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_pooled( super::build::AssetLockBuildAmount::DrainAll { minimum_lock_duffs }, - account, + &[account.into()], + account.account_index(), funding_type, destination_index, asset_lock_signer, + on_out_point, ) .await { @@ -638,6 +676,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 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..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 @@ -11,16 +11,21 @@ //! 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`]. +//! `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`; 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). 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 @@ -38,15 +43,18 @@ 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::version::PlatformVersion; +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 tokio_util::task::AbortOnDropHandle; use crate::broadcaster::TransactionBroadcaster; use crate::error::is_asset_lock_already_consumed; @@ -144,6 +152,8 @@ impl PlatformWallet { /// signature on the state transition. The raw key never crosses /// the FFI boundary. /// * `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`). /// @@ -163,7 +173,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 +198,7 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver, + 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 @@ -253,12 +263,20 @@ impl PlatformWallet { ) -> Result<(), PlatformWalletError> where AS: ::key_wallet::signer::ExtendedPubKeySigner + Send + Sync, - P: OrchardProver, + 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). + // 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). @@ -287,7 +305,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 \ @@ -313,7 +330,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, @@ -342,7 +358,6 @@ impl PlatformWallet { .into(), )); } - let pool_fee_credits = self.shield_from_asset_lock_pool_fee(num_actions)?; AssetLockFunding::DrainWalletBalance { account_index, operation, @@ -352,6 +367,49 @@ 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 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| { + 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 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 + // be interrupted, and its result is dropped. + let spawn_proof = |out_point, shield_amount| { + let sender_ovk = sender_ovk.clone(); + AbortOnDropHandle::new(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 // top-ups (`accounts.asset_lock_shielded_address_topup` — @@ -361,20 +419,45 @@ impl PlatformWallet { // derivation, and `manager/accessors.rs` for the // persistence/UI tag (`fundingTypeRaw == 5`). // `destination_index = 0` is unused for this funding type. + // + // 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 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), + )) + }; + 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); + }, + ); + // Resolution never waits on speculation: whatever has not started + // proving by the time the lock resolves is dropped. + let (resolution, speculative) = resolve_while_speculating(resolve, speculate).await; let ResolvedFunding { proof, path, tracked_out_point, - } = match self - .asset_locks - .resolve_funding_with_is_timeout_fallback( - funding, - AssetLockFundingType::AssetLockShieldedAddressTopUp, - /* destination_index */ 0, - asset_lock_signer, - ) - .await? - { + } = match resolution? { FundingResolution::Resolved(rf) => rf, FundingResolution::IsTimeout { out_point } => { tracing::warn!( @@ -406,7 +489,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 @@ -416,25 +501,7 @@ 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 = 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 @@ -445,9 +512,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 @@ -461,27 +526,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 +533,38 @@ 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 — 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 (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 `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 - // 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 proving = match speculative { + Some((out_point, amount, proving)) + if out_point == proof_out_point && amount == shield_amount => + { + proving + } + // Made for other inputs: dropping it cancels it if still queued. + // `_` 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(); // 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 +575,21 @@ impl PlatformWallet { |proof, s| { build_and_broadcast_shielded( sdk.clone(), - recipient, - shield_amount, + &bundle, proof, path.clone(), asset_lock_signer, - &prover, - sender_ovk.clone(), surplus_output, - dummy_outputs, s, + platform_version, ) }, ) .await?; + // 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 @@ -661,11 +718,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 @@ -677,11 +733,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 \ @@ -694,12 +751,11 @@ 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 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 @@ -810,17 +866,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_lock_value_duffs(wallet, op).await? } }; duffs.checked_mul(CREDITS_PER_DUFF).ok_or_else(|| { @@ -830,13 +876,67 @@ async fn lookup_asset_lock_value_credits( }) } +/// 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 { + wallet + .asset_locks + .tracked_lock_amount(out_point) + .await + .ok_or_else(|| { + PlatformWalletError::AddressSync(format!( + "tracked asset lock {} not found in manager", + out_point + )) + }) +} + +/// 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: AbortOnDropHandle>, +) -> 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(), + )), + } +} + /// 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,50 +966,57 @@ 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`, 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( +async fn build_and_broadcast_shielded( sdk: std::sync::Arc, - recipient: OrchardAddress, - shield_amount: Credits, + bundle: &ProvedShieldFromAssetLockBundle, proof: AssetLockProof, path: ::key_wallet::bip32::DerivationPath, asset_lock_signer: &AS, - prover: &P, - sender_ovk: Option, surplus_output: Option, - dummy_outputs: usize, settings: Option, + platform_version: &PlatformVersion, ) -> 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, - shield_amount, - proof, - &path, - asset_lock_signer, - prover, - [0u8; 36], - sender_ovk, - surplus_output, - dummy_outputs, - sdk.version(), - ) - .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(), _ => 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 @@ -928,6 +1035,30 @@ 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) +} + /// Pre-flight check for the recipient list. /// /// Today: non-empty, exactly one recipient whose `Credits` value is @@ -1540,6 +1671,60 @@ 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 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 bded4bc0c50..c1e7116bb2f 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; @@ -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(|| { @@ -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.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) = 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,