Skip to content

zcash_client_backend: Support creation of transparent change#2550

Merged
nuttycom merged 2 commits into
mainfrom
feat/transparent-change
Jul 9, 2026
Merged

zcash_client_backend: Support creation of transparent change#2550
nuttycom merged 2 commits into
mainfrom
feat/transparent-change

Conversation

@schell

@schell schell commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Closes #1839.

Adds support for returning change to the transparent pool when the net flows of a transaction under construction are fully transparent, enabling zallet to replicate zcashd's z_sendmany behavior for fully-transparent spends. Builds on the transparent-spending support for propose_transaction added in #2422.

All new API surface is gated on the transparent-inputs feature flag.

Design

  • fees::TransparentChangePolicy — a new policy enum with variants ShieldChange (the default, preserving existing behavior) and TransparentChangeAllowed. Configured on the ZIP 317 and fixed change strategies via new with_transparent_change_policy builder methods. The policy only takes effect when the transaction's net flows are fully transparent; transactions involving shielded flows always shield their change, so no shielded change information is ever leaked to the transparent pool.
  • ChangeValue::transparent(value) — a new non-ephemeral transparent change value, distinct from the ephemeral (ZIP 320) transparent output value. Transparent change is always emitted as a single output (the note-splitting policy applies only to shielded change), the exact-balance no-change case is preserved, zero-valued transparent outputs are never created, and the existing DustOutputPolicy machinery applies. The proposal protobuf encoding is wire-compatible: transparent change is encoded with the existing transparent valuePool and isEphemeral unset, a combination that previously failed to decode.
  • WalletWrite::reserve_next_n_internal_addresses — reserves BIP 44 internal-scope (change) transparent addresses, parallel to the existing ephemeral reservation API. The zcash_client_sqlite implementation reuses the existing scope-generic reservation machinery and internal-scope gap limit; no migration is required, since internal-scope gap addresses are already generated at account creation and received change is recorded via the existing Recipient::InternalTransparent handling.
  • create_proposed_transactions (and the PCZT construction/extraction paths) now realize transparent change outputs by reserving internal-scope addresses and recording the outputs against the receiving account.

Because general (non-shielding) transfers exclude coinbase UTXOs from input selection (CoinbaseFilter::NonCoinbaseOnly), transparent change can never be produced by a transaction that spends coinbase outputs, structurally satisfying zcashd's coinbase-spend-forbids-change rule.

Testing

  • Fees-level unit tests covering the transparent-change branch, the exact-match no-change case, the shielded-flows no-op, non-splitting of transparent change, and dust rejection.
  • End-to-end tests of the full t→t-with-change lifecycle: proposal, proposal serialization round trip, transaction creation, internal-scope address reservation/exposure, and post-mining spendability of the change output at the change address.
  • Gap-limit enforcement tests for internal-scope address reservation, including independence from ephemeral-scope reservations.
  • Verified with cargo fmt --check, cargo clippy --all-features --all-targets -- -D warnings, workspace checks across feature combinations (--no-default-features, transparent-inputs alone and with orchard), intra-doc link validation, and the full zcash_client_sqlite test suite (319 passed).

AI disclosure

The commits in this PR were prepared with AI assistance (Claude / opencode) and carry Co-Authored-By trailers accordingly. I have reviewed and take responsibility for all changes.

@dannywillems
dannywillems self-requested a review July 8, 2026 09:56

@dannywillems dannywillems left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We want to have #2539 first, as discussed with @nuttycom. Only requesting changes to block the merge - not requesting actual changes for now.

@nuttycom
nuttycom force-pushed the feat/transparent-change branch from 80741aa to a404d2b Compare July 8, 2026 16:03
@nuttycom
nuttycom requested a review from dannywillems July 8, 2026 16:03
@nuttycom
nuttycom force-pushed the feat/transparent-change branch from a404d2b to 8e370ef Compare July 8, 2026 16:04
.clone()
// Count the standard size of the P2PKH change output when change is
// to be returned to the transparent pool.
.chain(wants_transparent_change.then_some(P2PKH_STANDARD_OUTPUT_SIZE)),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a potential gap here - what happens when the input being spent is a p2sh multisig address? In that case, the transparent change should be returned to the original p2sh multisig. This isn't necessary for the transparent address handling that's needed for z_sendfromaccount at the moment, but I think that this needs an issue filed for follow-up.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, that's a gap. I've created #2570 to track it.

nullcopy
nullcopy previously approved these changes Jul 8, 2026

@nullcopy nullcopy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some questions that may need to become follow-up issues, but nothing blocking for now.

utACK 8e370ef

Comment thread zcash_client_backend/src/data_api/testing/transparent.rs

assert_matches!(
st.wallet_mut().reserve_next_n_internal_addresses(account_id, 1),
Err(e) if is_reached_gap_limit(&e, account_id, 5)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this pattern guard the best way to assert the error reason? Why not match the exact error code?

e.g. if this returns an unexpected error AND is_reached_gap_limit() trips, then this assert will pass when it shouldn't

@schell schell Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The indirection is deliberate, though the reason is non-obvious in that the specialization lives at the call site. This test is in zcash_client_backend, which can't name SqliteClientError (that would invert the crate dependency), so instead it takes a predicate, which is the same pattern as send_multi_step_proposed_transfer. The sqlite-side closure matches the exact variant, scope, and index, so an unexpected error can't trip it. I've documented that contract on the test parameter.

@nullcopy nullcopy Jul 9, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have some backend defined errors, and I wonder if this would be better as one. Not worth blocking on, but something we should log for a future decision. Maybe just a follow-up issue for now

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #2594.

// This variant is placed at the end of the enum in order to preserve the encoded
// representation of the prior variants.
#[cfg(feature = "transparent-inputs")]
InternalTransparent {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does InternalTransparent need to be distinct from `InternalAccount'?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! InternalAccount is converted via into_recipient_with_note and carries a shielded Note, while transparent change goes through the outpoint path and must record the on-chain-observable recipient address. This variant is just the construction-time mirror of the pre-existing Recipient::InternalTransparent / Recipient::InternalAccount distinction. There is some rationale on Recipient::InternalTransparent in zcash_client_backend/src/wallet.rs.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, the naming is confusing, because InternalTransparent is internal and belonging to the account. Let's not block here, but later we should revisit the naming and perhaps even the deliniation of those two variants. I suspect there's an axis of symmetry we can refactor on here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Filed as #2595.

Comment thread zcash_client_backend/src/fees.rs
Comment thread zcash_client_backend/src/fees/common.rs
Comment thread zcash_client_backend/src/fees/zip317.rs
schell and others added 2 commits July 9, 2026 13:04
Adds `fees::TransparentChangePolicy` and `with_transparent_change_policy`
builders on the ZIP 317 and fixed change strategies. When a strategy is
configured with `TransparentChangeAllowed` and the net flows of the
transaction under construction are fully transparent, change is returned
to the transparent pool as a single non-ephemeral output (represented by
the new `ChangeValue::transparent` constructor) instead of being shielded
to the fallback change pool. The policy has no effect on transactions
that involve shielded flows, so shielded change information is never
leaked to the transparent pool.

The proposal protobuf encoding is wire-compatible: a transparent change
value is encoded using the existing transparent value pool with
`isEphemeral` unset, a combination that previously failed to decode with
`ProposalDecodingError::InvalidChangeRecipient`.

This is required in order for `zallet` to replicate `zcashd`'s
`z_sendmany` behavior for fully-transparent spends.

Part of #1839.

Co-Authored-By: Claude <noreply@anthropic.com>
…outputs

Adds `WalletWrite::reserve_next_n_internal_addresses`, which reserves
BIP 44 internal-scope (change) transparent addresses parallel to the
existing ephemeral address reservation API, and implements it for
`WalletDb` using the existing scope-generic reservation machinery,
subject to the internal-scope gap limit. No migration is required:
internal-scope gap addresses are already generated at account creation.

`create_proposed_transactions` now realizes non-ephemeral transparent
change values from a proposal by reserving internal-scope addresses,
adding the corresponding transparent outputs to the transaction, and
recording them via the existing `Recipient::InternalTransparent`
variant; the PCZT construction and extraction paths are extended
accordingly. The new `PcztRecipient::InternalTransparent` variant is
appended to the end of the enum to preserve the encoded representation
of the prior variants.

Includes end-to-end coverage of the t->t-with-change lifecycle
(proposal, serialization round trip, transaction creation,
internal-scope address exposure, and post-mining spendability of the
change output), of the exact-balance no-change case, and of
internal-scope gap limit enforcement.

Part of #1839.

Co-Authored-By: Claude <noreply@anthropic.com>
@schell
schell force-pushed the feat/transparent-change branch from 7ed2c4e to d739b42 Compare July 9, 2026 01:08
@schell
schell requested review from nullcopy and nuttycom July 9, 2026 01:08

@nullcopy nullcopy left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create some issues to revisit later for possible optimizations, but this looks good to me!

utACK d739b42

@dannywillems
dannywillems dismissed their stale review July 9, 2026 07:47

2539 has been merged. Unblocking by dismissing by request for changes.

@nuttycom
nuttycom merged commit 1b6e85e into main Jul 9, 2026
49 checks passed
@nuttycom
nuttycom deleted the feat/transparent-change branch July 9, 2026 12:01
czarcas7ic pushed a commit to zakura-core/librustzcash that referenced this pull request Jul 10, 2026
…ielded

Rename `Recipient::InternalAccount` (and the `BuildRecipient`/`PcztRecipient`
mirrors) to `InternalShielded`, for symmetry with `Recipient::InternalTransparent`.
The prior name did not communicate the actual distinction between the two
internal variants, which is payload domain (shielded note vs. transparent
address) rather than any special relationship to "the account" — as noted in
review of zcash#2550: zcash#2595

The enum-level and per-variant doc comments on `Recipient` are expanded to
spell out the two independent axes the variants vary along (relationship to
the wallet, and payload domain).

`PcztRecipient`'s variant order (and thus its postcard encoding) is
unchanged; only the variant name changes for that private mirror type.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

zcash_client_backend: Support creation of transparent change.

4 participants