Skip to content

test framework: cover the account-based transaction methods#145

Draft
dannywillems wants to merge 10 commits into
mainfrom
dw/sendfromaccount
Draft

test framework: cover the account-based transaction methods#145
dannywillems wants to merge 10 commits into
mainfrom
dw/sendfromaccount

Conversation

@dannywillems

@dannywillems dannywillems commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Stacked on #163 (transparent-to-transparent coverage). Base auto-retargets to main when that merges.

Depends on zcash/zallet#531, which implements the three RPCs. These tests fail against zallet main.

History rebuilt. The previous branch's first commit extracted the shared Z3-stack wallet-test helpers into util.py — that has since landed upstream, so it is dropped. What remains is rebuilt on the current tree.

What this covers

Three new tests plus one rewrite, for z_sendfromaccount / z_proposetransaction / z_finalizetransaction.

wallet_z_sendfromaccount.py — the RPC surface. 13 validation tests (recipients, account, fund_source, privacy_policy, checked in that order), then each fund source end to end with exact per-account balances: orchard (single, multi-recipient, memo, minconf), sapling, any_transparent, and an explicit address list.

wallet_z_proposetransaction.py — the two-step flow. Propose returns an inspectable PCZT and the policy the transaction requires; finalize signs and broadcasts it. The point of the split is that the caller decides after seeing what the transaction reveals, so the test pins that finalize holds them to it: a weaker policy than reported is refused, a more permissive one is accepted, a PCZT signed by the wrong account is rejected, and a tampered PCZT is rejected.

wallet_z_send_scenarios.py — the workflows the feature exists for, driven as an exchange, an OTC desk, a payroll operator, or an individual would drive them: batch customer withdrawals, a review-then-settle settlement, payroll with memos, a merchant payment that de-shields to a t-addr, a deposit sweep from named addresses, a consolidation of all transparent reserves, and a payout from a legacy Sapling pool.

wallet_z_sendmany.py — rewritten and re-enabled. It was disabled (not merely unmigrated) because the zcashd original leaned on z_exportviewingkey, z_getbalanceforviewingkey, sendtoaddress, and a Sprout section with no Z3 equivalent. It now uses the account/UA API.

The four scenarios that matter

Most of the scenarios are happy paths. Four exist to pin the guarantees, because each is a way the feature could appear to work while being wrong:

  • A fund source isolates its pool. Asking for sapling when the money is in Orchard must fail, not silently spend the Orchard. A fallback bug would pass every happy path and surface only as an accounting error.
  • An address list restricts. Sweeping one user's deposit address must not reach into a sibling address, even though both belong to the account and together hold enough. The test proves the refusal is the restriction working and not a shortfall, by then paying the same amount from both addresses.
  • A proposal has no side effects. One that is never finalized moves nothing and locks nothing.
  • Finalize holds the caller to the reported policy, so a caller cannot under-acknowledge what the transaction reveals.

A zallet bug this surfaced

The ported memo test sent deadbeef… as a memo. Per ZIP 302 a memo whose first byte is below 0xF5 is a UTF-8 text memo, and those bytes are not valid UTF-8. Zallet accepts such a memo when sending (it takes the raw bytes) but fails to decode it when reading the note back — so get_memo errors and z_listunspent then fails for the entire wallet, permanently.

The test now sends valid text, which is what a real caller does. But the asymmetry is a genuine zallet bug (a single malformed memo, from any source, bricks the RPC) and it is pre-existing rather than introduced by #531, so it is not fixed here. Worth an issue.

R3 (double-spending one set of notes via two proposals) is skipped: the second finalize crashes zallet's sync task. That is an upstream bug, not something this test should assert.

Testing

All four pass against a zallet built from zcash/zallet#531. pyflakes clean across the suite.

Per the conventions added in #163: RPC arguments are named constants (MIN_CONFIRMATIONS, INTERNAL_FEE) rather than bare literals, enums (FundSource, Pool, PrivacyPolicy, Receiver) rather than strings, shared helpers hoisted into util.py rather than copied, and type annotations throughout.

@dannywillems
dannywillems marked this pull request as draft June 30, 2026 15:56
@dannywillems
dannywillems force-pushed the dw/sendfromaccount branch 4 times, most recently from a6140e5 to e327192 Compare June 30, 2026 19:33
@dannywillems dannywillems linked an issue Jul 1, 2026 that may be closed by this pull request
dannywillems and others added 10 commits July 11, 2026 12:56
Zallet's `z_sendmany` can now spend transparent funds, so a t-to-t transfer is
testable for the first time. Nothing in the running suite exercised it: the only
enabled tests that send anything drive `z_shieldcoinbase` (t-to-z) or a shielded
`z_sendmany`.

Add `wallet_transparent_spend.py`, and reactivate the two disabled tests whose
subject is transparent spending, migrating them from the zcashd RPCs to the Z3
stack. Both were disabled only for the account/UA migration
(`getnewaddress` -> `z_getaddressforaccount`, `sendtoaddress` -> `z_sendmany`):

- `wallet_changeaddresses.py` asserts that two consecutive transparent sends do
  not reuse a change address, which would publicly link them.
- `regtest_signrawtransaction.py` spends a transparent input, which only builds
  if it is signed under the target height's consensus branch id. The name is
  historical; Zallet signs transparent inputs internally.

On regtest all transparent funds start as coinbase, and coinbase cannot be spent
transparently, so each test first shields coinbase and unshields to obtain a
non-coinbase UTXO.

Two zcashd behaviours no longer hold and are pinned as such rather than dropped:
a transparent source must be named explicitly (`ANY_TADDR` is unsupported), and
the change of a transparent-to-shielded send is shielded rather than returned to
the transparent pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The three transparent-spend tests each carried a private copy of the same two
helpers, and open-coded the same walk over a transaction's transparent outputs.
Move them into the test framework so there is one definition each:

