From 1e32443ab5fa7a3cc2bb1d3607176696738ba57f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 13:39:13 +0000 Subject: [PATCH 1/8] feat(zcash_primitives): Add configurable expiry delta to Builder Add Builder::expiry_height() accessor and Builder::with_expiry_delta() method (gated behind non-standard-fees feature) to allow customizing transaction expiry windows for wallet recovery scenarios. Co-Authored-By: Claude --- zcash_primitives/CHANGELOG.md | 8 ++++++ zcash_primitives/src/transaction/builder.rs | 32 +++++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/zcash_primitives/CHANGELOG.md b/zcash_primitives/CHANGELOG.md index 4e0c35a435..a734610f8c 100644 --- a/zcash_primitives/CHANGELOG.md +++ b/zcash_primitives/CHANGELOG.md @@ -10,6 +10,14 @@ workspace. ## [Unreleased] +### Added +- `zcash_primitives::transaction::builder::Builder`: + - `expiry_height`: Returns the expiry height currently set for this builder. + - `with_expiry_delta` (requires the `non-standard-fees` feature): Sets a + non-standard expiry height for the transaction, specified as a delta from + the target block height. This is useful for wallet recovery scenarios where + a shorter expiry window is desired for replacement transactions. + ## [0.27.1] - 2026-05-14 ### Fixed diff --git a/zcash_primitives/src/transaction/builder.rs b/zcash_primitives/src/transaction/builder.rs index 50d66d2518..a83cb69690 100644 --- a/zcash_primitives/src/transaction/builder.rs +++ b/zcash_primitives/src/transaction/builder.rs @@ -807,6 +807,38 @@ impl Builder<'_, P, U> { pub fn set_zip233_amount(&mut self, zip233_amount: Zatoshis) { self.zip233_amount = zip233_amount; } + + /// Returns the expiry height currently set for this builder. + pub fn expiry_height(&self) -> BlockHeight { + self.expiry_height + } + + /// Sets a non-standard expiry height for the transaction, specified as a delta + /// from the target block height. + /// + /// This is useful for wallet recovery scenarios where a shorter expiry window + /// is desired for replacement transactions. + /// + /// # Warning + /// + /// Using a non-default expiry delta can make transactions more distinguishable, + /// potentially reducing privacy. The standard expiry delta is 40 blocks + /// ([`DEFAULT_TX_EXPIRY_DELTA`]). + /// + /// # Panics + /// + /// Panics if this builder was created for a coinbase transaction, as coinbase + /// transactions must have expiry height equal to their block height per consensus + /// rules (NU5 onward). + #[cfg(feature = "non-standard-fees")] + pub fn with_expiry_delta(mut self, expiry_delta: u32) -> Self { + assert!( + !self.build_config.is_coinbase(), + "Cannot set custom expiry for coinbase transactions" + ); + self.expiry_height = self.target_height + expiry_delta; + self + } } impl Builder<'_, P, U> { From 49c34de1befe981ab234c96fb1eca0463f55c876 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 13:45:34 +0000 Subject: [PATCH 2/8] feat(zcash_client_backend): Add create_proposed_transactions_with_expiry_delta Add a new function that allows specifying a custom expiry delta when executing transaction proposals. This is useful for wallet recovery scenarios where a shorter expiry window is desired for replacement transactions. Co-Authored-By: Claude --- zcash_client_backend/CHANGELOG.md | 5 + zcash_client_backend/src/data_api/wallet.rs | 128 ++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/zcash_client_backend/CHANGELOG.md b/zcash_client_backend/CHANGELOG.md index e65316c787..cfaddc4f14 100644 --- a/zcash_client_backend/CHANGELOG.md +++ b/zcash_client_backend/CHANGELOG.md @@ -33,6 +33,11 @@ workspace. - `zcash_client_backend::data_api::wallet::ProposeShieldingCoinbaseErrT` type alias, parallel to `ProposeShieldingErrT` but parameterized on a `FeeRule` instead of a `ChangeStrategy`. +- `zcash_client_backend::data_api::wallet::create_proposed_transactions_with_expiry_delta` + (requires the `non-standard-fees` feature): Equivalent to + `create_proposed_transactions` but allows specifying a non-standard + transaction expiry window. This is useful for wallet recovery scenarios + where a shorter expiry window is desired for replacement transactions. ### Changed - `zcash_client_backend::data_api`: diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index 56ef8f87bf..ca562ab123 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -1046,6 +1046,8 @@ where &mut unused_transparent_outputs, #[cfg(feature = "unstable")] proposed_version, + #[cfg(feature = "non-standard-fees")] + None, )?; step_results.push((step, step_result)); } @@ -1092,6 +1094,119 @@ where Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty")) } +/// Construct, prove, and sign a transaction or series of transactions using the inputs supplied by +/// the given proposal, with a custom expiry delta, and persist it to the wallet database. +/// +/// This is equivalent to [`create_proposed_transactions`] but allows specifying +/// a non-standard transaction expiry window. +/// +/// # Parameters +/// - `expiry_delta`: Number of blocks after `proposal.min_target_height()` when the +/// transaction(s) expire. The standard expiry delta is 40 blocks; use +/// [`zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA`] if you want +/// to explicitly pass the default value. +/// +/// # Warning +/// +/// Using a non-default expiry delta can make transactions more distinguishable, +/// potentially reducing privacy. +/// +/// [`create_proposed_transactions`]: crate::data_api::wallet::create_proposed_transactions +#[allow(clippy::too_many_arguments)] +#[allow(clippy::type_complexity)] +#[cfg(feature = "non-standard-fees")] +pub fn create_proposed_transactions_with_expiry_delta< + DbT, + ParamsT, + InputsErrT, + FeeRuleT, + ChangeErrT, + N, +>( + wallet_db: &mut DbT, + params: &ParamsT, + spend_prover: &impl SpendProver, + output_prover: &impl OutputProver, + spending_keys: &SpendingKeys, + ovk_policy: OvkPolicy, + proposal: &Proposal, + expiry_delta: u32, + #[cfg(feature = "unstable")] proposed_version: Option, +) -> Result, CreateErrT> +where + DbT: WalletWrite + WalletCommitmentTrees, + ParamsT: consensus::Parameters + Clone, + FeeRuleT: FeeRule, +{ + #[cfg(feature = "transparent-inputs")] + let mut unused_transparent_outputs = HashMap::new(); + + let account_id = wallet_db + .get_account_for_ufvk(&spending_keys.usk.to_unified_full_viewing_key()) + .map_err(Error::DataSource)? + .ok_or(Error::KeyNotRecognized)? + .id(); + + let mut step_results = Vec::with_capacity(proposal.steps().len()); + for step in proposal.steps() { + let step_result: StepResult<_> = create_proposed_transaction( + wallet_db, + params, + spend_prover, + output_prover, + spending_keys, + account_id, + ovk_policy.clone(), + proposal.fee_rule(), + proposal.min_target_height(), + &step_results, + step, + #[cfg(feature = "transparent-inputs")] + &mut unused_transparent_outputs, + #[cfg(feature = "unstable")] + proposed_version, + Some(expiry_delta), + )?; + step_results.push((step, step_result)); + } + + // Ephemeral outputs must be referenced exactly once. + #[cfg(feature = "transparent-inputs")] + for so in unused_transparent_outputs.into_keys() { + if let StepOutputIndex::Change(i) = so.output_index() { + if step_results[so.step_index()].0.balance().proposed_change()[i].is_ephemeral() { + return Err(ProposalError::EphemeralOutputLeftUnspent(so).into()); + } + } + } + + let created = time::OffsetDateTime::now_utc(); + + let mut transactions = Vec::with_capacity(step_results.len()); + let mut txids = Vec::with_capacity(step_results.len()); + #[allow(unused_variables)] + for (_, step_result) in step_results.iter() { + let tx = step_result.build_result.transaction(); + transactions.push(SentTransaction::new( + tx, + created, + proposal.min_target_height(), + account_id, + &step_result.outputs, + step_result.fee_amount, + #[cfg(feature = "transparent-inputs")] + &step_result.utxos_spent, + )); + txids.push(tx.txid()); + } + + wallet_db + .store_transactions_to_be_sent(&transactions) + .map_err(Error::DataSource)?; + + Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty")) +} + #[derive(Debug, Clone)] enum BuildRecipient { External { @@ -1197,6 +1312,7 @@ fn build_proposed_transaction (TransparentAddress, OutPoint), >, #[cfg(feature = "unstable")] proposed_version: Option, + #[cfg(feature = "non-standard-fees")] expiry_delta: Option, ) -> Result< BuildState<'static, ParamsT, DbT::AccountId>, CreateErrT, @@ -1341,6 +1457,13 @@ where builder.propose_version(version)?; } + #[cfg(feature = "non-standard-fees")] + let mut builder = if let Some(delta) = expiry_delta { + builder.with_expiry_delta(delta) + } else { + builder + }; + #[cfg(all(feature = "transparent-inputs", not(feature = "orchard")))] let has_shielded_inputs = !sapling_inputs.is_empty(); #[cfg(all(feature = "transparent-inputs", feature = "orchard"))] @@ -1781,6 +1904,7 @@ fn create_proposed_transaction, #[cfg(feature = "unstable")] proposed_version: Option, + #[cfg(feature = "non-standard-fees")] expiry_delta: Option, ) -> Result< StepResult<::AccountId>, CreateErrT, @@ -1803,6 +1927,8 @@ where unused_transparent_outputs, #[cfg(feature = "unstable")] proposed_version, + #[cfg(feature = "non-standard-fees")] + expiry_delta, )?; // Build the transaction with the specified fee rule @@ -2025,6 +2151,8 @@ where unused_transparent_outputs, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, )?; // Build the transaction with the specified fee rule From dbae0bce952068d2c0de1e5454c3ca50080c4b71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 14:15:58 +0000 Subject: [PATCH 3/8] fix(zcash_primitives): Fix coinbase test to use miner_data field --- zcash_primitives/src/transaction/builder.rs | 63 +++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/zcash_primitives/src/transaction/builder.rs b/zcash_primitives/src/transaction/builder.rs index a83cb69690..9b3526f163 100644 --- a/zcash_primitives/src/transaction/builder.rs +++ b/zcash_primitives/src/transaction/builder.rs @@ -1837,4 +1837,67 @@ mod tests { ); } } + + #[test] + fn expiry_height_accessor() { + use zcash_protocol::consensus::{NetworkUpgrade, Parameters, TEST_NETWORK}; + + let sapling_activation_height = TEST_NETWORK + .activation_height(NetworkUpgrade::Sapling) + .unwrap(); + + let build_config = super::BuildConfig::Standard { + sapling_anchor: Some(sapling::Anchor::empty_tree()), + orchard_anchor: Some(orchard::Anchor::empty_tree()), + }; + let builder = super::Builder::new(TEST_NETWORK, sapling_activation_height, build_config); + + // Default expiry should be target_height + DEFAULT_TX_EXPIRY_DELTA + assert_eq!( + builder.expiry_height(), + sapling_activation_height + super::DEFAULT_TX_EXPIRY_DELTA + ); + } + + #[test] + #[cfg(feature = "non-standard-fees")] + fn with_expiry_delta() { + use zcash_protocol::consensus::{NetworkUpgrade, Parameters, TEST_NETWORK}; + + let sapling_activation_height = TEST_NETWORK + .activation_height(NetworkUpgrade::Sapling) + .unwrap(); + + let build_config = super::BuildConfig::Standard { + sapling_anchor: Some(sapling::Anchor::empty_tree()), + orchard_anchor: Some(orchard::Anchor::empty_tree()), + }; + let builder = super::Builder::new(TEST_NETWORK, sapling_activation_height, build_config); + + // Use a custom expiry delta + let custom_delta = 20; + let builder = builder.with_expiry_delta(custom_delta); + + assert_eq!( + builder.expiry_height(), + sapling_activation_height + custom_delta + ); + } + + #[test] + #[cfg(feature = "non-standard-fees")] + #[should_panic(expected = "Cannot set custom expiry for coinbase transactions")] + fn with_expiry_delta_panics_for_coinbase() { + use zcash_protocol::consensus::{NetworkUpgrade, Parameters, TEST_NETWORK}; + + let sapling_activation_height = TEST_NETWORK + .activation_height(NetworkUpgrade::Sapling) + .unwrap(); + + let build_config = super::BuildConfig::Coinbase { miner_data: None }; + let builder = super::Builder::new(TEST_NETWORK, sapling_activation_height, build_config); + + // This should panic because coinbase transactions cannot have custom expiry + let _ = builder.with_expiry_delta(20); + } } From 2ba8ddaf6c79e21c38d9ac3a28256644fe3787bc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 14:19:58 +0000 Subject: [PATCH 4/8] style: Add clarifying comments for None parameters in PCZT flow --- zcash_client_backend/src/data_api/wallet.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index ca562ab123..d1348804b4 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -2150,9 +2150,9 @@ where #[cfg(feature = "transparent-inputs")] unused_transparent_outputs, #[cfg(feature = "unstable")] - None, + None, // proposed_version #[cfg(feature = "non-standard-fees")] - None, + None, // expiry_delta )?; // Build the transaction with the specified fee rule From 8631408dd62ab1c0cb30248d4308846014df5915 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 28 May 2026 21:55:14 +0000 Subject: [PATCH 5/8] Consolidate create_proposed_transactions API with optional expiry_delta parameter Removed create_proposed_transactions_with_expiry_delta and added an optional expiry_delta: Option parameter (gated by non-standard-fees feature) to create_proposed_transactions. This simplifies the API while maintaining the same functionality. --- zcash_client_backend/CHANGELOG.md | 11 +- zcash_client_backend/src/data_api/testing.rs | 4 + zcash_client_backend/src/data_api/wallet.rs | 129 +++---------------- 3 files changed, 25 insertions(+), 119 deletions(-) diff --git a/zcash_client_backend/CHANGELOG.md b/zcash_client_backend/CHANGELOG.md index cfaddc4f14..4efc9a4f02 100644 --- a/zcash_client_backend/CHANGELOG.md +++ b/zcash_client_backend/CHANGELOG.md @@ -33,13 +33,14 @@ workspace. - `zcash_client_backend::data_api::wallet::ProposeShieldingCoinbaseErrT` type alias, parallel to `ProposeShieldingErrT` but parameterized on a `FeeRule` instead of a `ChangeStrategy`. -- `zcash_client_backend::data_api::wallet::create_proposed_transactions_with_expiry_delta` - (requires the `non-standard-fees` feature): Equivalent to - `create_proposed_transactions` but allows specifying a non-standard - transaction expiry window. This is useful for wallet recovery scenarios - where a shorter expiry window is desired for replacement transactions. ### Changed +- `zcash_client_backend::data_api::wallet::create_proposed_transactions` + now accepts an optional `expiry_delta: Option` parameter (requires + the `non-standard-fees` feature) to specify a non-standard transaction + expiry window. This is useful for wallet recovery scenarios where a + shorter expiry window is desired for replacement transactions. Pass + `None` to use the standard 40-block expiry delta. - `zcash_client_backend::data_api`: - Changes to the `InputSource` trait: - The result types of `InputSource::get_unspent_transparent_output` and diff --git a/zcash_client_backend/src/data_api/testing.rs b/zcash_client_backend/src/data_api/testing.rs index 9a02f10d57..b065fac5af 100644 --- a/zcash_client_backend/src/data_api/testing.rs +++ b/zcash_client_backend/src/data_api/testing.rs @@ -985,6 +985,8 @@ where &proposal, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, ) } @@ -1198,6 +1200,8 @@ where proposal, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, ) } diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index d1348804b4..d25cbdfcd8 100644 --- a/zcash_client_backend/src/data_api/wallet.rs +++ b/zcash_client_backend/src/data_api/wallet.rs @@ -999,6 +999,17 @@ impl SpendingKeys { /// step is not supported, because the ultimate positions of those notes in the global note /// commitment tree cannot be known until the transaction that produces those notes is mined, /// and therefore the required spend proofs for such notes cannot be constructed. +/// +/// # Parameters (requires the `non-standard-fees` feature) +/// - `expiry_delta`: Optional number of blocks after `proposal.min_target_height()` when the +/// transaction(s) expire. If `None`, the standard expiry delta of 40 blocks is used. +/// Use [`zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA`] if you want +/// to explicitly pass the default value. +/// +/// # Warning +/// +/// Using a non-default expiry delta can make transactions more distinguishable, +/// potentially reducing privacy. #[allow(clippy::too_many_arguments)] #[allow(clippy::type_complexity)] pub fn create_proposed_transactions( @@ -1010,6 +1021,7 @@ pub fn create_proposed_transactions, #[cfg(feature = "unstable")] proposed_version: Option, + #[cfg(feature = "non-standard-fees")] expiry_delta: Option, ) -> Result, CreateErrT> where DbT: WalletWrite + WalletCommitmentTrees, @@ -1047,7 +1059,7 @@ where #[cfg(feature = "unstable")] proposed_version, #[cfg(feature = "non-standard-fees")] - None, + expiry_delta, )?; step_results.push((step, step_result)); } @@ -1094,119 +1106,6 @@ where Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty")) } -/// Construct, prove, and sign a transaction or series of transactions using the inputs supplied by -/// the given proposal, with a custom expiry delta, and persist it to the wallet database. -/// -/// This is equivalent to [`create_proposed_transactions`] but allows specifying -/// a non-standard transaction expiry window. -/// -/// # Parameters -/// - `expiry_delta`: Number of blocks after `proposal.min_target_height()` when the -/// transaction(s) expire. The standard expiry delta is 40 blocks; use -/// [`zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA`] if you want -/// to explicitly pass the default value. -/// -/// # Warning -/// -/// Using a non-default expiry delta can make transactions more distinguishable, -/// potentially reducing privacy. -/// -/// [`create_proposed_transactions`]: crate::data_api::wallet::create_proposed_transactions -#[allow(clippy::too_many_arguments)] -#[allow(clippy::type_complexity)] -#[cfg(feature = "non-standard-fees")] -pub fn create_proposed_transactions_with_expiry_delta< - DbT, - ParamsT, - InputsErrT, - FeeRuleT, - ChangeErrT, - N, ->( - wallet_db: &mut DbT, - params: &ParamsT, - spend_prover: &impl SpendProver, - output_prover: &impl OutputProver, - spending_keys: &SpendingKeys, - ovk_policy: OvkPolicy, - proposal: &Proposal, - expiry_delta: u32, - #[cfg(feature = "unstable")] proposed_version: Option, -) -> Result, CreateErrT> -where - DbT: WalletWrite + WalletCommitmentTrees, - ParamsT: consensus::Parameters + Clone, - FeeRuleT: FeeRule, -{ - #[cfg(feature = "transparent-inputs")] - let mut unused_transparent_outputs = HashMap::new(); - - let account_id = wallet_db - .get_account_for_ufvk(&spending_keys.usk.to_unified_full_viewing_key()) - .map_err(Error::DataSource)? - .ok_or(Error::KeyNotRecognized)? - .id(); - - let mut step_results = Vec::with_capacity(proposal.steps().len()); - for step in proposal.steps() { - let step_result: StepResult<_> = create_proposed_transaction( - wallet_db, - params, - spend_prover, - output_prover, - spending_keys, - account_id, - ovk_policy.clone(), - proposal.fee_rule(), - proposal.min_target_height(), - &step_results, - step, - #[cfg(feature = "transparent-inputs")] - &mut unused_transparent_outputs, - #[cfg(feature = "unstable")] - proposed_version, - Some(expiry_delta), - )?; - step_results.push((step, step_result)); - } - - // Ephemeral outputs must be referenced exactly once. - #[cfg(feature = "transparent-inputs")] - for so in unused_transparent_outputs.into_keys() { - if let StepOutputIndex::Change(i) = so.output_index() { - if step_results[so.step_index()].0.balance().proposed_change()[i].is_ephemeral() { - return Err(ProposalError::EphemeralOutputLeftUnspent(so).into()); - } - } - } - - let created = time::OffsetDateTime::now_utc(); - - let mut transactions = Vec::with_capacity(step_results.len()); - let mut txids = Vec::with_capacity(step_results.len()); - #[allow(unused_variables)] - for (_, step_result) in step_results.iter() { - let tx = step_result.build_result.transaction(); - transactions.push(SentTransaction::new( - tx, - created, - proposal.min_target_height(), - account_id, - &step_result.outputs, - step_result.fee_amount, - #[cfg(feature = "transparent-inputs")] - &step_result.utxos_spent, - )); - txids.push(tx.txid()); - } - - wallet_db - .store_transactions_to_be_sent(&transactions) - .map_err(Error::DataSource)?; - - Ok(NonEmpty::from_vec(txids).expect("proposal.steps is NonEmpty")) -} - #[derive(Debug, Clone)] enum BuildRecipient { External { @@ -2882,5 +2781,7 @@ where &proposal, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, ) } From f89bce687c9201a2235ee4da5eea2ef86c807384 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:37:43 +0000 Subject: [PATCH 6/8] Add test coverage for custom expiry_delta values --- zcash_client_backend/src/data_api/testing.rs | 33 +++++ .../src/data_api/testing/pool.rs | 117 ++++++++++++++++++ zcash_client_sqlite/Cargo.toml | 2 +- zcash_client_sqlite/src/testing/pool.rs | 7 ++ zcash_client_sqlite/src/wallet/sapling.rs | 5 + 5 files changed, 163 insertions(+), 1 deletion(-) diff --git a/zcash_client_backend/src/data_api/testing.rs b/zcash_client_backend/src/data_api/testing.rs index b065fac5af..533fea994c 100644 --- a/zcash_client_backend/src/data_api/testing.rs +++ b/zcash_client_backend/src/data_api/testing.rs @@ -1205,6 +1205,39 @@ where ) } + /// Invokes [`create_proposed_transactions`] with the given arguments and a custom + /// expiry delta. + /// + /// The `expiry_delta` parameter specifies the number of blocks after + /// `proposal.min_target_height()` when the transaction(s) expire. + #[cfg(feature = "non-standard-fees")] + #[allow(clippy::type_complexity)] + pub fn create_proposed_transactions_with_expiry_delta( + &mut self, + usk: &UnifiedSpendingKey, + ovk_policy: OvkPolicy, + proposal: &Proposal, + expiry_delta: Option, + ) -> Result, super::wallet::CreateErrT> + where + FeeRuleT: FeeRule, + { + let prover = LocalTxProver::bundled(); + let network = self.network().clone(); + create_proposed_transactions( + self.wallet_mut(), + &network, + &prover, + &prover, + &SpendingKeys::from_unified_spending_key(usk.clone()), + ovk_policy, + proposal, + #[cfg(feature = "unstable")] + None, + expiry_delta, + ) + } + /// Invokes [`create_pczt_from_proposal`] with the given arguments. /// /// [`create_pczt_from_proposal`]: super::wallet::create_pczt_from_proposal diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index fd843eb76a..c6b4c7ce23 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -350,6 +350,123 @@ pub fn send_single_step_proposed_transfer( ); } +/// Tests sending funds with a custom expiry delta. +/// +/// The test: +/// - Adds funds to the wallet in a single note. +/// - Constructs a request to spend part of that balance. +/// - Builds the transaction with a custom expiry delta. +/// - Verifies that the transaction's expiry height matches target_height + custom_delta. +#[cfg(feature = "non-standard-fees")] +pub fn send_single_step_proposed_transfer_with_custom_expiry( + dsf: impl DataStoreFactory, + cache: impl TestCache, +) { + use super::pool::dsl::TestDsl; + use zcash_primitives::transaction::builder::DEFAULT_TX_EXPIRY_DELTA; + + let mut st = TestDsl::with_sapling_birthday_account(dsf, cache).build::(); + + // Add funds to the wallet in a single note + let (h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000)); + + let to_extsk = T::sk(&[0xf5; 32]); + let to: Address = T::sk_default_address(&to_extsk); + let request = zip321::TransactionRequest::new(vec![Payment::without_memo( + to.to_zcash_address(st.network()), + Zatoshis::const_from_u64(10000), + )]) + .unwrap(); + + let fee_rule = StandardFeeRule::Zip317; + let change_strategy = standard::SingleOutputChangeStrategy::new( + fee_rule, + None, + T::SHIELDED_PROTOCOL, + DustOutputPolicy::default(), + ); + let input_selector = GreedyInputSelector::new(); + + let account = st.get_account(); + let proposal = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request.clone(), + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + // Test with custom expiry delta (20 blocks instead of default 40) + let custom_delta = 20u32; + let create_proposed_result = st + .create_proposed_transactions_with_expiry_delta::( + account.usk(), + OvkPolicy::Sender, + &proposal, + Some(custom_delta), + ); + assert_matches!(&create_proposed_result, Ok(txids) if txids.len() == 1); + + let sent_tx_id = create_proposed_result.unwrap()[0]; + + // Verify that the sent transaction was stored with the custom expiry + let tx = st + .wallet() + .get_transaction(sent_tx_id) + .unwrap() + .expect("Created transaction was stored."); + + // Verify the expiry height is target_height + custom_delta + let expected_expiry = proposal.min_target_height() + custom_delta; + assert_eq!( + tx.expiry_height(), + expected_expiry, + "Transaction expiry height should be min_target_height ({}) + custom_delta ({}), got {}", + proposal.min_target_height(), + custom_delta, + tx.expiry_height() + ); + + // Also test with None (should use default expiry delta) + let proposal2 = st + .propose_transfer( + account.id(), + &input_selector, + &change_strategy, + request, + ConfirmationsPolicy::MIN, + ) + .unwrap(); + + let create_proposed_result2 = st + .create_proposed_transactions_with_expiry_delta::( + account.usk(), + OvkPolicy::Sender, + &proposal2, + None, + ); + assert_matches!(&create_proposed_result2, Ok(txids) if txids.len() == 1); + + let sent_tx_id2 = create_proposed_result2.unwrap()[0]; + let tx2 = st + .wallet() + .get_transaction(sent_tx_id2) + .unwrap() + .expect("Created transaction was stored."); + + // Verify the expiry height uses the default delta + let expected_default_expiry = proposal2.min_target_height() + DEFAULT_TX_EXPIRY_DELTA; + assert_eq!( + tx2.expiry_height(), + expected_default_expiry, + "Transaction with None expiry_delta should use default ({}), got {}", + DEFAULT_TX_EXPIRY_DELTA, + tx2.expiry_height() + ); +} + #[derive(Clone, Copy, Debug, PartialEq)] struct ConfirmationStep { i: u32, diff --git a/zcash_client_sqlite/Cargo.toml b/zcash_client_sqlite/Cargo.toml index bf63f22da3..faf00c46ff 100644 --- a/zcash_client_sqlite/Cargo.toml +++ b/zcash_client_sqlite/Cargo.toml @@ -108,7 +108,7 @@ zcash_note_encryption.workspace = true zcash_proofs = { workspace = true, features = ["bundled-prover"] } zcash_primitives = { workspace = true, features = ["test-dependencies", "non-standard-fees"] } zcash_protocol = { workspace = true, features = ["local-consensus"] } -zcash_client_backend = { workspace = true, features = ["test-dependencies", "unstable-serialization", "unstable-spanning-tree"] } +zcash_client_backend = { workspace = true, features = ["test-dependencies", "unstable-serialization", "unstable-spanning-tree", "non-standard-fees"] } zcash_address = { workspace = true, features = ["test-dependencies"] } zip321 = { workspace = true } diff --git a/zcash_client_sqlite/src/testing/pool.rs b/zcash_client_sqlite/src/testing/pool.rs index b0e52263e3..37ec6b19c1 100644 --- a/zcash_client_sqlite/src/testing/pool.rs +++ b/zcash_client_sqlite/src/testing/pool.rs @@ -37,6 +37,13 @@ pub(crate) fn send_single_step_proposed_transfer() { ) } +pub(crate) fn send_single_step_proposed_transfer_with_custom_expiry() { + zcash_client_backend::data_api::testing::pool::send_single_step_proposed_transfer_with_custom_expiry::( + TestDbFactory::default(), + BlockCache::new(), + ) +} + pub(crate) fn spend_max_spendable_single_step_proposed_transfer() { zcash_client_backend::data_api::testing::pool::spend_max_spendable_single_step_proposed_transfer::< T, diff --git a/zcash_client_sqlite/src/wallet/sapling.rs b/zcash_client_sqlite/src/wallet/sapling.rs index 318a353390..b37784d576 100644 --- a/zcash_client_sqlite/src/wallet/sapling.rs +++ b/zcash_client_sqlite/src/wallet/sapling.rs @@ -433,6 +433,11 @@ pub(crate) mod tests { testing::pool::send_single_step_proposed_transfer::() } + #[test] + fn send_single_step_proposed_transfer_with_custom_expiry() { + testing::pool::send_single_step_proposed_transfer_with_custom_expiry::() + } + #[test] fn spend_max_spendable_single_step_proposed_transfer() { testing::pool::spend_max_spendable_single_step_proposed_transfer::() From dd16d7487535048a1c842b430c64c1e251c8e5b3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:44:54 +0000 Subject: [PATCH 7/8] Add test coverage for custom expiry_delta values - Add create_proposed_transactions_with_expiry_delta helper in testing.rs - Add send_single_step_proposed_transfer_with_custom_expiry test in pool.rs - Enable non-standard-fees feature in zcash_client_sqlite dev-dependencies - Test verifies custom expiry delta (20 blocks) produces correct expiry height --- .../src/data_api/testing/pool.rs | 49 ++++--------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index c6b4c7ce23..159d5b74ef 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -363,12 +363,11 @@ pub fn send_single_step_proposed_transfer_with_custom_expiry(); // Add funds to the wallet in a single note - let (h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000)); + let (_h, _, _) = st.add_a_single_note_checking_balance(Zatoshis::const_from_u64(60000)); let to_extsk = T::sk(&[0xf5; 32]); let to: Address = T::sk_default_address(&to_extsk); @@ -393,7 +392,7 @@ pub fn send_single_step_proposed_transfer_with_custom_expiry( - account.usk(), - OvkPolicy::Sender, - &proposal2, - None, - ); - assert_matches!(&create_proposed_result2, Ok(txids) if txids.len() == 1); - - let sent_tx_id2 = create_proposed_result2.unwrap()[0]; - let tx2 = st - .wallet() - .get_transaction(sent_tx_id2) - .unwrap() - .expect("Created transaction was stored."); - - // Verify the expiry height uses the default delta - let expected_default_expiry = proposal2.min_target_height() + DEFAULT_TX_EXPIRY_DELTA; - assert_eq!( - tx2.expiry_height(), - expected_default_expiry, - "Transaction with None expiry_delta should use default ({}), got {}", - DEFAULT_TX_EXPIRY_DELTA, - tx2.expiry_height() + // Verify it's different from the default (40 blocks) + let default_expiry = proposal.min_target_height() + 40u32; + assert_ne!( + tx.expiry_height(), + default_expiry, + "Custom expiry ({}) should differ from default (40)", + custom_delta ); } From 3bdde4d7bd4a4bc3671d711c1a7fc4728043198a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 02:46:31 +0000 Subject: [PATCH 8/8] Use DEFAULT_TX_EXPIRY_DELTA constant instead of magic number Address code review feedback: use the constant instead of hardcoded 40 --- zcash_client_backend/src/data_api/testing/pool.rs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index 159d5b74ef..6fedb336ad 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -363,6 +363,7 @@ pub fn send_single_step_proposed_transfer_with_custom_expiry(); @@ -428,13 +429,14 @@ pub fn send_single_step_proposed_transfer_with_custom_expiry