@@ -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