- `unified_address_for` derives a UA for an account at a pinned diversifier
  index. An index may only ever be used with one receiver set, so a test wanting
  several addresses varies the index, not the receivers; the helper documents
  that, which is the trap the diversifier constants exist to avoid.
- `transparent_output_addresses` reads a transaction's transparent output
  addresses from the node, so assertions reflect what was committed on-chain
  rather than the wallet's view of it.
- `transparent_change_address` picks the change output out of a fully
  transparent send, asserting the recipient-plus-change shape on the way. That
  shape assertion is the point: it fails if the change was shielded instead.

Add a `Receiver` enum for unified-address receiver types, alongside the existing
`Pool` / `PrivacyPolicy` / `TotalBalanceField` enums, and use it in
`first_transparent_receiver`. It is deliberately not a `Pool`: `p2pkh` and `p2sh`
are distinct receiver kinds that share the single transparent pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The test runner execs each script directly, so a script without the executable
bit fails CI before it runs. Every other script in qa/rpc-tests is 100755; this
one was committed 100644.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The transparent-spend tests passed bare `1` and `None` as the positional minconf
and fee arguments of z_sendmany. A reader has to count parameters to know what
they are, and a comment at the call site would not help: it is invisible to every
other caller and rots on the next refactor.

Name them once, at the definition. `MIN_CONFIRMATIONS` and `INTERNAL_FEE` join
the framework constants in util.py, documented there, so a call reads as intent:

    w.z_sendmany(taddr, recipients, MIN_CONFIRMATIONS, INTERNAL_FEE, policy)

`INTERNAL_FEE` is deliberately not `zip317.ZIP_317_FEE`. Both are None, but that
one is documented as the zcashd sentinel for "use the conventional fee", whereas
zallet computes the fee itself and REQUIRES the argument to be null. Same value,
different reason; conflating them would mislead.

Record the rule in AGENTS.md, along with the two related ones this branch already
follows: prefer an enum to a bare string, and hoist a shared helper into util.py
rather than copying it between tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ble it

The zcashd original leaned on RPCs zallet does not implement (z_exportviewingkey,
z_getbalanceforviewingkey, sendtoaddress, getwalletinfo's shielded-balance fields)
and on a Sprout section that has no Z3 equivalent at all, which is why it was
disabled rather than migrated.

Rewrite it against the account/UA API: balances come from z_getbalances rather
than a viewing key, addresses from z_getaddressforaccount, and funding from
z_shieldcoinbase rather than sendtoaddress. What survives is what the test was
always for: that z_sendmany credits the recipient exactly, that the privacy policy
is enforced, and that a source can pay across pools.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the RPC surface: parameter validation (recipients, account, fund_source,
privacy_policy, checked in that order), and each fund source end to end with
exact per-account balances.

The two properties worth naming, because they are what `fund_source` is for:

- A fund source ISOLATES. Asking for `sapling` when the account holds only
  Orchard notes reports insufficient funds rather than quietly spending the
  Orchard (FS1), and naming a transparent address with no funds does the same
  (FA2). An account being solvent overall is not enough.
- Coinbase is never selected. Consensus requires it to be spent to a single
  shielded output, so a transparent fund source spends only NON-coinbase UTXOs;
  the test manufactures those by shielding coinbase and de-shielding.

The memo test sends a UTF-8 text memo. A memo whose first byte is below 0xF5 is
a text memo (ZIP 302) and its bytes must be valid UTF-8; zallet does not check
that when sending but does when reading the note back, so a malformed memo makes
z_listunspent fail for the whole wallet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Covers the two-step flow: propose returns an inspectable PCZT and the privacy
policy the transaction requires; finalize signs and broadcasts it.

The point of the split is that the caller decides AFTER seeing what the
transaction reveals, so the test pins that finalize holds them to it: a policy
weaker than the proposal reported is refused (R2), a more permissive one is
accepted (R4), a PCZT signed by the wrong account is rejected (R5), and a
tampered PCZT is rejected (R6).

R3 (double-spending one set of notes via two proposals) is skipped: the second
finalize crashes zallet's sync task, which is an upstream bug rather than
something this test should assert.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Where the per-RPC tests cover the API surface, these exercise the workflows the
fund-source feature exists for, driven the way an exchange, an OTC desk, a payroll
operator, or an individual would drive them: batch customer withdrawals, a
review-then-settle OTC settlement, a payroll run with memos, a merchant payment
that de-shields to a t-addr, a deposit sweep from named addresses, a consolidation
of all transparent reserves, and a payout from a legacy Sapling pool.

Four scenarios exist to pin the guarantees rather than the happy path, because
each is a way the feature could appear to work while being wrong:

- A fund source ISOLATES its pool: asking for Sapling when the money is in Orchard
  must fail, not silently spend the Orchard. A fallback bug would pass every happy
  path and only show up as an accounting error.
- An address list RESTRICTS: sweeping one user's deposit address must not reach
  into a sibling address, even though both belong to the account and together hold
  enough. The test proves the refusal is the restriction working and not a
  shortfall, by then paying the same amount from both addresses.
- A proposal has NO side effects: one that is never finalized moves nothing and
  locks nothing.
- Finalize HOLDS THE CALLER to the policy the proposal reported, so a caller
  cannot under-acknowledge what the transaction reveals.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dannywillems
dannywillems changed the base branch from main to t-t-tests July 12, 2026 07:28
@dannywillems dannywillems changed the title Add tests for z_sendfromaccount test framework: cover the account-based transaction methods Jul 12, 2026
Base automatically changed from t-t-tests to main July 13, 2026 06:35
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.

rpc: Design and implement z_sendfromaccount

1 participant