Skip to content
6 changes: 6 additions & 0 deletions zcash_client_backend/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<u32>` 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
Expand Down
37 changes: 37 additions & 0 deletions zcash_client_backend/src/data_api/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -985,6 +985,8 @@ where
&proposal,
#[cfg(feature = "unstable")]
None,
#[cfg(feature = "non-standard-fees")]
None,
)
}

Expand Down Expand Up @@ -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<InputsErrT, FeeRuleT, ChangeErrT, N>(
&mut self,
usk: &UnifiedSpendingKey,
ovk_policy: OvkPolicy,
proposal: &Proposal<FeeRuleT, N>,
expiry_delta: Option<u32>,
) -> Result<NonEmpty<TxId>, super::wallet::CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>>
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,
)
}

Expand Down
90 changes: 90 additions & 0 deletions zcash_client_backend/src/data_api/testing/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,96 @@ pub fn send_single_step_proposed_transfer<T: ShieldedPoolTester>(
);
}

/// 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<T: ShieldedPoolTester>(
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::<T>();

// 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::<Infallible, _, Infallible, _>(
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,
Expand Down
31 changes: 30 additions & 1 deletion zcash_client_backend/src/data_api/wallet.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>(
Expand All @@ -1010,6 +1021,7 @@ pub fn create_proposed_transactions<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeEr
ovk_policy: OvkPolicy,
proposal: &Proposal<FeeRuleT, N>,
#[cfg(feature = "unstable")] proposed_version: Option<TxVersion>,
#[cfg(feature = "non-standard-fees")] expiry_delta: Option<u32>,
) -> Result<NonEmpty<TxId>, CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>>
where
DbT: WalletWrite + WalletCommitmentTrees,
Expand Down Expand Up @@ -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));
}
Expand Down Expand Up @@ -1197,6 +1211,7 @@ fn build_proposed_transaction<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N>
(TransparentAddress, OutPoint),
>,
#[cfg(feature = "unstable")] proposed_version: Option<TxVersion>,
#[cfg(feature = "non-standard-fees")] expiry_delta: Option<u32>,
) -> Result<
BuildState<'static, ParamsT, DbT::AccountId>,
CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>,
Expand Down Expand Up @@ -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"))]
Expand Down Expand Up @@ -1781,6 +1803,7 @@ fn create_proposed_transaction<DbT, ParamsT, InputsErrT, FeeRuleT, ChangeErrT, N
(TransparentAddress, OutPoint),
>,
#[cfg(feature = "unstable")] proposed_version: Option<TxVersion>,
#[cfg(feature = "non-standard-fees")] expiry_delta: Option<u32>,
) -> Result<
StepResult<<DbT as WalletRead>::AccountId>,
CreateErrT<DbT, InputsErrT, FeeRuleT, ChangeErrT, N>,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2754,5 +2781,7 @@ where
&proposal,
#[cfg(feature = "unstable")]
None,
#[cfg(feature = "non-standard-fees")]
None,
)
}
2 changes: 1 addition & 1 deletion zcash_client_sqlite/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
7 changes: 7 additions & 0 deletions zcash_client_sqlite/src/testing/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,13 @@ pub(crate) fn send_single_step_proposed_transfer<T: ShieldedPoolTester>() {
)
}

pub(crate) fn send_single_step_proposed_transfer_with_custom_expiry<T: ShieldedPoolTester>() {
zcash_client_backend::data_api::testing::pool::send_single_step_proposed_transfer_with_custom_expiry::<T>(
TestDbFactory::default(),
BlockCache::new(),
)
}

pub(crate) fn spend_max_spendable_single_step_proposed_transfer<T: ShieldedPoolTester>() {
zcash_client_backend::data_api::testing::pool::spend_max_spendable_single_step_proposed_transfer::<
T,
Expand Down
5 changes: 5 additions & 0 deletions zcash_client_sqlite/src/wallet/sapling.rs
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,11 @@ pub(crate) mod tests {
testing::pool::send_single_step_proposed_transfer::<SaplingPoolTester>()
}

#[test]
fn send_single_step_proposed_transfer_with_custom_expiry() {
testing::pool::send_single_step_proposed_transfer_with_custom_expiry::<SaplingPoolTester>()
}

#[test]
fn spend_max_spendable_single_step_proposed_transfer() {
testing::pool::spend_max_spendable_single_step_proposed_transfer::<SaplingPoolTester>()
Expand Down
8 changes: 8 additions & 0 deletions zcash_primitives/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading