diff --git a/zcash_client_backend/CHANGELOG.md b/zcash_client_backend/CHANGELOG.md index e65316c787..4efc9a4f02 100644 --- a/zcash_client_backend/CHANGELOG.md +++ b/zcash_client_backend/CHANGELOG.md @@ -35,6 +35,12 @@ workspace. instead of a `ChangeStrategy`. ### 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..533fea994c 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,41 @@ where proposal, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, + ) + } + + /// 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, ) } diff --git a/zcash_client_backend/src/data_api/testing/pool.rs b/zcash_client_backend/src/data_api/testing/pool.rs index fd843eb76a..6fedb336ad 100644 --- a/zcash_client_backend/src/data_api/testing/pool.rs +++ b/zcash_client_backend/src/data_api/testing/pool.rs @@ -350,6 +350,96 @@ 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, + 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() + ); + + // Verify it's different from the default + let default_expiry = proposal.min_target_height() + DEFAULT_TX_EXPIRY_DELTA; + assert_ne!( + tx.expiry_height(), + default_expiry, + "Custom expiry ({}) should differ from default ({})", + custom_delta, + DEFAULT_TX_EXPIRY_DELTA + ); +} + #[derive(Clone, Copy, Debug, PartialEq)] struct ConfirmationStep { i: u32, diff --git a/zcash_client_backend/src/data_api/wallet.rs b/zcash_client_backend/src/data_api/wallet.rs index 56ef8f87bf..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, @@ -1046,6 +1058,8 @@ where &mut unused_transparent_outputs, #[cfg(feature = "unstable")] proposed_version, + #[cfg(feature = "non-standard-fees")] + expiry_delta, )?; step_results.push((step, step_result)); } @@ -1197,6 +1211,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 +1356,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 +1803,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 +1826,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 @@ -2024,7 +2049,9 @@ where #[cfg(feature = "transparent-inputs")] unused_transparent_outputs, #[cfg(feature = "unstable")] - None, + None, // proposed_version + #[cfg(feature = "non-standard-fees")] + None, // expiry_delta )?; // Build the transaction with the specified fee rule @@ -2754,5 +2781,7 @@ where &proposal, #[cfg(feature = "unstable")] None, + #[cfg(feature = "non-standard-fees")] + None, ) } 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::() 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..9b3526f163 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> { @@ -1805,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); + } }