Skip to content

A clean wallet cannot spend its own transparent funds in one transaction: change fragments by design, and the only multi-address source is gated behind the zcashd legacy pool #644

Description

@shrikus

Version: zallet 0.1.0-beta.1 (rebuilt against zebra-rpc 11.1.0), zebra backend,
zcash_client_backend 0.24.0-rc.1, testnet.

Summary

A wallet created from scratch — no migrate-zcashd-wallet — reaches a state where it holds N ZEC
of spendable transparent funds and cannot pay N ZEC in one transaction: the funds sit on
several of its own addresses and no z_sendmany source can gather them. The wallet creates this
condition itself, on every payment.

Mechanics

Change fragments by design. Transparent change goes to the next available internal address —
reserve_next_n_internal_addresses(account_id, …) in zcash_client_backend/src/data_api/wallet.rs:
"Add any transparent change outputs, sending them to the next available internal-scope (change)
transparent addresses of the account"
. GreedyInputSelector also stops once the target is covered,
leaving tails behind. After a few rounds the funds are spread over A/B/C/D…

No source gathers them. fromaddress accepts only "ANY_TADDR", a t-addr, a z-addr, or a UA
(Address::decode — an account UUID is rejected, contrary to what one might expect):

source policy result
bare t-addr TransparentSpendPolicy::from_one_address that address's UTXOs only
UA SpendPolicy::default() → shielded-only asserted by your own shielded_source_permits_no_transparent_spending test — "even though the unified address it names does carry a transparent receiver"
"ANY_TADDR" any_account_addr() — does gather but only via get_legacy_pool_account()

Across all of Zallet, TransparentSpendPolicy is constructed in exactly two places:
z_send_many.rs:87 (from_one_address) and z_send_many.rs:110 (any_account_addr, inside
legacy_pool_spend_policy). TransparentSpendPolicy::from_addresses(…), which accepts a set of
addresses, is never called.

get_legacy_pool_account() requires both features.legacy_pool_seed_fingerprint and an
account at ZIP 32 index 0x7FFFFFFFis_legacy_pool_account =
seed_fingerprint == legacy_seed_fp && account_index == ZCASH_LEGACY_ACCOUNT. A clean wallet's
account is index 0, and "a regular account of the legacy seed is therefore not the legacy pool".

The capability is not missing from the backend: any_account_addr() is documented as "non-coinbase
UTXOs from arbitrary transparent receivers belonging to the account"
, with no legacy requirement.
Only the RPC layer gates it.

Why it matters

We run a mining pool. Miners are paid transparent→transparent, and that is not negotiable: most
payout addresses are exchange deposits, and exchanges block a deposit whose transaction has shielded
inputs. So the path is coinbase → z_shieldcoinbase → unshield to our own t-addr → one z_sendmany
to all payees. Splitting the payout is not an option either — ZIP 317 charges per output, and a
500-payee round is expensive enough once.

Real state after a single payout: 151.43815934 on the internal change address, 0.1 on the public
receiver, 0 on the address our config names. A 170 ZEC round is unpayable while the wallet reports
the total as spendable.

Workaround, verified end-to-end

is_legacy_pool_account checks only the seed fingerprint and the ZIP 32 index, not provenance — so a
clean wallet can manufacture the legacy pool:

zallet rpc z_recoveraccounts '[{"name":"LEGACYPOOL","seedfp":"<this wallet's own seedfp>","zip32_account_index":2147483647,"birthday_height":<tip>}]'
# then in zallet.toml:  features.legacy_pool_seed_fingerprint = "<the same seedfp>"

Before the option, "ANY_TADDR"-11 "The legacy pool of funds is disabled". After it, it
resolves. With the account's funds split across four addresses — 3.0 / 2.0 / 2.0 / 0.99985,
the last being a change address the wallet itself created — a single z_sendmany "ANY_TADDR" of
7 ZEC, more than any one address holds, succeeded:

txid a9efd22b75628c340bb7c1cee6dff7b1bd31cc2a5581526d3a28b7ce2c396429
transparent inputs : 4
transparent outputs: 2

So the capability works perfectly well for an ordinary HD account. The only thing standing between a
clean wallet and it is the 0x7FFFFFFF index check.

Ask

Let a regular account spend its own transparent pool — any of:

  • allow any_account_addr() for a normal account (an explicit account/UA source opting into
    transparent selection, guarded by AllowLinkingAccountAddresses, which already governs the
    linkage);
  • expose TransparentSpendPolicy::from_addresses(…) through z_sendmany (accept a list in
    fromaddress);
  • or drop the 0x7FFFFFFF condition so legacy_pool_seed_fingerprint alone enables one-pool
    semantics for the named seed.

Otherwise please bless the workaround above and document it — but as it stands, the documented modern
semantics ("each account is a separate pool of funds … the semantics used throughout Zallet, and
that should be used by everyone going forward"
) are unusable for transparent funds: the wallet
scatters its change and then refuses to gather it unless you pretend to be an imported zcashd wallet.

Metadata

Metadata

Assignees

Labels

C-bugCategory: This is a bugbeta.2

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions