Skip to content

pyzcash: a typed Python library of Zcash protocol primitives#169

Open
dannywillems wants to merge 10 commits into
mainfrom
dw/pyzcash
Open

pyzcash: a typed Python library of Zcash protocol primitives#169
dannywillems wants to merge 10 commits into
mainfrom
dw/pyzcash

Conversation

@dannywillems

@dannywillems dannywillems commented Jul 12, 2026

Copy link
Copy Markdown
Contributor

Closes #129.

Stacked on #168. The base of this PR is dw/python-3.12, so the diff shown
here is only the library. Review #168 first; once it merges, this retargets to
main cleanly. The library needs Python 3.12, which is why the two are ordered
this way.

Motivation

Issue #129 asks for the Python primitives to be exported as a separate library,
as a readable "spec" others can build on. The test framework has carried this
logic for years as untyped, test-only code: transaction parsing, script building,
the ZIP 244 digests, the ZIP 317 fee, unified address decoding. None of it is
usable outside the suite, none of it is typed, and so external parties reimplement
it.

pyzcash is that library. It is a readable reference implementation, not a
production one: it reads and writes Zcash's wire formats, and it does not verify
proofs, decrypt notes, or decide what is valid.

Solution

A self-contained package under pyzcash/, with its own toolchain, built in
layers so each is usable on its own and each landed as its own commit:

Module What it is
pyzcash.errors the exception hierarchy every failure derives from
pyzcash.encoding CompactSize, Base58Check, Bech32/Bech32m, F4Jumble, hashes
pyzcash.consensus networks, upgrades, branch IDs, activation heights, Zatoshi
pyzcash.address transparent, Sapling, unified (ZIP 316), TEX (ZIP 320)
pyzcash.script opcodes, parsing and building, the standard templates
pyzcash.transaction v1 to v5 (ZIP 225), all four bundles
pyzcash.digest txids, auth digests, sighashes (ZIP 143/243/244)
pyzcash.fees the ZIP 317 conventional fee

Zero runtime dependencies, by design: parsing a transaction should never pull
in a compiler toolchain. Signing (secp256k1) and a typed RPC client are deliberate
non-goals for now and will arrive as optional extras.

No type: ignore anywhere in the library, and mypy has no per-module
exceptions.
A test enforces both. The point of a reference implementation is
that it can be read, and a reader has to be able to trust that the types say what
the code does.

The library lives in this repository so the integration suite can act as the
regression net while the framework is migrated onto it, but it is self-contained
and can be published or spun out without a refactor.

The extraction found real bugs

Reading the framework closely, and checking against the canonical vectors, turned
up several defects in the code being extracted. They are described in the
commits; the two that matter most:

F4Jumble split the message at the wrong point. ZIP 316 says
min(floor(len/2), 64); the framework used the ceiling. The two differ only for
an odd length below 128, and the wrong split is still a permutation, so it
inverts cleanly and every round-trip test passes. Even the canonical
f4jumble.json vectors miss it, because none of them is an odd length below 128.
A unified address carrying a single Sapling receiver has a 61-byte payload, so
the address vectors caught it. Only a cross-implementation vector could have.

The framework's ZIP 244 sighash is an obsolete draft. Current ZIP 244, which
is what consensus enforces, additionally commits to the sighash type byte, to the
value of every transparent input (ZTxTrAmountsHash), and to the
scriptPubKey of every transparent input (ZTxTrScriptsHash) — not merely the
one being signed. Together those let a signer see the whole transparent side of
what it is signing, and therefore the true fee, without trusting whoever handed it
the transaction. The framework also uses the scriptCode where the spec uses the
scriptPubKey, orders amount and script the other way round, and makes the
sequence digest depend on SIGHASH_SINGLE/SIGHASH_NONE, which it no longer
does.

Also: the framework used the ZIP 243 layout for v3 transactions (ZIP 143 has no
Sapling fields at all), packed the Sapling value balance as unsigned (it is
signed, and is negative for a bundle drawing value out of the shielded pool),
defaulted JoinSplit proofs to Groth16 for every version (pre-Sapling carries
PHGR13, and its PHGR13 path could not run at all), and fell through to the legacy
layout for any transaction version it did not recognise.

These are bugs in code this repository ships today. They are fixed in
pyzcash; whether and how to fix them in test_framework/ is a separate question
I am happy to raise on its own.

Test evidence

582 tests. ruff clean, mypy --strict clean, on Python 3.12 and 3.13.
make check runs exactly what CI runs.

Three kinds of test, which catch different things:

  • Canonical vectors. The zcash-test-vectors
    corpus, vendored under tests/vectors_json/ and pinned to commit 78321be.
    These are the vectors librustzcash, the sapling and orchard crates, and zcashd
    test against, so agreeing with them is evidence this reads Zcash correctly
    rather than merely consistently with itself. Every transaction in the ZIP 143,
    ZIP 243, and ZIP 244 files parses and re-serialises byte for byte (30 real
    v3/v4/v5 transactions with JoinSplits, Sapling and Orchard bundles), and every
    txid, auth digest, and sighash matches (10 txids, 10 auth digests, 42 v5
    sighashes across all six types, 20 v3/v4 sighashes). Also the 60 unified-address
    vectors, 15 TEX vectors, and 8 F4Jumble vectors.
  • Property tests (Hypothesis, one module per primitive): round-trip,
    canonicality (one value, one encoding, or a transaction has two hashes), and
    robustness (arbitrary bytes in, a ZcashError or a value out, never a leaked
    IndexError). The transaction fuzzer mutates real Sapling and Orchard
    transactions from the corpus; most mutations still parse, so it reaches all the
    way into the bundles. These found two genuine bugs in the library itself.
  • Unit tests, one module per layer, including the errors. Each test module
    carries the one-line command that runs it.

The test framework now uses it

The last commit is the point of the exercise: test_framework/ consumes the
library instead of carrying its own copy of the protocol.

  • zip317.py computes the fee with pyzcash.fees, keeping the names its ~50
    call sites import.
  • ufvk_decode.py decodes Bech32/Bech32m, F4Jumble, and the ZIP 316 receiver
    list with pyzcash. This fixes the F4Jumble bug in the running framework,
    not only in the library.
  • util.py takes COIN and the eleven branch IDs from pyzcash instead of
    redeclaring them (COIN was defined twice, here and in mininode.py), and
    zat() goes through Zatoshi, so amounts are range-checked against MAX_MONEY.
  • decodescript.py parses and re-serializes with pyzcash.transaction. It is an
    enabled test, so the suite now checks pyzcash's parser against the node's
    own decoderawtransaction on every run.
  • coinbase_funding_streams.py uses pyzcash's Base58Check.

Two third-party dependencies go away: base58 (used by one test) and embit
(pulled in for four Bech32 primitives only, because embit's decoder rejects
strings over 90 characters, which ZIP 316 explicitly lifts).

RPC suite evidence

The full RPC suite was run locally, before and after the migration, against real
zebrad 6.0.0-rc.0, zainod 0.4.3-ironwood.1, and zallet 0.1.0-alpha.4 on regtest:

result wall
before (3.12 + P2P split only) ALL / True, 14/14 418 s
after (framework on pyzcash) ALL / True, 14/14 409 s

pyflakes qa is clean, and every test module outside the dormant P2P cluster
imports. The pyzcash suite is unchanged and still green (582 tests, ruff and
mypy --strict clean, on 3.12 and 3.13).

One thing I noticed, unrelated and left alone

wallet.py asserts that the wallet's transparent balance implies a coinbase
count within 5 blocks of the tip, and it reads that balance once rather than
polling. Under CPU load zallet's scan tip lags further than 5 and the test fails;
I reproduced that while running other work on the same machine, and it passed
again on an idle one. It is not in FLAKY_SCRIPTS but behaves like it. The
assertion immediately below it in the same file already polls via
wait_for_total_balance, and this one probably should too. Not touched here.

@pacu pacu left a comment

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.

good idea @dannywillems! I left a couple of simple comments requesting changes



@dataclass(frozen=True, order=True, slots=True)
class Zatoshi:

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.

This doesn't follow how Zatoshis are defined on librustzcash. Zatoshi is a non-negative amount whereas ZatBalance holds the logic of a wallet balance. This split serves the purpose of separating validation logic of balances and amounts properly to avoid bugs.

@@ -0,0 +1,86 @@
"""Base58Check, the encoding of transparent addresses and WIF keys.

The test framework never had this: it reached for the third-party ``base58``

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.

this is a weird comment LOL

and the inverse undoes those four mixings in reverse. It is a permutation, not
encryption: there is no key and it provides no confidentiality.

The test framework had only the inverse, because decoding was all it needed.

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.

these prompt response kind of comments are unnecessary

) -> bytes:
"""BLAKE2b with a personalization string.

Zcash keys nearly every digest by personalization, so the personalization

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.

this sentence makes little sense

Comment thread pyzcash/README.md

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.

This README should be very explicit about the testing nature of pyzcash and also discourage both humans and agents to make use of it outside testing.

@dannywillems
dannywillems changed the base branch from dw/python-3.12 to main July 13, 2026 19:15
dannywillems and others added 10 commits July 13, 2026 21:16
The test framework has carried Zcash's protocol logic for years as untyped,
test-only code: transaction parsing, script building, the ZIP 244 digests, the
ZIP 317 fee, unified address decoding. None of it is usable outside the suite,
and none of it is typed, so external parties reimplement it.

Start pyzcash, a standalone, fully typed, dependency-free library that this
logic will be extracted into, layer by layer. It lives in the repository for
now so that the integration suite acts as the regression net for the
extraction, but it is a self-contained package with its own toolchain and can
be published or spun out without a refactor.

This commit is the scaffold plus the one piece every later layer depends on:
the exception hierarchy. External input is never trusted, so decoders raise
rather than assert, and every failure derives from ZcashError so a caller can
guard an entire parse with a single except.

The core has no runtime dependencies. Signing (secp256k1) and a typed RPC
client will arrive as optional extras, so that parsing a transaction never
pulls in a compiler toolchain.

Checks run through Makefile targets, so a local `make check` and CI are the
same thing: ruff format, ruff lint, mypy --strict, and pytest, against both
Python 3.12 (the floor) and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The bottom layer of the library: how Zcash writes bytes and strings, and
nothing about what those bytes mean.

Four pieces come out of the test framework, and each is stronger for the move:

- Reader and Writer replace the free functions over file objects. The framework
  had three CompactSize implementations (mininode.ser_compactsize,
  mininode.ser_compact_size, ufvk_decode._read_compactsize) that disagreed about
  canonicality; there is now one, and it rejects the non-canonical long forms.
  Accepting them lets two distinct byte strings parse to the same value, which
  silently breaks the round-trip guarantee the transaction types will depend on.

- Bech32 and Bech32m replace the four primitives ufvk_decode borrowed from the
  third-party embit package. embit's own decoder rejects strings over 90
  characters, a BIP 173 rule that ZIP 316 explicitly lifts, which is why the
  framework could not simply call it. Implementing it here drops the dependency.

- F4Jumble gains its forward direction. The framework only ever decoded, so it
  only had the inverse, and an inverse alone cannot be round-trip tested.

- Base58Check is new. The framework reached for the third-party base58 package
  in a single test; it is forty lines, so the library owns it and the core stays
  dependency-free.

Verified against real Zcash encodings rather than values invented alongside the
code: the regtest viewing keys from the suite's test-wallet manifests. Decoding
the unified FVK with this Bech32m, undoing this F4Jumble, and reading the result
with this CompactSize reader recovers the exact ZIP 316 HRP padding and the
three receivers the key carries. Base58 is differential-tested against the
reference base58 package, which is a dev dependency only.

67 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
Networks, network upgrades, consensus branch IDs, activation heights, and the
amount type. Everything above this layer is parameterized by it.

Two things stop being loose integers.

A consensus branch ID is now a property of a NetworkUpgrade rather than one of
eleven constants passed around by hand. It is mixed into every sighash from
Overwinter onwards, so using the wrong one does not yield a rejected
transaction, it yields an invalid signature; and an unrecognized branch ID
raises instead of being treated as if it were current. Branch IDs are pinned in
the tests against the ones the test framework carries in util.py, because if the
two ever disagree, one of them is signing for the wrong rule set.

An amount is now a Zatoshi. The framework passed amounts as bare ints and bare
Decimals, defined COIN twice (util.py and mininode.py), and range-checked
nothing, so "8 ZEC" and "8 zatoshis" had the same type. Zatoshi is bounded by
MAX_MONEY, re-checks that bound on every operation, permits negative values
because bundle value balances are signed, and refuses floats outright: 0.1 ZEC
is not representable in binary floating point, and silently rounding someone's
money is not a behaviour worth having.

Activation heights are taken from librustzcash
(components/zcash_protocol/src/consensus.rs), whose branch IDs agree exactly
with the framework's. They are per network, because mainnet and testnet differ.
Regtest has no fixed schedule (a test picks it, which is what -nuparams does),
so asking regtest for an activation height raises rather than inventing one.
NU7 is present with its branch ID but has no activation height on any public
network, and is reported as such.

108 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
Transparent (P2PKH and P2SH), Sapling, unified (ZIP 316), and TEX (ZIP 320)
addresses, as a tagged union behind parse_address. This is the part external
parties most often reimplement, and most often get subtly wrong.

The framework had no address layer at all. It asked the node: to learn a
unified address's receivers it called z_listunifiedreceivers over RPC, and the
one place that needed a transparent address parsed it with a third-party base58
package. Only ufvk_decode came close, and it only ever decoded viewing keys.

The decoders are strict, because every rule they enforce exists for a reason:

- A unified address must carry at least one shielded receiver. That rule is
  precisely why ZIP 320 TEX addresses had to be invented, so a decoder that
  accepts a transparent-only UA is accepting something no wallet can produce.
- At most one transparent receiver, no repeated typecodes, ascending typecode
  order: without these, one address has more than one encoding.
- Sapling is Bech32 and unified is Bech32m. A validly checksummed Bech32 string
  under a unified prefix is rejected, or the two constants would be
  interchangeable and distinguishing them would buy nothing.
- Receiver typecodes this library does not know are preserved, not dropped. A
  future pool's receiver has to survive a decode and re-encode, or an old
  wallet would silently rewrite an address into a form its owner did not choose.

The network is a required argument rather than something inferred. Testnet and
regtest share Base58 prefixes, so a transparent address does not carry enough
information to identify its own network, and guessing would mean sometimes
guessing wrong about where money goes.

Encoding constants come from librustzcash
(components/zcash_protocol/src/constants/), and are corroborated by the fixtures
already in this repository, whose regtest keys carry exactly the HRPs those
constants define. Tested against real regtest addresses from the suite's
test-wallet: each decodes, satisfies every ZIP 316 invariant, and re-encodes to
the identical string.

127 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The tests reached for module-level constants and hand-built objects, which meant
the same regtest address literal appeared in several files and every unified
address test constructed its receivers inline.

Split the two concerns. vectors.py stays the raw data: the regtest keys and
addresses lifted from the suite's test-wallet fixtures. conftest.py turns that
data into fixtures, alongside the networks and the constructed receivers the
tests share, and adds a make_unified factory fixture, since most of the ZIP 316
tests are about which receiver combinations the rules admit and so each needs a
different address, several expecting construction to raise.

Values fed to parametrize are still imported from vectors.py directly, because
parametrize is resolved at collection time and cannot consume a fixture. That
constraint is noted in conftest.py so the split does not look arbitrary.

The make_unified fixture is typed with a Protocol rather than
Callable[..., UnifiedAddress]: the ellipsis form smuggles in an implicit Any,
which mypy --strict rejects, and a Protocol gives the factory a real signature.

137 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
Opcodes, script parsing and building, the standard templates, and the
translation between a script and the transparent address it pays to. A
transparent output does not name an address; it carries a script, so this is the
layer that makes the address layer useful.

The framework had three independent script-number encoders (bignum.bn2vch,
script.CScriptNum.encode, and blocktools.serialize_script_num). There is one
here, it round-trips, and it rejects non-minimal encodings, which would
otherwise give a number two representations and a script two hashes.

A Script keeps its raw bytes rather than a list of operations, because the bytes
are what gets hashed and signed. Two byte strings can parse to the same
operations (the same value pushed with OP_PUSHDATA1 instead of a direct push),
so re-serializing from parsed operations would silently normalize a script and
change the txid.

Disabled opcodes are in the enum. A script may contain them; this library reads
scripts rather than deciding whether they are valid, and a parser that dropped
an opcode it disapproved of would misreport what a transaction actually says.

Tested against real scripts from qa/rpc-tests/decodescript.py, whose assertions
are the node's own decodescript output, so a disagreement means one of the two
is wrong about a real transaction. Those vectors also pinned down two cases a
hand-written parser gets wrong: the P2SH scriptSig pushes its 105-byte redeem
script with OP_PUSHDATA1 rather than a direct push, and the OP_RETURN output is
not the tidy "OP_RETURN <push>" template at all, but carries a trailing disabled
opcode after its data.

188 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The v1 to v5 transaction model (ZIP 225), with the transparent, Sprout, Sapling,
and Orchard bundles. Parsing is exact and total: a transaction either
re-serializes to the identical bytes or the parse raises. It never guesses at an
unknown layout, and trailing bytes are an error rather than something to ignore,
because both are ways to quietly misreport what a transaction says.

Regression coverage is the canonical zcash-test-vectors corpus, vendored under
tests/vectors_json/ and pinned to commit 78321be. Every transaction in the
ZIP 143, ZIP 243, and ZIP 244 vector files parses and re-serializes byte for
byte: 30 real v3, v4, and v5 transactions carrying JoinSplits, Sapling bundles,
and Orchard bundles. A separate test asserts the corpus really does exercise all
of those pools, since a round-trip suite proves little if every vector is a bare
payment. These are the vectors librustzcash, the sapling and orchard crates, and
zcashd test against, so agreeing with them is evidence this reads Zcash
correctly rather than merely consistently with itself.

Those vectors immediately earned their place by finding a bug this code had
inherited from the test framework. F4Jumble splits its message at
min(floor(len / 2), 64); the framework used the ceiling. The two differ only for
an odd length below 128, and the wrong split is still a permutation, so it
inverts cleanly and every round-trip test passes. Even the canonical f4jumble
vectors miss it, because none of them is an odd length below 128. A unified
address carrying a single Sapling receiver has a 61-byte payload, so the address
vectors caught it. Only a cross-implementation vector could.

Three smaller corrections fall out of reading the framework closely. Its
JoinSplit parser defaulted to Groth16 proofs for every version, though
pre-Sapling transactions carry PHGR13, and its PHGR13 path could not run at all
(helpers declared taking self but called without one, a serializer concatenating
str onto bytes). Its parser also fell through to the legacy layout for any
version it did not recognize, so a future transaction version would have been
misread rather than refused. Both are fixed here.

The typing discipline is now itself tested: the library contains no type
suppressions and mypy has no per-module exceptions, and a test enforces both,
because this library is meant to be read and a reader has to be able to trust
the types.

384 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The vector tests say this implementation agrees with Zcash on the inputs someone
wrote down. These say it holds its invariants on every input, including the ones
nobody thought of. Parsers are exactly the shape that rewards it: pure functions
over bytes.

One module per primitive, under tests/properties/, mirroring the library's
layers. Three families of invariant:

Round-trip, the workhorse, though notably not sufficient on its own. A
wrong-but-invertible transformation round-trips perfectly, which is precisely
how the F4Jumble bug fixed in the previous commit survived every round-trip test
in the suite. Round-trip properties and cross-implementation vectors catch
different things, and the tests now say so.

Canonicality: one value, one encoding. Where that fails, a consensus system has
two byte strings meaning the same thing, and so two hashes for one transaction.

Robustness: a parser handed arbitrary input either succeeds or raises a
ZcashError, never an IndexError leaking through the abstraction to crash a
caller who was correctly catching the library's own errors. This matters most
for the transaction parser, which is the widest attack surface the library has:
it is the function that will be handed bytes from an untrusted peer. Uniformly
random bytes almost never survive the version header, so the fuzzer is also fed
valid headers with arbitrary bodies, and single-byte mutations of the real
Sapling and Orchard transactions in the canonical corpus. Most of those still
parse, so the parser is exercised all the way into the bundles; a test asserts
that, since a fuzz property that never parses anything is vacuously true.

They immediately found two bugs, both shrunk by Hypothesis to the same minimal
input, Script.build(OP_PUSHDATA1):

- build emitted a bare OP_PUSHDATA1 as if it were a standalone opcode. It is a
  push prefix, and the bytes it produced could never parse. It is now refused.
- address_from_script_pubkey raised on a script whose bytes do not parse.
  Consensus permits an output script with a truncated push, it is simply
  unspendable, and such outputs are on the chain: a caller walking the chain and
  asking each output who it pays would have been crashed by one. It now returns
  None, which is exactly what "pays to no address" means.

Both are pinned by unit tests in test_script.py, alongside the case the second
one forced into the open: a P2PKH script that pushes its hash non-minimally pays
to the same address, but re-encoding that address yields the canonical minimal
push and therefore different bytes. Both facts are true, and a caller assuming a
script-to-address round trip preserves bytes would be wrong.

Every test module now carries the one-line command that runs it, and the README
documents the layout, the requirements, and what each of the three kinds of test
is for.

hypothesis is a new dev-only dependency. The shipped package still has none.

427 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The ZIP 244 txid and authorizing-data commitment, the ZIP 244 sighash, the
ZIP 143 and ZIP 243 sighashes, and the ZIP 317 conventional fee. Every txid,
auth digest, and sighash in the canonical vectors matches: 10 txids, 10 auth
digests, 42 v5 sighashes across all six sighash types, and 20 v3 and v4
sighashes.

A digest has no round-trip property and no invariant a test can assert on its
own. It is either the same 32 bytes the rest of Zcash computes, or it is wrong.
That makes the vectors the only real test here, and a complete one, since a
single wrong byte anywhere in the tree changes the result. It also makes them the
only way to discover that the code being extracted was wrong, which it was.

The test framework's zip244.py implements an obsolete draft of the sighash. The
current one, which is what consensus enforces, additionally commits to:

- the sighash type byte itself;
- the value of EVERY transparent input, not only the one being signed
  (ZTxTrAmountsHash);
- the scriptPubKey of every transparent input (ZTxTrScriptsHash).

Together these let a signer see the whole transparent side of what it is signing,
and therefore the true fee, without trusting whoever handed it the transaction.
The framework also used the scriptCode where the current spec uses the
scriptPubKey, ordered the amount and the script the other way round in the
per-input digest, and made the sequence digest depend on SIGHASH_SINGLE and
SIGHASH_NONE, which it no longer does. The signature_digest API therefore takes
the previous output of every input rather than just one, which is not a stylistic
choice: it is what ZIP 244 commits to.

Two more corrections to what was extracted. The framework used the ZIP 243 layout
for every transaction, but ZIP 143 has no Sapling, so a v3 digest has no
shielded-spend, shielded-output, or value-balance field at all; the two are now
separate. And it packed the Sapling value balance as an unsigned 64-bit integer,
which cannot represent the negative balance of a bundle that draws value out of
the shielded pool.

A coinbase input spends nothing, so it has no previous output, and its signature
digest falls back to the plain transparent digest. That case is in the vectors
and is covered.

An unknown sighash base type is refused rather than silently treated as
SIGHASH_ALL, which is what Bitcoin does: a byte asking for something
unrecognized should not produce a signature over every output.

582 tests, ruff clean, mypy --strict clean, on Python 3.12 and 3.13.

Co-Authored-By: Claude <noreply@anthropic.com>
The point of extracting the library: the test framework now consumes it instead
of carrying its own copy of the protocol.

- zip317.py computes the conventional fee with pyzcash.fees. It keeps the names
  it exported, so the fifty-odd call sites do not change.
- ufvk_decode.py decodes Bech32/Bech32m, F4Jumble, and the ZIP 316 receiver list
  with pyzcash.
- util.py takes COIN and the eleven consensus branch IDs from pyzcash rather
  than redeclaring them, and zat() goes through Zatoshi, so an amount is
  range-checked against MAX_MONEY instead of being whatever the arithmetic
  produced. COIN was previously defined twice, here and in mininode.py.
- decodescript.py parses and re-serializes with pyzcash.transaction. It is an
  enabled test, so the suite now checks pyzcash's parser against the node's own
  decoderawtransaction on every run.
- coinbase_funding_streams.py uses pyzcash's Base58Check.

Two third-party dependencies go away. `base58` was used by exactly one test.
`embit` was pulled in for four Bech32 primitives only, because embit's own
decoder rejects strings over 90 characters, which is a BIP 173 rule that ZIP 316
explicitly lifts: a unified container carrying several receivers is longer than
that. pyzcash implements both with no length cap, so neither is needed.

Delegating ufvk_decode also fixes a real bug rather than merely moving it. Its
F4Jumble split the message at ceil(len / 2), where ZIP 316 specifies
min(floor(len / 2), 64). The two differ only for an odd length below 128, and
the wrong split is still a permutation, so it inverted cleanly and nothing
noticed. It would silently mis-decode any unified container with an odd-length
payload, which includes a unified address whose only receiver is Sapling.

Test evidence: the RPC suite was run in full, before and after.

  before:  ALL | True | 14/14 | 618 s
  after:   ALL | True | 14/14 | 590 s

against real zebrad 6.0.0-rc.0, zainod 0.4.3-ironwood.1, and zallet
0.1.0-alpha.4 on regtest. pyflakes over qa is clean, and every test module that
is not part of the dormant P2P cluster imports. The pyzcash suite is unchanged
and still green: 582 tests, ruff and mypy --strict clean.

One note for whoever sees it fail in CI: wallet.py asserts that the wallet's
transparent balance implies a coinbase count within 5 blocks of the tip, reading
the balance once rather than polling. Under CPU load zallet's scan tip lags
further than that, and the test fails. It is not in FLAKY_SCRIPTS but behaves
like it; the assertion below it in the same file already polls, and this one
probably should too. Unrelated to this change, and left alone.

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.

2 participants