pyzcash: a typed Python library of Zcash protocol primitives#169
pyzcash: a typed Python library of Zcash protocol primitives#169dannywillems wants to merge 10 commits into
Conversation
pacu
left a comment
There was a problem hiding this comment.
good idea @dannywillems! I left a couple of simple comments requesting changes
|
|
||
|
|
||
| @dataclass(frozen=True, order=True, slots=True) | ||
| class Zatoshi: |
There was a problem hiding this comment.
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`` | |||
| 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. |
There was a problem hiding this comment.
these prompt response kind of comments are unnecessary
| ) -> bytes: | ||
| """BLAKE2b with a personalization string. | ||
|
|
||
| Zcash keys nearly every digest by personalization, so the personalization |
There was a problem hiding this comment.
this sentence makes little sense
There was a problem hiding this comment.
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.
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>
Closes #129.
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.
pyzcashis that library. It is a readable reference implementation, not aproduction 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 inlayers so each is usable on its own and each landed as its own commit:
pyzcash.errorspyzcash.encodingpyzcash.consensusZatoshipyzcash.addresspyzcash.scriptpyzcash.transactionpyzcash.digestpyzcash.feesZero 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: ignoreanywhere in the library, and mypy has no per-moduleexceptions. 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 foran 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.jsonvectors 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 thescriptPubKey of every transparent input (
ZTxTrScriptsHash) — not merely theone 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
scriptCodewhere the spec uses thescriptPubKey, orders amount and script the other way round, and makes thesequence digest depend on
SIGHASH_SINGLE/SIGHASH_NONE, which it no longerdoes.
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 intest_framework/is a separate questionI am happy to raise on its own.
Test evidence
582 tests.
ruffclean,mypy --strictclean, on Python 3.12 and 3.13.make checkruns exactly what CI runs.Three kinds of test, which catch different things:
corpus, vendored under
tests/vectors_json/and pinned to commit78321be.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.
canonicality (one value, one encoding, or a transaction has two hashes), and
robustness (arbitrary bytes in, a
ZcashErroror a value out, never a leakedIndexError). The transaction fuzzer mutates real Sapling and Orchardtransactions 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.
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 thelibrary instead of carrying its own copy of the protocol.
zip317.pycomputes the fee withpyzcash.fees, keeping the names its ~50call sites import.
ufvk_decode.pydecodes Bech32/Bech32m, F4Jumble, and the ZIP 316 receiverlist with pyzcash. This fixes the F4Jumble bug in the running framework,
not only in the library.
util.pytakesCOINand the eleven branch IDs from pyzcash instead ofredeclaring them (
COINwas defined twice, here and inmininode.py), andzat()goes throughZatoshi, so amounts are range-checked against MAX_MONEY.decodescript.pyparses and re-serializes withpyzcash.transaction. It is anenabled test, so the suite now checks pyzcash's parser against the node's
own
decoderawtransactionon every run.coinbase_funding_streams.pyuses pyzcash's Base58Check.Two third-party dependencies go away:
base58(used by one test) andembit(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:
ALL / True, 14/14ALL / True, 14/14pyflakes qais clean, and every test module outside the dormant P2P clusterimports. The pyzcash suite is unchanged and still green (582 tests, ruff and
mypy --strictclean, on 3.12 and 3.13).One thing I noticed, unrelated and left alone
wallet.pyasserts that the wallet's transparent balance implies a coinbasecount 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_SCRIPTSbut behaves like it. Theassertion immediately below it in the same file already polls via
wait_for_total_balance, and this one probably should too. Not touched here.