Skip to content

Commit d739b42

Browse files
schellclaude
andcommitted
zcash_client_backend, zcash_client_sqlite: create transparent change outputs
Adds `WalletWrite::reserve_next_n_internal_addresses`, which reserves BIP 44 internal-scope (change) transparent addresses parallel to the existing ephemeral address reservation API, and implements it for `WalletDb` using the existing scope-generic reservation machinery, subject to the internal-scope gap limit. No migration is required: internal-scope gap addresses are already generated at account creation. `create_proposed_transactions` now realizes non-ephemeral transparent change values from a proposal by reserving internal-scope addresses, adding the corresponding transparent outputs to the transaction, and recording them via the existing `Recipient::InternalTransparent` variant; the PCZT construction and extraction paths are extended accordingly. The new `PcztRecipient::InternalTransparent` variant is appended to the end of the enum to preserve the encoded representation of the prior variants. Includes end-to-end coverage of the t->t-with-change lifecycle (proposal, serialization round trip, transaction creation, internal-scope address exposure, and post-mining spendability of the change output), of the exact-balance no-change case, and of internal-scope gap limit enforcement. Part of #1839. Co-Authored-By: Claude <noreply@anthropic.com>
1 parent dc4769e commit d739b42

8 files changed

Lines changed: 495 additions & 0 deletions

File tree

zcash_client_backend/CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,13 @@ workspace.
115115
such a change value is represented by the existing transparent `valuePool`
116116
with `isEphemeral` unset; decoding this combination previously returned
117117
`ProposalDecodingError::InvalidChangeRecipient`.
118+
- `zcash_client_backend::data_api::WalletWrite::reserve_next_n_internal_addresses`
119+
(behind `transparent-inputs`): reserves the next `n` available
120+
internal-scope (change) transparent addresses for an account, parallel to
121+
the existing `reserve_next_n_ephemeral_addresses` method.
122+
`create_proposed_transactions` uses this to allocate the recipient
123+
address(es) for non-ephemeral transparent change outputs, which it records
124+
using the existing `Recipient::InternalTransparent` variant.
118125
- `zcash_client_backend::data_api::NoteCommitmentTree`
119126
- `zcash_client_backend::data_api::SentTransactionOutput::note_commitment_tree`
120127
- `zcash_client_backend::proto::proposal::ValuePool::Ironwood`, so that a proposal

zcash_client_backend/src/data_api.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3604,6 +3604,38 @@ pub trait WalletWrite: WalletRead {
36043604
)
36053605
}
36063606

3607+
/// Reserves the next `n` available internal-scope (change) transparent addresses for
3608+
/// the given account, as described in [BIP 44] under the `change` path level. This
3609+
/// cannot be undone, so as far as possible, errors associated with transaction
3610+
/// construction should have been reported before calling this method.
3611+
///
3612+
/// Internal-scope transparent addresses are used to receive change for transactions
3613+
/// having fully-transparent value flows, when the change strategy in use is configured
3614+
/// with [`TransparentChangePolicy::TransparentChangeAllowed`].
3615+
///
3616+
/// To ensure that funds sent to internal-scope addresses are recoverable, implementations
3617+
/// of this method should observe a gap limit as described in [BIP 44]; change addresses
3618+
/// receive funds immediately upon reservation, so a smaller gap limit than the one used
3619+
/// for external addresses may be observed.
3620+
///
3621+
/// Returns an error if there is insufficient space within the gap limit to allocate
3622+
/// the given number of addresses, or if the account identifier does not correspond
3623+
/// to a known account.
3624+
///
3625+
/// [BIP 44]: https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki
3626+
/// [`TransparentChangePolicy::TransparentChangeAllowed`]: crate::fees::TransparentChangePolicy::TransparentChangeAllowed
3627+
#[cfg(feature = "transparent-inputs")]
3628+
fn reserve_next_n_internal_addresses(
3629+
&mut self,
3630+
_account_id: Self::AccountId,
3631+
_n: usize,
3632+
) -> Result<Vec<(TransparentAddress, TransparentAddressMetadata)>, Self::Error> {
3633+
unimplemented!(
3634+
"WalletWrite::reserve_next_n_internal_addresses must be overridden for wallets to \
3635+
create transactions that produce transparent change"
3636+
)
3637+
}
3638+
36073639
/// Updates the wallet backend with respect to the status of a specific transaction, from the
36083640
/// perspective of the main chain.
36093641
///

zcash_client_backend/src/data_api/testing.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3402,6 +3402,15 @@ impl WalletWrite for MockWalletDb {
34023402
Err(())
34033403
}
34043404

3405+
#[cfg(feature = "transparent-inputs")]
3406+
fn reserve_next_n_internal_addresses(
3407+
&mut self,
3408+
_account_id: Self::AccountId,
3409+
_n: usize,
3410+
) -> Result<Vec<(TransparentAddress, TransparentAddressMetadata)>, Self::Error> {
3411+
Err(())
3412+
}
3413+
34053414
fn set_transaction_status(
34063415
&mut self,
34073416
_txid: TxId,

zcash_client_backend/src/data_api/testing/transparent.rs

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2441,3 +2441,293 @@ where
24412441
.expect("AllFunds gather should succeed");
24422442
assert_eq!(all.len(), n_dust);
24432443
}
2444+
2445+
/// Tests that [`WalletWrite::reserve_next_n_internal_addresses`] reserves sequential
2446+
/// internal-scope (change) addresses, that reservation observes the internal-scope gap
2447+
/// limit, and that internal-scope reservations are accounted independently of
2448+
/// ephemeral-scope reservations.
2449+
///
2450+
/// This test expects the data store to be configured with the default gap limits, under
2451+
/// which the internal-scope gap limit is 5.
2452+
///
2453+
/// The `is_reached_gap_limit` predicate must return `true` if and only if the provided
2454+
/// error is the backend's exact "reached gap limit" error variant, the scope reported by
2455+
/// that error is [`TransparentKeyScope::INTERNAL`], and the address index reported by that
2456+
/// error equals the provided expected index. It must not match any other error. (A
2457+
/// predicate is used because this test cannot name the backend's concrete error type
2458+
/// without inverting the crate dependency.)
2459+
pub fn reserve_next_n_internal_addresses_gap_limit<DSF>(
2460+
dsf: DSF,
2461+
cache: impl TestCache,
2462+
is_reached_gap_limit: impl Fn(
2463+
&<DSF::DataStore as crate::data_api::WalletRead>::Error,
2464+
DSF::AccountId,
2465+
u32,
2466+
) -> bool,
2467+
) where
2468+
DSF: DataStoreFactory,
2469+
{
2470+
use std::collections::HashSet;
2471+
use transparent::keys::NonHardenedChildIndex;
2472+
2473+
let mut st = TestBuilder::new()
2474+
.with_data_store_factory(dsf)
2475+
.with_block_cache(cache)
2476+
.with_account_from_sapling_activation(BlockHash([0; 32]))
2477+
.build();
2478+
let account_id = st.test_account().cloned().unwrap().id();
2479+
2480+
// Seed the chain so that a chain height is known; address reservation records the
2481+
// exposure height of each reserved address.
2482+
let not_our_key = ExtendedSpendingKey::master(&[]).to_diversifiable_full_viewing_key();
2483+
let not_our_value = Zatoshis::const_from_u64(10000);
2484+
let (start_height, _, _) =
2485+
st.generate_next_block(&not_our_key, AddressType::DefaultExternal, not_our_value);
2486+
st.scan_cached_blocks(start_height, 1);
2487+
2488+
// Reserving internal addresses yields distinct, sequentially-indexed addresses derived
2489+
// under the internal (change) key scope.
2490+
let reserved = st
2491+
.wallet_mut()
2492+
.reserve_next_n_internal_addresses(account_id, 3)
2493+
.unwrap();
2494+
assert_eq!(reserved.len(), 3);
2495+
for (i, (_, meta)) in reserved.iter().enumerate() {
2496+
assert_eq!(meta.scope(), Some(TransparentKeyScope::INTERNAL));
2497+
assert_eq!(
2498+
meta.address_index(),
2499+
Some(NonHardenedChildIndex::const_from_index(
2500+
u32::try_from(i).unwrap()
2501+
)),
2502+
);
2503+
}
2504+
// None of the reserved addresses have received funds, so the gap cannot advance: with
2505+
// the default internal-scope gap limit of 5, only two more addresses may be reserved.
2506+
// Reservation continues at the next sequential indices, so the returned addresses are
2507+
// distinct from those of the first batch.
2508+
let more = st
2509+
.wallet_mut()
2510+
.reserve_next_n_internal_addresses(account_id, 2)
2511+
.unwrap();
2512+
assert_eq!(more.len(), 2);
2513+
for (i, (_, meta)) in more.iter().enumerate() {
2514+
assert_eq!(meta.scope(), Some(TransparentKeyScope::INTERNAL));
2515+
assert_eq!(
2516+
meta.address_index(),
2517+
Some(NonHardenedChildIndex::const_from_index(
2518+
u32::try_from(reserved.len() + i).unwrap()
2519+
)),
2520+
);
2521+
}
2522+
let unique_addrs = reserved
2523+
.iter()
2524+
.chain(more.iter())
2525+
.map(|(a, _)| *a)
2526+
.collect::<HashSet<_>>();
2527+
assert_eq!(unique_addrs.len(), reserved.len() + more.len());
2528+
2529+
assert_matches!(
2530+
st.wallet_mut().reserve_next_n_internal_addresses(account_id, 1),
2531+
Err(e) if is_reached_gap_limit(&e, account_id, 5)
2532+
);
2533+
2534+
// Internal-scope reservations must not consume ephemeral-scope gap space.
2535+
let ephemeral = st
2536+
.wallet_mut()
2537+
.reserve_next_n_ephemeral_addresses(account_id, 1)
2538+
.unwrap();
2539+
assert_eq!(ephemeral[0].1.scope(), Some(TransparentKeyScope::EPHEMERAL),);
2540+
assert_eq!(
2541+
ephemeral[0].1.address_index(),
2542+
Some(NonHardenedChildIndex::const_from_index(0)),
2543+
);
2544+
}
2545+
2546+
/// Tests the full lifecycle of a t->t transfer with transparent change: a change strategy
2547+
/// configured with [`TransparentChangePolicy::TransparentChangeAllowed`] must propose a
2548+
/// non-ephemeral transparent change output, and transaction creation must send that change
2549+
/// to a previously-unexposed internal-scope (change) transparent address of the spending
2550+
/// account, where it is recorded as received and becomes spendable once mined.
2551+
///
2552+
/// [`TransparentChangePolicy::TransparentChangeAllowed`]: crate::fees::TransparentChangePolicy::TransparentChangeAllowed
2553+
pub fn propose_t2t_with_transparent_change<DSF>(dsf: DSF, cache: impl TestCache)
2554+
where
2555+
DSF: DataStoreFactory,
2556+
{
2557+
use std::convert::Infallible;
2558+
2559+
use crate::{
2560+
fees::{ChangeValue, TransparentChangePolicy},
2561+
wallet::{Exposure, OvkPolicy},
2562+
};
2563+
2564+
let utxo_value = Zatoshis::const_from_u64(100_000);
2565+
let transfer_amount = Zatoshis::const_from_u64(40_000);
2566+
let (mut st, account, outpoint) = setup_transparent_only_account(dsf, cache, utxo_value);
2567+
2568+
let network = *st.network();
2569+
let request = t2t_request(&network, transfer_amount);
2570+
2571+
let input_selector = GreedyInputSelector::new();
2572+
let change_strategy = standard::SingleOutputChangeStrategy::new(
2573+
StandardFeeRule::Zip317,
2574+
None,
2575+
ShieldedPool::Sapling,
2576+
DustOutputPolicy::default(),
2577+
)
2578+
.with_transparent_change_policy(TransparentChangePolicy::TransparentChangeAllowed);
2579+
2580+
let proposal = st
2581+
.propose_transfer_with_policy(
2582+
account.id(),
2583+
&input_selector,
2584+
&change_strategy,
2585+
request,
2586+
ConfirmationsPolicy::MIN,
2587+
&SpendPolicy::default().with_transparent(TransparentSpendPolicy::any_account_addr()),
2588+
)
2589+
.expect("t->t proposal with transparent change must succeed");
2590+
2591+
// A t->t transfer with non-ephemeral transparent change is a single step.
2592+
assert_eq!(proposal.steps().len(), 1);
2593+
let step = &proposal.steps().head;
2594+
assert_eq!(step.transparent_inputs().len(), 1);
2595+
assert_eq!(step.transparent_inputs()[0].outpoint(), &outpoint);
2596+
assert!(step.shielded_inputs().is_none());
2597+
2598+
// Under ZIP 317, one P2PKH input and two P2PKH outputs (the payment plus the change
2599+
// output) require `5_000 * max(1, 2) = 10_000` zats in fees.
2600+
let expected_fee = Zatoshis::const_from_u64(10_000);
2601+
let expected_change = ((utxo_value - transfer_amount).unwrap() - expected_fee).unwrap();
2602+
assert_eq!(step.balance().fee_required(), expected_fee);
2603+
assert_eq!(
2604+
step.balance().proposed_change(),
2605+
[ChangeValue::transparent(expected_change)],
2606+
);
2607+
assert!(!step.balance().proposed_change()[0].is_ephemeral());
2608+
2609+
// A proposal containing a transparent change output must survive a serialization
2610+
// round trip.
2611+
super::check_proposal_serialization_roundtrip(&network, st.wallet(), &proposal);
2612+
2613+
// Creating the transaction should reserve an internal-scope address for the change.
2614+
let txids = st
2615+
.create_proposed_transactions::<Infallible, _, Infallible, _>(
2616+
account.usk(),
2617+
OvkPolicy::Sender,
2618+
&proposal,
2619+
)
2620+
.expect("transaction creation must succeed");
2621+
assert_eq!(txids.len(), 1);
2622+
let txid = txids.head;
2623+
2624+
// The transaction must be fully transparent, with exactly the payment and change outputs.
2625+
let tx = st
2626+
.wallet()
2627+
.get_transaction(txid)
2628+
.unwrap()
2629+
.expect("the created transaction is retrievable");
2630+
assert!(tx.sapling_bundle().is_none());
2631+
#[cfg(feature = "orchard")]
2632+
assert!(tx.orchard_bundle().is_none());
2633+
let bundle = tx
2634+
.transparent_bundle()
2635+
.expect("the transaction has a transparent bundle");
2636+
assert_eq!(bundle.vin.len(), 1);
2637+
assert_eq!(bundle.vout.len(), 2);
2638+
2639+
// Identify the change output as the output that does not pay the external recipient.
2640+
let payment_recipient = TransparentAddress::PublicKeyHash([7u8; 20]);
2641+
let change_outputs: Vec<_> = bundle
2642+
.vout
2643+
.iter()
2644+
.filter(|out| out.recipient_address() != Some(payment_recipient))
2645+
.collect();
2646+
assert_eq!(change_outputs.len(), 1);
2647+
let change_output = change_outputs[0];
2648+
assert_eq!(change_output.value(), expected_change);
2649+
let change_address = change_output
2650+
.recipient_address()
2651+
.expect("the change output pays a standard P2PKH address");
2652+
2653+
// The change address must be an internal-scope (change) address of the spending account,
2654+
// exposed at the current chain height by having been reserved for change.
2655+
let receivers = st
2656+
.wallet()
2657+
.get_transparent_receivers(account.id(), true, false)
2658+
.unwrap();
2659+
let change_meta = receivers
2660+
.get(&change_address)
2661+
.expect("the change address belongs to the spending account");
2662+
assert_eq!(change_meta.scope(), Some(TransparentKeyScope::INTERNAL));
2663+
let cur_height = st.wallet().chain_height().unwrap().unwrap();
2664+
assert_matches!(
2665+
change_meta.exposure(),
2666+
Exposure::Exposed { at_height, .. } if at_height == cur_height
2667+
);
2668+
2669+
// Mine the transaction; the change output should then be spendable at the change address.
2670+
let (h, _) = st.generate_next_block_including(txid);
2671+
st.scan_cached_blocks(h, 1);
2672+
2673+
let mut expected_balance = Balance::ZERO;
2674+
expected_balance
2675+
.add_spendable_value(expected_change)
2676+
.unwrap();
2677+
check_balance::<DSF>(
2678+
&st,
2679+
&account,
2680+
&change_address,
2681+
ConfirmationsPolicy::MIN,
2682+
&expected_balance,
2683+
);
2684+
}
2685+
2686+
/// Tests that when a fully-transparent transaction balances exactly (input value equals
2687+
/// payments plus the minimum fee), no transparent change output is produced even when the
2688+
/// change strategy is configured with [`TransparentChangePolicy::TransparentChangeAllowed`].
2689+
///
2690+
/// [`TransparentChangePolicy::TransparentChangeAllowed`]: crate::fees::TransparentChangePolicy::TransparentChangeAllowed
2691+
pub fn propose_t2t_transparent_change_exact_match<DSF>(dsf: DSF, cache: impl TestCache)
2692+
where
2693+
DSF: DataStoreFactory,
2694+
{
2695+
use crate::fees::TransparentChangePolicy;
2696+
2697+
// Under ZIP 317, one P2PKH input and one P2PKH output require the minimum fee of
2698+
// 10_000 zats, so a 50_000-zat UTXO exactly covers a 40_000-zat payment.
2699+
let utxo_value = Zatoshis::const_from_u64(50_000);
2700+
let transfer_amount = Zatoshis::const_from_u64(40_000);
2701+
let (mut st, account, _outpoint) = setup_transparent_only_account(dsf, cache, utxo_value);
2702+
2703+
let network = *st.network();
2704+
let request = t2t_request(&network, transfer_amount);
2705+
2706+
let input_selector = GreedyInputSelector::new();
2707+
let change_strategy = standard::SingleOutputChangeStrategy::new(
2708+
StandardFeeRule::Zip317,
2709+
None,
2710+
ShieldedPool::Sapling,
2711+
DustOutputPolicy::default(),
2712+
)
2713+
.with_transparent_change_policy(TransparentChangePolicy::TransparentChangeAllowed);
2714+
2715+
let proposal = st
2716+
.propose_transfer_with_policy(
2717+
account.id(),
2718+
&input_selector,
2719+
&change_strategy,
2720+
request,
2721+
ConfirmationsPolicy::MIN,
2722+
&SpendPolicy::default().with_transparent(TransparentSpendPolicy::any_account_addr()),
2723+
)
2724+
.expect("exactly-balanced t->t proposal must succeed");
2725+
2726+
assert_eq!(proposal.steps().len(), 1);
2727+
let step = &proposal.steps().head;
2728+
assert_eq!(
2729+
step.balance().fee_required(),
2730+
Zatoshis::const_from_u64(10_000),
2731+
);
2732+
assert_eq!(step.balance().proposed_change(), []);
2733+
}

0 commit comments

Comments
 (0)