From 7dc3dea1b459d9c53fbb522084aac4cb5548e807 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 15:21:13 +0200 Subject: [PATCH 1/9] Spend transparent funds in z_sendmany `z_sendmany` passed `SpendPolicy::default()` to `propose_transfer`, which permits the shielded pools and no transparent spending, so no proposal could ever select a transparent input. Transparent-to-transparent transfers were therefore impossible, and the `AllowFullyTransparent` privacy policy was unreachable even though `enforce_privacy_policy` already implemented it. Derive the spend policy from the source address instead. A bare transparent `fromaddress` now permits transparent spending restricted to that one address's UTXOs (`TransparentSpendPolicy::from_one_address`), so a shielded send can never silently reach into transparent funds, and the named address is not linked to the account's other transparent receivers. Every other source stays shielded-only. Coinbase UTXOs remain excluded, since consensus requires them to be spent to a single shielded output; that is still `z_shieldcoinbase`'s job. Allow transparent change, so a fully transparent send keeps its change in the transparent pool at an internal-scope BIP 44 address rather than sweeping it into Orchard. The change strategy only emits transparent change when the net flows are fully transparent, so shielded flows are unaffected. Three gaps had to be closed for this to work. All were latent, because nothing could reach them while transparent spending was impossible: - `get_account_for_address` compared the source against `list_addresses`, which yields unified addresses. A bare transparent receiver never compares equal to one, so a taddr `fromaddress` always failed with "no payment source found for address". Resolve it with `find_account_for_address`, which maps an address back to its account through the receivers. - `DbConnection` did not forward `InputSource::select_spendable_transparent_outputs` or `WalletWrite::reserve_next_n_internal_addresses`. Both have `unimplemented!()` defaults, so selecting transparent inputs and producing transparent change each panicked the wallet process rather than returning an error. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/database/connection.rs | 35 +++ .../json_rpc/methods/z_send_many.rs | 242 ++++++++++++++++-- .../src/components/json_rpc/payments.rs | 17 ++ 3 files changed, 274 insertions(+), 20 deletions(-) diff --git a/zallet-core/src/components/database/connection.rs b/zallet-core/src/components/database/connection.rs index 4e9860e0..004edb67 100644 --- a/zallet-core/src/components/database/connection.rs +++ b/zallet-core/src/components/database/connection.rs @@ -18,6 +18,7 @@ use zcash_client_backend::{ error::{FindAccountForAddressError, RewindError}, wallet::{ConfirmationsPolicy, TargetHeight}, }, + fees::StandardFeeRule, keys::{UnifiedAddressRequest, UnifiedFullViewingKey, UnifiedSpendingKey}, wallet::{Note, ReceivedNote, TransparentAddressMetadata, WalletTransparentOutput}, }; @@ -500,6 +501,32 @@ impl InputSource for DbConnection { }) } + #[allow(clippy::too_many_arguments)] + fn select_spendable_transparent_outputs( + &self, + account: Self::AccountId, + target_height: TargetHeight, + confirmations_policy: ConfirmationsPolicy, + output_filter: CoinbaseFilter, + address_allow_list: Option<&[TransparentAddress]>, + target_value: TargetValue, + max_inputs: usize, + fee_rule: &StandardFeeRule, + ) -> Result>, Self::Error> { + self.with(|db_data| { + db_data.select_spendable_transparent_outputs( + account, + target_height, + confirmations_policy, + output_filter, + address_allow_list, + target_value, + max_inputs, + fee_rule, + ) + }) + } + fn get_account_metadata( &self, account: Self::AccountId, @@ -661,6 +688,14 @@ impl WalletWrite for DbConnection { self.with_mut(|mut db_data| db_data.reserve_next_n_ephemeral_addresses(account_id, n)) } + fn reserve_next_n_internal_addresses( + &mut self, + account_id: Self::AccountId, + n: usize, + ) -> Result, Self::Error> { + self.with_mut(|mut db_data| db_data.reserve_next_n_internal_addresses(account_id, n)) + } + fn set_transaction_status( &mut self, txid: zcash_protocol::TxId, diff --git a/zallet-core/src/components/json_rpc/methods/z_send_many.rs b/zallet-core/src/components/json_rpc/methods/z_send_many.rs index 56335581..e7ced65f 100644 --- a/zallet-core/src/components/json_rpc/methods/z_send_many.rs +++ b/zallet-core/src/components/json_rpc/methods/z_send_many.rs @@ -13,11 +13,14 @@ use zcash_client_backend::{ Account, wallet::{ ConfirmationsPolicy, create_proposed_transactions, - input_selection::{GreedyInputSelector, SpendPolicy}, + input_selection::{GreedyInputSelector, SpendPolicy, TransparentSpendPolicy}, propose_transfer, }, }, - fees::{DustOutputPolicy, StandardFeeRule, standard::MultiOutputChangeStrategy}, + fees::{ + DustOutputPolicy, StandardFeeRule, TransparentChangePolicy, + standard::MultiOutputChangeStrategy, + }, wallet::OvkPolicy, }; use zcash_client_sqlite::ReceivedNoteId; @@ -64,6 +67,45 @@ pub(super) const PARAM_FEE_DESC: &str = "If set, it must be null."; pub(super) const PARAM_PRIVACY_POLICY_DESC: &str = "Policy for what information leakage is acceptable."; +/// The sources of funds a transfer from `source` may draw upon. +/// +/// Spending from a bare transparent address draws only on that address's UTXOs: the funds are +/// already public, and confining selection to the named address avoids linking it to the +/// account's other transparent receivers. Every other source stays shielded-only, so a +/// shielded send can never silently reach into transparent funds. +/// +/// Coinbase UTXOs are excluded: `TransparentSpendPolicy` defaults to +/// `CoinbasePolicy::NonCoinbase`, and consensus requires coinbase to be spent to a single +/// shielded output, which is `z_shieldcoinbase`'s job. +/// +/// The privacy policy deliberately does not narrow this: the selector returns its best +/// proposal, and `enforce_privacy_policy` rejects it afterwards if it leaks more than the +/// caller permitted. +fn spend_policy_for(source: &Address) -> SpendPolicy { + match source { + Address::Transparent(taddr) => SpendPolicy::shielded_pools([]) + .with_transparent(TransparentSpendPolicy::from_one_address(*taddr)), + _ => SpendPolicy::default(), + } +} + +/// Whether change may be returned to the transparent pool. +/// +/// Permitted exactly when `spend_policy` can spend transparent funds in the first place, which +/// keeps a fully transparent send transparent end to end rather than sweeping its change into a +/// shielded pool. A shielded send therefore cannot acquire a transparent change output by this +/// route. +/// +/// The change strategy independently enforces the same thing (it emits transparent change only +/// when the transaction's net flows are fully transparent, i.e. it has no shielded input or +/// output at all), but that is its invariant, not ours. +fn transparent_change_policy_for(spend_policy: &SpendPolicy) -> TransparentChangePolicy { + match spend_policy.transparent() { + Some(_) => TransparentChangePolicy::TransparentChangeAllowed, + None => TransparentChangePolicy::ShieldChange, + } +} + #[allow(clippy::too_many_arguments)] pub(crate) async fn call( mut wallet: DbHandle, @@ -88,7 +130,7 @@ pub(crate) async fn call( let request = build_request(&amounts)?; - let account = match fromaddress.as_str() { + let (account, spend_policy) = match fromaddress.as_str() { // Select from the legacy transparent address pool. // TODO: Support this if we're going to. https://github.com/zcash/zallet/issues/138 "ANY_TADDR" => Err(LegacyCode::WalletAccountsUnsupported @@ -101,7 +143,9 @@ pub(crate) async fn call( ) })?; - get_account_for_address(wallet.as_ref(), &address) + let account = get_account_for_address(wallet.as_ref(), &address)?; + + Ok((account, spend_policy_for(&address))) } }?; @@ -198,19 +242,40 @@ pub(crate) async fn call( } } + let transparent_change_policy = transparent_change_policy_for(&spend_policy); + + // Where shielded change goes when the transaction has no shielded flows to infer a pool + // from. A transaction that does have shielded flows ignores this and keeps its change in + // the pool it is already using. + // + // This stays Orchard rather than Ironwood: the change strategy promotes it to Ironwood + // itself once NU6.3 is active (the turnstile forbids value from entering the Orchard + // pool, so change out of a purely transparent transaction has to land in Ironwood), and + // it does so against the transaction's target height, which is not known here. Naming + // Ironwood outright would instead send change to a pool that does not exist yet on a + // chain where NU6.3 has not activated. + let fallback_change_pool = ShieldedPool::Orchard; + + // Shielded change is split across several notes, per the wallet's note-management + // configuration, so the account keeps a usable set of denominations. + let split_policy = APP.config().note_management.split_policy(); + + // Change too small to be worth its own output is added to the fee instead. + let dust_output_policy = DustOutputPolicy::default(); + + // No memo is attached to change. A change memo would force the change into a shielded + // pool, since a transparent output cannot carry one. + let change_memo = None; + let change_strategy = MultiOutputChangeStrategy::new( StandardFeeRule::Zip317, - None, - ShieldedPool::Orchard, - DustOutputPolicy::default(), - APP.config().note_management.split_policy(), - ); - - // TODO: Once `zcash_client_backend` supports spending transparent coins arbitrarily, - // consider using the privacy policy here to avoid selecting incompatible funds. This - // would match what `zcashd` did more closely (though we might instead decide to let - // the selector return its best proposal and then continue to reject afterwards, as we - // currently do). + change_memo, + fallback_change_pool, + dust_output_policy, + split_policy, + ) + .with_transparent_change_policy(transparent_change_policy); + let input_selector = GreedyInputSelector::new(); let proposal = propose_transfer::<_, _, _, _, Infallible>( @@ -221,11 +286,7 @@ pub(crate) async fn call( &change_strategy, request, confirmations_policy, - // Zallet does not yet spend transparent funds in a general transfer; `ANY_TADDR` - // spending is rejected above. The default `SpendPolicy` permits every shielded pool - // present in the build and no transparent spending, preserving the prior - // shielded-only behavior. - &SpendPolicy::default(), + &spend_policy, // Do not request a specific transaction version; building falls back to the version // implied by the target height. None, @@ -366,3 +427,144 @@ async fn run( broadcast_transactions(&wallet, chain, txids.into()).await } + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use zcash_client_backend::{ + data_api::wallet::input_selection::{CoinbasePolicy, TransparentSource}, + fees::TransparentChangePolicy, + }; + use zcash_keys::{ + address::{Address, UnifiedAddress}, + keys::{UnifiedAddressRequest, UnifiedSpendingKey}, + }; + use zcash_protocol::consensus::Network; + use zip32::AccountId; + + use super::{SpendPolicy, spend_policy_for, transparent_change_policy_for}; + + /// A unified address carrying every receiver type, derived from `seed` and `account`. + /// + /// No wallet database and no chain: the policy derivations under test are pure functions + /// of the source address, which is what makes them unit-testable here rather than in + /// `integration-tests`. + /// + /// Returns `None` for the seeds and diversifiers ZIP 32 rejects, so a property can skip + /// them rather than assert on an address that cannot exist. + fn ua_from(seed: &[u8; 32], account: u32) -> Option { + let account = AccountId::try_from(account).ok()?; + let usk = UnifiedSpendingKey::from_seed(&Network::TestNetwork, seed, account).ok()?; + let (ua, _) = usk + .to_unified_full_viewing_key() + .default_address(UnifiedAddressRequest::ALLOW_ALL) + .ok()?; + Some(ua) + } + + /// ZIP 32 account indices are non-hardened, so they occupy the low 31 bits. + fn arb_account() -> impl Strategy { + 0u32..(1 << 31) + } + + proptest! { + // Each case derives a spending key, which is expensive, so take fewer samples than + // the default 256. The properties hold for every source address, not for rare + // corners of the seed space, so a modest sample establishes them. + #![proptest_config(ProptestConfig::with_cases(32))] + + /// Whatever key it was derived from, a transparent source draws only on that one + /// address's UTXOs, and on no shielded note. + #[test] + fn transparent_source_spends_only_that_address_and_no_shielded_pool( + seed in any::<[u8; 32]>(), + account in arb_account(), + ) { + let Some(ua) = ua_from(&seed, account) else { return Ok(()) }; + let Some(&taddr) = ua.transparent() else { return Ok(()) }; + + let policy = spend_policy_for(&Address::Transparent(taddr)); + + // A transparent send must not reach into the account's shielded funds. The + // permitted-pool SET being empty says this exhaustively: it forbids every + // shielded pool, including any added to `ShieldedPool` after this was written, + // which enumerating the variants here would not. + prop_assert!( + policy.shielded().is_empty(), + "a transparent source must permit no shielded pool, got {:?}", + policy.shielded(), + ); + + let transparent = policy + .transparent() + .expect("a transparent source permits transparent spending"); + + // Only the named address, so spending it does not link the source to the + // account's other transparent receivers. + match transparent.source() { + TransparentSource::FromAddresses(addrs) => prop_assert_eq!( + addrs.iter().copied().collect::>(), + vec![taddr], + "selection must be confined to the named address", + ), + other => prop_assert!( + false, + "expected a single-address source, got {other:?}", + ), + } + + // Coinbase must be spent to a single shielded output (`z_shieldcoinbase`'s + // job), so a general transfer never draws on it. + prop_assert_eq!(transparent.coinbase(), CoinbasePolicy::NonCoinbase); + } + + /// The property whose absence made transparent spending impossible, inverted: a + /// shielded source must never be able to select a transparent input, even though the + /// unified address it names does carry a transparent receiver. + #[test] + fn shielded_source_permits_no_transparent_spending( + seed in any::<[u8; 32]>(), + account in arb_account(), + ) { + let Some(ua) = ua_from(&seed, account) else { return Ok(()) }; + + let policy = spend_policy_for(&Address::Unified(ua)); + + prop_assert!( + policy.transparent().is_none(), + "a shielded source must not permit transparent spending", + ); + + // Shielded selection is left exactly as it was before transparent spending + // existed. Comparing against the default's pool set keeps that true for any + // pool added later, rather than pinning today's three. + let unchanged = SpendPolicy::default(); + prop_assert_eq!(policy.shielded(), unchanged.shielded()); + } + + /// Change may be returned to the transparent pool exactly when the source could spend + /// transparent funds in the first place. + #[test] + fn transparent_change_permitted_exactly_when_transparent_funds_are_spendable( + seed in any::<[u8; 32]>(), + account in arb_account(), + ) { + let Some(ua) = ua_from(&seed, account) else { return Ok(()) }; + let Some(&taddr) = ua.transparent() else { return Ok(()) }; + + let transparent_source = spend_policy_for(&Address::Transparent(taddr)); + prop_assert_eq!( + transparent_change_policy_for(&transparent_source), + TransparentChangePolicy::TransparentChangeAllowed, + "a fully transparent send keeps its change transparent", + ); + + let shielded_source = spend_policy_for(&Address::Unified(ua)); + prop_assert_eq!( + transparent_change_policy_for(&shielded_source), + TransparentChangePolicy::ShieldChange, + "a shielded send must not acquire a transparent change output", + ); + } + } +} diff --git a/zallet-core/src/components/json_rpc/payments.rs b/zallet-core/src/components/json_rpc/payments.rs index b0cfc272..39c88994 100644 --- a/zallet-core/src/components/json_rpc/payments.rs +++ b/zallet-core/src/components/json_rpc/payments.rs @@ -628,6 +628,23 @@ pub(super) fn get_account_for_address( wallet: &DbConnection, address: &Address, ) -> RpcResult { + // A bare transparent address is generally not a wallet address in its own right: it is + // a *receiver* of one of the account's unified addresses, so it never compares equal to + // any `AddressInfo` in the scan below (those hold the whole UA). `find_account_for_address` + // resolves an address through its receivers, so it maps such a taddr back to its owning + // account; without it, a taddr `fromaddress` can never be spent from. + if let Some(account_id) = wallet + .find_account_for_address(wallet.params(), address) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + { + return Ok(wallet + .get_account(account_id) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .expect("present")); + } + + // Fall back to scanning the account address lists, which also covers address kinds the + // receiver index does not resolve. // TODO: Make this more efficient with a `WalletRead` method. // https://github.com/zcash/librustzcash/issues/1944 for account_id in wallet From e00641bb720da7b7da94e3c2f5cf88ae7abc8733 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 15:21:13 +0200 Subject: [PATCH 2/9] AGENTS: this repository hosts unit tests, not integration tests Integration and scenario tests belong in zcash/integration-tests, which drives the whole Z3 stack over real RPC against a regtest chain. A test here that stands up a chain, mines blocks, or sends a transaction end to end is in the wrong repo. State what does belong here (pure derivations, parameter parsing and validation, error mapping, response shapes, property tests), and note the corollary: if a piece of logic can only be tested by building a chain, that is usually a sign it should be extracted into a pure function that can be unit-tested, with the end-to-end behaviour covered in integration-tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index a339f1ef..1da7b7c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -160,6 +160,43 @@ on `PATH` at run time. PRs MUST NOT introduce new warnings from `cargo +beta clippy --tests --all-features --all-targets`. Preexisting beta clippy warnings need not be resolved, but new ones introduced by a PR will block merging. +## Testing Scope: Unit Tests Only + +**This repository does NOT host integration tests or scenario tests.** Those +live in [zcash/integration-tests](https://github.com/zcash/integration-tests), +which drives the whole Z3 stack (`zebrad` + `zainod` + `zallet`) over real RPC +against a regtest chain. Do not add a test here that stands up a chain, mines +blocks, sends a transaction end to end, or otherwise asserts on the behaviour of +the stack as a whole; it belongs there. + +What belongs here is unit tests: fast, in-process, no chain, no network, no +fixture wallet seeded by mining. Test the pure logic a function owns, at the +boundary where a decision is actually made: + +- Pure derivations and policy decisions (e.g. "a transparent `fromaddress` + yields a spend policy that permits only that address's UTXOs, and no shielded + pool"). +- Parsing, validation, and error mapping of RPC parameters. +- Serialization and response shapes. +- Property tests (`proptest`) over the above where the input space is wide. + Prefer a property over a fixture: derive the key material from an arbitrary + seed rather than a hardcoded one, so the property is established for every + input rather than for the one that happened to be written down. + +Assert on the whole of a value rather than enumerating its parts, so a test does +not silently stop covering a variant added later. Prefer "the permitted-pool set +is empty" to a loop over today's three pools. + +If a piece of logic can only be tested by building a chain, that is usually a +sign it should be extracted into a pure function that CAN be unit-tested, with +the remaining end-to-end behaviour covered in `integration-tests`. Prefer +extracting. + +A change to this repository that needs new stack-level coverage should be paired +with a PR to `integration-tests`, referenced from the description, and wired via +a `ZIT-Revision: ` line so this repository's CI exercises it (see the +Cross-Repository CI Integration docs). + ## Auditing Dependency Version Bumps This applies to any change to a pinned dependency version: the `Dockerfile` and `Dockerfile.stagex` apt pins and base-image digests, and by extension any other pinned external artifact. From e864d1145ebf96785cba64ad5ecb124cef189360 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 15:21:13 +0200 Subject: [PATCH 3/9] CHANGELOG: transparent spending in z_sendmany Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 778bcdbe..f3ce5530 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,29 @@ be considered breaking changes. ### Added +- `z_sendmany` can now spend transparent funds, making transparent-to-transparent + transfers possible. Previously it passed a shielded-only spend policy to the + proposal builder, so no transparent input could ever be selected, and the + `AllowFullyTransparent` privacy policy was unreachable. + - Transparent funds are spendable only when `fromaddress` names a transparent + address. Input selection then draws on that address's UTXOs alone, so a + shielded send can never silently reach into transparent funds, and the named + address is not linked to the account's other transparent receivers. Every + other source remains shielded-only, unchanged. (`ANY_TADDR` as a source is + still unsupported.) + - Coinbase outputs are not spendable this way: consensus requires them to be + spent to a single shielded output, which remains `z_shieldcoinbase`'s job. A + transparent spend therefore requires a non-coinbase UTXO. + - A transaction that both spends transparent funds and has transparent + recipients or change requires the `AllowFullyTransparent` privacy policy. A + transparent source paying a shielded recipient still only needs + `AllowRevealedSenders`. + - Change on a fully transparent send stays in the transparent pool, at an + internal-scope (BIP 44) change address, and a fresh one is reserved per + transaction so that consecutive sends cannot be linked through a reused + change address. Transparent change is emitted only when the transaction has + no shielded input or output at all; any send with a shielded component + continues to shield its change exactly as before. - The `zebra` chain backend (and the `zaino` backend's read-state-service mode) now support regtest. The read-state service builds a Zebra Regtest network whose network-upgrade activation heights mirror the wallet's configured @@ -39,6 +62,19 @@ be considered breaking changes. ### Fixed +- `get_account_for_address` now resolves a bare transparent address to the account + that owns it. It previously compared the address against the account's listed + addresses, which are unified addresses; a transparent receiver never compares + equal to the unified address it belongs to, so passing a `taddr` as a payment + source failed with "Invalid from address, no payment source found for address." +- The wallet database connection now implements + `InputSource::select_spendable_transparent_outputs` and + `WalletWrite::reserve_next_n_internal_addresses`. Both are defaulted in their + traits to an `unimplemented!()`, so omitting them compiled cleanly and instead + panicked the wallet process at run time: the first on selecting any transparent + input, the second on producing transparent change. Nothing reached either while + transparent spending was impossible; both are on the path of any transparent + spend, and of the forthcoming `z_sendfromaccount` and `z_proposetransaction`. - `steady_state` sync no longer crashes the whole wallet when the backend's best chain reorgs away a block the wallet had already stored (`BlockConflict`). Previously this error wasn't recognized as retryable, so it propagated as a From 96c1aa805aea801c6c8a451ee30d87716552082e Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 16:45:05 +0200 Subject: [PATCH 4/9] Share the Orchard action limit and the send runner `z_sendmany` inlines the Orchard action-limit check and owns the private `run` that builds, signs, and broadcasts a proposal. The account-based transaction methods need both: they build a proposal the same way and must enforce the same limit, and re-implementing either would let the two paths drift. Lift the limit check into `check_orchard_actions_limit`, which takes any proposal rather than reaching into `z_sendmany`'s locals, and make `run` visible to its sibling methods. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../json_rpc/methods/z_send_many.rs | 103 ++++++++++-------- 1 file changed, 60 insertions(+), 43 deletions(-) diff --git a/zallet-core/src/components/json_rpc/methods/z_send_many.rs b/zallet-core/src/components/json_rpc/methods/z_send_many.rs index e7ced65f..fbb37f6f 100644 --- a/zallet-core/src/components/json_rpc/methods/z_send_many.rs +++ b/zallet-core/src/components/json_rpc/methods/z_send_many.rs @@ -296,48 +296,7 @@ pub(crate) async fn call( enforce_privacy_policy(&proposal, privacy_policy)?; - let orchard_actions_limit = APP.config().builder.limits.orchard_actions().into(); - for step in proposal.steps() { - let orchard_spends = step - .shielded_inputs() - .iter() - .flat_map(|inputs| inputs.notes()) - .filter(|note| note.note().pool() == ShieldedPool::Orchard) - .count(); - - let orchard_outputs = step - .payment_pools() - .values() - .filter(|pool| pool == &&PoolType::ORCHARD) - .count() - + step - .balance() - .proposed_change() - .iter() - .filter(|change| change.output_pool() == PoolType::ORCHARD) - .count(); - - let orchard_actions = orchard_spends.max(orchard_outputs); - - if orchard_actions > orchard_actions_limit { - let (count, kind) = if orchard_outputs <= orchard_actions_limit { - (orchard_spends, "inputs") - } else if orchard_spends <= orchard_actions_limit { - (orchard_outputs, "outputs") - } else { - (orchard_actions, "actions") - }; - - return Err(LegacyCode::Misc.with_message(fl!( - "err-excess-orchard-actions", - count = count, - kind = kind, - limit = orchard_actions_limit, - config = "-orchardactionlimit=N", - bound = format!("N >= %u"), - ))); - } - } + check_orchard_actions_limit(&proposal)?; let derivation = account.source().key_derivation().ok_or_else(|| { LegacyCode::InvalidAddressOrKey @@ -391,6 +350,64 @@ pub(crate) async fn call( )) } +/// Rejects a proposal whose Orchard action count exceeds the configured limit. +/// +/// A step's action count is the greater of its Orchard spends and its Orchard outputs (an +/// action carries at most one of each), so the limit is checked against that maximum, and the +/// error names whichever of the two actually breached it. +/// +/// Shared with the account-based transaction methods, which enforce the same limit on the +/// proposals they build. +pub(super) fn check_orchard_actions_limit( + proposal: &Proposal, +) -> RpcResult<()> { + let orchard_actions_limit = APP.config().builder.limits.orchard_actions().into(); + + for step in proposal.steps() { + let orchard_spends = step + .shielded_inputs() + .iter() + .flat_map(|inputs| inputs.notes()) + .filter(|note| note.note().pool() == ShieldedPool::Orchard) + .count(); + + let orchard_outputs = step + .payment_pools() + .values() + .filter(|pool| pool == &&PoolType::ORCHARD) + .count() + + step + .balance() + .proposed_change() + .iter() + .filter(|change| change.output_pool() == PoolType::ORCHARD) + .count(); + + let orchard_actions = orchard_spends.max(orchard_outputs); + + if orchard_actions > orchard_actions_limit { + let (count, kind) = if orchard_outputs <= orchard_actions_limit { + (orchard_spends, "inputs") + } else if orchard_spends <= orchard_actions_limit { + (orchard_outputs, "outputs") + } else { + (orchard_actions, "actions") + }; + + return Err(LegacyCode::Misc.with_message(fl!( + "err-excess-orchard-actions", + count = count, + kind = kind, + limit = orchard_actions_limit, + config = "-orchardactionlimit=N", + bound = format!("N >= %u"), + ))); + } + } + + Ok(()) +} + /// Construct and send the transaction, returning the resulting txid. /// Errors in transaction construction will throw. /// @@ -400,7 +417,7 @@ pub(crate) async fn call( /// 2. #1360 Note selection is not optimal. /// 3. #1277 Spendable notes are not locked, so an operation running in parallel /// could also try to use them. -async fn run( +pub(super) async fn run( mut wallet: DbHandle, chain: C, proposal: Proposal, From 8bc42616a82af543f5888fcbeec62ac992987b79 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 16:50:07 +0200 Subject: [PATCH 5/9] Share the proposal builder across the send paths `z_sendmany` builds its proposal inline: fee rule, change strategy, dust and split policies, input selector, and the `propose_transfer` call. The account-based transaction methods build the same proposal from the same pieces, and differ only in which funds they may draw upon. Lift it into `propose_transfer_with_policy`, taking the `SpendPolicy` as its one varying input. `z_sendmany` derives that policy from its source address; the account methods will derive it from `fund_source`. Everything else about the proposal is then shared by construction rather than by two copies agreeing. `transparent_change_policy_for` moves with it, since it is a property of the spend policy rather than of `z_sendmany`. No behaviour change. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../json_rpc/methods/z_send_many.rs | 78 ++----------- .../src/components/json_rpc/payments.rs | 107 +++++++++++++++++- 2 files changed, 111 insertions(+), 74 deletions(-) diff --git a/zallet-core/src/components/json_rpc/methods/z_send_many.rs b/zallet-core/src/components/json_rpc/methods/z_send_many.rs index fbb37f6f..cf76894b 100644 --- a/zallet-core/src/components/json_rpc/methods/z_send_many.rs +++ b/zallet-core/src/components/json_rpc/methods/z_send_many.rs @@ -13,14 +13,10 @@ use zcash_client_backend::{ Account, wallet::{ ConfirmationsPolicy, create_proposed_transactions, - input_selection::{GreedyInputSelector, SpendPolicy, TransparentSpendPolicy}, - propose_transfer, + input_selection::{SpendPolicy, TransparentSpendPolicy}, }, }, - fees::{ - DustOutputPolicy, StandardFeeRule, TransparentChangePolicy, - standard::MultiOutputChangeStrategy, - }, + fees::StandardFeeRule, wallet::OvkPolicy, }; use zcash_client_sqlite::ReceivedNoteId; @@ -40,7 +36,7 @@ use crate::{ payments::{ AmountParameter, IncompatiblePrivacyPolicy, PrivacyPolicy, SendResult, broadcast_transactions, build_request, enforce_privacy_policy, - get_account_for_address, + get_account_for_address, propose_transfer_with_policy, }, server::LegacyCode, }, @@ -89,23 +85,6 @@ fn spend_policy_for(source: &Address) -> SpendPolicy { } } -/// Whether change may be returned to the transparent pool. -/// -/// Permitted exactly when `spend_policy` can spend transparent funds in the first place, which -/// keeps a fully transparent send transparent end to end rather than sweeping its change into a -/// shielded pool. A shielded send therefore cannot acquire a transparent change output by this -/// route. -/// -/// The change strategy independently enforces the same thing (it emits transparent change only -/// when the transaction's net flows are fully transparent, i.e. it has no shielded input or -/// output at all), but that is its invariant, not ours. -fn transparent_change_policy_for(spend_policy: &SpendPolicy) -> TransparentChangePolicy { - match spend_policy.transparent() { - Some(_) => TransparentChangePolicy::TransparentChangeAllowed, - None => TransparentChangePolicy::ShieldChange, - } -} - #[allow(clippy::too_many_arguments)] pub(crate) async fn call( mut wallet: DbHandle, @@ -242,57 +221,14 @@ pub(crate) async fn call( } } - let transparent_change_policy = transparent_change_policy_for(&spend_policy); - - // Where shielded change goes when the transaction has no shielded flows to infer a pool - // from. A transaction that does have shielded flows ignores this and keeps its change in - // the pool it is already using. - // - // This stays Orchard rather than Ironwood: the change strategy promotes it to Ironwood - // itself once NU6.3 is active (the turnstile forbids value from entering the Orchard - // pool, so change out of a purely transparent transaction has to land in Ironwood), and - // it does so against the transaction's target height, which is not known here. Naming - // Ironwood outright would instead send change to a pool that does not exist yet on a - // chain where NU6.3 has not activated. - let fallback_change_pool = ShieldedPool::Orchard; - - // Shielded change is split across several notes, per the wallet's note-management - // configuration, so the account keeps a usable set of denominations. - let split_policy = APP.config().note_management.split_policy(); - - // Change too small to be worth its own output is added to the fee instead. - let dust_output_policy = DustOutputPolicy::default(); - - // No memo is attached to change. A change memo would force the change into a shielded - // pool, since a transparent output cannot carry one. - let change_memo = None; - - let change_strategy = MultiOutputChangeStrategy::new( - StandardFeeRule::Zip317, - change_memo, - fallback_change_pool, - dust_output_policy, - split_policy, - ) - .with_transparent_change_policy(transparent_change_policy); - - let input_selector = GreedyInputSelector::new(); - - let proposal = propose_transfer::<_, _, _, _, Infallible>( + let proposal = propose_transfer_with_policy( wallet.as_mut(), ¶ms, account.id(), - &input_selector, - &change_strategy, request, confirmations_policy, &spend_policy, - // Do not request a specific transaction version; building falls back to the version - // implied by the target height. - None, - ) - // TODO: Map errors to `zcashd` shape. - .map_err(|e| LegacyCode::Wallet.with_message(format!("Failed to propose transaction: {e}")))?; + )?; enforce_privacy_policy(&proposal, privacy_policy)?; @@ -459,7 +395,9 @@ mod tests { use zcash_protocol::consensus::Network; use zip32::AccountId; - use super::{SpendPolicy, spend_policy_for, transparent_change_policy_for}; + use crate::components::json_rpc::payments::transparent_change_policy_for; + + use super::{SpendPolicy, spend_policy_for}; /// A unified address carrying every receiver type, derived from `seed` and `account`. /// diff --git a/zallet-core/src/components/json_rpc/payments.rs b/zallet-core/src/components/json_rpc/payments.rs index 39c88994..b49fe400 100644 --- a/zallet-core/src/components/json_rpc/payments.rs +++ b/zallet-core/src/components/json_rpc/payments.rs @@ -1,4 +1,4 @@ -use std::{collections::HashSet, fmt}; +use std::{collections::HashSet, convert::Infallible, fmt}; use abscissa_core::Application; use jsonrpsee::core::JsonValue; @@ -7,17 +7,29 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use zcash_address::ZcashAddress; use zcash_client_backend::{ - data_api::WalletRead, + data_api::{ + WalletRead, + wallet::{ + ConfirmationsPolicy, + input_selection::{GreedyInputSelector, SpendPolicy}, + propose_transfer, + }, + }, + fees::{ + DustOutputPolicy, StandardFeeRule, TransparentChangePolicy, + standard::MultiOutputChangeStrategy, + }, proposal::Proposal, zip321::{Payment, TransactionRequest}, }; -use zcash_client_sqlite::wallet::Account; +use zcash_client_sqlite::{AccountUuid, ReceivedNoteId, wallet::Account}; use zcash_keys::address::Address; -use zcash_protocol::{PoolType, TxId, memo::MemoBytes, value::Zatoshis}; +use zcash_protocol::{PoolType, ShieldedPool, TxId, memo::MemoBytes, value::Zatoshis}; use crate::{ components::{chain::Chain, database::DbConnection}, fl, + network::Network, prelude::APP, }; @@ -624,6 +636,93 @@ mod parse_memo_tests { } } +/// Whether change may be returned to the transparent pool. +/// +/// Permitted exactly when `spend_policy` can spend transparent funds in the first place, which +/// keeps a fully transparent send transparent end to end rather than sweeping its change into a +/// shielded pool. A shielded send therefore cannot acquire a transparent change output by this +/// route. +/// +/// The change strategy independently enforces the same thing (it emits transparent change only +/// when the transaction's net flows are fully transparent, i.e. it has no shielded input or +/// output at all), but that is its invariant, not ours. +pub(super) fn transparent_change_policy_for(spend_policy: &SpendPolicy) -> TransparentChangePolicy { + match spend_policy.transparent() { + Some(_) => TransparentChangePolicy::TransparentChangeAllowed, + None => TransparentChangePolicy::ShieldChange, + } +} + +/// Proposes a transfer of `request` from `account`, drawing only on the funds `spend_policy` +/// permits. +/// +/// Every send path builds its proposal here, so they agree on the fee rule, the change strategy, +/// and the input selector, and differ only in the spend policy they ask for. That policy is what +/// distinguishes `z_sendmany`'s source address from the account methods' `fund_source`. +/// +/// The privacy policy is deliberately not consulted: the selector returns its best proposal, and +/// `enforce_privacy_policy` rejects it afterwards if it leaks more than the caller permitted. +pub(super) fn propose_transfer_with_policy( + wallet: &mut DbConnection, + params: &Network, + account: AccountUuid, + request: TransactionRequest, + confirmations_policy: ConfirmationsPolicy, + spend_policy: &SpendPolicy, +) -> RpcResult> { + // Where shielded change goes when the transaction has no shielded flows to infer a pool + // from. A transaction that does have shielded flows ignores this and keeps its change in + // the pool it is already using. + // + // This stays Orchard rather than Ironwood: the change strategy promotes it to Ironwood + // itself once NU6.3 is active (the turnstile forbids value from entering the Orchard pool, + // so change out of a purely transparent transaction has to land in Ironwood), and it does + // so against the transaction's target height, which is not known here. Naming Ironwood + // outright would instead send change to a pool that does not exist yet on a chain where + // NU6.3 has not activated. + let fallback_change_pool = ShieldedPool::Orchard; + + // Shielded change is split across several notes, per the wallet's note-management + // configuration, so the account keeps a usable set of denominations. + let split_policy = APP.config().note_management.split_policy(); + + // Change too small to be worth its own output is added to the fee instead. + let dust_output_policy = DustOutputPolicy::default(); + + // No memo is attached to change. A change memo would force the change into a shielded + // pool, since a transparent output cannot carry one. + let change_memo = None; + + let change_strategy = MultiOutputChangeStrategy::new( + StandardFeeRule::Zip317, + change_memo, + fallback_change_pool, + dust_output_policy, + split_policy, + ) + .with_transparent_change_policy(transparent_change_policy_for(spend_policy)); + + let input_selector = GreedyInputSelector::new(); + + // Do not request a specific transaction version; building falls back to the version implied + // by the target height. + let proposed_version = None; + + propose_transfer::<_, _, _, _, Infallible>( + wallet, + params, + account, + &input_selector, + &change_strategy, + request, + confirmations_policy, + spend_policy, + proposed_version, + ) + // TODO: Map errors to `zcashd` shape. + .map_err(|e| LegacyCode::Wallet.with_message(format!("Failed to propose transaction: {e}"))) +} + pub(super) fn get_account_for_address( wallet: &DbConnection, address: &Address, From 947628c902674011a01ab507775f61e5c20aa633 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 18:12:24 +0200 Subject: [PATCH 6/9] Implement z_proposetransaction Proposes a transaction from an account and returns it as a PCZT, alongside the privacy policy required to execute it. Nothing is signed, no proof is generated, and no key is touched: the caller inspects what the transaction would reveal and decides, rather than having to commit to a privacy policy up front as `z_sendmany` demands. The `fund_source` parameter names where the account's funds may be drawn from: `orchard`, `sapling`, `any_transparent`, or an explicit list of transparent addresses. It is translated into a `SpendPolicy`, which is what the proposal builder already consumes, so the account methods select inputs through exactly the same path as `z_sendmany` rather than a second implementation of selection. That translation is the whole of `fund_source`. This is worth spelling out because it is where the transparent-spend work pays off: `zcash_client_backend` could not select transparent inputs when these methods were first written, so the original implementation carried its own transparent input selector and an `InputSource` wrapper to filter notes by pool, around a thousand lines in total. librustzcash now does both -- `TransparentSpendPolicy` names which transparent addresses may be drawn upon, and `SpendPolicy::shielded_pools` restricts the shielded pools -- so all of it collapses into a `match` from `FundSource` to `SpendPolicy`, and the `if spends_transparent { .. } else { .. }` fork in each method disappears. `fund_source: "orchard"` permits the Ironwood pool as well as Orchard. Ironwood notes are Orchard-shaped, and once NU6.3 activates an account's Orchard-receiver funds are held there rather than in the legacy Orchard pool, so restricting the source to Orchard alone would report insufficient funds for an account whose Orchard money is all in Ironwood. The required privacy policy is recorded against the PCZT's content hash so that `z_finalizetransaction` can hold the caller's acknowledgement against what the transaction actually reveals, rather than re-deriving it from the PCZT, which does not carry enough information to do so faithfully. Enables the `pczt` feature of `zcash_client_backend` and the `serde` feature of `zcash_client_sqlite` (`create_pczt_from_proposal` serializes the account id), and adds the `pczt` crate at the librustzcash rev the workspace already pins, in all three manifests so the lockstep check still sees one rev. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 97 ++++++ Cargo.toml | 2 + backends/zaino/Cargo.lock | 120 +++---- backends/zaino/Cargo.toml | 1 + backends/zaino/supply-chain/config.toml | 3 + backends/zebra/Cargo.lock | 126 +++----- backends/zebra/Cargo.toml | 1 + backends/zebra/supply-chain/config.toml | 3 + supply-chain/config.toml | 23 ++ zallet-core/Cargo.toml | 3 + zallet-core/src/components/json_rpc.rs | 1 + .../src/components/json_rpc/fund_source.rs | 292 ++++++++++++++++++ .../src/components/json_rpc/methods.rs | 47 +++ .../json_rpc/methods/z_propose_transaction.rs | 148 +++++++++ .../src/components/json_rpc/payments.rs | 65 +++- 15 files changed, 776 insertions(+), 156 deletions(-) create mode 100644 zallet-core/src/components/json_rpc/fund_source.rs create mode 100644 zallet-core/src/components/json_rpc/methods/z_propose_transaction.rs diff --git a/Cargo.lock b/Cargo.lock index 566dcbe9..83672107 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -693,6 +693,7 @@ dependencies = [ "iana-time-zone", "js-sys", "num-traits", + "serde", "wasm-bindgen", "windows-link", ] @@ -1043,6 +1044,41 @@ dependencies = [ "petgraph 0.7.1", ] +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -2979,6 +3015,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.7.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=29c7fb28889a69df15b44fc9465cf4a464f766e9#29c7fb28889a69df15b44fc9465cf4a464f766e9" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty", + "orchard 0.15.0-pre.2", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0-pre.0", + "zcash_protocol 0.10.0-pre.0", + "zcash_script", + "zcash_transparent 0.9.0-pre.0", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3955,6 +4019,33 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_with" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "serdect" version = "0.2.0" @@ -4932,6 +5023,7 @@ checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -5505,6 +5597,7 @@ dependencies = [ "nonempty", "once_cell", "orchard 0.15.0-pre.2", + "pczt", "phf", "proptest", "quote", @@ -5606,13 +5699,16 @@ dependencies = [ "nonempty", "orchard 0.15.0-pre.2", "pasta_curves", + "pczt", "percent-encoding", + "postcard", "prost", "rand_core 0.6.4", "rayon", "sapling-crypto", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "subtle", "time", @@ -5664,6 +5760,7 @@ dependencies = [ "schemerz-rusqlite", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "static_assertions", "subtle", diff --git a/Cargo.toml b/Cargo.toml index 40eb9bad..a19eb4d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,6 +70,7 @@ serde = { version = "1", features = ["serde_derive"] } semver = "1" serde_json = { version = "1", features = ["arbitrary_precision", "raw_value"] } toml = "0.8" +pczt = { version = "0.7", features = ["prover", "signer"] } zcash_address = "0.13.0-pre.0" # Randomness @@ -169,6 +170,7 @@ tonic = "0.14" # Do not edit these revs by hand. equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } +pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } transparent = { package = "zcash_transparent", git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } diff --git a/backends/zaino/Cargo.lock b/backends/zaino/Cargo.lock index 48e3ffb1..1cb66a1f 100644 --- a/backends/zaino/Cargo.lock +++ b/backends/zaino/Cargo.lock @@ -1351,7 +1351,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1497,7 +1497,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2646,7 +2646,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2893,7 +2893,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3270,7 +3270,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3594,6 +3594,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.7.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=29c7fb28889a69df15b44fc9465cf4a464f766e9#29c7fb28889a69df15b44fc9465cf4a464f766e9" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty 0.11.0", + "orchard 0.15.0-pre.2", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0-pre.0", + "zcash_protocol 0.10.0-pre.0", + "zcash_script", + "zcash_transparent 0.9.0-pre.0", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -4020,7 +4048,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4569,7 +4597,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4628,7 +4656,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5308,7 +5336,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6061,6 +6089,7 @@ checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -6344,7 +6373,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6415,15 +6444,6 @@ dependencies = [ "windows-targets 0.42.2", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6466,21 +6486,6 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6503,12 +6508,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6521,12 +6520,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6539,12 +6532,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6563,12 +6550,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6581,12 +6562,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6599,12 +6574,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6617,12 +6586,6 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6865,6 +6828,7 @@ dependencies = [ "nix 0.29.0", "nonempty 0.11.0", "orchard 0.15.0-pre.2", + "pczt", "phf", "quote", "rand 0.8.6", @@ -7003,13 +6967,16 @@ dependencies = [ "nonempty 0.11.0", "orchard 0.15.0-pre.2", "pasta_curves", + "pczt", "percent-encoding", + "postcard", "prost", "rand_core 0.6.4", "rayon", "sapling-crypto", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "subtle", "time", @@ -7061,6 +7028,7 @@ dependencies = [ "schemerz-rusqlite", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "static_assertions", "subtle", diff --git a/backends/zaino/Cargo.toml b/backends/zaino/Cargo.toml index 828d4c32..055b21eb 100644 --- a/backends/zaino/Cargo.toml +++ b/backends/zaino/Cargo.toml @@ -81,6 +81,7 @@ tempfile = "3" # all three manifests and lockfiles at once); do not edit these revs by hand. equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } +pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } transparent = { package = "zcash_transparent", git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } diff --git a/backends/zaino/supply-chain/config.toml b/backends/zaino/supply-chain/config.toml index 7ab882a6..282be87b 100644 --- a/backends/zaino/supply-chain/config.toml +++ b/backends/zaino/supply-chain/config.toml @@ -42,6 +42,9 @@ audit-as-crates-io = true [policy."orchard:0.15.0-pre.2@git:475ef0ff77d45aebff93cb039d639250d82518a3"] audit-as-crates-io = true +[policy.pczt] +audit-as-crates-io = false + [policy.shardtree] audit-as-crates-io = true diff --git a/backends/zebra/Cargo.lock b/backends/zebra/Cargo.lock index b665b36b..3f2435e2 100644 --- a/backends/zebra/Cargo.lock +++ b/backends/zebra/Cargo.lock @@ -219,7 +219,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -230,7 +230,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1258,7 +1258,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1404,7 +1404,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2542,7 +2542,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2745,7 +2745,7 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a1886916523694cd6ea3d175f03a1e5010699a2a4cc13696d83d7bea1d80638" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3100,7 +3100,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3304,7 +3304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -3410,6 +3410,34 @@ dependencies = [ "password-hash", ] +[[package]] +name = "pczt" +version = "0.7.0" +source = "git+https://github.com/zcash/librustzcash.git?rev=29c7fb28889a69df15b44fc9465cf4a464f766e9#29c7fb28889a69df15b44fc9465cf4a464f766e9" +dependencies = [ + "blake2b_simd", + "bls12_381", + "document-features", + "ff", + "getset", + "jubjub", + "nonempty", + "orchard 0.15.0-pre.2", + "pasta_curves", + "postcard", + "rand_core 0.6.4", + "redjubjub", + "sapling-crypto", + "secp256k1", + "serde", + "serde_with", + "zcash_note_encryption", + "zcash_primitives 0.29.0-pre.0", + "zcash_protocol 0.10.0-pre.0", + "zcash_script", + "zcash_transparent 0.9.0-pre.0", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -3809,7 +3837,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -4321,7 +4349,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4867,7 +4895,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5017,7 +5045,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5775,6 +5803,7 @@ checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ "getrandom 0.4.3", "js-sys", + "serde_core", "wasm-bindgen", ] @@ -6018,7 +6047,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -6080,15 +6109,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -6125,21 +6145,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -6173,12 +6178,6 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -6191,12 +6190,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -6209,12 +6202,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -6239,12 +6226,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -6257,12 +6238,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -6275,12 +6250,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -6293,12 +6262,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -6441,6 +6404,7 @@ dependencies = [ "nix 0.29.0", "nonempty", "orchard 0.15.0-pre.2", + "pczt", "phf", "quote", "rand 0.8.6", @@ -6583,13 +6547,16 @@ dependencies = [ "nonempty", "orchard 0.15.0-pre.2", "pasta_curves", + "pczt", "percent-encoding", + "postcard", "prost", "rand_core 0.6.4", "rayon", "sapling-crypto", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "subtle", "time", @@ -6641,6 +6608,7 @@ dependencies = [ "schemerz-rusqlite", "secp256k1", "secrecy 0.8.0", + "serde", "shardtree", "static_assertions", "subtle", diff --git a/backends/zebra/Cargo.toml b/backends/zebra/Cargo.toml index 50764dc2..480a4761 100644 --- a/backends/zebra/Cargo.toml +++ b/backends/zebra/Cargo.toml @@ -85,6 +85,7 @@ tokio-console = ["zallet-core/tokio-console"] # all three manifests and lockfiles at once); do not edit these revs by hand. equihash = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } f4jumble = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } +pczt = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } transparent = { package = "zcash_transparent", git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_address = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } zcash_client_backend = { git = "https://github.com/zcash/librustzcash.git", rev = "29c7fb28889a69df15b44fc9465cf4a464f766e9" } diff --git a/backends/zebra/supply-chain/config.toml b/backends/zebra/supply-chain/config.toml index 3d0c7cd1..ec07165f 100644 --- a/backends/zebra/supply-chain/config.toml +++ b/backends/zebra/supply-chain/config.toml @@ -42,6 +42,9 @@ audit-as-crates-io = true [policy."orchard:0.15.0-pre.2@git:475ef0ff77d45aebff93cb039d639250d82518a3"] audit-as-crates-io = true +[policy.pczt] +audit-as-crates-io = false + [policy.shardtree] audit-as-crates-io = true diff --git a/supply-chain/config.toml b/supply-chain/config.toml index 60fac2a2..fb6ba74b 100644 --- a/supply-chain/config.toml +++ b/supply-chain/config.toml @@ -42,6 +42,9 @@ audit-as-crates-io = true [policy."orchard:0.15.0-pre.2@git:475ef0ff77d45aebff93cb039d639250d82518a3"] audit-as-crates-io = true +[policy.pczt] +audit-as-crates-io = false + [policy.shardtree] audit-as-crates-io = true @@ -399,6 +402,18 @@ criteria = "safe-to-deploy" version = "0.8.1" criteria = "safe-to-deploy" +[[exemptions.darling]] +version = "0.21.3" +criteria = "safe-to-deploy" + +[[exemptions.darling_core]] +version = "0.21.3" +criteria = "safe-to-deploy" + +[[exemptions.darling_macro]] +version = "0.21.3" +criteria = "safe-to-deploy" + [[exemptions.deadpool]] version = "0.12.3" criteria = "safe-to-deploy" @@ -1235,6 +1250,14 @@ criteria = "safe-to-deploy" version = "1.1.1" criteria = "safe-to-deploy" +[[exemptions.serde_with]] +version = "3.17.0" +criteria = "safe-to-deploy" + +[[exemptions.serde_with_macros]] +version = "3.17.0" +criteria = "safe-to-deploy" + [[exemptions.serdect]] version = "0.2.0" criteria = "safe-to-deploy" diff --git a/zallet-core/Cargo.toml b/zallet-core/Cargo.toml index 310e5447..3be91d47 100644 --- a/zallet-core/Cargo.toml +++ b/zallet-core/Cargo.toml @@ -17,6 +17,7 @@ tokio = { workspace = true, features = ["fs", "io-std", "io-util", "rt-multi-thr # Data structures nonempty.workspace = true +pczt.workspace = true # Build script shadow-rs.workspace = true @@ -115,11 +116,13 @@ uuid.workspace = true zcash_client_backend = { workspace = true, features = [ "lightwalletd-tonic-tls-webpki-roots", "orchard", + "pczt", "sync-decryptor", "transparent-inputs", ] } zcash_client_sqlite = { workspace = true, features = [ "orchard", + "serde", "transparent-inputs", ] } zcash_note_encryption.workspace = true diff --git a/zallet-core/src/components/json_rpc.rs b/zallet-core/src/components/json_rpc.rs index 6b1afa8f..2607322f 100644 --- a/zallet-core/src/components/json_rpc.rs +++ b/zallet-core/src/components/json_rpc.rs @@ -23,6 +23,7 @@ use super::sync::WalletDecryptorHandle; #[cfg(zallet_build = "wallet")] mod asyncop; +mod fund_source; pub(crate) mod methods; #[cfg(zallet_build = "wallet")] mod payments; diff --git a/zallet-core/src/components/json_rpc/fund_source.rs b/zallet-core/src/components/json_rpc/fund_source.rs new file mode 100644 index 00000000..e90694e2 --- /dev/null +++ b/zallet-core/src/components/json_rpc/fund_source.rs @@ -0,0 +1,292 @@ +//! The `fund_source` parameter of the account-based transaction methods. +//! +//! `fund_source` names where an account's funds may be drawn from. It is translated into a +//! [`SpendPolicy`], which is what the proposal builder actually consumes, so the account-based +//! methods select inputs through exactly the same path as `z_sendmany`. + +use std::collections::HashSet; + +use jsonrpsee::core::{JsonValue, RpcResult}; +use nonempty::NonEmpty; +use transparent::address::TransparentAddress; +use zcash_client_backend::data_api::wallet::input_selection::{ + SpendPolicy, TransparentSpendPolicy, +}; +use zcash_keys::address::Address; +use zcash_protocol::ShieldedPool; + +use crate::network::Network; + +use super::server::LegacyCode; + +/// Where an account's funds may be drawn from when constructing a transaction. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) enum FundSource { + /// Spend only Orchard-family notes. + Orchard, + /// Spend only Sapling notes. + Sapling, + /// Spend any of the account's transparent funds. + AnyTransparent, + /// Spend only transparent funds received at the given transparent addresses. + Transparent(HashSet), +} + +impl FundSource { + /// Parses a `fund_source` JSON-RPC argument. + /// + /// Accepts either one of the strings `"orchard"`, `"sapling"`, `"any_transparent"`, or a + /// non-empty array of transparent address strings. + pub(super) fn parse(value: &JsonValue, params: &Network) -> RpcResult { + match value { + JsonValue::String(s) => match s.as_str() { + "orchard" => Ok(Self::Orchard), + "sapling" => Ok(Self::Sapling), + "any_transparent" => Ok(Self::AnyTransparent), + other => Err(LegacyCode::InvalidParameter.with_message(format!( + "Invalid fund_source: expected \"orchard\", \"sapling\", \"any_transparent\", \ + or an array of transparent addresses, got \"{other}\"." + ))), + }, + JsonValue::Array(addrs) => { + if addrs.is_empty() { + return Err(LegacyCode::InvalidParameter.with_static( + "Invalid fund_source: the array of transparent addresses is empty.", + )); + } + let mut set = HashSet::new(); + for addr in addrs { + let s = addr.as_str().ok_or_else(|| { + LegacyCode::InvalidParameter.with_static( + "Invalid fund_source: array entries must be transparent address \ + strings.", + ) + })?; + match Address::decode(params, s) { + Some(Address::Transparent(ta)) => { + set.insert(ta); + } + _ => { + return Err(LegacyCode::InvalidParameter.with_message(format!( + "Invalid fund_source: \"{s}\" is not a transparent address." + ))); + } + } + } + Ok(Self::Transparent(set)) + } + _ => Err(LegacyCode::InvalidParameter.with_static( + "Invalid fund_source: expected a string or an array of transparent addresses.", + )), + } + } + + /// The sources of funds the proposal builder may draw upon for this `fund_source`. + /// + /// Each variant names exactly one kind of input and forbids the rest, which is the whole + /// point of the parameter: naming `sapling` must not quietly spend an Orchard note, and + /// naming a transparent address must not quietly spend a shielded one. A source that cannot + /// cover the payment fails as insufficient funds rather than reaching into another pool. + /// + /// `Orchard` permits the Ironwood pool as well. Ironwood notes are Orchard-shaped, and once + /// NU6.3 activates an account's Orchard-receiver funds are held there rather than in the + /// legacy Orchard pool; restricting the source to `ShieldedPool::Orchard` alone would report + /// insufficient funds for an account whose Orchard funds are all in Ironwood. + /// + /// Coinbase UTXOs are never drawn upon: `TransparentSpendPolicy` defaults to + /// `CoinbasePolicy::NonCoinbase`, because consensus requires coinbase to be spent to a + /// single shielded output, which is `z_shieldcoinbase`'s job. + pub(super) fn spend_policy(&self) -> SpendPolicy { + match self { + Self::Orchard => { + SpendPolicy::shielded_pools([ShieldedPool::Orchard, ShieldedPool::Ironwood]) + } + Self::Sapling => SpendPolicy::shielded_pools([ShieldedPool::Sapling]), + Self::AnyTransparent => SpendPolicy::shielded_pools([]) + .with_transparent(TransparentSpendPolicy::any_account_addr()), + Self::Transparent(addrs) => { + let mut addrs = addrs.iter().copied(); + let first = addrs + .next() + .expect("`parse` rejects an empty address array"); + let addrs = NonEmpty::from((first, addrs.collect())); + + SpendPolicy::shielded_pools([]) + .with_transparent(TransparentSpendPolicy::from_addresses(addrs)) + } + } + } +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + use zcash_client_backend::data_api::wallet::input_selection::{ + CoinbasePolicy, TransparentSource, + }; + use zcash_keys::{ + encoding::AddressCodec, + keys::{UnifiedAddressRequest, UnifiedSpendingKey}, + }; + use zcash_protocol::consensus; + use zip32::AccountId; + + use super::*; + + fn test_params() -> Network { + Network::Consensus(consensus::Network::TestNetwork) + } + + /// A transparent address of the account derived from `seed`, and its string encoding. + /// + /// Returns `None` for the seeds ZIP 32 rejects, so a property can skip them rather than + /// assert on an address that cannot exist. + fn taddr_from(seed: &[u8; 32], account: u32) -> Option { + let params = test_params(); + let account = AccountId::try_from(account).ok()?; + let usk = UnifiedSpendingKey::from_seed(¶ms, seed, account).ok()?; + let (ua, _) = usk + .to_unified_full_viewing_key() + .default_address(UnifiedAddressRequest::ALLOW_ALL) + .ok()?; + ua.transparent().copied() + } + + #[test] + fn parses_the_string_sources() { + let params = test_params(); + assert_eq!( + FundSource::parse(&JsonValue::from("orchard"), ¶ms).unwrap(), + FundSource::Orchard, + ); + assert_eq!( + FundSource::parse(&JsonValue::from("sapling"), ¶ms).unwrap(), + FundSource::Sapling, + ); + assert_eq!( + FundSource::parse(&JsonValue::from("any_transparent"), ¶ms).unwrap(), + FundSource::AnyTransparent, + ); + } + + #[test] + fn rejects_an_unknown_source() { + let params = test_params(); + assert!(FundSource::parse(&JsonValue::from("ironwood"), ¶ms).is_err()); + assert!(FundSource::parse(&JsonValue::from("transparent"), ¶ms).is_err()); + assert!(FundSource::parse(&JsonValue::from(7), ¶ms).is_err()); + } + + #[test] + fn rejects_an_empty_address_array() { + let params = test_params(); + let empty = JsonValue::Array(vec![]); + assert!(FundSource::parse(&empty, ¶ms).is_err()); + } + + #[test] + fn rejects_a_non_transparent_address_in_the_array() { + let params = test_params(); + let seed = [3u8; 32]; + let usk = UnifiedSpendingKey::from_seed(¶ms, &seed, AccountId::ZERO).unwrap(); + let (ua, _) = usk + .to_unified_full_viewing_key() + .default_address(UnifiedAddressRequest::ALLOW_ALL) + .unwrap(); + + // A unified address is not a transparent address, even though it has a transparent + // receiver: the array names the addresses whose UTXOs may be spent, and a UA does not + // identify one. + let arr = JsonValue::Array(vec![JsonValue::from(ua.encode(¶ms))]); + assert!(FundSource::parse(&arr, ¶ms).is_err()); + } + + /// A shielded source must never permit transparent spending, and must permit only the pool + /// it names: the parameter exists to bound where the funds come from. + #[test] + fn shielded_sources_permit_only_their_own_pool() { + let orchard = FundSource::Orchard.spend_policy(); + assert!(orchard.transparent().is_none()); + assert!(orchard.permits_shielded(ShieldedPool::Orchard)); + // Orchard-receiver funds live in Ironwood once NU6.3 activates. + assert!(orchard.permits_shielded(ShieldedPool::Ironwood)); + assert!(!orchard.permits_shielded(ShieldedPool::Sapling)); + + let sapling = FundSource::Sapling.spend_policy(); + assert!(sapling.transparent().is_none()); + assert!(sapling.permits_shielded(ShieldedPool::Sapling)); + assert!(!sapling.permits_shielded(ShieldedPool::Orchard)); + assert!(!sapling.permits_shielded(ShieldedPool::Ironwood)); + } + + /// `any_transparent` spends the account's transparent funds and no shielded note. The + /// permitted-pool set being empty says so exhaustively, including for any pool added later. + #[test] + fn any_transparent_permits_transparent_and_no_shielded_pool() { + let policy = FundSource::AnyTransparent.spend_policy(); + + assert!(policy.shielded().is_empty()); + + let transparent = policy + .transparent() + .expect("transparent spending permitted"); + assert!(matches!( + transparent.source(), + TransparentSource::AnyAccountAddr, + )); + assert_eq!(transparent.coinbase(), CoinbasePolicy::NonCoinbase); + } + + proptest! { + // Each case derives a spending key, which is expensive, so take fewer samples than the + // default 256. The properties hold for every address, not for rare corners of the seed + // space, so a modest sample establishes them. + #![proptest_config(ProptestConfig::with_cases(16))] + + /// Naming transparent addresses confines selection to exactly those addresses, and to + /// no shielded pool. + #[test] + fn named_transparent_addresses_are_the_only_source( + seed in any::<[u8; 32]>(), + account in 0u32..(1 << 31), + ) { + let Some(taddr) = taddr_from(&seed, account) else { return Ok(()) }; + + let source = FundSource::Transparent([taddr].into_iter().collect()); + let policy = source.spend_policy(); + + prop_assert!(policy.shielded().is_empty()); + + let transparent = policy + .transparent() + .expect("a transparent source permits transparent spending"); + + match transparent.source() { + TransparentSource::FromAddresses(addrs) => prop_assert_eq!( + addrs.iter().copied().collect::>(), + vec![taddr], + ), + other => prop_assert!(false, "expected named addresses, got {other:?}"), + } + prop_assert_eq!(transparent.coinbase(), CoinbasePolicy::NonCoinbase); + } + + /// A transparent address round-trips through the JSON parameter. + #[test] + fn parses_an_array_of_transparent_addresses( + seed in any::<[u8; 32]>(), + account in 0u32..(1 << 31), + ) { + let params = test_params(); + let Some(taddr) = taddr_from(&seed, account) else { return Ok(()) }; + + let arr = JsonValue::Array(vec![JsonValue::from(taddr.encode(¶ms))]); + let parsed = FundSource::parse(&arr, ¶ms).expect("a valid transparent address"); + + prop_assert_eq!( + parsed, + FundSource::Transparent([taddr].into_iter().collect()), + ); + } + } +} diff --git a/zallet-core/src/components/json_rpc/methods.rs b/zallet-core/src/components/json_rpc/methods.rs index 3288122e..fdcf2cdc 100644 --- a/zallet-core/src/components/json_rpc/methods.rs +++ b/zallet-core/src/components/json_rpc/methods.rs @@ -69,6 +69,8 @@ mod z_get_total_balance; #[cfg(zallet_build = "wallet")] mod z_import_address; #[cfg(zallet_build = "wallet")] +mod z_propose_transaction; +#[cfg(zallet_build = "wallet")] mod z_send_many; #[cfg(zallet_build = "wallet")] mod z_shieldcoinbase; @@ -624,6 +626,33 @@ pub(crate) trait WalletRpc { /// - `"NoPrivacy"`: Allow the transaction to reveal any information necessary to /// create it. This implies revealing information described under /// `"AllowFullyTransparent"` and `"AllowLinkingAccountAddresses"`. + /// Proposes a transaction sending funds from an account, returning a PCZT for + /// inspection along with the privacy policy required to execute it. + /// + /// Unlike `z_sendmany`, the source of funds is an account UUID together with an + /// explicit fund source, rather than an address. This method does not sign or broadcast + /// the transaction, and does not generate proofs; pass the returned PCZT to + /// `z_finalizetransaction` to do so. + /// + /// # Arguments + /// - `account`: The UUID of the account to send the funds from. + /// - `fund_source`: Where funds may be drawn from. One of the strings `"orchard"`, + /// `"sapling"`, `"any_transparent"`, or an array of transparent address strings. + /// - `recipients`: An array of JSON objects representing the amounts to send, with the + /// same shape as `z_sendmany`'s `amounts`. + /// - `minconf` (numeric, optional): Only use funds confirmed at least this many times. + /// + /// The privacy policy required to execute the proposal is computed from the resulting + /// transaction and returned alongside the PCZT; the caller does not supply one. + #[method(name = "z_proposetransaction")] + async fn z_propose_transaction( + &self, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, + ) -> z_propose_transaction::Response; + #[method(name = "z_sendmany")] async fn z_send_many( &self, @@ -1067,6 +1096,24 @@ impl WalletRpcServer for WalletRpcImpl { .await } + async fn z_propose_transaction( + &self, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, + ) -> z_propose_transaction::Response { + z_propose_transaction::call( + self.wallet().await?, + self.keystore.clone(), + account, + fund_source, + recipients, + minconf, + ) + .await + } + async fn z_send_many( &self, fromaddress: String, diff --git a/zallet-core/src/components/json_rpc/methods/z_propose_transaction.rs b/zallet-core/src/components/json_rpc/methods/z_propose_transaction.rs new file mode 100644 index 00000000..bc44362d --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/z_propose_transaction.rs @@ -0,0 +1,148 @@ +use std::convert::Infallible; +use std::num::NonZeroU32; + +use abscissa_core::Application; +use documented::Documented; +use jsonrpsee::core::{JsonValue, RpcResult}; +use orchard::builder::BundleType; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use zcash_client_backend::{ + data_api::{ + WalletRead, + wallet::{ConfirmationsPolicy, create_pczt_from_proposal}, + }, + wallet::OvkPolicy, +}; + +use crate::{ + components::{ + database::DbHandle, + json_rpc::{ + fund_source::FundSource, + payments::{ + AmountParameter, build_request, pczt_policy_key, propose_transfer_with_policy, + record_required_policy, required_privacy_policy, + }, + server::LegacyCode, + utils::parse_account_parameter, + }, + keystore::KeyStore, + }, + prelude::*, +}; + +/// Response to a `z_proposetransaction` RPC request. +pub(crate) type Response = RpcResult; + +/// A proposed transaction, returned for inspection before it is finalized. +#[derive(Clone, Debug, Serialize, Deserialize, Documented, JsonSchema)] +pub(crate) struct ResultType { + /// The proposed transaction as a hex-encoded PCZT. + /// + /// This can be inspected to review the transaction's effects, and later passed to + /// `z_finalizetransaction` to sign and broadcast it. + pczt: String, + + /// The privacy policy required to execute this transaction. + /// + /// This is the strictest policy that permits the proposed transaction; it must be supplied + /// to `z_finalizetransaction` as acknowledgement of the transaction's privacy implications. + privacy_policy: String, +} + +pub(super) const PARAM_ACCOUNT_DESC: &str = "The UUID of the account to send the funds from."; +pub(super) const PARAM_FUND_SOURCE_DESC: &str = "Where funds may be drawn from: \"orchard\", \"sapling\", \"any_transparent\", or an array \ + of transparent addresses."; +pub(super) const PARAM_RECIPIENTS_DESC: &str = + "An array of JSON objects representing the amounts to send."; +pub(super) const PARAM_RECIPIENTS_REQUIRED: bool = true; +pub(super) const PARAM_MINCONF_DESC: &str = "Only use funds confirmed at least this many times."; + +pub(crate) async fn call( + mut wallet: DbHandle, + keystore: KeyStore, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, +) -> Response { + let request = build_request(&recipients)?; + + let account_id = parse_account_parameter(wallet.as_ref(), &keystore, &account).await?; + + // Validate that the account exists before proposing, for a clear error. + if wallet + .as_ref() + .get_account(account_id) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .is_none() + { + return Err(LegacyCode::InvalidParameter + .with_message(format!("No account with UUID {}", account_id.expose_uuid()))); + } + + let fund_source = FundSource::parse(&fund_source, wallet.params())?; + + let confirmations_policy = match minconf { + Some(minconf) => NonZeroU32::new(minconf).map_or( + ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, true), + |c| ConfirmationsPolicy::new_symmetrical(c, false), + ), + None => { + APP.config().builder.confirmations_policy().map_err(|_| { + LegacyCode::Wallet.with_message( + "Configuration error: minimum confirmations for spending trusted TXOs cannot exceed that for untrusted TXOs.") + })? + } + }; + + let params = *wallet.params(); + + let proposal = propose_transfer_with_policy( + wallet.as_mut(), + ¶ms, + account_id, + request, + confirmations_policy, + &fund_source.spend_policy(), + )?; + + // No privacy policy is required to PROPOSE: the point of this method is to let the caller + // see what the transaction would reveal, and decide. The policy is reported here and + // enforced at `z_finalizetransaction`. + let privacy_policy = required_privacy_policy(&proposal); + + // Build the PCZT from the proposal. This touches no spending key and generates no proof; + // both are deferred to `z_finalizetransaction`. + // No expiry override: let the builder derive the expiry from the target height. + let target_expiry_height = None; + + // The change strategy does not request unpadded Orchard bundles, so the bundle type must + // be the default; the two have to agree or the PCZT will not match the proposal. + let orchard_pool_bundle_type = BundleType::DEFAULT; + + let pczt = create_pczt_from_proposal::<_, _, Infallible, _, Infallible, _>( + wallet.as_mut(), + ¶ms, + account_id, + OvkPolicy::Sender, + &proposal, + target_expiry_height, + orchard_pool_bundle_type, + ) + .map_err(|e| LegacyCode::Wallet.with_message(format!("Failed to create PCZT: {e}")))?; + + // Record the required policy so `z_finalizetransaction` can check the caller's + // acknowledgement against what the transaction actually reveals, rather than re-deriving it + // from the PCZT, which does not carry enough information to do so faithfully. + let pczt_bytes = pczt + .serialize() + .map_err(|e| LegacyCode::Wallet.with_message(format!("Failed to serialize PCZT: {e:?}")))?; + record_required_policy(pczt_policy_key(&pczt_bytes), privacy_policy); + + Ok(ResultType { + pczt: hex::encode(pczt_bytes), + privacy_policy: privacy_policy.to_string(), + }) +} diff --git a/zallet-core/src/components/json_rpc/payments.rs b/zallet-core/src/components/json_rpc/payments.rs index b49fe400..2b1d3ebb 100644 --- a/zallet-core/src/components/json_rpc/payments.rs +++ b/zallet-core/src/components/json_rpc/payments.rs @@ -1,10 +1,16 @@ -use std::{collections::HashSet, convert::Infallible, fmt}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + convert::Infallible, + fmt, + sync::{LazyLock, Mutex}, +}; use abscissa_core::Application; use jsonrpsee::core::JsonValue; use jsonrpsee::{core::RpcResult, types::ErrorObjectOwned}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use zcash_address::ZcashAddress; use zcash_client_backend::{ data_api::{ @@ -636,6 +642,63 @@ mod parse_memo_tests { } } +/// Maximum number of proposal policies retained by [`RequiredPolicyCache`]. +const REQUIRED_POLICY_CACHE_CAPACITY: usize = 256; + +/// A bounded, insertion-ordered cache mapping a PCZT (by content hash) to the [`PrivacyPolicy`] +/// required to execute it. +/// +/// `z_proposetransaction` computes the required policy exactly from the proposal and records it +/// here; `z_finalizetransaction` looks it up to enforce that the caller acknowledged a sufficient +/// policy, without having to re-derive it from the (lossy) PCZT. Entries are evicted in insertion +/// order once the capacity is exceeded. +struct RequiredPolicyCache { + by_pczt: HashMap<[u8; 32], PrivacyPolicy>, + order: VecDeque<[u8; 32]>, + capacity: usize, +} + +impl RequiredPolicyCache { + fn new(capacity: usize) -> Self { + Self { + by_pczt: HashMap::new(), + order: VecDeque::new(), + capacity, + } + } + + fn insert(&mut self, key: [u8; 32], policy: PrivacyPolicy) { + // Re-inserting an existing key just refreshes the policy without growing the cache. + if self.by_pczt.insert(key, policy).is_none() { + self.order.push_back(key); + while self.order.len() > self.capacity { + if let Some(evicted) = self.order.pop_front() { + self.by_pczt.remove(&evicted); + } + } + } + } +} + +static REQUIRED_POLICY_CACHE: LazyLock> = + LazyLock::new(|| Mutex::new(RequiredPolicyCache::new(REQUIRED_POLICY_CACHE_CAPACITY))); + +/// The cache key for a PCZT: the SHA-256 of its serialized bytes. +/// +/// `z_proposetransaction` and `z_finalizetransaction` hash the same canonical serialization, so +/// the policy recorded at proposal time is found again at finalize time. +pub(super) fn pczt_policy_key(pczt_bytes: &[u8]) -> [u8; 32] { + Sha256::digest(pczt_bytes).into() +} + +/// Records the privacy policy required to execute the PCZT identified by `key`. +pub(super) fn record_required_policy(key: [u8; 32], policy: PrivacyPolicy) { + REQUIRED_POLICY_CACHE + .lock() + .expect("policy cache mutex is not poisoned") + .insert(key, policy); +} + /// Whether change may be returned to the transparent pool. /// /// Permitted exactly when `spend_policy` can spend transparent funds in the first place, which From 33fdd2f8662907e7213cdce340afe59686af38e4 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 18:14:22 +0200 Subject: [PATCH 7/9] Implement z_sendfromaccount Sends funds from an account in one shot: propose, enforce the privacy policy, sign, and broadcast, returning the txid rather than an operation id. The source of funds is an account UUID plus a `fund_source`, rather than an address as in `z_sendmany`. Because the send is executed immediately rather than proposed for review, the privacy policy is required rather than optional: the caller has no opportunity to inspect what the transaction reveals, so they must acknowledge it up front. The proposal is built by the shared `propose_transfer_with_policy`, so there is no separate transparent-spend path here: `fund_source.spend_policy()` is the whole of the difference from `z_sendmany`. `SendResult` gains the `Documented` and `JsonSchema` derives that the OpenRPC generator requires of a method result type; it was previously only used as an async-operation payload, which is not part of that surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/json_rpc/methods.rs | 49 ++++++ .../json_rpc/methods/z_send_from_account.rs | 152 ++++++++++++++++++ .../src/components/json_rpc/payments.rs | 3 +- 3 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 zallet-core/src/components/json_rpc/methods/z_send_from_account.rs diff --git a/zallet-core/src/components/json_rpc/methods.rs b/zallet-core/src/components/json_rpc/methods.rs index fdcf2cdc..aeecd820 100644 --- a/zallet-core/src/components/json_rpc/methods.rs +++ b/zallet-core/src/components/json_rpc/methods.rs @@ -71,6 +71,8 @@ mod z_import_address; #[cfg(zallet_build = "wallet")] mod z_propose_transaction; #[cfg(zallet_build = "wallet")] +mod z_send_from_account; +#[cfg(zallet_build = "wallet")] mod z_send_many; #[cfg(zallet_build = "wallet")] mod z_shieldcoinbase; @@ -653,6 +655,32 @@ pub(crate) trait WalletRpc { minconf: Option, ) -> z_propose_transaction::Response; + /// Sends funds from an account in one shot, signing and broadcasting the transaction. + /// + /// Unlike `z_sendmany`, the source of funds is an account UUID together with an explicit + /// fund source, rather than an address. Unlike `z_proposetransaction`, the transaction is + /// executed immediately, so the caller must acknowledge its privacy implications up front + /// by supplying the policy to enforce. + /// + /// # Arguments + /// - `account`: The UUID of the account to send the funds from. + /// - `fund_source`: Where funds may be drawn from. One of the strings `"orchard"`, + /// `"sapling"`, `"any_transparent"`, or an array of transparent address strings. + /// - `recipients`: An array of JSON objects representing the amounts to send, with the + /// same shape as `z_sendmany`'s `amounts`. + /// - `minconf` (numeric, optional): Only use funds confirmed at least this many times. + /// - `privacy_policy`: Policy for what information leakage is acceptable, using the same + /// values as `z_sendmany`. Required: the send is not proposed for review first. + #[method(name = "z_sendfromaccount")] + async fn z_send_from_account( + &self, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, + privacy_policy: String, + ) -> z_send_from_account::Response; + #[method(name = "z_sendmany")] async fn z_send_many( &self, @@ -1114,6 +1142,27 @@ impl WalletRpcServer for WalletRpcImpl { .await } + async fn z_send_from_account( + &self, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, + privacy_policy: String, + ) -> z_send_from_account::Response { + z_send_from_account::call( + self.wallet().await?, + self.keystore.clone(), + self.chain().await?, + account, + fund_source, + recipients, + minconf, + privacy_policy, + ) + .await + } + async fn z_send_many( &self, fromaddress: String, diff --git a/zallet-core/src/components/json_rpc/methods/z_send_from_account.rs b/zallet-core/src/components/json_rpc/methods/z_send_from_account.rs new file mode 100644 index 00000000..96f41957 --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/z_send_from_account.rs @@ -0,0 +1,152 @@ +use std::num::NonZeroU32; + +use abscissa_core::Application; +use jsonrpsee::core::{JsonValue, RpcResult}; +use secrecy::ExposeSecret; +use zcash_client_backend::data_api::{ + Account, WalletRead, + wallet::{ConfirmationsPolicy, SpendingKeys}, +}; +use zcash_keys::keys::UnifiedSpendingKey; + +use crate::{ + components::{ + chain::Chain, + database::DbHandle, + json_rpc::{ + fund_source::FundSource, + methods::z_send_many::{check_orchard_actions_limit, run}, + payments::{ + AmountParameter, SendResult, build_request, enforce_privacy_policy, + parse_privacy_policy, propose_transfer_with_policy, + }, + server::LegacyCode, + utils::parse_account_parameter, + }, + keystore::KeyStore, + }, + prelude::*, +}; + +#[cfg(feature = "zcashd-import")] +use crate::components::json_rpc::utils::collect_standalone_transparent_keys; + +/// Response to a `z_sendfromaccount` RPC request. +pub(crate) type Response = RpcResult; + +/// The result of a `z_sendfromaccount` request: the resulting transaction ID(s). +pub(crate) type ResultType = SendResult; + +pub(super) const PARAM_ACCOUNT_DESC: &str = "The UUID of the account to send the funds from."; +pub(super) const PARAM_FUND_SOURCE_DESC: &str = "Where funds may be drawn from: \"orchard\", \"sapling\", \"any_transparent\", or an array \ + of transparent addresses."; +pub(super) const PARAM_RECIPIENTS_DESC: &str = + "An array of JSON objects representing the amounts to send."; +pub(super) const PARAM_RECIPIENTS_REQUIRED: bool = true; +pub(super) const PARAM_MINCONF_DESC: &str = "Only use funds confirmed at least this many times."; +pub(super) const PARAM_PRIVACY_POLICY_DESC: &str = + "Policy for what information leakage is acceptable."; + +#[allow(clippy::too_many_arguments)] +pub(crate) async fn call( + mut wallet: DbHandle, + keystore: KeyStore, + chain: C, + account: JsonValue, + fund_source: JsonValue, + recipients: Vec, + minconf: Option, + privacy_policy: String, +) -> Response { + let request = build_request(&recipients)?; + + let account_id = parse_account_parameter(wallet.as_ref(), &keystore, &account).await?; + + // Fetch the account up front: it both validates that the account exists and provides the + // key derivation needed to sign the transaction. + let account = wallet + .as_ref() + .get_account(account_id) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| { + LegacyCode::InvalidParameter + .with_message(format!("No account with UUID {}", account_id.expose_uuid())) + })?; + + let fund_source = FundSource::parse(&fund_source, wallet.params())?; + + // Unlike `z_proposetransaction`, which reports what the transaction would reveal and lets + // the caller decide, this method sends in one shot. So the caller must acknowledge the + // privacy implications up front: the policy is required, not optional. + let privacy_policy = parse_privacy_policy(Some(&privacy_policy))?; + + let confirmations_policy = match minconf { + Some(minconf) => NonZeroU32::new(minconf).map_or( + ConfirmationsPolicy::new_symmetrical(NonZeroU32::MIN, true), + |c| ConfirmationsPolicy::new_symmetrical(c, false), + ), + None => { + APP.config().builder.confirmations_policy().map_err(|_| { + LegacyCode::Wallet.with_message( + "Configuration error: minimum confirmations for spending trusted TXOs cannot exceed that for untrusted TXOs.") + })? + } + }; + + let params = *wallet.params(); + + let proposal = propose_transfer_with_policy( + wallet.as_mut(), + ¶ms, + account_id, + request, + confirmations_policy, + &fund_source.spend_policy(), + )?; + + enforce_privacy_policy(&proposal, privacy_policy)?; + + check_orchard_actions_limit(&proposal)?; + + let derivation = account.source().key_derivation().ok_or_else(|| { + LegacyCode::InvalidAddressOrKey + .with_static("Cannot spend from an account that has no spending key.") + })?; + + // Fetch the spending key last, to avoid a keystore decryption if unnecessary. + let seed = keystore + .decrypt_seed(derivation.seed_fingerprint()) + .await + .map_err(|e| match e.kind() { + // TODO: Improve internal error types. + // https://github.com/zcash/zallet/issues/256 + crate::error::ErrorKind::Generic if e.to_string() == "Wallet is locked" => { + LegacyCode::WalletUnlockNeeded.with_message(e.to_string()) + } + _ => LegacyCode::Database.with_message(e.to_string()), + })?; + let usk = UnifiedSpendingKey::from_seed( + wallet.params(), + seed.expose_secret(), + derivation.account_index(), + ) + .map_err(|e| LegacyCode::InvalidAddressOrKey.with_message(e.to_string()))?; + + #[cfg(feature = "zcashd-import")] + let standalone_keys = + collect_standalone_transparent_keys(wallet.as_ref(), &keystore, account_id, &proposal) + .await?; + + // Unlike `z_sendmany`, this performs the whole operation in one shot rather than handing it + // to the background operation system, so the caller gets the txid rather than an opid. + run( + wallet, + chain, + proposal, + #[cfg(feature = "zcashd-import")] + SpendingKeys::new(usk, standalone_keys), + #[cfg(not(feature = "zcashd-import"))] + SpendingKeys::from_unified_spending_key(usk), + ) + .await +} diff --git a/zallet-core/src/components/json_rpc/payments.rs b/zallet-core/src/components/json_rpc/payments.rs index 2b1d3ebb..dd2cbb88 100644 --- a/zallet-core/src/components/json_rpc/payments.rs +++ b/zallet-core/src/components/json_rpc/payments.rs @@ -6,6 +6,7 @@ use std::{ }; use abscissa_core::Application; +use documented::Documented; use jsonrpsee::core::JsonValue; use jsonrpsee::{core::RpcResult, types::ErrorObjectOwned}; use schemars::JsonSchema; @@ -859,7 +860,7 @@ pub(super) async fn broadcast_transactions( } /// The result of sending a payment. -#[derive(Clone, Debug, Serialize)] +#[derive(Clone, Debug, Serialize, Documented, JsonSchema)] pub(crate) struct SendResult { /// The ID of the resulting transaction, if the payment only produced one. /// From 4fd83d6391f9aaefb207b8b7da66000cf1d83610 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 18:17:57 +0200 Subject: [PATCH 8/9] Implement z_finalizetransaction Takes a PCZT from `z_proposetransaction`, applies this account's proof generation keys, proofs, and spend authorizing signatures, extracts the transaction, and broadcasts it. Shielded spends do not say which of them belong to this account, so each candidate signature is attempted and wrong-key errors are ignored, following the reference driver in `zcash_client_backend`. Transparent inputs, by contrast, identify themselves through their BIP 44 derivation, so the seed fingerprint and coin type pick out this account's inputs and the corresponding key is derived to sign each. The privacy policy the caller supplies is held against the policy `z_proposetransaction` recorded for this PCZT, so a caller cannot acknowledge a weaker policy than the transaction actually requires. On a cache miss (eviction, restart, or a PCZT proposed elsewhere) the requirement cannot be re-derived faithfully from the PCZT, so the supplied policy is accepted as acknowledgement only; see zcash/wallet#217. The Orchard proving and verifying keys are deterministic and take seconds to build, so they are built once and shared. Proving, signing, and verification are CPU-bound and run on the blocking pool. `bip32` is taken at the 0.6 line rather than through the workspace: the workspace entry is the 0.2 line used by the zcashd-import path, and `extract_bip_44_fields` speaks the newer one. Co-Authored-By: Claude Opus 4.8 (1M context) --- Cargo.lock | 1 + backends/zaino/Cargo.lock | 1 + backends/zebra/Cargo.lock | 1 + zallet-core/Cargo.toml | 1 + .../src/components/json_rpc/methods.rs | 38 ++ .../methods/z_finalize_transaction.rs | 360 ++++++++++++++++++ .../src/components/json_rpc/payments.rs | 13 + 7 files changed, 415 insertions(+) create mode 100644 zallet-core/src/components/json_rpc/methods/z_finalize_transaction.rs diff --git a/Cargo.lock b/Cargo.lock index 83672107..ae9bddbd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5570,6 +5570,7 @@ dependencies = [ "base64ct", "bech32 0.11.1", "bip0039", + "bip32", "blake2b_simd", "clap", "clap_complete", diff --git a/backends/zaino/Cargo.lock b/backends/zaino/Cargo.lock index 1cb66a1f..bb9c2e12 100644 --- a/backends/zaino/Cargo.lock +++ b/backends/zaino/Cargo.lock @@ -6802,6 +6802,7 @@ dependencies = [ "base64ct", "bech32 0.11.1", "bip0039", + "bip32", "blake2b_simd", "clap", "clap_complete", diff --git a/backends/zebra/Cargo.lock b/backends/zebra/Cargo.lock index 3f2435e2..a08bf26e 100644 --- a/backends/zebra/Cargo.lock +++ b/backends/zebra/Cargo.lock @@ -6378,6 +6378,7 @@ dependencies = [ "base64ct", "bech32 0.11.1", "bip0039", + "bip32", "blake2b_simd", "clap", "clap_complete", diff --git a/zallet-core/Cargo.toml b/zallet-core/Cargo.toml index 3be91d47..5b15a9fc 100644 --- a/zallet-core/Cargo.toml +++ b/zallet-core/Cargo.toml @@ -16,6 +16,7 @@ futures.workspace = true tokio = { workspace = true, features = ["fs", "io-std", "io-util", "rt-multi-thread", "signal"] } # Data structures +bip32 = { version = "=0.6.0-pre.1", default-features = false, features = ["alloc"] } nonempty.workspace = true pczt.workspace = true diff --git a/zallet-core/src/components/json_rpc/methods.rs b/zallet-core/src/components/json_rpc/methods.rs index aeecd820..b7f2b2f6 100644 --- a/zallet-core/src/components/json_rpc/methods.rs +++ b/zallet-core/src/components/json_rpc/methods.rs @@ -63,6 +63,8 @@ mod validate_address; mod verify_message; mod view_transaction; #[cfg(zallet_build = "wallet")] +mod z_finalize_transaction; +#[cfg(zallet_build = "wallet")] mod z_get_balance_for_account; #[cfg(zallet_build = "wallet")] mod z_get_total_balance; @@ -646,6 +648,25 @@ pub(crate) trait WalletRpc { /// /// The privacy policy required to execute the proposal is computed from the resulting /// transaction and returned alongside the PCZT; the caller does not supply one. + /// Finalizes a proposed transaction, signing and broadcasting it. + /// + /// Takes a PCZT produced by `z_proposetransaction`, applies this account's spend + /// authorizing signatures and proofs, extracts the resulting transaction, and broadcasts + /// it, returning the resulting txid. + /// + /// # Arguments + /// - `account`: The UUID of the account whose keys should sign the transaction. + /// - `pczt`: The hex-encoded PCZT to finalize, as returned by `z_proposetransaction`. + /// - `privacy_policy`: Policy for what information leakage is acceptable, acknowledging + /// the privacy implications reported by `z_proposetransaction`. + #[method(name = "z_finalizetransaction")] + async fn z_finalize_transaction( + &self, + account: JsonValue, + pczt: String, + privacy_policy: String, + ) -> z_finalize_transaction::Response; + #[method(name = "z_proposetransaction")] async fn z_propose_transaction( &self, @@ -1124,6 +1145,23 @@ impl WalletRpcServer for WalletRpcImpl { .await } + async fn z_finalize_transaction( + &self, + account: JsonValue, + pczt: String, + privacy_policy: String, + ) -> z_finalize_transaction::Response { + z_finalize_transaction::call( + self.wallet().await?, + self.keystore.clone(), + self.chain().await?, + account, + pczt, + privacy_policy, + ) + .await + } + async fn z_propose_transaction( &self, account: JsonValue, diff --git a/zallet-core/src/components/json_rpc/methods/z_finalize_transaction.rs b/zallet-core/src/components/json_rpc/methods/z_finalize_transaction.rs new file mode 100644 index 00000000..c2cf83da --- /dev/null +++ b/zallet-core/src/components/json_rpc/methods/z_finalize_transaction.rs @@ -0,0 +1,360 @@ +use std::sync::LazyLock; + +use bip32::ChildNumber; +use jsonrpsee::core::{JsonValue, RpcResult}; +use pczt::{ + Pczt, + roles::{ + prover::Prover, + signer::{self, Signer}, + updater::Updater, + }, +}; +use secrecy::ExposeSecret; +use transparent::keys::{NonHardenedChildIndex, TransparentKeyScope}; +use zcash_client_backend::data_api::{ + Account, WalletRead, wallet::extract_and_store_transaction_from_pczt, +}; +use zcash_client_sqlite::ReceivedNoteId; +use zcash_keys::keys::UnifiedSpendingKey; +use zcash_proofs::prover::LocalTxProver; +use zcash_protocol::consensus::NetworkConstants; +use zip32::fingerprint::SeedFingerprint; + +use crate::components::{ + chain::Chain, + database::DbHandle, + json_rpc::{ + payments::{ + SendResult, broadcast_transactions, cached_required_policy, parse_privacy_policy, + pczt_policy_key, + }, + server::LegacyCode, + utils::parse_account_parameter, + }, + keystore::KeyStore, +}; + +/// The Orchard proving and verifying keys are deterministic and expensive to build (each +/// takes on the order of seconds), so build them once and reuse them across requests. +static ORCHARD_PROVING_KEY: LazyLock = LazyLock::new(|| { + orchard::circuit::ProvingKey::build(orchard::circuit::OrchardCircuitVersion::FixedPostNu6_2) +}); +static ORCHARD_VERIFYING_KEY: LazyLock = LazyLock::new(|| { + orchard::circuit::VerifyingKey::build(orchard::circuit::OrchardCircuitVersion::FixedPostNu6_2) +}); + +/// Response to a `z_finalizetransaction` RPC request. +pub(crate) type Response = RpcResult; + +/// The result of a `z_finalizetransaction` request: the resulting transaction ID(s). +pub(crate) type ResultType = SendResult; + +pub(super) const PARAM_ACCOUNT_DESC: &str = + "The UUID of the account whose keys should sign the transaction."; +pub(super) const PARAM_PCZT_DESC: &str = + "The hex-encoded PCZT to finalize, as returned by z_proposetransaction."; +pub(super) const PARAM_PRIVACY_POLICY_DESC: &str = "Policy for what information leakage is acceptable, acknowledging the transaction's privacy \ + implications."; + +pub(crate) async fn call( + wallet: DbHandle, + keystore: KeyStore, + chain: C, + account: JsonValue, + pczt: String, + privacy_policy: String, +) -> Response { + let privacy_policy = parse_privacy_policy(Some(&privacy_policy))?; + + let pczt = decode_pczt(&pczt)?; + + // If this PCZT was proposed by this node, `z_proposetransaction` recorded the policy it + // requires (computed exactly from the proposal). Enforce that the caller acknowledged a + // sufficient policy. On a cache miss (eviction, restart, or a PCZT proposed elsewhere) we + // cannot re-derive the requirement reliably, so the supplied policy is accepted as + // acknowledgement only. https://github.com/zcash/wallet/issues/217 + let pczt_bytes = pczt + .clone() + .serialize() + .map_err(|e| LegacyCode::Wallet.with_message(format!("Failed to serialize PCZT: {e:?}")))?; + if let Some(required) = cached_required_policy(&pczt_policy_key(&pczt_bytes)) { + if !privacy_policy.is_compatible_with(required) { + return Err(LegacyCode::InvalidParameter.with_message(format!( + "The privacy policy {privacy_policy} does not permit this transaction, which \ + requires at least {required}." + ))); + } + } + + let account_id = parse_account_parameter(wallet.as_ref(), &keystore, &account).await?; + + let account = wallet + .as_ref() + .get_account(account_id) + .map_err(|e| LegacyCode::Database.with_message(e.to_string()))? + .ok_or_else(|| { + LegacyCode::InvalidParameter + .with_message(format!("No account with UUID {}", account_id.expose_uuid())) + })?; + + let derivation = account.source().key_derivation().ok_or_else(|| { + LegacyCode::InvalidAddressOrKey + .with_static("Cannot sign for an account that has no spending key.") + })?; + + // The seed fingerprint and coin type identify which transparent inputs belong to this + // account, so that the correct key can be derived for each. + let seed_fp = *derivation.seed_fingerprint(); + let coin_type = ChildNumber::new(wallet.params().coin_type(), true) + .map_err(|e| LegacyCode::Wallet.with_message(format!("Invalid coin type: {e}")))?; + + let seed = keystore + .decrypt_seed(derivation.seed_fingerprint()) + .await + .map_err(|e| match e.kind() { + crate::error::ErrorKind::Generic if e.to_string() == "Wallet is locked" => { + LegacyCode::WalletUnlockNeeded.with_message(e.to_string()) + } + _ => LegacyCode::Database.with_message(e.to_string()), + })?; + let usk = UnifiedSpendingKey::from_seed( + wallet.params(), + seed.expose_secret(), + derivation.account_index(), + ) + .map_err(|e| LegacyCode::InvalidAddressOrKey.with_message(e.to_string()))?; + + // Proving, signing, and proof verification are CPU-bound; run them on the blocking pool. + let (wallet, txid) = crate::spawn_blocking!("z_finalizetransaction prover", move || { + let pczt = authorize_pczt(pczt, &usk, &seed_fp, coin_type)?; + + let prover = LocalTxProver::bundled(); + let (spend_vk, output_vk) = prover.verifying_keys(); + + let mut wallet = wallet; + let txid = extract_and_store_transaction_from_pczt::<_, ReceivedNoteId>( + wallet.as_mut(), + pczt, + Some((&spend_vk, &output_vk)), + Some(&ORCHARD_VERIFYING_KEY), + ) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to extract transaction from PCZT: {e}")) + })?; + + Ok::<_, jsonrpsee::types::ErrorObjectOwned>((wallet, txid)) + }) + .await + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("PCZT finalization task panicked: {e}")) + })??; + + broadcast_transactions(&wallet, chain, vec![txid]).await +} + +/// Decodes the hex-encoded PCZT argument into a [`Pczt`], mapping malformed input to a +/// JSON-RPC invalid-parameter error. +fn decode_pczt(pczt: &str) -> RpcResult { + let bytes = hex::decode(pczt.trim()) + .map_err(|e| LegacyCode::InvalidParameter.with_message(format!("Invalid PCZT hex: {e}")))?; + Pczt::parse(&bytes) + .map_err(|e| LegacyCode::InvalidParameter.with_message(format!("Invalid PCZT: {e:?}"))) +} + +/// Adds proof generation keys, creates proofs, and applies this account's spend authorizing +/// signatures to a PCZT, returning the fully-authorized PCZT ready for extraction. +/// +/// Handles Sapling, Orchard, and transparent inputs. For the shielded pools the spend +/// metadata does not say which spends belong to this account, so each candidate signature is +/// attempted and wrong-key errors are ignored, matching the reference driver in +/// `zcash_client_backend`. Transparent inputs are matched to this account by their BIP 44 +/// derivation (seed fingerprint and coin type), and the corresponding key is derived to sign. +fn authorize_pczt( + pczt: Pczt, + usk: &UnifiedSpendingKey, + seed_fp: &SeedFingerprint, + coin_type: ChildNumber, +) -> RpcResult { + let sapling_extsk = usk.sapling(); + + // 1. Add Sapling proof generation keys to the account's (non-dummy) spends (Orchard has + // no equivalent step), and identify the transparent inputs belonging to this account + // by their BIP 44 derivation so they can be signed below. + let mut transparent_inputs: Vec<(usize, TransparentKeyScope, NonHardenedChildIndex)> = vec![]; + let pczt = Updater::new(pczt) + .update_sapling_with(|mut updater| { + let spends_without_pgk = updater + .bundle() + .spends() + .iter() + .enumerate() + .filter_map(|(index, spend)| { + spend.proof_generation_key().is_none().then_some(index) + }) + .collect::>(); + + for index in spends_without_pgk { + updater.update_spend_with(index, |mut spend_updater| { + spend_updater + .set_proof_generation_key(sapling_extsk.expsk.proof_generation_key()) + })?; + } + + Ok(()) + }) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!( + "Failed to update PCZT with proof generation keys: {e:?}" + )) + })? + .update_transparent_with(|updater| { + for (index, input) in updater.bundle().inputs().iter().enumerate() { + for derivation in input.bip32_derivation().values() { + if let Some((_account, scope, address_index)) = + derivation.extract_bip_44_fields(seed_fp, coin_type) + { + transparent_inputs.push((index, scope, address_index)); + break; + } + } + } + Ok(()) + }) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to read transparent inputs: {e:?}")) + })? + .finish(); + + // 2. Create proofs, building each (expensive) proving key only when the PCZT needs it. + let prover = Prover::new(pczt); + let prover = if prover.requires_sapling_proofs() { + let sapling_prover = LocalTxProver::bundled(); + prover + .create_sapling_proofs(&sapling_prover, &sapling_prover) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to create Sapling proofs: {e:?}")) + })? + } else { + prover + }; + let prover = if prover.requires_orchard_proof() { + prover + .create_orchard_proof(&ORCHARD_PROVING_KEY) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to create Orchard proof: {e:?}")) + })? + } else { + prover + }; + let pczt = prover.finish(); + + // 3. Derive the signing key for each transparent input identified above. + let mut transparent_keys = vec![]; + for (index, scope, address_index) in transparent_inputs { + let sk = usk + .transparent() + .derive_secret_key(scope, address_index) + .map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to derive transparent key: {e}")) + })?; + transparent_keys.push((index, sk)); + } + + // 4. Apply spend authorizing signatures for both shielded pools and the transparent + // inputs. + let mut signer = Signer::new(pczt).map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to start PCZT signer: {e:?}")) + })?; + + let sapling_ask = &sapling_extsk.expsk.ask; + for index in 0.. { + match signer.sign_sapling(index, sapling_ask) { + Err(signer::Error::InvalidIndex) => break, + Ok(()) + | Err(signer::Error::SaplingSign( + sapling::pczt::SignerError::WrongSpendAuthorizingKey, + )) => {} + Err(e) => { + return Err(LegacyCode::Wallet + .with_message(format!("Failed to apply Sapling signature: {e:?}"))); + } + } + } + + let orchard_ask = orchard::keys::SpendAuthorizingKey::from(usk.orchard()); + for index in 0.. { + match signer.sign_orchard(index, &orchard_ask) { + Err(signer::Error::InvalidIndex) => break, + Ok(()) + | Err(signer::Error::OrchardSign( + orchard::pczt::SignerError::WrongSpendAuthorizingKey, + )) => {} + Err(e) => { + return Err(LegacyCode::Wallet + .with_message(format!("Failed to apply Orchard signature: {e:?}"))); + } + } + } + + for (index, sk) in &transparent_keys { + signer.sign_transparent(*index, sk).map_err(|e| { + LegacyCode::Wallet.with_message(format!("Failed to apply transparent signature: {e:?}")) + })?; + } + + Ok(signer.finish()) +} + +#[cfg(test)] +mod tests { + use proptest::prelude::*; + + use super::*; + + #[test] + fn rejects_non_hex_input() { + let err = decode_pczt("nothex").expect_err("non-hex PCZT should be rejected"); + assert!( + err.message().starts_with("Invalid PCZT hex:"), + "unexpected message: {}", + err.message(), + ); + } + + #[test] + fn rejects_valid_hex_that_is_not_a_pczt() { + // Valid hex, but not a PCZT (wrong magic bytes / structure). + let err = decode_pczt("00010203").expect_err("non-PCZT bytes should be rejected"); + assert!( + err.message().starts_with("Invalid PCZT:"), + "unexpected message: {}", + err.message(), + ); + } + + #[test] + fn ignores_surrounding_whitespace() { + // Whitespace is trimmed before decoding, so the error is about the PCZT contents, + // not the hex. + let err = decode_pczt(" 00 ").expect_err("non-PCZT bytes should be rejected"); + assert!(err.message().starts_with("Invalid PCZT:")); + } + + proptest! { + /// Decoding never panics, whatever the caller passes. + #[test] + fn never_panics_on_arbitrary_strings(s in ".*") { + let _ = decode_pczt(&s); + } + + /// Arbitrary well-formed hex that is not a real PCZT is rejected cleanly (never + /// parses, never panics). + #[test] + fn rejects_arbitrary_hex_bytes(bytes in prop::collection::vec(any::(), 0..64)) { + let err = decode_pczt(&hex::encode(&bytes)) + .expect_err("random bytes are not a valid PCZT"); + prop_assert!(err.message().starts_with("Invalid PCZT:")); + } + } +} diff --git a/zallet-core/src/components/json_rpc/payments.rs b/zallet-core/src/components/json_rpc/payments.rs index dd2cbb88..a5fbed55 100644 --- a/zallet-core/src/components/json_rpc/payments.rs +++ b/zallet-core/src/components/json_rpc/payments.rs @@ -668,6 +668,10 @@ impl RequiredPolicyCache { } } + fn get(&self, key: &[u8; 32]) -> Option { + self.by_pczt.get(key).copied() + } + fn insert(&mut self, key: [u8; 32], policy: PrivacyPolicy) { // Re-inserting an existing key just refreshes the policy without growing the cache. if self.by_pczt.insert(key, policy).is_none() { @@ -700,6 +704,15 @@ pub(super) fn record_required_policy(key: [u8; 32], policy: PrivacyPolicy) { .insert(key, policy); } +/// Returns the previously-recorded required policy for the PCZT identified by `key`, if it is +/// still cached. +pub(super) fn cached_required_policy(key: &[u8; 32]) -> Option { + REQUIRED_POLICY_CACHE + .lock() + .expect("policy cache mutex is not poisoned") + .get(key) +} + /// Whether change may be returned to the transparent pool. /// /// Permitted exactly when `spend_policy` can spend transparent funds in the first place, which From 5d61c6bb2a15fb01c634613ce0b8887a98ff668a Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 11 Jul 2026 18:18:32 +0200 Subject: [PATCH 9/9] CHANGELOG: the account-based transaction methods Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f3ce5530..0289017c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ be considered breaking changes. ### Added +- Three account-based transaction methods. Where `z_sendmany` takes an address as + its source of funds, these take an account UUID together with a `fund_source` + naming where within the account the funds may be drawn from: `"orchard"`, + `"sapling"`, `"any_transparent"`, or an explicit array of transparent addresses. + A source that cannot cover the payment reports insufficient funds rather than + quietly reaching into another pool. + - `z_proposetransaction` proposes a transaction and returns it as a PCZT, along + with the privacy policy required to execute it. Nothing is signed, no proof is + generated, and no spending key is touched, so the caller can inspect what the + transaction would reveal and then decide, instead of having to commit to a + privacy policy up front as `z_sendmany` requires. + - `z_finalizetransaction` signs and broadcasts a PCZT from + `z_proposetransaction`. The privacy policy the caller supplies is held against + the one recorded for that PCZT at proposal time, so a caller cannot acknowledge + a weaker policy than the transaction actually requires. + - `z_sendfromaccount` does both in one shot, returning the txid. Because there is + no proposal to review, the privacy policy is required rather than optional. + - `fund_source: "orchard"` permits the Ironwood pool as well as Orchard. Ironwood + notes are Orchard-shaped, and once NU6.3 activates an account's + Orchard-receiver funds are held there rather than in the legacy Orchard pool, + so restricting the source to Orchard alone would report insufficient funds for + an account whose Orchard money is all in Ironwood. - `z_sendmany` can now spend transparent funds, making transparent-to-transparent transfers possible. Previously it passed a shielded-only spend policy to the proposal builder, so no transparent input could ever be selected, and the