From e981cf1aa8d0311c9fdeb73bddca89d6a05dd4ff Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Thu, 16 Jul 2026 13:54:11 +0200 Subject: [PATCH 01/29] test framework: scaffold the Orchard -> Ironwood migration test Add wallet_ironwood_migration.py, scaffolding for the Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005). The migration itself is still being built: zallet will expose the command, and the librustzcash note-split planner crate zcash_ironwood_migration_backend is still evolving. The module therefore registers the end-to-end flow and its fixtures without asserting any final behavior, so it stays independent of the final zallet/librustzcash API. It reuses the deferred-activation pattern from wallet_ironwood_activation.py to mint a real v2 Orchard note as the migration source, then marks TODO points where the migration command and its post-conditions will plug in. The denomination policy is deliberately not pinned, since it is still in flux. Two things keep it from failing the suite while unimplemented: it is listed in DISABLED_SCRIPTS so the runner does not spend a coinbase-maturity window on a test that can only skip today (the framework's equivalent of skip/xfail: registered in NEW_SCRIPTS but excluded from the run lists), and if run directly it self-skips by probing for the migration RPC and returning cleanly when none is found. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/pull-tester/rpc-tests.py | 2 + qa/rpc-tests/wallet_ironwood_migration.py | 198 ++++++++++++++++++++++ 2 files changed, 200 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration.py diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index 9c9a2f829..85c4f9656 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -130,6 +130,7 @@ 'wallet_orchard_init.py', # no zallet equiv yet: resendwallettransactions 'wallet_orchard_persistence.py', # deprecated; z_getbalanceforaccount->z_getbalances 'wallet_orchard_reindex.py', # deprecated; z_getbalanceforaccount->z_getbalances + 'wallet_ironwood_migration.py', # no zallet equiv yet: Orchard->Ironwood migration RPC (zcash_ironwood_migration_backend note-split planner still evolving); scaffolding self-skips 'wallet_ironwood_reorg.py', # zebra-backend: wait_for_wallet_sync never converges after invalidateblock (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_ironwood_birthday.py', # zebra-backend: recovered account's wait_for_wallet_sync(timeout=300) still times out (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_overwintertx.py', # deprecated; getnewaddress->z_getaddressforaccount, z_getnewaddress->z_getaddressforaccount @@ -281,6 +282,7 @@ # vv Tests less than 7m vv # The two-shield Ironwood tests each mine two coinbase-maturity windows. 'wallet_ironwood_crosspool.py', + 'wallet_ironwood_migration.py', 'wallet_ironwood_spending.py', 'wallet_ironwood_invariants.py', # vv Tests less than 5m vv diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py new file mode 100644 index 000000000..56db0102c --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005), against the Z3 +# stack (zebrad + zaino + zallet). +# +# SCAFFOLDING ONLY. The migration itself is still being built: zallet will +# eventually expose the command, and the librustzcash note-split planner crate +# `zcash_ironwood_migration_backend` is still evolving. This module registers +# the end-to-end flow and its fixtures WITHOUT asserting any final behavior, so +# it stays independent of the final zallet/librustzcash API. +# +# Two things keep it from failing the suite while the feature is unimplemented: +# * It is listed in DISABLED_SCRIPTS in qa/pull-tester/rpc-tests.py, so the +# runner does not spend a coinbase-maturity window on a test that can only +# skip today (the framework's equivalent of pytest's skip/xfail: the test is +# registered in NEW_SCRIPTS but excluded from the run lists). Remove that +# entry to enable it once the RPC lands. +# * If it is run directly, it SELF-SKIPS: run_test probes for the migration +# RPC and, finding none, returns cleanly (exit 0) instead of failing. +# +# The flow, once the feature exists: +# 1. Orchard era (pre-NU6.3): mint a real, spendable v2 Orchard note -- the +# migration SOURCE. (Mirrors wallet_ironwood_activation.py: a direct +# many-UTXO Orchard shield can be left unspendable by fee estimation, so we +# shield into Sapling then pay the Orchard-only receiver.) +# 2. NU6.3 activates, bringing the Ironwood pool live. +# 3. TODO(migration): invoke the migration command / RPC to move the Orchard +# value into the Ironwood pool via the note-split planner. +# 4. TODO(assert): the Orchard pool drains and the Ironwood pool gains the +# value, split into planner denominations. The denomination policy +# ({1,2,5}*10^k ZEC and the sub-0.01 residual) is NOT pinned here because it +# is still evolving; when finalized, assert only stable, conservation-style +# invariants (see the TODO block in run_test). +# + +from decimal import Decimal + +from test_framework.util import ( + COIN, + Pool, + PrivacyPolicy, + _RPC_EXCEPTIONS, + account_spendable_zat, + assert_true, + ironwood_notes, + nu_activation_ironwood_at, + shield_coinbase, + wait_account_settled, + wait_and_assert_operationid_status, + wait_for_account_spendable, + wait_for_tx_scanned, +) +from test_framework.util_ironwood import IronwoodTestFramework + +# Defer NU6.3 past coinbase maturity so the Orchard-era shield (which needs +# mature coinbase) is mined before activation. Mirrors +# wallet_ironwood_activation.py. +IRONWOOD_HEIGHT = 210 + +# JSON-RPC "method not found" code; an unknown method returns this before it +# parses any argument. +RPC_METHOD_NOT_FOUND = -32601 + +# A deliberately-invalid argument used only to probe whether a candidate method +# exists. An unknown method rejects the CALL (method-not-found); an existing +# method rejects this ARGUMENT (some other error). Passing it means the probe +# can never trigger a real migration. +PROBE_SENTINEL = '__ironwood_migration_probe__' + +# Candidate names for the not-yet-released zallet migration RPC. The final name +# is undecided while the feature evolves, so the probe treats the flow as +# unimplemented (and self-skips) unless one of these resolves to a real method. +# Update this list -- or replace the probe with a proper capability check -- +# once zallet settles the RPC surface. +MIGRATION_RPC_CANDIDATES = ( + 'z_migrate_pool', + 'z_migratepool', + 'z_migrateironwood', + 'z_migrate_to_ironwood', +) + + +def orchard_notes(wallet, minconf=1): + return [u for u in wallet.z_listunspent(minconf) + if u['pool'] == Pool.ORCHARD] + + +class WalletIronwoodMigrationTest(IronwoodTestFramework): + + def __init__(self): + super().__init__() + # Deferred activation gives an Orchard era in which to mint the v2 + # Orchard note the migration consumes. + self.activation_heights = nu_activation_ironwood_at(IRONWOOD_HEIGHT) + + def migration_rpc_name(self, w): + """Return the name of the migration RPC if zallet exposes one, else + None. Probes each candidate with an invalid sentinel argument and treats + a 'method not found' error as 'not implemented'.""" + for name in MIGRATION_RPC_CANDIDATES: + method = getattr(w, name) + try: + method(PROBE_SENTINEL) + except _RPC_EXCEPTIONS as e: + msg = str(e.error.get('message', '')).lower() + if (e.error.get('code') == RPC_METHOD_NOT_FOUND + or 'method not found' in msg): + continue # this candidate does not exist; try the next + return name # exists (rejected the argument, not the method) + else: + return name # exists and (surprisingly) accepted the sentinel + return None + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + # Bring the wallet up to the chain tip so account RPCs are accepted. + self.sync_all() + + # Capability gate: skip cleanly until zallet exposes the migration RPC. + # This keeps the module registered and runnable without failing while + # the feature (and the librustzcash planner) are still evolving. + rpc_name = self.migration_rpc_name(w) + if rpc_name is None: + print("SKIP: no zallet Orchard->Ironwood migration RPC yet " + "(tried {}); scaffolding only.".format( + ", ".join(MIGRATION_RPC_CANDIDATES))) + return + print("Found migration RPC: {}".format(rpc_name)) + + # ---- Setup skeleton: mint the migration SOURCE (a v2 Orchard note) -- + acct = w.z_listaccounts()[0]['account_uuid'] + ua = w.z_getaddressforaccount(acct, ['orchard'])['address'] + sapling_ua = w.z_getaddressforaccount(acct, ['sapling'])['address'] + + print("Orchard era: mint a spendable Orchard note (pre-NU6.3)...") + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "must still be in the Orchard era before funding") + _, sapling_zat = shield_coinbase( + node, w, taddr, sapling_ua, acct, Pool.SAPLING) + orch_target = (Decimal(sapling_zat) / COIN / 2).quantize(Decimal('0.0001')) + opid = w.z_sendmany( + sapling_ua, [{'address': ua, 'amount': orch_target}], 1, None, + PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) + txid = wait_and_assert_operationid_status(w, opid) + assert_true(txid is not None, "Sapling -> Orchard send should succeed") + node.generate(1) + wait_for_tx_scanned(w, txid) + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "the Orchard note must be minted before NU6.3 activates") + wait_for_account_spendable(w, acct, Pool.ORCHARD, min_zat=1) + wait_account_settled(w, acct) + orchard_zat = account_spendable_zat(w, acct, Pool.ORCHARD) + assert_true(orchard_zat > 0, "the Orchard note should be spendable") + assert_true(len(orchard_notes(w)) >= 1, "expected an Orchard source note") + assert_true(len(ironwood_notes(w)) == 0, + "no Ironwood notes should exist before migration") + print(" Orchard source funded: {} zat".format(orchard_zat)) + + # Cross the NU6.3 activation boundary so the Ironwood pool is live. + if node.getblockcount() < IRONWOOD_HEIGHT: + node.generate(IRONWOOD_HEIGHT - node.getblockcount()) + assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, + "NU6.3 must be active before migrating") + self.sync_all() + + # ---- TODO(migration): invoke the migration command ----------------- + # The exact argument shape and return value are still being designed in + # zallet + zcash_ironwood_migration_backend. Wire the real call here, + # e.g. an opid-returning `w.(ua)` / `w.(acct, ...)`, + # then confirm and scan the resulting transaction(s) as elsewhere: + # opid = getattr(w, rpc_name)(...) + # mtxid = wait_and_assert_operationid_status(w, opid) + # node.generate(1); wait_for_tx_scanned(w, mtxid) + print("TODO: invoke {} to migrate Orchard -> Ironwood".format(rpc_name)) + + # ---- TODO(assert): value moved Orchard -> Ironwood ----------------- + # Do NOT pin the denomination policy here (the {1,2,5}*10^k split and the + # sub-0.01 residual handling are still evolving). When finalized, assert + # only stable invariants, for example: + # * the Orchard pool drains to (near) zero, + # * the Ironwood pool gains the migrated value (minus fees), + # * value is conserved across the two pools, + # * each produced Ironwood note is a planned denomination. + print("TODO: assert Orchard drains and Ironwood gains the value " + "(conservation only; denomination policy left to the feature)") + + print("\nIronwood migration scaffolding reached the migration point.") + + +if __name__ == '__main__': + WalletIronwoodMigrationTest().main() From c5671ec462c26f92cc43546f60346ec79b99afc7 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Thu, 16 Jul 2026 13:54:11 +0200 Subject: [PATCH 02/29] CHANGELOG: Ironwood migration test scaffolding Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/qa/CHANGELOG.md b/qa/CHANGELOG.md index 1a68044ee..cf25b8a3b 100644 --- a/qa/CHANGELOG.md +++ b/qa/CHANGELOG.md @@ -11,6 +11,8 @@ We do not publish package releases or Git tags for this project. Each version en ### Added +- `wallet_ironwood_migration.py` adds scaffolding for the Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005). It stands up the fixtures (deferred NU6.3 so a real v2 Orchard note can be minted as the migration source) and marks TODO points where the still-evolving zallet migration RPC and the `zcash_ironwood_migration_backend` note-split planner will plug in, without asserting any final behavior (the `{1,2,5}*10^k` denomination policy is deliberately not pinned). It self-skips when no migration RPC is exposed, and is listed in `DISABLED_SCRIPTS` so the suite does not run it until the feature lands. + - `wallet_transparent_spend.py` covers transparent-to-transparent spending through `z_sendmany`: it shields coinbase and unshields to obtain a non-coinbase transparent UTXO (coinbase cannot be spent transparently), pins both privacy-policy rejections (`AllowRevealedSenders` and `AllowRevealedRecipients` are each insufficient on their own), and asserts the resulting transaction is fully transparent with its change at a fresh internal-scope address. - `wallet_legacy_pool_spend.py` covers `z_sendmany`'s `ANY_TADDR` source, which spends the legacy `zcashd` pool of funds. Zallet holds that pool in the account derived from the migrated wallet's mnemonic at ZIP 32 index `0x7FFFFFFF`, so the test creates that account with `z_recoveraccounts` and names its seed to the wallet through the new `ZalletArgs.legacy_pool_seed_fingerprint` (which sets `features.legacy_pool_seed_fingerprint` in `zallet.toml`). It pins both configuration rejections (the option unset, and set to a seed with no legacy account), funds the pool across two transparent receivers such that neither covers the payment alone, pins the `AllowLinkingAccountAddresses` requirement that the resulting linkage carries (and that a transparent recipient on top of it needs `NoPrivacy`), and asserts the spend draws on both addresses. From 9b888f2567f9a4c44c3189171716192e10ccbf12 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Thu, 16 Jul 2026 14:32:10 +0200 Subject: [PATCH 03/29] test framework: target the generic pool-migration RPC in the migration test zallet is gaining a generic pool-migration JSON-RPC parameterized by from_pool/to_pool (backed by the librustzcash zcash_ironwood_migration_backend note-split planner). Point the scaffolding's capability probe at the real method names and drive it generically as orchard -> ironwood. The probe now resolves each migration method (start, status, advance, cancel, list) from a small per-method candidate list, so it stays robust while zallet settles the exact names; the start method is the capability gate. The flow grows in stages: * Stage 0 (method absent): self-skip cleanly (exit 0), as before. The currently-shipping zallet build lands here. * Stage 1 (method present, engine not landed): assert the RPC surface only -- start is callable for orchard -> ironwood and either returns a plan-summary object or a clear "not implemented yet" response, and an unsupported pool pair is rejected with an error. * Stage 2 (engine landed): the fund_orchard_source fixture and the TODO block mark where the funded end-to-end drive-and-assert goes. The denomination policy is deliberately not pinned; assert only conservation-style invariants once it is finalized. Kept registered in NEW_SCRIPTS and listed in DISABLED_SCRIPTS. Running the module against the current build self-skips and exits 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/pull-tester/rpc-tests.py | 2 +- qa/rpc-tests/wallet_ironwood_migration.py | 280 ++++++++++++++-------- 2 files changed, 186 insertions(+), 96 deletions(-) diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index 85c4f9656..575095efd 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -130,7 +130,7 @@ 'wallet_orchard_init.py', # no zallet equiv yet: resendwallettransactions 'wallet_orchard_persistence.py', # deprecated; z_getbalanceforaccount->z_getbalances 'wallet_orchard_reindex.py', # deprecated; z_getbalanceforaccount->z_getbalances - 'wallet_ironwood_migration.py', # no zallet equiv yet: Orchard->Ironwood migration RPC (zcash_ironwood_migration_backend note-split planner still evolving); scaffolding self-skips + 'wallet_ironwood_migration.py', # no zallet equiv yet: generic pool-migration RPC (z_startpoolmigration &c, zcash_ironwood_migration_backend note-split planner still evolving); scaffolding self-skips 'wallet_ironwood_reorg.py', # zebra-backend: wait_for_wallet_sync never converges after invalidateblock (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_ironwood_birthday.py', # zebra-backend: recovered account's wait_for_wallet_sync(timeout=300) still times out (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_overwintertx.py', # deprecated; getnewaddress->z_getaddressforaccount, z_getnewaddress->z_getaddressforaccount diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index 56db0102c..b8944df77 100644 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -7,34 +7,31 @@ # Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005), against the Z3 # stack (zebrad + zaino + zallet). # -# SCAFFOLDING ONLY. The migration itself is still being built: zallet will -# eventually expose the command, and the librustzcash note-split planner crate -# `zcash_ironwood_migration_backend` is still evolving. This module registers -# the end-to-end flow and its fixtures WITHOUT asserting any final behavior, so -# it stays independent of the final zallet/librustzcash API. +# zallet is gaining a GENERIC pool-migration JSON-RPC, parameterized by +# `from_pool` / `to_pool`, backed by the librustzcash note-split planner crate +# `zcash_ironwood_migration_backend`. Both are still evolving, so this module +# stays independent of the final API and grows with the feature in stages: # -# Two things keep it from failing the suite while the feature is unimplemented: -# * It is listed in DISABLED_SCRIPTS in qa/pull-tester/rpc-tests.py, so the -# runner does not spend a coinbase-maturity window on a test that can only -# skip today (the framework's equivalent of pytest's skip/xfail: the test is -# registered in NEW_SCRIPTS but excluded from the run lists). Remove that -# entry to enable it once the RPC lands. -# * If it is run directly, it SELF-SKIPS: run_test probes for the migration -# RPC and, finding none, returns cleanly (exit 0) instead of failing. +# * Stage 0 -- method ABSENT: the start method does not exist yet. run_test +# probes for it and SELF-SKIPS (returns cleanly, exit 0). This is where the +# currently-shipping zallet build lands. +# * Stage 1 -- method PRESENT (stub answers, engine not landed): assert the +# RPC surface only. The start method must be callable for orchard->ironwood +# and either return a plan-summary object OR a clear "not implemented yet" +# response, and it must reject an unsupported pool pair with an error. The +# end-to-end assertions (notes actually prepared, value actually crossing +# the pools) stay TODO until the engine lands. +# * Stage 2 -- engine landed (future): wire the funded end-to-end flow. The +# `fund_orchard_source` helper already stands up the fixture (a real v2 +# Orchard note minted pre-NU6.3 as the migration source); the TODO block in +# run_test says where the drive-and-assert goes. The denomination policy +# ({1,2,5}*10^k ZEC and the sub-0.01 residual) is deliberately NOT pinned +# here -- assert only conservation-style invariants when it is finalized. # -# The flow, once the feature exists: -# 1. Orchard era (pre-NU6.3): mint a real, spendable v2 Orchard note -- the -# migration SOURCE. (Mirrors wallet_ironwood_activation.py: a direct -# many-UTXO Orchard shield can be left unspendable by fee estimation, so we -# shield into Sapling then pay the Orchard-only receiver.) -# 2. NU6.3 activates, bringing the Ironwood pool live. -# 3. TODO(migration): invoke the migration command / RPC to move the Orchard -# value into the Ironwood pool via the note-split planner. -# 4. TODO(assert): the Orchard pool drains and the Ironwood pool gains the -# value, split into planner denominations. The denomination policy -# ({1,2,5}*10^k ZEC and the sub-0.01 residual) is NOT pinned here because it -# is still evolving; when finalized, assert only stable, conservation-style -# invariants (see the TODO block in run_test). +# The module is registered in NEW_SCRIPTS but listed in DISABLED_SCRIPTS in +# qa/pull-tester/rpc-tests.py (the framework's equivalent of skip/xfail), so the +# suite does not spend a coinbase-maturity window on it while it can only skip. +# Remove that entry once the migration RPC lands. # from decimal import Decimal @@ -46,6 +43,7 @@ _RPC_EXCEPTIONS, account_spendable_zat, assert_true, + expect_rpc_error, ironwood_notes, nu_activation_ironwood_at, shield_coinbase, @@ -65,22 +63,54 @@ # parses any argument. RPC_METHOD_NOT_FOUND = -32601 -# A deliberately-invalid argument used only to probe whether a candidate method -# exists. An unknown method rejects the CALL (method-not-found); an existing -# method rejects this ARGUMENT (some other error). Passing it means the probe -# can never trigger a real migration. -PROBE_SENTINEL = '__ironwood_migration_probe__' - -# Candidate names for the not-yet-released zallet migration RPC. The final name -# is undecided while the feature evolves, so the probe treats the flow as -# unimplemented (and self-skips) unless one of these resolves to a real method. -# Update this list -- or replace the probe with a proper capability check -- -# once zallet settles the RPC surface. -MIGRATION_RPC_CANDIDATES = ( - 'z_migrate_pool', +# The generic migration is driven by pool names. Ironwood notes are +# Orchard-shaped, so the migration moves value from the Orchard pool into the +# Ironwood pool. +FROM_POOL = Pool.ORCHARD +TO_POOL = Pool.IRONWOOD + +# A pool name no build supports, used to assert input validation independently +# of which migration directions are wired. +UNSUPPORTED_POOL = 'nonexistent_pool' + +# Markers a stub uses to say "the RPC exists but the engine is not wired yet". +# A start response containing any of these is treated as an acceptable Stage-1 +# answer (the engine has not landed). +NOT_IMPLEMENTED_MARKERS = ( + 'not implemented', + 'unimplemented', + 'not yet', + 'notimplemented', + 'coming soon', + 'todo', +) + +# Candidate names per migration method. The exact names may be adjusted to +# zallet conventions, so probe a small list per method and take the first that +# resolves to a real method. The `start` method is the capability gate. +START_RPC_CANDIDATES = ( + 'z_startpoolmigration', + 'z_start_pool_migration', + 'z_poolmigrationstart', 'z_migratepool', - 'z_migrateironwood', - 'z_migrate_to_ironwood', + 'z_migrate_pool', +) +STATUS_RPC_CANDIDATES = ( + 'z_getpoolmigrationstatus', + 'z_poolmigrationstatus', + 'z_get_pool_migration_status', +) +ADVANCE_RPC_CANDIDATES = ( + 'z_advancepoolmigration', + 'z_advance_pool_migration', +) +CANCEL_RPC_CANDIDATES = ( + 'z_cancelpoolmigration', + 'z_cancel_pool_migration', +) +LIST_RPC_CANDIDATES = ( + 'z_listpoolmigrations', + 'z_list_pool_migrations', ) @@ -97,54 +127,81 @@ def __init__(self): # Orchard note the migration consumes. self.activation_heights = nu_activation_ironwood_at(IRONWOOD_HEIGHT) - def migration_rpc_name(self, w): - """Return the name of the migration RPC if zallet exposes one, else - None. Probes each candidate with an invalid sentinel argument and treats - a 'method not found' error as 'not implemented'.""" - for name in MIGRATION_RPC_CANDIDATES: + # ---- capability probing ------------------------------------------------ + + @staticmethod + def _is_method_not_found(e): + msg = str(e.error.get('message', '')).lower() + return (e.error.get('code') == RPC_METHOD_NOT_FOUND + or 'method not found' in msg) + + def resolve_rpc_name(self, w, candidates, *probe_args): + """Return the first candidate name that resolves to a real method, else + None. Probes with `probe_args` (deliberately invalid, so the probe can + never trigger a migration): an unknown method rejects the CALL + (method-not-found); an existing method rejects the ARGUMENTS.""" + for name in candidates: method = getattr(w, name) try: - method(PROBE_SENTINEL) + method(*probe_args) except _RPC_EXCEPTIONS as e: - msg = str(e.error.get('message', '')).lower() - if (e.error.get('code') == RPC_METHOD_NOT_FOUND - or 'method not found' in msg): + if self._is_method_not_found(e): continue # this candidate does not exist; try the next - return name # exists (rejected the argument, not the method) + return name # exists (rejected the arguments, not the method) else: - return name # exists and (surprisingly) accepted the sentinel + return name # exists and (surprisingly) accepted the probe return None - def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - - # Bring the wallet up to the chain tip so account RPCs are accepted. - self.sync_all() + # ---- Stage 1: RPC-surface (stub-level) assertions ---------------------- - # Capability gate: skip cleanly until zallet exposes the migration RPC. - # This keeps the module registered and runnable without failing while - # the feature (and the librustzcash planner) are still evolving. - rpc_name = self.migration_rpc_name(w) - if rpc_name is None: - print("SKIP: no zallet Orchard->Ironwood migration RPC yet " - "(tried {}); scaffolding only.".format( - ", ".join(MIGRATION_RPC_CANDIDATES))) + def assert_start_callable(self, w, start_name): + """The start method must be callable for orchard->ironwood, returning a + plan-summary object OR a clear 'not implemented yet' response.""" + start = getattr(w, start_name) + try: + result = start(FROM_POOL, TO_POOL) + except _RPC_EXCEPTIONS as e: + message = e.error.get('message', '') + assert_true( + any(m in message.lower() for m in NOT_IMPLEMENTED_MARKERS), + "start migration ({}->{}) should return a plan summary or a " + "clear 'not implemented yet' error; got: {!r}".format( + FROM_POOL, TO_POOL, message)) + print(" start responded 'not implemented yet' (stub): {!r}. OK" + .format(message)) return - print("Found migration RPC: {}".format(rpc_name)) + # A returned result is a plan summary; do not pin its exact shape while + # it evolves, only that it is a structured (object) response. + assert_true(isinstance(result, dict), + "start migration should return a plan-summary object; " + "got {!r}".format(result)) + print(" start returned a plan summary: keys={}. OK".format( + sorted(result.keys()))) + + def assert_input_validation(self, w, start_name): + """An unsupported pool pair must be rejected with an RPC error.""" + start = getattr(w, start_name) + e = expect_rpc_error(start, UNSUPPORTED_POOL, TO_POOL) + print(" unsupported pool pair ({}->{}) rejected: {!r}. OK".format( + UNSUPPORTED_POOL, TO_POOL, e.error.get('message', ''))) - # ---- Setup skeleton: mint the migration SOURCE (a v2 Orchard note) -- - acct = w.z_listaccounts()[0]['account_uuid'] + # ---- Stage 2 fixture (used once the engine lands) ---------------------- + + def fund_orchard_source(self, node, w, taddr, acct): + """Mint a single spendable v2 Orchard note pre-NU6.3 -- the migration + source -- and return its spendable value in zat. A direct many-UTXO + Orchard shield can be left unspendable by fee estimation, so shield into + Sapling first, then pay the Orchard-only receiver (mirrors + wallet_ironwood_activation.py).""" ua = w.z_getaddressforaccount(acct, ['orchard'])['address'] sapling_ua = w.z_getaddressforaccount(acct, ['sapling'])['address'] - print("Orchard era: mint a spendable Orchard note (pre-NU6.3)...") assert_true(node.getblockcount() < IRONWOOD_HEIGHT, "must still be in the Orchard era before funding") _, sapling_zat = shield_coinbase( node, w, taddr, sapling_ua, acct, Pool.SAPLING) - orch_target = (Decimal(sapling_zat) / COIN / 2).quantize(Decimal('0.0001')) + orch_target = (Decimal(sapling_zat) / COIN / 2).quantize( + Decimal('0.0001')) opid = w.z_sendmany( sapling_ua, [{'address': ua, 'amount': orch_target}], 1, None, PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) @@ -161,37 +218,70 @@ def run_test(self): assert_true(len(orchard_notes(w)) >= 1, "expected an Orchard source note") assert_true(len(ironwood_notes(w)) == 0, "no Ironwood notes should exist before migration") - print(" Orchard source funded: {} zat".format(orchard_zat)) + return orchard_zat + + # ---- driver ------------------------------------------------------------ + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] - # Cross the NU6.3 activation boundary so the Ironwood pool is live. - if node.getblockcount() < IRONWOOD_HEIGHT: - node.generate(IRONWOOD_HEIGHT - node.getblockcount()) - assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, - "NU6.3 must be active before migrating") + # Bring the wallet up to the chain tip so account RPCs are accepted. self.sync_all() - # ---- TODO(migration): invoke the migration command ----------------- - # The exact argument shape and return value are still being designed in - # zallet + zcash_ironwood_migration_backend. Wire the real call here, - # e.g. an opid-returning `w.(ua)` / `w.(acct, ...)`, - # then confirm and scan the resulting transaction(s) as elsewhere: - # opid = getattr(w, rpc_name)(...) - # mtxid = wait_and_assert_operationid_status(w, opid) - # node.generate(1); wait_for_tx_scanned(w, mtxid) - print("TODO: invoke {} to migrate Orchard -> Ironwood".format(rpc_name)) - - # ---- TODO(assert): value moved Orchard -> Ironwood ----------------- - # Do NOT pin the denomination policy here (the {1,2,5}*10^k split and the - # sub-0.01 residual handling are still evolving). When finalized, assert - # only stable invariants, for example: + # Stage 0 gate: skip cleanly until zallet exposes the start method. This + # keeps the module registered and runnable without failing while the + # feature (and the librustzcash planner) are still evolving. + start_name = self.resolve_rpc_name( + w, START_RPC_CANDIDATES, UNSUPPORTED_POOL, UNSUPPORTED_POOL) + if start_name is None: + print("SKIP: no zallet pool-migration start RPC yet (tried {}); " + "scaffolding only.".format(", ".join(START_RPC_CANDIDATES))) + return + + # Report which of the companion methods this build exposes + # (informational; only `start` gates the flow). + status_name = self.resolve_rpc_name( + w, STATUS_RPC_CANDIDATES, UNSUPPORTED_POOL) + advance_name = self.resolve_rpc_name( + w, ADVANCE_RPC_CANDIDATES, UNSUPPORTED_POOL) + cancel_name = self.resolve_rpc_name( + w, CANCEL_RPC_CANDIDATES, UNSUPPORTED_POOL) + list_name = self.resolve_rpc_name(w, LIST_RPC_CANDIDATES) + print("Pool-migration RPCs: start={} status={} advance={} cancel={} " + "list={}".format(start_name, status_name, advance_name, + cancel_name, list_name)) + + # Stage 1: assert the RPC surface (callable + input validation) without + # the engine. These do not need funded notes. + print("Stage 1: assert the pool-migration RPC surface...") + self.assert_start_callable(w, start_name) + self.assert_input_validation(w, start_name) + print(" RPC surface OK.") + + # ---- Stage 2 TODO(end-to-end): drive a real migration -------------- + # Once the note-split engine lands, fund the source and drive the flow: + # acct = w.z_listaccounts()[0]['account_uuid'] + # orchard_zat = self.fund_orchard_source(node, w, taddr, acct) + # # cross the NU6.3 boundary so the Ironwood pool is live: + # node.generate(max(0, IRONWOOD_HEIGHT - node.getblockcount())) + # self.sync_all() + # plan = getattr(w, start_name)(FROM_POOL, TO_POOL) + # # advance/poll with `advance_name`/`status_name` until complete, + # # confirming and scanning each produced transaction. + # Then assert only stable invariants (NOT the denomination policy, + # which is still evolving): # * the Orchard pool drains to (near) zero, # * the Ironwood pool gains the migrated value (minus fees), # * value is conserved across the two pools, # * each produced Ironwood note is a planned denomination. - print("TODO: assert Orchard drains and Ironwood gains the value " - "(conservation only; denomination policy left to the feature)") + assert node is not None # `node`/`taddr` drive the Stage-2 flow above + assert taddr is not None + print("TODO: drive the funded Orchard -> Ironwood migration and assert " + "conservation, once the note-split engine lands.") - print("\nIronwood migration scaffolding reached the migration point.") + print("\nIronwood migration RPC-surface checks passed.") if __name__ == '__main__': From 887df6bf1e8fcf983415cec98469a287d79f2a1e Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Thu, 16 Jul 2026 21:40:38 +0200 Subject: [PATCH 04/29] qa: add wallet_ironwood_migration_preview RPC test Exercise z_previewpoolmigration end to end: fund an Orchard note pre-NU6.3, cross the activation boundary, then assert input validation (an unsupported pool pair and an unknown strategy are rejected), a well-shaped plan, and value conservation for both the default and canonical strategies. The denomination policy is deliberately not pinned, so the test survives strategy tuning. Stage 0 self-skips when the running zallet lacks the method, so it is safe against older builds. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/pull-tester/rpc-tests.py | 2 + .../wallet_ironwood_migration_preview.py | 291 ++++++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_preview.py diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index 575095efd..09da4336a 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -131,6 +131,7 @@ 'wallet_orchard_persistence.py', # deprecated; z_getbalanceforaccount->z_getbalances 'wallet_orchard_reindex.py', # deprecated; z_getbalanceforaccount->z_getbalances 'wallet_ironwood_migration.py', # no zallet equiv yet: generic pool-migration RPC (z_startpoolmigration &c, zcash_ironwood_migration_backend note-split planner still evolving); scaffolding self-skips + 'wallet_ironwood_migration_preview.py', # needs a pinned zallet exposing z_previewpoolmigration (the note-split planning-preview RPC); Stage-0 self-skips on older builds 'wallet_ironwood_reorg.py', # zebra-backend: wait_for_wallet_sync never converges after invalidateblock (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_ironwood_birthday.py', # zebra-backend: recovered account's wait_for_wallet_sync(timeout=300) still times out (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_overwintertx.py', # deprecated; getnewaddress->z_getaddressforaccount, z_getnewaddress->z_getaddressforaccount @@ -283,6 +284,7 @@ # The two-shield Ironwood tests each mine two coinbase-maturity windows. 'wallet_ironwood_crosspool.py', 'wallet_ironwood_migration.py', + 'wallet_ironwood_migration_preview.py', 'wallet_ironwood_spending.py', 'wallet_ironwood_invariants.py', # vv Tests less than 5m vv diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py new file mode 100644 index 000000000..98559ffc4 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -0,0 +1,291 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Orchard -> Ironwood migration PLANNING PREVIEW (NU6.3, ZIP 2005), against the +# Z3 stack (zebrad + zaino + zallet). +# +# This exercises `z_previewpoolmigration`: the read-only planning slice of the +# generic pool-migration surface. Unlike `z_startpoolmigration` (which needs the +# still-unreleased Ironwood PCZT builder and therefore stays a stub; see +# wallet_ironwood_migration.py), the preview is fully wired: it reads the +# account's spendable Orchard balance and runs the librustzcash note-split +# planner (`zcash_ironwood_migration_backend::note_splitting`) to return the +# proposed decomposition. Nothing is scheduled, built, proved, or broadcast. +# +# Staging (mirrors wallet_ironwood_migration.py): +# +# * Stage 0 -- method ABSENT: the preview method does not exist in the running +# zallet build. run_test probes for it and SELF-SKIPS (returns cleanly, exit +# 0). This is where a zallet build predating the preview RPC lands. +# * Stage 1 -- method PRESENT: assert the full planning contract. Because the +# preview needs no engine build path, this is a REAL end-to-end assertion, +# not a stub check: fund a v2 Orchard note pre-NU6.3, cross the activation +# boundary, then assert the returned plan conserves value and reports sane +# crossing denominations. Input validation (unsupported pool pair, unknown +# strategy) is asserted too. +# +# The denomination POLICY ({1,2,5}*10^k ZEC vs the canonical powers of ten, and +# the sub-0.01 residual) is deliberately NOT pinned here; only conservation-style +# invariants are asserted, so this test survives strategy tuning. +# +# Registered in NEW_SCRIPTS but listed in DISABLED_SCRIPTS in +# qa/pull-tester/rpc-tests.py until a zallet build exposing +# `z_previewpoolmigration` is the pinned one; the Stage-0 self-skip keeps it safe +# to run against older builds in the meantime. +# + +from decimal import Decimal + +from test_framework.util import ( + COIN, + Pool, + PrivacyPolicy, + _RPC_EXCEPTIONS, + account_spendable_zat, + assert_equal, + assert_true, + expect_rpc_error, + ironwood_notes, + nu_activation_ironwood_at, + shield_coinbase, + wait_account_settled, + wait_and_assert_operationid_status, + wait_for_account_spendable, + wait_for_tx_scanned, +) +from test_framework.util_ironwood import IronwoodTestFramework + +# Defer NU6.3 past coinbase maturity so the Orchard-era shield (which needs +# mature coinbase) is mined before activation. Mirrors +# wallet_ironwood_activation.py / wallet_ironwood_migration.py. +IRONWOOD_HEIGHT = 210 + +# JSON-RPC "method not found" code; an unknown method returns this before it +# parses any argument. +RPC_METHOD_NOT_FOUND = -32601 + +# The generic migration is driven by pool names. Ironwood notes are +# Orchard-shaped, so the migration moves value from the Orchard pool into the +# Ironwood pool. +FROM_POOL = Pool.ORCHARD +TO_POOL = Pool.IRONWOOD + +# A pool name no build supports, used to assert input validation independently +# of which migration directions are wired. +UNSUPPORTED_POOL = 'nonexistent_pool' + +# A strategy name no build supports, used to assert strategy validation. +UNSUPPORTED_STRATEGY = 'nonexistent_strategy' + +# The ZIP-317 minimum fee (zat) the preview reserves as the note-split ("prep") +# fee. The preview documents this reserve; the engine computes the real prep fee +# once the build path lands. +ZIP317_MINIMUM_FEE_ZAT = 10000 + +# Candidate names for the preview method. The exact name may be adjusted to +# zallet conventions, so probe a small list and take the first that resolves. +PREVIEW_RPC_CANDIDATES = ( + 'z_previewpoolmigration', + 'z_preview_pool_migration', + 'z_poolmigrationpreview', + 'z_migratepoolpreview', +) + + +class WalletIronwoodMigrationPreviewTest(IronwoodTestFramework): + + def __init__(self): + super().__init__() + # Deferred activation gives an Orchard era in which to mint the v2 + # Orchard note the preview plans over. + self.activation_heights = nu_activation_ironwood_at(IRONWOOD_HEIGHT) + + # ---- capability probing ------------------------------------------------ + + @staticmethod + def _is_method_not_found(e): + msg = str(e.error.get('message', '')).lower() + return (e.error.get('code') == RPC_METHOD_NOT_FOUND + or 'method not found' in msg) + + def resolve_rpc_name(self, w, candidates, *probe_args): + """Return the first candidate name that resolves to a real method, else + None. Probes with `probe_args` (deliberately invalid, so the probe can + never trigger real work): an unknown method rejects the CALL + (method-not-found); an existing method rejects the ARGUMENTS.""" + for name in candidates: + method = getattr(w, name) + try: + method(*probe_args) + except _RPC_EXCEPTIONS as e: + if self._is_method_not_found(e): + continue # this candidate does not exist; try the next + return name # exists (rejected the arguments, not the method) + else: + return name # exists and (surprisingly) accepted the probe + return None + + # ---- fixture ----------------------------------------------------------- + + def fund_orchard_source(self, node, w, taddr, acct): + """Mint a single spendable v2 Orchard note pre-NU6.3 -- the migration + source the preview plans over -- and return its spendable value in zat. + A direct many-UTXO Orchard shield can be left unspendable by fee + estimation, so shield into Sapling first, then pay the Orchard-only + receiver (mirrors wallet_ironwood_migration.py).""" + ua = w.z_getaddressforaccount(acct, ['orchard'])['address'] + sapling_ua = w.z_getaddressforaccount(acct, ['sapling'])['address'] + + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "must still be in the Orchard era before funding") + _, sapling_zat = shield_coinbase( + node, w, taddr, sapling_ua, acct, Pool.SAPLING) + orch_target = (Decimal(sapling_zat) / COIN / 2).quantize( + Decimal('0.0001')) + opid = w.z_sendmany( + sapling_ua, [{'address': ua, 'amount': orch_target}], 1, None, + PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) + txid = wait_and_assert_operationid_status(w, opid) + assert_true(txid is not None, "Sapling -> Orchard send should succeed") + node.generate(1) + wait_for_tx_scanned(w, txid) + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "the Orchard note must be minted before NU6.3 activates") + wait_for_account_spendable(w, acct, Pool.ORCHARD, min_zat=1) + wait_account_settled(w, acct) + orchard_zat = account_spendable_zat(w, acct, Pool.ORCHARD) + assert_true(orchard_zat > 0, "the Orchard note should be spendable") + assert_true(len(ironwood_notes(w)) == 0, + "no Ironwood notes should exist before migration") + return orchard_zat + + # ---- assertions -------------------------------------------------------- + + @staticmethod + def assert_plan_shape(plan): + """A preview response is a structured plan-summary object with the + expected keys and internally-consistent counts.""" + assert_true(isinstance(plan, dict), + "preview should return a plan-summary object; got " + "{!r}".format(plan)) + for key in ('from_pool', 'to_pool', 'strategy', 'account_balance_zat', + 'prep_fee_zat', 'total_input_zat', 'total_migratable_zat', + 'source_change_zat', 'note_count', 'notes'): + assert_true(key in plan, + "preview plan missing key {!r}; got keys {}".format( + key, sorted(plan.keys()))) + assert_equal(FROM_POOL, plan['from_pool'], "from_pool echoed") + assert_equal(TO_POOL, plan['to_pool'], "to_pool echoed") + assert_equal(len(plan['notes']), plan['note_count'], + "note_count matches the number of notes") + + def assert_conservation(self, plan, orchard_zat): + """The plan conserves value: the prepared notes, the residual left in + the source pool, and the reserved prep fee sum to the input balance; the + migratable total is the sum of the crossing values; and every crossing + is a positive value its note fully funds.""" + assert_equal(orchard_zat, plan['account_balance_zat'], + "preview reports the account's spendable Orchard balance") + assert_equal(orchard_zat, plan['total_input_zat'], + "the plan decomposes the full spendable balance") + assert_equal(ZIP317_MINIMUM_FEE_ZAT, plan['prep_fee_zat'], + "the preview reserves the ZIP-317 minimum prep fee") + + notes = plan['notes'] + crossings_sum = sum(n['crossing_zat'] for n in notes) + outputs_sum = sum(n['output_zat'] for n in notes) + for n in notes: + assert_true(n['crossing_zat'] > 0, + "each crossing value is positive") + assert_true(n['output_zat'] >= n['crossing_zat'], + "each prepared note fully funds its crossing value") + + assert_equal(crossings_sum, plan['total_migratable_zat'], + "total_migratable is the sum of the crossing values") + assert_equal( + orchard_zat, + outputs_sum + plan['source_change_zat'] + plan['prep_fee_zat'], + "notes + residual + prep fee conserve the input balance") + + # ---- driver ------------------------------------------------------------ + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + acct = w.z_listaccounts()[0]['account_uuid'] + + # Stage 0 gate: skip cleanly until the running zallet exposes the preview + # method. The probe uses an unsupported pool pair so it can never do real + # work, only reveal whether the method resolves. + preview_name = self.resolve_rpc_name( + w, PREVIEW_RPC_CANDIDATES, acct, UNSUPPORTED_POOL, UNSUPPORTED_POOL) + if preview_name is None: + print("SKIP: no zallet pool-migration preview RPC yet (tried {}); " + "scaffolding only.".format(", ".join(PREVIEW_RPC_CANDIDATES))) + return + preview = getattr(w, preview_name) + print("Pool-migration preview RPC: {}".format(preview_name)) + + # Fund the Orchard source in the Orchard era, then cross the NU6.3 + # boundary so the migration (and hence the preview) is enabled. + print("Funding an Orchard source note pre-NU6.3...") + orchard_zat = self.fund_orchard_source(node, w, taddr, acct) + print(" Orchard source spendable: {} zat.".format(orchard_zat)) + + to_activation = max(0, IRONWOOD_HEIGHT - node.getblockcount()) + if to_activation > 0: + node.generate(to_activation) + self.sync_all() + assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, + "NU6.3 must be active before previewing the migration") + # The pre-NU6.3 note stays an Orchard note (the migration source); it is + # not reclassified as Ironwood. + assert_equal(orchard_zat, + account_spendable_zat(w, acct, Pool.ORCHARD), + "the Orchard source note survives NU6.3 activation") + + # Stage 1a: input validation. + print("Asserting preview input validation...") + e = expect_rpc_error(preview, acct, UNSUPPORTED_POOL, TO_POOL) + print(" unsupported pool pair rejected: {!r}. OK".format( + e.error.get('message', ''))) + e = expect_rpc_error( + preview, acct, FROM_POOL, TO_POOL, 1, UNSUPPORTED_STRATEGY) + print(" unknown strategy rejected: {!r}. OK".format( + e.error.get('message', ''))) + + # Stage 1b: the default (randomized) strategy plans and conserves value. + print("Previewing the Orchard -> Ironwood plan (default strategy)...") + plan = preview(acct, FROM_POOL, TO_POOL) + self.assert_plan_shape(plan) + assert_true(plan['note_count'] > 0, + "a funded account should produce at least one note") + assert_true(plan['total_migratable_zat'] > 0, + "a funded account should migrate a positive amount") + self.assert_conservation(plan, orchard_zat) + print(" default plan: note_count={} migratable={} change={}. OK" + .format(plan['note_count'], plan['total_migratable_zat'], + plan['source_change_zat'])) + + # Stage 1c: the canonical strategy is also selectable and conserves + # value (the decomposition differs; only invariants are asserted). + print("Previewing with the canonical strategy...") + canonical = preview(acct, FROM_POOL, TO_POOL, 1, 'canonical') + self.assert_plan_shape(canonical) + assert_equal('canonical', canonical['strategy'], + "the canonical strategy is echoed back") + self.assert_conservation(canonical, orchard_zat) + print(" canonical plan: note_count={} migratable={}. OK".format( + canonical['note_count'], canonical['total_migratable_zat'])) + + print("\nIronwood migration preview checks passed.") + + +if __name__ == '__main__': + WalletIronwoodMigrationPreviewTest().main() From f8bf8431957f74f3421b28e268228dce3cfe9da2 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 00:01:18 +0200 Subject: [PATCH 05/29] qa: update wallet_ironwood_migration_preview for the engine plan API The zallet preview RPC was rewired onto the migration engine's planning slice (zcash_pool_migration_backend::engine::plan_migration), which changes the response contract. Update the test to match: - Drop the strategy parameter and its validation: the engine no longer exposes a denomination-strategy choice, so the method takes only account, from_pool, to_pool, and an optional minconf. - Assert the new plan shape: funding_notes carrying each note's crossing value, self-funding output, and transfer schedule (broadcast height and expiry); plus the note_split and preparation summaries. - Replace the old single-transaction conservation identity (which no longer holds now that preparation is a multi-transaction, fee-charging, reconciled plan) with honest invariants: each output funds its crossing plus the fixed transfer fee buffer, the migratable total is the sum of the crossings and never exceeds the balance, funding notes are a subset of the raw split, and every transfer is scheduled at or after the chain tip with an expiry beyond its broadcast height. - The reserved prep fee is now the padded ZIP-317 preparation-transaction fee (PREP_TX_ACTIONS * marginal fee), not the ZIP-317 minimum. The Stage-0 self-skip is unchanged, so the test stays safe against zallet builds that predate the preview RPC. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_preview.py | 148 ++++++++++-------- 1 file changed, 87 insertions(+), 61 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py index 98559ffc4..94d94c628 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_preview.py +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -9,11 +9,12 @@ # # This exercises `z_previewpoolmigration`: the read-only planning slice of the # generic pool-migration surface. Unlike `z_startpoolmigration` (which needs the -# still-unreleased Ironwood PCZT builder and therefore stays a stub; see -# wallet_ironwood_migration.py), the preview is fully wired: it reads the -# account's spendable Orchard balance and runs the librustzcash note-split -# planner (`zcash_ironwood_migration_backend::note_splitting`) to return the -# proposed decomposition. Nothing is scheduled, built, proved, or broadcast. +# still-unreleased engine commit/reconcile slices that build, pre-sign, and +# persist the PCZTs, and therefore stays a stub; see wallet_ironwood_migration.py), +# the preview is fully wired: it enumerates the account's spendable Orchard notes +# and runs the librustzcash migration engine's planning slice +# (`zcash_pool_migration_backend::engine::plan_migration`) to return the proposed +# plan. Nothing is scheduled, built, proved, or broadcast. # # Staging (mirrors wallet_ironwood_migration.py): # @@ -23,13 +24,14 @@ # * Stage 1 -- method PRESENT: assert the full planning contract. Because the # preview needs no engine build path, this is a REAL end-to-end assertion, # not a stub check: fund a v2 Orchard note pre-NU6.3, cross the activation -# boundary, then assert the returned plan conserves value and reports sane -# crossing denominations. Input validation (unsupported pool pair, unknown -# strategy) is asserted too. +# boundary, then assert the returned plan reports sane crossing denominations, +# a transfer schedule (a broadcast height and expiry per funding note), and +# the value invariants below. Input validation (an unsupported pool pair) is +# asserted too. # -# The denomination POLICY ({1,2,5}*10^k ZEC vs the canonical powers of ten, and -# the sub-0.01 residual) is deliberately NOT pinned here; only conservation-style -# invariants are asserted, so this test survives strategy tuning. +# The denomination POLICY (the {1,2,5}*10^k ZEC quantization and the sub-0.01 +# residual) is deliberately NOT pinned here; only structural and conservation- +# style invariants are asserted, so this test survives strategy tuning. # # Registered in NEW_SCRIPTS but listed in DISABLED_SCRIPTS in # qa/pull-tester/rpc-tests.py until a zallet build exposing @@ -77,13 +79,18 @@ # of which migration directions are wired. UNSUPPORTED_POOL = 'nonexistent_pool' -# A strategy name no build supports, used to assert strategy validation. -UNSUPPORTED_STRATEGY = 'nonexistent_strategy' +# The ZIP-317 fee (zat) the preview reserves per note-preparation transaction: +# the fixed padded action count times the marginal fee (PREP_TX_ACTIONS = 16, +# MARGINAL_FEE = 5000 zat). The engine and the preview derive this from the +# crate's own constants; it is reproduced here to assert the reported value. +PREP_TX_ACTIONS = 16 +MARGINAL_FEE_ZAT = 5000 +PREP_FEE_ZAT = PREP_TX_ACTIONS * MARGINAL_FEE_ZAT -# The ZIP-317 minimum fee (zat) the preview reserves as the note-split ("prep") -# fee. The preview documents this reserve; the engine computes the real prep fee -# once the build path lands. -ZIP317_MINIMUM_FEE_ZAT = 10000 +# The per-note fee buffer (zat) each funding note carries on top of its crossing +# value so it self-funds its migration transfer: two source-pool plus two +# destination-pool actions at the marginal fee (4 * MARGINAL_FEE). +TRANSFER_FEE_BUFFER_ZAT = 4 * MARGINAL_FEE_ZAT # Candidate names for the preview method. The exact name may be adjusted to # zallet conventions, so probe a small list and take the first that resolves. @@ -167,48 +174,71 @@ def fund_orchard_source(self, node, w, taddr, acct): @staticmethod def assert_plan_shape(plan): """A preview response is a structured plan-summary object with the - expected keys and internally-consistent counts.""" + expected keys, nested funding-note and summary objects, and + internally-consistent counts.""" assert_true(isinstance(plan, dict), "preview should return a plan-summary object; got " "{!r}".format(plan)) - for key in ('from_pool', 'to_pool', 'strategy', 'account_balance_zat', - 'prep_fee_zat', 'total_input_zat', 'total_migratable_zat', - 'source_change_zat', 'note_count', 'notes'): + for key in ('from_pool', 'to_pool', 'enabling_upgrade', + 'account_balance_zat', 'prep_fee_zat', + 'total_migratable_zat', 'source_change_zat', + 'funding_note_count', 'funding_notes', 'note_split', + 'preparation'): assert_true(key in plan, "preview plan missing key {!r}; got keys {}".format( key, sorted(plan.keys()))) assert_equal(FROM_POOL, plan['from_pool'], "from_pool echoed") assert_equal(TO_POOL, plan['to_pool'], "to_pool echoed") - assert_equal(len(plan['notes']), plan['note_count'], - "note_count matches the number of notes") + assert_equal(len(plan['funding_notes']), plan['funding_note_count'], + "funding_note_count matches the number of funding notes") + + # Each funding note carries its crossing value, its self-funding output, + # and a transfer schedule. + for note in plan['funding_notes']: + for key in ('output_zat', 'crossing_zat', 'broadcast_height', + 'expiry_height'): + assert_true(key in note, + "funding note missing key {!r}; got {}".format( + key, sorted(note.keys()))) + + # The note-split and preparation summaries have their own shapes. + for key in ('note_count', 'total_migratable_zat', 'crossing_values'): + assert_true(key in plan['note_split'], + "note_split missing key {!r}".format(key)) + for key in ('layer_count', 'transaction_count', 'residual_note_count'): + assert_true(key in plan['preparation'], + "preparation missing key {!r}".format(key)) def assert_conservation(self, plan, orchard_zat): - """The plan conserves value: the prepared notes, the residual left in - the source pool, and the reserved prep fee sum to the input balance; the - migratable total is the sum of the crossing values; and every crossing - is a positive value its note fully funds.""" + """The plan's value invariants: it reports the account's spendable + source-pool balance, reserves the padded ZIP-317 prep fee, and each + funding note has a positive crossing that its output covers by exactly + the fixed transfer fee buffer. The migratable total is the sum of the + crossings and never exceeds the balance.""" assert_equal(orchard_zat, plan['account_balance_zat'], "preview reports the account's spendable Orchard balance") - assert_equal(orchard_zat, plan['total_input_zat'], - "the plan decomposes the full spendable balance") - assert_equal(ZIP317_MINIMUM_FEE_ZAT, plan['prep_fee_zat'], - "the preview reserves the ZIP-317 minimum prep fee") + assert_equal(PREP_FEE_ZAT, plan['prep_fee_zat'], + "the preview reserves the padded ZIP-317 prep fee") - notes = plan['notes'] + notes = plan['funding_notes'] crossings_sum = sum(n['crossing_zat'] for n in notes) - outputs_sum = sum(n['output_zat'] for n in notes) for n in notes: assert_true(n['crossing_zat'] > 0, "each crossing value is positive") - assert_true(n['output_zat'] >= n['crossing_zat'], - "each prepared note fully funds its crossing value") + assert_equal(n['crossing_zat'] + TRANSFER_FEE_BUFFER_ZAT, + n['output_zat'], + "each output funds the crossing plus the fee buffer") + assert_true(n['expiry_height'] > n['broadcast_height'], + "the transfer expiry follows its broadcast height") assert_equal(crossings_sum, plan['total_migratable_zat'], "total_migratable is the sum of the crossing values") - assert_equal( - orchard_zat, - outputs_sum + plan['source_change_zat'] + plan['prep_fee_zat'], - "notes + residual + prep fee conserve the input balance") + assert_true(plan['total_migratable_zat'] <= plan['account_balance_zat'], + "the migratable value never exceeds the input balance") + # Reconciliation only ever drops funding notes, so at most as many as the + # raw note split proposed. + assert_true(len(notes) <= plan['note_split']['note_count'], + "funding notes are a subset of the raw split") # ---- driver ------------------------------------------------------------ @@ -255,34 +285,30 @@ def run_test(self): e = expect_rpc_error(preview, acct, UNSUPPORTED_POOL, TO_POOL) print(" unsupported pool pair rejected: {!r}. OK".format( e.error.get('message', ''))) - e = expect_rpc_error( - preview, acct, FROM_POOL, TO_POOL, 1, UNSUPPORTED_STRATEGY) - print(" unknown strategy rejected: {!r}. OK".format( - e.error.get('message', ''))) - # Stage 1b: the default (randomized) strategy plans and conserves value. - print("Previewing the Orchard -> Ironwood plan (default strategy)...") + # Stage 1b: the migration plans, is scheduled, and conserves value. + print("Previewing the Orchard -> Ironwood plan...") plan = preview(acct, FROM_POOL, TO_POOL) self.assert_plan_shape(plan) - assert_true(plan['note_count'] > 0, - "a funded account should produce at least one note") + assert_true(plan['funding_note_count'] > 0, + "a funded account should produce at least one funding note") assert_true(plan['total_migratable_zat'] > 0, "a funded account should migrate a positive amount") + assert_equal(len(plan['funding_notes']), plan['funding_note_count'], + "one schedule entry per funding note") self.assert_conservation(plan, orchard_zat) - print(" default plan: note_count={} migratable={} change={}. OK" - .format(plan['note_count'], plan['total_migratable_zat'], - plan['source_change_zat'])) - - # Stage 1c: the canonical strategy is also selectable and conserves - # value (the decomposition differs; only invariants are asserted). - print("Previewing with the canonical strategy...") - canonical = preview(acct, FROM_POOL, TO_POOL, 1, 'canonical') - self.assert_plan_shape(canonical) - assert_equal('canonical', canonical['strategy'], - "the canonical strategy is echoed back") - self.assert_conservation(canonical, orchard_zat) - print(" canonical plan: note_count={} migratable={}. OK".format( - canonical['note_count'], canonical['total_migratable_zat'])) + print(" plan: funding_notes={} migratable={} change={} " + "layers={} txs={}. OK".format( + plan['funding_note_count'], plan['total_migratable_zat'], + plan['source_change_zat'], + plan['preparation']['layer_count'], + plan['preparation']['transaction_count'])) + + # Every scheduled transfer is broadcast at or after the current tip. + tip = node.getblockcount() + for n in plan['funding_notes']: + assert_true(n['broadcast_height'] >= tip - 1, + "a transfer is scheduled at or after the chain tip") print("\nIronwood migration preview checks passed.") From 2857b7ca584315f69829e6771d12403e0557d82c Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 00:01:44 +0200 Subject: [PATCH 06/29] CHANGELOG: wallet_ironwood_migration_preview and engine crate rename Document the fully-wired z_previewpoolmigration test, and update the migration scaffolding entry for the renamed engine crate (zcash_ironwood_migration_backend -> zcash_pool_migration_backend). Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/CHANGELOG.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/qa/CHANGELOG.md b/qa/CHANGELOG.md index cf25b8a3b..cc423736f 100644 --- a/qa/CHANGELOG.md +++ b/qa/CHANGELOG.md @@ -11,7 +11,9 @@ We do not publish package releases or Git tags for this project. Each version en ### Added -- `wallet_ironwood_migration.py` adds scaffolding for the Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005). It stands up the fixtures (deferred NU6.3 so a real v2 Orchard note can be minted as the migration source) and marks TODO points where the still-evolving zallet migration RPC and the `zcash_ironwood_migration_backend` note-split planner will plug in, without asserting any final behavior (the `{1,2,5}*10^k` denomination policy is deliberately not pinned). It self-skips when no migration RPC is exposed, and is listed in `DISABLED_SCRIPTS` so the suite does not run it until the feature lands. +- `wallet_ironwood_migration.py` adds scaffolding for the Orchard -> Ironwood value-pool migration (NU6.3, ZIP 2005). It stands up the fixtures (deferred NU6.3 so a real v2 Orchard note can be minted as the migration source) and marks TODO points where the still-evolving zallet migration RPC and the `zcash_pool_migration_backend` note-split planner will plug in, without asserting any final behavior (the `{1,2,5}*10^k` denomination policy is deliberately not pinned). It self-skips when no migration RPC is exposed, and is listed in `DISABLED_SCRIPTS` so the suite does not run it until the feature lands. + +- `wallet_ironwood_migration_preview.py` exercises `z_previewpoolmigration`, the fully-wired planning preview of the Orchard -> Ironwood migration. It funds a v2 Orchard note pre-NU6.3, crosses the activation boundary, and asserts the returned plan: input validation (an unsupported pool pair is rejected), the plan shape (funding notes carrying each crossing value, its self-funding output, and its transfer schedule; plus the note-split and preparation summaries), and value invariants (each output funds its crossing plus the fixed transfer fee buffer, the migratable total is the sum of the crossings and does not exceed the balance, and every transfer is scheduled at or after the chain tip). The denomination policy is deliberately not pinned. It self-skips (Stage 0) when the running zallet has no preview RPC, and is listed in `DISABLED_SCRIPTS` until a zallet build exposing `z_previewpoolmigration` is the pinned one. - `wallet_transparent_spend.py` covers transparent-to-transparent spending through `z_sendmany`: it shields coinbase and unshields to obtain a non-coinbase transparent UTXO (coinbase cannot be spent transparently), pins both privacy-policy rejections (`AllowRevealedSenders` and `AllowRevealedRecipients` are each insufficient on their own), and asserts the resulting transaction is fully transparent with its change at a fresh internal-scope address. From 13ac3c3e920a4eda2bd8fa73fed175745ea00b59 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 09:25:39 +0200 Subject: [PATCH 07/29] qa: strengthen the Ironwood migration preview denomination checks Add ZIP-318 denomination-structure invariants to the migration preview test: every crossing in the raw note split is a canonical {1,2,5}*10^k value in zatoshi (down to the sub-1-ZEC dust floor, so not necessarily a whole ZEC) and within the denomination cap, the split is non-increasing (a descending greedy decomposition) summing to its migratable total, and each scheduled funding note is a canonical denomination. Also assert the shape is deterministic: a second preview of the same wallet yields the same multiset of crossings, since only the randomized transfer schedule varies. Mark the migration preview and generic migration test scripts executable so the test runner can exec them. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/rpc-tests/wallet_ironwood_migration.py | 0 .../wallet_ironwood_migration_preview.py | 53 +++++++++++++++++++ 2 files changed, 53 insertions(+) mode change 100644 => 100755 qa/rpc-tests/wallet_ironwood_migration.py mode change 100644 => 100755 qa/rpc-tests/wallet_ironwood_migration_preview.py diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py old mode 100644 new mode 100755 diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py old mode 100644 new mode 100755 index 94d94c628..6d9312020 --- a/qa/rpc-tests/wallet_ironwood_migration_preview.py +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -92,6 +92,25 @@ # destination-pool actions at the marginal fee (4 * MARGINAL_FEE). TRANSFER_FEE_BUFFER_ZAT = 4 * MARGINAL_FEE_ZAT +# ZIP 318's denomination cap: no crossing exceeds DENOM_CAP ZEC, keeping a +# whale's crossings within the shared denomination set. +DENOM_CAP_ZEC = 10_000 +DENOM_CAP_ZAT = DENOM_CAP_ZEC * COIN + + +def is_canonical_denomination(zat): + """True if `zat` is a canonical ZIP-318 crossing value: a {1, 2, 5} * 10^k + amount in zatoshi. The split mints denominations from the cap down to a + sub-1-ZEC dust floor, so a crossing need not be a whole number of ZEC + (e.g. 0.5 ZEC = 50_000_000 zat is canonical); this checks the {1,2,5}*10^k + shape directly in zatoshi.""" + if zat <= 0: + return False + v = zat + while v % 10 == 0: + v //= 10 + return v in (1, 2, 5) + # Candidate names for the preview method. The exact name may be adjusted to # zallet conventions, so probe a small list and take the first that resolves. PREVIEW_RPC_CANDIDATES = ( @@ -240,6 +259,29 @@ def assert_conservation(self, plan, orchard_zat): assert_true(len(notes) <= plan['note_split']['note_count'], "funding notes are a subset of the raw split") + def assert_denomination_structure(self, plan): + """ZIP-318 denomination invariants: every crossing in the raw note split + is a canonical {1,2,5}*10^k whole-ZEC value within the cap, the split is + non-increasing (a descending greedy decomposition summing to its + migratable total), and each scheduled funding note is a canonical + denomination too (the funding notes are the reconciled subset).""" + split = plan['note_split']['crossing_values'] + assert_true(len(split) > 0, "a funded account yields a non-empty split") + for v in split: + assert_true(is_canonical_denomination(v), + "note-split crossing {} zat is a canonical " + "{{1,2,5}}*10^k ZEC denomination".format(v)) + assert_true(v <= DENOM_CAP_ZAT, + "note-split crossing {} zat within the {}-ZEC cap" + .format(v, DENOM_CAP_ZEC)) + assert_equal(split, sorted(split, reverse=True), + "the raw note split is non-increasing (descending greedy)") + assert_equal(sum(split), plan['note_split']['total_migratable_zat'], + "the split's crossings sum to its migratable total") + for n in plan['funding_notes']: + assert_true(is_canonical_denomination(n['crossing_zat']), + "each scheduled funding note is a canonical denomination") + # ---- driver ------------------------------------------------------------ def run_test(self): @@ -297,6 +339,7 @@ def run_test(self): assert_equal(len(plan['funding_notes']), plan['funding_note_count'], "one schedule entry per funding note") self.assert_conservation(plan, orchard_zat) + self.assert_denomination_structure(plan) print(" plan: funding_notes={} migratable={} change={} " "layers={} txs={}. OK".format( plan['funding_note_count'], plan['total_migratable_zat'], @@ -310,6 +353,16 @@ def run_test(self): assert_true(n['broadcast_height'] >= tip - 1, "a transfer is scheduled at or after the chain tip") + # Determinism of SHAPE: the denomination decomposition is a function of + # the balance, so a second preview yields the same multiset of crossings + # (only the randomized transfer schedule may differ, not the split). + print("Re-previewing to assert the denomination shape is deterministic...") + plan2 = preview(acct, FROM_POOL, TO_POOL) + assert_equal(sorted(plan['note_split']['crossing_values']), + sorted(plan2['note_split']['crossing_values']), + "the denomination shape is stable across previews") + print(" denominations stable across two previews. OK") + print("\nIronwood migration preview checks passed.") From c69fee6d0317e099a269f7fb592ffc44da44ce56 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 11:14:10 +0200 Subject: [PATCH 08/29] AGENTS: require named constants (no magic numbers) Co-Authored-By: Claude Opus 4.8 (1M context) --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 790ba1efe..a1daec7e1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,6 +143,13 @@ PRs MUST NOT introduce new `pyflakes` warnings. All lint checks in `.github/work ## Test Style: Name Your Arguments, Comment at the Definition +**Never use magic numbers.** Name every non-obvious numeric or string literal as +a module-level constant with a comment stating what it is (e.g. +`MARGINAL_FEE_ZAT = 5000`, `DENOM_CAP_ZEC = 10_000`, `IRONWOOD_HEIGHT = 210`), +and reuse the framework's own constants (`COIN`, ...) rather than re-deriving +their values. This holds in fixtures and assertions, not just production code. +Relatedly: + **Do not pass a bare literal as a positional RPC argument, and do not annotate one with a comment at the call site.** The RPC surface is positional and wide, so a call like From a9e1c69451b3ad5c029e597b36b16334c2d286ab Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 15:00:40 +0200 Subject: [PATCH 09/29] wallet_ironwood_migration_preview: add real-world preview scenarios Extend the Orchard -> Ironwood preview test with the cases a real caller hits, all asserted against the RPC's actual behavior and verified end-to-end on the zebra backend: - Realistic bad requests are rejected with an RPC error, never a plan: migrating a pool to itself, the reverse (Ironwood -> Orchard) and other unsupported directions, an unsupported source pool (Sapling), and a nonexistent account (in addition to the existing unknown-pool-name case). - A minconf larger than any recent note's confirmations leaves nothing spendable, so a funded account correctly reports nothing to migrate rather than a stale plan (exercises the preview's minconf filter and its empty-balance path). - The preview is side-effect-free: after previewing and every rejected request, the account's spendable Orchard balance is unchanged and no Ironwood notes have been minted. Also note in the source fixture's docstring that the single funded note is split by the engine into the several funding notes/crossings the plan asserts, which is the common real-world case (a wallet consolidates, then migrates). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_preview.py | 91 ++++++++++++++++++- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py index 6d9312020..5bc010db1 100755 --- a/qa/rpc-tests/wallet_ironwood_migration_preview.py +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -26,8 +26,12 @@ # not a stub check: fund a v2 Orchard note pre-NU6.3, cross the activation # boundary, then assert the returned plan reports sane crossing denominations, # a transfer schedule (a broadcast height and expiry per funding note), and -# the value invariants below. Input validation (an unsupported pool pair) is -# asserted too. +# the value invariants below. It also covers the real-world cases a caller +# hits: realistic bad requests (a pool migrated to itself, the +# reverse/unsupported direction, an unsupported source pool, a nonexistent +# account, and an unknown pool name) are REJECTED; a minconf larger than any +# note's confirmations leaves NOTHING to migrate; and the preview is +# SIDE-EFFECT-FREE (no funds move, no Ironwood notes are minted). # # The denomination POLICY (the {1,2,5}*10^k ZEC quantization and the sub-0.01 # residual) is deliberately NOT pinned here; only structural and conservation- @@ -97,6 +101,15 @@ DENOM_CAP_ZEC = 10_000 DENOM_CAP_ZAT = DENOM_CAP_ZEC * COIN +# A confirmation depth larger than any regtest chain height. Requiring it +# excludes every spendable note, so a caller who asks for more confirmations +# than a recent note has must see "nothing to migrate", never a stale or partial +# plan. Exercises the preview's minconf filter and its empty-balance path. +UNREACHABLE_MINCONF = 1_000_000 + +# A well-formed but nonexistent account id, for the invalid-account rejection. +NONEXISTENT_ACCOUNT = '00000000-0000-0000-0000-000000000000' + def is_canonical_denomination(zat): """True if `zat` is a canonical ZIP-318 crossing value: a {1, 2, 5} * 10^k @@ -161,7 +174,10 @@ def fund_orchard_source(self, node, w, taddr, acct): source the preview plans over -- and return its spendable value in zat. A direct many-UTXO Orchard shield can be left unspendable by fee estimation, so shield into Sapling first, then pay the Orchard-only - receiver (mirrors wallet_ironwood_migration.py).""" + receiver (mirrors wallet_ironwood_migration.py). The engine then splits + this note into the several funding notes/crossings the plan asserts, + which is the common real-world case (a wallet consolidates, then + migrates).""" ua = w.z_getaddressforaccount(acct, ['orchard'])['address'] sapling_ua = w.z_getaddressforaccount(acct, ['sapling'])['address'] @@ -282,6 +298,56 @@ def assert_denomination_structure(self, plan): assert_true(is_canonical_denomination(n['crossing_zat']), "each scheduled funding note is a canonical denomination") + def assert_rejects_invalid_requests(self, preview, acct): + """Realistic caller mistakes the preview must reject with an RPC error + (never a plan): migrating a pool to itself, an unsupported migration + direction, a source pool with no wired migration, and a nonexistent + account. The pool-pair checks run before the account is looked up, so + they use the real account; the account check uses a valid pool pair so + the account is what fails.""" + # Migrating a pool into itself is meaningless. + e = expect_rpc_error(preview, acct, FROM_POOL, FROM_POOL) + print(" same-pool rejected: {!r}. OK".format( + e.error.get('message', ''))) + # The reverse direction (Ironwood -> Orchard) is not a supported + # migration; only Orchard -> Ironwood is wired. + e = expect_rpc_error(preview, acct, TO_POOL, FROM_POOL) + print(" reverse direction rejected: {!r}. OK".format( + e.error.get('message', ''))) + # Sapling is a valid pool name but has no wired migration to Ironwood. + e = expect_rpc_error(preview, acct, Pool.SAPLING, TO_POOL) + print(" unsupported source pool rejected: {!r}. OK".format( + e.error.get('message', ''))) + # A nonexistent account (valid pool pair, so the account is what the + # request turns on): it is rejected -- whether because the id does not + # resolve or because it holds no spendable source-pool balance, a caller + # who names the wrong account gets a clean error, never a plan. + e = expect_rpc_error(preview, NONEXISTENT_ACCOUNT, FROM_POOL, TO_POOL) + print(" nonexistent account rejected: {!r}. OK".format( + e.error.get('message', ''))) + + def assert_high_minconf_has_nothing_to_migrate(self, preview, acct): + """A confirmation requirement no recent note can meet leaves nothing + spendable, so a funded account reports nothing to migrate rather than a + stale plan. Exercises the preview's minconf filter and empty-balance + path, the case a caller hits by demanding more confirmations than the + note has.""" + e = expect_rpc_error( + preview, acct, FROM_POOL, TO_POOL, UNREACHABLE_MINCONF) + print(" unreachable minconf yields nothing to migrate: {!r}. OK" + .format(e.error.get('message', ''))) + + def assert_preview_is_read_only(self, w, acct, before_orchard_zat): + """The preview must be side-effect-free: after previewing (and the + rejected requests), the account's spendable Orchard balance is unchanged + and no Ironwood notes have been minted. A user can preview freely + without any funds crossing the turnstile.""" + assert_equal( + before_orchard_zat, account_spendable_zat(w, acct, Pool.ORCHARD), + "previewing does not change the Orchard balance") + assert_true(len(ironwood_notes(w)) == 0, + "previewing mints no Ironwood notes") + # ---- driver ------------------------------------------------------------ def run_test(self): @@ -322,11 +388,14 @@ def run_test(self): account_spendable_zat(w, acct, Pool.ORCHARD), "the Orchard source note survives NU6.3 activation") - # Stage 1a: input validation. + # Stage 1a: input validation -- an unknown pool name, plus realistic + # caller mistakes (same pool, reverse direction, unsupported source, + # nonexistent account). print("Asserting preview input validation...") e = expect_rpc_error(preview, acct, UNSUPPORTED_POOL, TO_POOL) - print(" unsupported pool pair rejected: {!r}. OK".format( + print(" unknown pool name rejected: {!r}. OK".format( e.error.get('message', ''))) + self.assert_rejects_invalid_requests(preview, acct) # Stage 1b: the migration plans, is scheduled, and conserves value. print("Previewing the Orchard -> Ironwood plan...") @@ -363,6 +432,18 @@ def run_test(self): "the denomination shape is stable across previews") print(" denominations stable across two previews. OK") + # A confirmation requirement no recent note can meet: the funded account + # still reports nothing to migrate (the minconf filter + empty-balance + # path), rather than a stale plan. + print("Asserting an unreachable minconf yields nothing to migrate...") + self.assert_high_minconf_has_nothing_to_migrate(preview, acct) + + # Everything above (every preview and every rejected request) must have + # been side-effect-free: no funds moved, no Ironwood notes minted. + print("Asserting previewing moved no funds...") + self.assert_preview_is_read_only(w, acct, orchard_zat) + print(" balance unchanged and no Ironwood notes. OK") + print("\nIronwood migration preview checks passed.") From c476b03a9c03d29ea029a5ed4a0ec479782345f2 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 15:30:08 +0200 Subject: [PATCH 10/29] wallet_ironwood_migration_preview: cover activation, account index, minconf More real-world preview cases, all asserted against the RPC's actual behavior and verified end-to-end on the zebra backend: - Previewing BEFORE NU6.3 activates is rejected with a clear "network upgrade required" error (the activation check runs before the balance, so a funded account still gets it) -- the case a user hits by trying to migrate early. - The account resolves by its ZIP-32 index as well as its UUID; both name the same account and yield the same plan. - A modest minconf still includes the well-confirmed source note (the included side of the confirmation filter), complementing the existing unreachable-minconf case on the excluded side. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_preview.py | 57 +++++++++++++++++-- 1 file changed, 52 insertions(+), 5 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py index 5bc010db1..9aef96175 100755 --- a/qa/rpc-tests/wallet_ironwood_migration_preview.py +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -27,11 +27,13 @@ # boundary, then assert the returned plan reports sane crossing denominations, # a transfer schedule (a broadcast height and expiry per funding note), and # the value invariants below. It also covers the real-world cases a caller -# hits: realistic bad requests (a pool migrated to itself, the -# reverse/unsupported direction, an unsupported source pool, a nonexistent -# account, and an unknown pool name) are REJECTED; a minconf larger than any -# note's confirmations leaves NOTHING to migrate; and the preview is -# SIDE-EFFECT-FREE (no funds move, no Ironwood notes are minted). +# hits: previewing BEFORE the upgrade activates is rejected; realistic bad +# requests (a pool migrated to itself, the reverse/unsupported direction, an +# unsupported source pool, a nonexistent account, and an unknown pool name) +# are REJECTED; the account resolves by either its UUID or its ZIP-32 index; +# a modest minconf still includes a well-confirmed note while a minconf +# larger than any note's confirmations leaves NOTHING to migrate; and the +# preview is SIDE-EFFECT-FREE (no funds move, no Ironwood notes are minted). # # The denomination POLICY (the {1,2,5}*10^k ZEC quantization and the sub-0.01 # residual) is deliberately NOT pinned here; only structural and conservation- @@ -107,6 +109,11 @@ # plan. Exercises the preview's minconf filter and its empty-balance path. UNREACHABLE_MINCONF = 1_000_000 +# A modest confirmation requirement the source note (minted many blocks before +# activation) easily meets, so an explicit minconf still includes it: the +# mirror of UNREACHABLE_MINCONF on the included side of the filter. +MODEST_MINCONF = 3 + # A well-formed but nonexistent account id, for the invalid-account rejection. NONEXISTENT_ACCOUNT = '00000000-0000-0000-0000-000000000000' @@ -326,6 +333,19 @@ def assert_rejects_invalid_requests(self, preview, acct): print(" nonexistent account rejected: {!r}. OK".format( e.error.get('message', ''))) + def assert_pre_activation_rejected(self, preview, acct): + """Before NU6.3 activates, the Orchard -> Ironwood turnstile is not open, + so previewing the migration is rejected with a clear 'network upgrade + required' error rather than a plan. This is the case a user hits by + trying to migrate before the upgrade. The activation check runs before + the balance is consulted, so a funded account still gets this error.""" + e = expect_rpc_error(preview, acct, FROM_POOL, TO_POOL) + message = str(e.error.get('message', '')) + assert_true('active' in message.lower() or 'upgrade' in message.lower(), + "the pre-activation rejection should mention the network " + "upgrade; got {!r}".format(message)) + print(" pre-activation preview rejected: {!r}. OK".format(message)) + def assert_high_minconf_has_nothing_to_migrate(self, preview, acct): """A confirmation requirement no recent note can meet leaves nothing spendable, so a funded account reports nothing to migrate rather than a @@ -376,6 +396,11 @@ def run_test(self): orchard_zat = self.fund_orchard_source(node, w, taddr, acct) print(" Orchard source spendable: {} zat.".format(orchard_zat)) + # Before crossing the NU6.3 boundary the migration is not yet enabled, + # so previewing it (even for this funded account) is rejected. + print("Asserting preview is rejected before NU6.3 activation...") + self.assert_pre_activation_rejected(preview, acct) + to_activation = max(0, IRONWOOD_HEIGHT - node.getblockcount()) if to_activation > 0: node.generate(to_activation) @@ -432,6 +457,28 @@ def run_test(self): "the denomination shape is stable across previews") print(" denominations stable across two previews. OK") + # An account can be named by its ZIP-32 index, not only its UUID; a real + # caller uses either, and both must resolve to the same plan. + print("Asserting preview-by-account-index matches preview-by-UUID...") + acct_index = w.z_listaccounts()[0].get('account') + assert_true(acct_index is not None, + "the account should expose a ZIP-32 index") + plan_by_index = preview(acct_index, FROM_POOL, TO_POOL) + assert_equal(sorted(plan['note_split']['crossing_values']), + sorted(plan_by_index['note_split']['crossing_values']), + "previewing by account index matches previewing by UUID") + print(" index and UUID agree. OK") + + # A modest confirmation requirement still includes the well-confirmed + # source note: the included side of the minconf filter (the excluded + # side is asserted just below). + print("Asserting a modest minconf still includes the mature note...") + plan_minconf = preview(acct, FROM_POOL, TO_POOL, MODEST_MINCONF) + assert_equal(sorted(plan['note_split']['crossing_values']), + sorted(plan_minconf['note_split']['crossing_values']), + "a modest minconf keeps the mature note in the plan") + print(" modest minconf keeps the note. OK") + # A confirmation requirement no recent note can meet: the funded account # still reports nothing to migrate (the minconf filter + empty-balance # path), rather than a stale plan. From ef3af01027ea8fd30d037a40ffbf00ef17546fca Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 15:30:21 +0200 Subject: [PATCH 11/29] wallet_ironwood_migration: assert status/list surface, fix stage-1 timing The start/status/list methods are now registered as scaffold stubs, so this lifecycle test runs Stage 1 rather than self-skipping. Two changes: - Cross the NU6.3 boundary before the Stage-1 surface checks. The start method validates the pool pair (which requires the upgrade) before returning its scaffold response, so running the check pre-activation got an "upgrade not active" error instead of the expected scaffold answer. Activate first so the surface checks reach the real response. - Assert the status and list methods' surface too (callable, returning a structured answer or a clear "not implemented yet"), the read-only half of the lifecycle surface assertable before the engine can run a migration. status takes a migration id, so probe it with a placeholder. Also enumerate the funded Stage-2 lifecycle scenarios in the run_test TODO -- the full run, resume-after-restart, status/list reflecting progress, and expiry/reorg recovery -- so the drive-and-assert is specified for when the engine's commit/broadcast slices land in zallet. Verified end-to-end on the zebra backend: the surface checks pass against the current stubs. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/rpc-tests/wallet_ironwood_migration.py | 123 ++++++++++++++++++---- 1 file changed, 104 insertions(+), 19 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index b8944df77..43a5a0965 100755 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -18,15 +18,19 @@ # * Stage 1 -- method PRESENT (stub answers, engine not landed): assert the # RPC surface only. The start method must be callable for orchard->ironwood # and either return a plan-summary object OR a clear "not implemented yet" -# response, and it must reject an unsupported pool pair with an error. The -# end-to-end assertions (notes actually prepared, value actually crossing -# the pools) stay TODO until the engine lands. +# response, and it must reject an unsupported pool pair with an error; the +# status and list methods, when present, must likewise be callable and +# return a structured (or "not implemented yet") answer even with no +# migration in progress. The end-to-end assertions (notes actually prepared, +# value actually crossing the pools) stay TODO until the engine lands. # * Stage 2 -- engine landed (future): wire the funded end-to-end flow. The # `fund_orchard_source` helper already stands up the fixture (a real v2 # Orchard note minted pre-NU6.3 as the migration source); the TODO block in -# run_test says where the drive-and-assert goes. The denomination policy -# ({1,2,5}*10^k ZEC and the sub-0.01 residual) is deliberately NOT pinned -# here -- assert only conservation-style invariants when it is finalized. +# run_test enumerates the real-world lifecycle scenarios to drive -- the full +# run, resume-after-restart, status/list reflecting progress, and +# expiry/reorg recovery. The denomination policy ({1,2,5}*10^k ZEC and the +# sub-0.01 residual) is deliberately NOT pinned here -- assert only +# conservation-style invariants when it is finalized. # # The module is registered in NEW_SCRIPTS but listed in DISABLED_SCRIPTS in # qa/pull-tester/rpc-tests.py (the framework's equivalent of skip/xfail), so the @@ -73,6 +77,11 @@ # of which migration directions are wired. UNSUPPORTED_POOL = 'nonexistent_pool' +# A non-empty but nonexistent migration id, for probing the status method's +# surface (the stub validates the id is non-empty, then answers): a real caller +# passes the id returned by the start method. +PLACEHOLDER_MIGRATION_ID = 'no-such-migration' + # Markers a stub uses to say "the RPC exists but the engine is not wired yet". # A start response containing any of these is treated as an acceptable Stage-1 # answer (the engine has not landed). @@ -185,6 +194,47 @@ def assert_input_validation(self, w, start_name): print(" unsupported pool pair ({}->{}) rejected: {!r}. OK".format( UNSUPPORTED_POOL, TO_POOL, e.error.get('message', ''))) + def assert_companion_surface(self, w, status_name, list_name): + """When exposed, the status and list methods must be callable and return + a structured response (or a clear 'not implemented yet'), even with no + migration in progress: the status of an account with no migration is a + well-defined empty answer, and listing is always valid. This is the + read-only half of the lifecycle surface, assertable before the engine + can actually run a migration.""" + if status_name is not None: + try: + result = getattr(w, status_name)(PLACEHOLDER_MIGRATION_ID) + except _RPC_EXCEPTIONS as e: + message = e.error.get('message', '') + assert_true( + any(m in message.lower() for m in NOT_IMPLEMENTED_MARKERS), + "status should return an object or 'not implemented yet'; " + "got {!r}".format(message)) + print(" status responded 'not implemented yet': {!r}. OK" + .format(message)) + else: + assert_true(isinstance(result, dict), + "status should return a status object; got " + "{!r}".format(result)) + print(" status returned an object: keys={}. OK".format( + sorted(result.keys()))) + if list_name is not None: + try: + result = getattr(w, list_name)() + except _RPC_EXCEPTIONS as e: + message = e.error.get('message', '') + assert_true( + any(m in message.lower() for m in NOT_IMPLEMENTED_MARKERS), + "list should return an array or 'not implemented yet'; " + "got {!r}".format(message)) + print(" list responded 'not implemented yet': {!r}. OK" + .format(message)) + else: + assert_true(isinstance(result, list), + "list should return an array of migrations; got " + "{!r}".format(result)) + print(" list returned {} migration(s). OK".format(len(result))) + # ---- Stage 2 fixture (used once the engine lands) ---------------------- def fund_orchard_source(self, node, w, taddr, acct): @@ -253,33 +303,68 @@ def run_test(self): "list={}".format(start_name, status_name, advance_name, cancel_name, list_name)) + # The start method validates the pool pair, which requires NU6.3 to be + # active, before returning its scaffold response. Cross the activation + # boundary so the surface checks reach that response rather than an + # "upgrade not active" rejection. No funded notes are needed for the + # surface; the funded Stage-2 flow will instead fund pre-activation and + # cross the boundary afterward. + to_activation = max(0, IRONWOOD_HEIGHT - node.getblockcount()) + if to_activation > 0: + node.generate(to_activation) + self.sync_all() + assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, + "NU6.3 must be active for the migration surface") + # Stage 1: assert the RPC surface (callable + input validation) without # the engine. These do not need funded notes. print("Stage 1: assert the pool-migration RPC surface...") self.assert_start_callable(w, start_name) self.assert_input_validation(w, start_name) + self.assert_companion_surface(w, status_name, list_name) print(" RPC surface OK.") # ---- Stage 2 TODO(end-to-end): drive a real migration -------------- - # Once the note-split engine lands, fund the source and drive the flow: + # Once the engine's commit/broadcast slices are wired into zallet (the + # start method actually builds, pre-signs, persists, and broadcasts the + # PCZTs), replace this block with the funded lifecycle scenarios below. + # Each is a distinct real-world case; the fixture (`fund_orchard_source`) + # and the boundary crossing are already in place: # acct = w.z_listaccounts()[0]['account_uuid'] # orchard_zat = self.fund_orchard_source(node, w, taddr, acct) - # # cross the NU6.3 boundary so the Ironwood pool is live: # node.generate(max(0, IRONWOOD_HEIGHT - node.getblockcount())) # self.sync_all() - # plan = getattr(w, start_name)(FROM_POOL, TO_POOL) - # # advance/poll with `advance_name`/`status_name` until complete, - # # confirming and scanning each produced transaction. - # Then assert only stable invariants (NOT the denomination policy, - # which is still evolving): - # * the Orchard pool drains to (near) zero, - # * the Ironwood pool gains the migrated value (minus fees), - # * value is conserved across the two pools, - # * each produced Ironwood note is a planned denomination. + # + # (a) FULL LIFECYCLE: start the migration, then advance/poll with + # `advance_name`/`status_name`, confirming and scanning each produced + # transaction (the preparation first, then each scheduled transfer), + # until status reports complete. Assert only stable invariants (NOT + # the denomination policy, still evolving): + # * the Orchard pool drains to (near) zero, + # * the Ironwood pool gains the migrated value (minus fees), + # * value is conserved across the two pools, + # * each produced Ironwood note is a planned denomination, + # * the amounts that cross match what `z_previewpoolmigration` + # promised for the same balance (ZIP-318 consent). + # (b) RESUME AFTER RESTART: after the preparation is mined but before the + # transfers complete, restart the wallet (stop/start the node's + # wallet), then assert the migration is still present in + # `status_name`/`list_name` (the store round-trips) and that + # advancing continues it to completion. A migration must survive the + # wallet being closed mid-run. + # (c) STATUS / LIST REFLECT PROGRESS: assert `status_name` moves through + # its lifecycle (planned -> in progress -> complete) as transactions + # mine, and that `list_name` shows exactly the one in-progress + # migration while it runs and none once it completes. + # (d) EXPIRY / REORG: let a scheduled transfer pass its expiry height + # without mining and assert it is rebuilt (not lost); invalidate the + # block that mined the preparation (a short reorg) and assert the + # migration recovers rather than double-spending or stalling. assert node is not None # `node`/`taddr` drive the Stage-2 flow above assert taddr is not None - print("TODO: drive the funded Orchard -> Ironwood migration and assert " - "conservation, once the note-split engine lands.") + print("TODO: drive the funded Orchard -> Ironwood lifecycle (full run, " + "resume-after-restart, status/list progress, expiry/reorg) once " + "the engine's commit/broadcast slices are wired into zallet.") print("\nIronwood migration RPC-surface checks passed.") From 16a612816fb66a1f28deb9ba8d286af6840ea4e5 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 16:16:10 +0200 Subject: [PATCH 12/29] wallet_ironwood_migration[_preview]: use fixed RPC method names The zallet pool-migration RPC interface is stable, so the per-method lists of candidate names (a hedge from when the naming was unsettled) are collapsed to a single fixed name each: START_RPC, STATUS_RPC, ADVANCE_RPC, CANCEL_RPC, LIST_RPC, and PREVIEW_RPC. The list-probing resolve_rpc_name helper becomes resolve_method, which probes one name and returns it or None. Behavior is unchanged (the real names were always the first candidate); both tests still pass end-to-end on the zebra backend. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/rpc-tests/wallet_ironwood_migration.py | 81 +++++++------------ .../wallet_ironwood_migration_preview.py | 46 +++++------ 2 files changed, 45 insertions(+), 82 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index 43a5a0965..d080c344c 100755 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -94,33 +94,15 @@ 'todo', ) -# Candidate names per migration method. The exact names may be adjusted to -# zallet conventions, so probe a small list per method and take the first that -# resolves to a real method. The `start` method is the capability gate. -START_RPC_CANDIDATES = ( - 'z_startpoolmigration', - 'z_start_pool_migration', - 'z_poolmigrationstart', - 'z_migratepool', - 'z_migrate_pool', -) -STATUS_RPC_CANDIDATES = ( - 'z_getpoolmigrationstatus', - 'z_poolmigrationstatus', - 'z_get_pool_migration_status', -) -ADVANCE_RPC_CANDIDATES = ( - 'z_advancepoolmigration', - 'z_advance_pool_migration', -) -CANCEL_RPC_CANDIDATES = ( - 'z_cancelpoolmigration', - 'z_cancel_pool_migration', -) -LIST_RPC_CANDIDATES = ( - 'z_listpoolmigrations', - 'z_list_pool_migrations', -) +# The pool-migration RPC method names. The zallet RPC interface is stable, so +# each is a single fixed name (earlier revisions probed a list of candidate +# names while the naming was unsettled). The `start` method is the capability +# gate. +START_RPC = 'z_startpoolmigration' +STATUS_RPC = 'z_getpoolmigrationstatus' +ADVANCE_RPC = 'z_advancepoolmigration' +CANCEL_RPC = 'z_cancelpoolmigration' +LIST_RPC = 'z_listpoolmigrations' def orchard_notes(wallet, minconf=1): @@ -144,22 +126,16 @@ def _is_method_not_found(e): return (e.error.get('code') == RPC_METHOD_NOT_FOUND or 'method not found' in msg) - def resolve_rpc_name(self, w, candidates, *probe_args): - """Return the first candidate name that resolves to a real method, else - None. Probes with `probe_args` (deliberately invalid, so the probe can - never trigger a migration): an unknown method rejects the CALL - (method-not-found); an existing method rejects the ARGUMENTS.""" - for name in candidates: - method = getattr(w, name) - try: - method(*probe_args) - except _RPC_EXCEPTIONS as e: - if self._is_method_not_found(e): - continue # this candidate does not exist; try the next - return name # exists (rejected the arguments, not the method) - else: - return name # exists and (surprisingly) accepted the probe - return None + def resolve_method(self, w, name, *probe_args): + """Return `name` if it resolves to a real RPC method, else None. Probes + with `probe_args` (deliberately invalid, so the probe can never trigger a + migration): an unknown method rejects the CALL (method-not-found); an + existing method rejects the ARGUMENTS.""" + try: + getattr(w, name)(*probe_args) + except _RPC_EXCEPTIONS as e: + return None if self._is_method_not_found(e) else name + return name # ---- Stage 1: RPC-surface (stub-level) assertions ---------------------- @@ -283,22 +259,19 @@ def run_test(self): # Stage 0 gate: skip cleanly until zallet exposes the start method. This # keeps the module registered and runnable without failing while the # feature (and the librustzcash planner) are still evolving. - start_name = self.resolve_rpc_name( - w, START_RPC_CANDIDATES, UNSUPPORTED_POOL, UNSUPPORTED_POOL) + start_name = self.resolve_method( + w, START_RPC, UNSUPPORTED_POOL, UNSUPPORTED_POOL) if start_name is None: - print("SKIP: no zallet pool-migration start RPC yet (tried {}); " - "scaffolding only.".format(", ".join(START_RPC_CANDIDATES))) + print("SKIP: no zallet pool-migration start RPC ({}) yet; " + "scaffolding only.".format(START_RPC)) return # Report which of the companion methods this build exposes # (informational; only `start` gates the flow). - status_name = self.resolve_rpc_name( - w, STATUS_RPC_CANDIDATES, UNSUPPORTED_POOL) - advance_name = self.resolve_rpc_name( - w, ADVANCE_RPC_CANDIDATES, UNSUPPORTED_POOL) - cancel_name = self.resolve_rpc_name( - w, CANCEL_RPC_CANDIDATES, UNSUPPORTED_POOL) - list_name = self.resolve_rpc_name(w, LIST_RPC_CANDIDATES) + status_name = self.resolve_method(w, STATUS_RPC, UNSUPPORTED_POOL) + advance_name = self.resolve_method(w, ADVANCE_RPC, UNSUPPORTED_POOL) + cancel_name = self.resolve_method(w, CANCEL_RPC, UNSUPPORTED_POOL) + list_name = self.resolve_method(w, LIST_RPC) print("Pool-migration RPCs: start={} status={} advance={} cancel={} " "list={}".format(start_name, status_name, advance_name, cancel_name, list_name)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_preview.py b/qa/rpc-tests/wallet_ironwood_migration_preview.py index 9aef96175..73c0385af 100755 --- a/qa/rpc-tests/wallet_ironwood_migration_preview.py +++ b/qa/rpc-tests/wallet_ironwood_migration_preview.py @@ -131,14 +131,10 @@ def is_canonical_denomination(zat): v //= 10 return v in (1, 2, 5) -# Candidate names for the preview method. The exact name may be adjusted to -# zallet conventions, so probe a small list and take the first that resolves. -PREVIEW_RPC_CANDIDATES = ( - 'z_previewpoolmigration', - 'z_preview_pool_migration', - 'z_poolmigrationpreview', - 'z_migratepoolpreview', -) +# The preview RPC method name. The zallet RPC interface is stable, so this is a +# single fixed name (earlier revisions probed a list of candidate names while +# the naming was unsettled). +PREVIEW_RPC = 'z_previewpoolmigration' class WalletIronwoodMigrationPreviewTest(IronwoodTestFramework): @@ -157,22 +153,16 @@ def _is_method_not_found(e): return (e.error.get('code') == RPC_METHOD_NOT_FOUND or 'method not found' in msg) - def resolve_rpc_name(self, w, candidates, *probe_args): - """Return the first candidate name that resolves to a real method, else - None. Probes with `probe_args` (deliberately invalid, so the probe can - never trigger real work): an unknown method rejects the CALL - (method-not-found); an existing method rejects the ARGUMENTS.""" - for name in candidates: - method = getattr(w, name) - try: - method(*probe_args) - except _RPC_EXCEPTIONS as e: - if self._is_method_not_found(e): - continue # this candidate does not exist; try the next - return name # exists (rejected the arguments, not the method) - else: - return name # exists and (surprisingly) accepted the probe - return None + def resolve_method(self, w, name, *probe_args): + """Return `name` if it resolves to a real RPC method, else None. Probes + with `probe_args` (deliberately invalid, so the probe can never trigger + real work): an unknown method rejects the CALL (method-not-found); an + existing method rejects the ARGUMENTS.""" + try: + getattr(w, name)(*probe_args) + except _RPC_EXCEPTIONS as e: + return None if self._is_method_not_found(e) else name + return name # ---- fixture ----------------------------------------------------------- @@ -381,11 +371,11 @@ def run_test(self): # Stage 0 gate: skip cleanly until the running zallet exposes the preview # method. The probe uses an unsupported pool pair so it can never do real # work, only reveal whether the method resolves. - preview_name = self.resolve_rpc_name( - w, PREVIEW_RPC_CANDIDATES, acct, UNSUPPORTED_POOL, UNSUPPORTED_POOL) + preview_name = self.resolve_method( + w, PREVIEW_RPC, acct, UNSUPPORTED_POOL, UNSUPPORTED_POOL) if preview_name is None: - print("SKIP: no zallet pool-migration preview RPC yet (tried {}); " - "scaffolding only.".format(", ".join(PREVIEW_RPC_CANDIDATES))) + print("SKIP: no zallet pool-migration preview RPC ({}) yet; " + "scaffolding only.".format(PREVIEW_RPC)) return preview = getattr(w, preview_name) print("Pool-migration preview RPC: {}".format(preview_name)) From 25af53490f18e6ca05545baeed0dee5a8fdc4424 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 18:20:05 +0200 Subject: [PATCH 13/29] wallet_ironwood_migration: drive the funded lifecycle (start + advance) Replace the RPC-surface stub flow with the real funded lifecycle now that z_startpoolmigration and z_advancepoolmigration are wired: fund an Orchard source pre-NU6.3, cross the boundary, start the migration, then advance one step per call (mining and syncing between steps) until it completes, asserting that Ironwood notes appear and the Orchard balance drains. The drive gates on the preview's layer_count: commit_preparation currently supports only single-layer preparation, and plan_migration produces a multi-layer (3-layer) preparation for every balance tried (1 to 137 ZEC), so the test skips cleanly with a clear message rather than failing. Once multi-layer preparation is supported the drive runs unchanged. Finding to investigate: the engine's standalone end-to-end test gets a single layer for 78 ZEC, but zallet's plan_migration over the funded wallet gets three layers for the same nominal balance; the wallet's actual Orchard note set (the source note plus any change/dust) likely differs from the one clean note the engine test uses. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/rpc-tests/wallet_ironwood_migration.py | 156 +++++++++++++--------- 1 file changed, 95 insertions(+), 61 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index d080c344c..318b09a62 100755 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -46,6 +46,7 @@ PrivacyPolicy, _RPC_EXCEPTIONS, account_spendable_zat, + assert_equal, assert_true, expect_rpc_error, ironwood_notes, @@ -82,6 +83,25 @@ # passes the id returned by the start method. PLACEHOLDER_MIGRATION_ID = 'no-such-migration' +# The most `z_advancepoolmigration` steps to drive before giving up: the +# migration advances one transaction per call (a preparation, then one transfer +# per funding note), so a generous bound covers a many-note wallet. +MAX_ADVANCE_STEPS = 80 + +# Blocks to mine between advance steps: enough to confirm the just-broadcast +# transaction and to move the chain tip past each transfer's scheduled height. +ADVANCE_MINE_BLOCKS = 8 + +# The Orchard source is funded so the note split is a SINGLE preparation layer: +# commit_preparation currently supports only single-layer preparation, and the +# planner otherwise chains dependent layers. 78 ZEC is a known single-layer +# balance (the engine's own end-to-end test asserts layer_count == 1 for it); +# larger balances produce more funding notes and dust-heavy small balances +# fragment, both of which fan out across layers. The preview's layer_count is +# checked before starting, so if this ever stops being single-layer the test +# skips rather than fails. +SOURCE_NOTE_ZEC = 78 + # Markers a stub uses to say "the RPC exists but the engine is not wired yet". # A start response containing any of these is treated as an acceptable Stage-1 # answer (the engine has not landed). @@ -226,8 +246,9 @@ def fund_orchard_source(self, node, w, taddr, acct): "must still be in the Orchard era before funding") _, sapling_zat = shield_coinbase( node, w, taddr, sapling_ua, acct, Pool.SAPLING) - orch_target = (Decimal(sapling_zat) / COIN / 2).quantize( - Decimal('0.0001')) + assert_true(sapling_zat > SOURCE_NOTE_ZEC * COIN, + "shielded enough Sapling to fund the source note") + orch_target = Decimal(SOURCE_NOTE_ZEC) opid = w.z_sendmany( sapling_ua, [{'address': ua, 'amount': orch_target}], 1, None, PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) @@ -276,70 +297,83 @@ def run_test(self): "list={}".format(start_name, status_name, advance_name, cancel_name, list_name)) - # The start method validates the pool pair, which requires NU6.3 to be - # active, before returning its scaffold response. Cross the activation - # boundary so the surface checks reach that response rather than an - # "upgrade not active" rejection. No funded notes are needed for the - # surface; the funded Stage-2 flow will instead fund pre-activation and - # cross the boundary afterward. + # Fund an Orchard source note BEFORE NU6.3, then cross the activation + # boundary so the migration is enabled. + acct = w.z_listaccounts()[0]['account_uuid'] + print("Funding an Orchard source note pre-NU6.3...") + orchard_before = self.fund_orchard_source(node, w, taddr, acct) + print(" Orchard source spendable: {} zat.".format(orchard_before)) + to_activation = max(0, IRONWOOD_HEIGHT - node.getblockcount()) if to_activation > 0: node.generate(to_activation) self.sync_all() assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, - "NU6.3 must be active for the migration surface") - - # Stage 1: assert the RPC surface (callable + input validation) without - # the engine. These do not need funded notes. - print("Stage 1: assert the pool-migration RPC surface...") - self.assert_start_callable(w, start_name) - self.assert_input_validation(w, start_name) - self.assert_companion_surface(w, status_name, list_name) - print(" RPC surface OK.") - - # ---- Stage 2 TODO(end-to-end): drive a real migration -------------- - # Once the engine's commit/broadcast slices are wired into zallet (the - # start method actually builds, pre-signs, persists, and broadcasts the - # PCZTs), replace this block with the funded lifecycle scenarios below. - # Each is a distinct real-world case; the fixture (`fund_orchard_source`) - # and the boundary crossing are already in place: - # acct = w.z_listaccounts()[0]['account_uuid'] - # orchard_zat = self.fund_orchard_source(node, w, taddr, acct) - # node.generate(max(0, IRONWOOD_HEIGHT - node.getblockcount())) - # self.sync_all() - # - # (a) FULL LIFECYCLE: start the migration, then advance/poll with - # `advance_name`/`status_name`, confirming and scanning each produced - # transaction (the preparation first, then each scheduled transfer), - # until status reports complete. Assert only stable invariants (NOT - # the denomination policy, still evolving): - # * the Orchard pool drains to (near) zero, - # * the Ironwood pool gains the migrated value (minus fees), - # * value is conserved across the two pools, - # * each produced Ironwood note is a planned denomination, - # * the amounts that cross match what `z_previewpoolmigration` - # promised for the same balance (ZIP-318 consent). - # (b) RESUME AFTER RESTART: after the preparation is mined but before the - # transfers complete, restart the wallet (stop/start the node's - # wallet), then assert the migration is still present in - # `status_name`/`list_name` (the store round-trips) and that - # advancing continues it to completion. A migration must survive the - # wallet being closed mid-run. - # (c) STATUS / LIST REFLECT PROGRESS: assert `status_name` moves through - # its lifecycle (planned -> in progress -> complete) as transactions - # mine, and that `list_name` shows exactly the one in-progress - # migration while it runs and none once it completes. - # (d) EXPIRY / REORG: let a scheduled transfer pass its expiry height - # without mining and assert it is rebuilt (not lost); invalidate the - # block that mined the preparation (a short reorg) and assert the - # migration recovers rather than double-spending or stalling. - assert node is not None # `node`/`taddr` drive the Stage-2 flow above - assert taddr is not None - print("TODO: drive the funded Orchard -> Ironwood lifecycle (full run, " - "resume-after-restart, status/list progress, expiry/reorg) once " - "the engine's commit/broadcast slices are wired into zallet.") - - print("\nIronwood migration RPC-surface checks passed.") + "NU6.3 must be active to migrate") + + # commit_preparation supports only single-layer preparation for now. + # Preview the plan and skip cleanly if this balance would fan out across + # dependent layers (a larger-balance limitation still being addressed). + preview = w.z_previewpoolmigration(acct, FROM_POOL, TO_POOL) + layers = preview['preparation']['layer_count'] + if layers > 1: + print("SKIP: this balance needs {} preparation layers; " + "commit_preparation supports single-layer only.".format(layers)) + return + print(" preview: {} layer(s), {} funding note(s).".format( + layers, preview['funding_note_count'])) + + # Start the migration: builds + pre-signs the preparation and persists it. + print("Starting the Orchard -> Ironwood migration...") + started = getattr(w, start_name)(acct, FROM_POOL, TO_POOL) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + assert_true(total_txs > 0, "the migration plans at least one transaction") + print(" started: id={!r} transaction_count={}".format( + migration_id, total_txs)) + + # status and list reflect the freshly started migration. + if status_name is not None: + st = getattr(w, status_name)(migration_id) + assert_equal(total_txs, st['progress']['total_transactions'], + "status reports the planned transaction count") + if list_name is not None: + assert_true(len(getattr(w, list_name)()) == 1, + "list shows the one in-progress migration") + + # Drive the migration to completion: advance one step per call (a + # preparation first, then one transfer per funding note), mining and + # syncing between steps so each broadcast transaction confirms and the + # chain tip reaches each transfer's scheduled height. + print("Driving the migration (advance + mine) to completion...") + completed = False + for step in range(MAX_ADVANCE_STEPS): + adv = getattr(w, advance_name)(acct, migration_id) + progress = adv['progress'] + print(" step {}: phase={} {}/{} - {}".format( + step, adv['phase'], progress['completed_transactions'], + progress['total_transactions'], adv['status'])) + if adv['phase'] == 'completed': + completed = True + break + node.generate(ADVANCE_MINE_BLOCKS) + self.sync_all() + wait_account_settled(w, acct) + assert_true(completed, + "the migration completed within {} advance steps".format( + MAX_ADVANCE_STEPS)) + + # The value crossed the turnstile: Ironwood notes now exist and the + # Orchard balance dropped as the funding notes were spent. + ironwood = ironwood_notes(w) + assert_true(len(ironwood) > 0, "the migration produced Ironwood notes") + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + assert_true(orchard_after < orchard_before, + "the Orchard balance drained as value crossed to Ironwood") + print(" {} Ironwood note(s); Orchard {} -> {} zat. OK".format( + len(ironwood), orchard_before, orchard_after)) + + print("\nIronwood migration lifecycle passed.") if __name__ == '__main__': From 56a371ea291fbc2545dd4af1c31df57bd50884a2 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 19:05:46 +0200 Subject: [PATCH 14/29] wallet_ironwood_migration: drive multi-layer preparations end to end The engine now commits a multi-layer preparation phase by phase (each later layer built once its predecessor mines), so the test no longer skips balances that fan out across layers. Remove the single-layer preview gate and let the advance loop drive the whole lifecycle. Verified on a zebra regtest node: a 137 ZEC Orchard source produces a 3-layer preparation and 10 funding notes, and the migration completes with 10 Ironwood notes minted and the Orchard balance drained. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/rpc-tests/wallet_ironwood_migration.py | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index 318b09a62..3b71c88bc 100755 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -92,14 +92,10 @@ # transaction and to move the chain tip past each transfer's scheduled height. ADVANCE_MINE_BLOCKS = 8 -# The Orchard source is funded so the note split is a SINGLE preparation layer: -# commit_preparation currently supports only single-layer preparation, and the -# planner otherwise chains dependent layers. 78 ZEC is a known single-layer -# balance (the engine's own end-to-end test asserts layer_count == 1 for it); -# larger balances produce more funding notes and dust-heavy small balances -# fragment, both of which fan out across layers. The preview's layer_count is -# checked before starting, so if this ever stops being single-layer the test -# skips rather than fails. +# A lower bound on the Orchard source balance to migrate; the fund helper shields +# the available coinbase into Orchard, so the real balance is typically larger and +# fans the preparation out across several dependent layers. The migration commits +# and drives those layers phase by phase, so any balance is exercised end to end. SOURCE_NOTE_ZEC = 78 # Markers a stub uses to say "the RPC exists but the engine is not wired yet". @@ -311,15 +307,12 @@ def run_test(self): assert_true(node.getblockcount() >= IRONWOOD_HEIGHT, "NU6.3 must be active to migrate") - # commit_preparation supports only single-layer preparation for now. - # Preview the plan and skip cleanly if this balance would fan out across - # dependent layers (a larger-balance limitation still being addressed). + # Preview the plan. A multi-layer preparation (a larger or more fragmented + # balance that fans out across dependent layers) is committed phase by + # phase: the advance loop below builds each later layer once its + # predecessor has mined, so no balance is skipped. preview = w.z_previewpoolmigration(acct, FROM_POOL, TO_POOL) layers = preview['preparation']['layer_count'] - if layers > 1: - print("SKIP: this balance needs {} preparation layers; " - "commit_preparation supports single-layer only.".format(layers)) - return print(" preview: {} layer(s), {} funding note(s).".format( layers, preview['funding_note_count'])) From 513b7daff06fc7341df3210c8b9497246382f0ed Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 20:51:51 +0200 Subject: [PATCH 15/29] wallet_ironwood_migration: raise drive budget; add scenario harness The transfers are broadcast on a randomized privacy schedule, so a run can need more advance steps than a tight schedule; raise the step and per-step mine budget so the lifecycle completes for any schedule spread (verified end to end: a 137 ZEC source, 3-layer preparation, completes at step 100 with 10 Ironwood notes). Add ironwood_migration_common.py, the shared scenario harness: funding by wallet shape, the advance/mine drive loop, and asserters for value conservation, the multi-layer anchor-bucket ordering, and the engine's machine-readable next-actions surface. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironwood_migration_common.py | 313 ++++++++++++++++++ qa/rpc-tests/wallet_ironwood_migration.py | 8 +- 2 files changed, 318 insertions(+), 3 deletions(-) create mode 100644 qa/rpc-tests/test_framework/ironwood_migration_common.py diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py new file mode 100644 index 000000000..402a01ed8 --- /dev/null +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -0,0 +1,313 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +"""Shared harness for the Orchard -> Ironwood pool-migration scenario suite. + +Every migration scenario is a small test file that subclasses +`IronwoodMigrationScenario` and implements `run_test`, using the helpers here to +fund a particular wallet shape, drive the migration, and assert the invariants. +The migration STATE MACHINE lives in the librustzcash engine +(`zcash_pool_migration_backend`); the mobile wallet drives it directly and +zallet is a thin consumer, so these scenarios exercise shared-engine behavior +through zallet's regtest JSON-RPC. + +Assertion helpers cover the cross-cutting invariants (value conservation, pool +exclusivity), the multi-layer ANCHOR-BUCKET ordering (a later preparation layer +is never actionable until its whole predecessor layer has mined), and the +machine-readable NEXT-ACTIONS surface a mobile wallet renders. +""" + +from decimal import Decimal + +from test_framework.util import ( + COIN, + Pool, + PrivacyPolicy, + _RPC_EXCEPTIONS, + account_spendable_zat, + assert_true, + ironwood_notes, + nu_activation_ironwood_at, + shield_coinbase, + wait_account_settled, + wait_and_assert_operationid_status, + wait_for_account_spendable, + wait_for_tx_scanned, +) +from test_framework.util_ironwood import IronwoodTestFramework + +# Defer NU6.3 past coinbase maturity so the Orchard-era shield (which needs +# mature coinbase) is mined before activation. +IRONWOOD_HEIGHT = 210 + +# The pool-migration RPC method names (the zallet interface is stable). +START_RPC = 'z_startpoolmigration' +STATUS_RPC = 'z_getpoolmigrationstatus' +ADVANCE_RPC = 'z_advancepoolmigration' +CANCEL_RPC = 'z_cancelpoolmigration' +LIST_RPC = 'z_listpoolmigrations' +PREVIEW_RPC = 'z_previewpoolmigration' + +# Ironwood notes are Orchard-shaped, so the migration moves value from the +# Orchard pool into the Ironwood pool. +FROM_POOL = Pool.ORCHARD +TO_POOL = Pool.IRONWOOD + +# JSON-RPC "method not found" code. +RPC_METHOD_NOT_FOUND = -32601 + + +def orchard_notes(wallet, minconf=1): + """The account's spendable Orchard notes.""" + return [u for u in wallet.z_listunspent(minconf) + if u['pool'] == Pool.ORCHARD] + + +class IronwoodMigrationScenario(IronwoodTestFramework): + """Base class for one migration scenario. + + Subclasses set any scenario-specific configuration and implement `run_test`, + typically: fund a wallet shape, `preview`, `start`, `drive_to_completion` (or + a partial drive for resume/cancel scenarios), then assert with the helpers. + """ + + # A generous cap: the migration advances one transaction per call, and the + # transfers are spread across a randomized privacy schedule, so a wide bound + # covers a many-note wallet with a spread schedule. + MAX_ADVANCE_STEPS = 250 + # Blocks mined between advance steps: enough to confirm the just-broadcast + # transaction and move the tip past each transfer's scheduled height. + ADVANCE_MINE_BLOCKS = 16 + + def __init__(self): + super().__init__() + # Deferred activation gives an Orchard era in which to mint the source + # notes the migration consumes. + self.activation_heights = nu_activation_ironwood_at(IRONWOOD_HEIGHT) + + # ---- capability probing ------------------------------------------------ + + @staticmethod + def _is_method_not_found(e): + msg = str(e.error.get('message', '')).lower() + return (e.error.get('code') == RPC_METHOD_NOT_FOUND + or 'method not found' in msg) + + def migration_rpcs_present(self, w): + """Whether the pool-migration RPC surface is wired in this build. Probes + `start` with deliberately invalid arguments: an unknown method rejects + the CALL (method-not-found); a wired method rejects the ARGUMENTS.""" + try: + getattr(w, START_RPC)('__invalid_pool__', '__invalid_pool__') + except _RPC_EXCEPTIONS as e: + return not self._is_method_not_found(e) + return True + + def skip_if_rpcs_absent(self, w): + """Self-skip (clean exit) if the migration RPCs are not wired yet.""" + if not self.migration_rpcs_present(w): + print("SKIP: pool-migration RPCs ({}) not wired in this build." + .format(START_RPC)) + return True + return False + + # ---- funding wallet shapes --------------------------------------------- + + def _orchard_ua(self, w, acct): + return w.z_getaddressforaccount(acct, ['orchard'])['address'] + + def _sapling_ua(self, w, acct): + return w.z_getaddressforaccount(acct, ['sapling'])['address'] + + def fund_orchard_notes(self, node, w, taddr, acct, amounts_zec): + """Mint one spendable v2 Orchard note per amount in `amounts_zec` + (Decimal or number, in ZEC), all pre-NU6.3, and return the account's + total spendable Orchard value in zat. + + A direct many-UTXO Orchard shield can be left unspendable by fee + estimation, so shield into Sapling first, then pay the Orchard receiver + once per note (sequential sends, so each mint is a distinct note and no + single transaction has a duplicated recipient). + """ + amounts = [Decimal(a) for a in amounts_zec] + ua = self._orchard_ua(w, acct) + sapling_ua = self._sapling_ua(w, acct) + + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "must still be in the Orchard era before funding") + # A per-note fee buffer so the Sapling shield covers every send. + needed = sum(amounts) + Decimal('0.001') * len(amounts) + _, sapling_zat = shield_coinbase( + node, w, taddr, sapling_ua, acct, Pool.SAPLING) + assert_true(Decimal(sapling_zat) > needed * COIN, + "shielded enough Sapling to fund {} source note(s)" + .format(len(amounts))) + + for amount in amounts: + opid = w.z_sendmany( + sapling_ua, [{'address': ua, 'amount': amount}], 1, None, + PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) + txid = wait_and_assert_operationid_status(w, opid) + assert_true(txid is not None, "Sapling -> Orchard send should succeed") + node.generate(1) + wait_for_tx_scanned(w, txid) + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "the Orchard notes must be minted before NU6.3 activates") + + wait_for_account_spendable(w, acct, Pool.ORCHARD, min_zat=1) + wait_account_settled(w, acct) + orchard_zat = account_spendable_zat(w, acct, Pool.ORCHARD) + assert_true(orchard_zat > 0, "the Orchard notes should be spendable") + assert_true(len(orchard_notes(w)) >= len(amounts), + "expected {} Orchard source note(s)".format(len(amounts))) + assert_true(len(ironwood_notes(w)) == 0, + "no Ironwood notes should exist before migration") + return orchard_zat + + def fund_orchard_note(self, node, w, taddr, acct, zec): + """Mint a single Orchard source note of `zec` ZEC (the common case).""" + return self.fund_orchard_notes(node, w, taddr, acct, [zec]) + + def activate_ironwood(self, node): + """Advance the chain past the NU6.3 activation height so the migration is + allowed (the source notes must already be minted).""" + while node.getblockcount() < IRONWOOD_HEIGHT: + node.generate(1) + self.sync_all() + + # ---- lifecycle ---------------------------------------------------------- + + def preview(self, w, acct): + """The migration plan preview (read-only).""" + return getattr(w, PREVIEW_RPC)(acct, FROM_POOL, TO_POOL) + + def start(self, w, acct): + """Start (commit) the migration; returns the start response.""" + return getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + + def status(self, w, migration_id): + """The full migration status, including the per-transaction view.""" + return getattr(w, STATUS_RPC)(migration_id) + + def advance(self, w, acct, migration_id): + """Advance the migration one step.""" + return getattr(w, ADVANCE_RPC)(acct, migration_id) + + def drive_to_completion(self, w, node, acct, migration_id, + on_step=None): + """Advance one step per call, mining and syncing between steps, until the + migration completes or the step cap is reached. `on_step(step, adv)` is + called after each advance (for per-step assertions). Returns True if the + migration completed.""" + for step in range(self.MAX_ADVANCE_STEPS): + adv = self.advance(w, acct, migration_id) + if on_step is not None: + on_step(step, adv) + if adv['phase'] == 'completed': + return True + node.generate(self.ADVANCE_MINE_BLOCKS) + self.sync_all() + wait_account_settled(w, acct) + return False + + # ---- assertions: cross-cutting invariants ------------------------------ + + def assert_value_crossed(self, w, acct, orchard_before): + """After a completed migration, Ironwood notes exist and the Orchard + balance has drained. (Denomination shape is deliberately not pinned.)""" + ironwood = ironwood_notes(w) + assert_true(len(ironwood) > 0, "the migration produced Ironwood notes") + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + assert_true(orchard_after < orchard_before, + "the Orchard balance drained as value crossed to Ironwood") + return ironwood, orchard_after + + # ---- assertions: the next-actions / anchor-bucket surface -------------- + + def assert_next_actions_consistent(self, w, migration_id): + """The machine-readable status surface must be internally consistent: a + `ready` transaction carries an `action` and no `blocked_on`; a waiting + (planned/signed/proved) one carries a `blocked_on` and no `action`; a + transaction blocked on its dependencies must have an unmined dependency; + and a preparation transaction of layer > 0 is never ready while any + transaction of its predecessor layer is unmined (the anchor-bucket + ordering the mobile wallet relies on).""" + st = self.status(w, migration_id) + txs = st['transactions'] + by_id = {t['id']: t for t in txs} + for t in txs: + if t['ready']: + assert_true(t.get('action') is not None, + "a ready transaction must name an action: {}".format(t)) + assert_true(t.get('blocked_on') is None, + "a ready transaction is not blocked: {}".format(t)) + elif t['state'] in ('planned', 'signed', 'proved', 'expired'): + assert_true(t.get('blocked_on') is not None, + "a waiting transaction must report a blocker: {}" + .format(t)) + assert_true(t.get('action') is None, + "a waiting transaction has no action: {}".format(t)) + if t.get('blocked_on') == 'dependencies': + unmined = [d for d in t['depends_on'] + if by_id[d]['state'] != 'mined'] + assert_true(len(unmined) > 0, + "a dependency-blocked transaction must have an " + "unmined dependency: {}".format(t)) + # Anchor-bucket ordering: no layer-N (>0) transaction is ready while any + # layer-(N-1) preparation transaction is unmined. + prep = [t for t in txs if t['kind'] == 'preparation'] + layers = sorted({t['layer'] for t in prep}) + for layer in layers: + if layer == 0: + continue + prior_unmined = any( + t['state'] != 'mined' for t in prep if t['layer'] == layer - 1) + if prior_unmined: + for t in prep: + if t['layer'] == layer: + assert_true(not t['ready'], + "layer {} must not be ready while layer {} " + "is unmined: {}".format(layer, layer - 1, t)) + return st + + def assert_layers_signed_in_separate_buckets(self, seen_states): + """Given the sequence of per-layer states observed across the drive, + assert every later preparation layer only became `signed` AFTER its + predecessor layer was fully `mined` (distinct anchor buckets). `seen_states` + is the list of status snapshots captured per step.""" + # For each layer > 0, find the first step at which any of its transactions + # was `signed`, and assert that at the prior step every predecessor-layer + # transaction was already `mined`. + for snap_prev, snap in zip(seen_states, seen_states[1:]): + prev_by_layer = self._prep_by_layer(snap_prev) + cur_by_layer = self._prep_by_layer(snap) + for layer, txs in cur_by_layer.items(): + if layer == 0: + continue + became_signed = any( + t['state'] in ('signed', 'proved') for t in txs) and all( + self._was_planned(layer, t['id'], snap_prev) for t in txs + if t['state'] in ('signed', 'proved')) + if became_signed: + prior = prev_by_layer.get(layer - 1, []) + assert_true(prior and all( + t['state'] == 'mined' for t in prior), + "layer {} was signed before layer {} fully mined" + .format(layer, layer - 1)) + + @staticmethod + def _prep_by_layer(snapshot): + out = {} + for t in snapshot['transactions']: + if t['kind'] == 'preparation': + out.setdefault(t['layer'], []).append(t) + return out + + @staticmethod + def _was_planned(layer, tx_id, snapshot): + for t in snapshot['transactions']: + if t['id'] == tx_id: + return t['state'] == 'planned' + return False diff --git a/qa/rpc-tests/wallet_ironwood_migration.py b/qa/rpc-tests/wallet_ironwood_migration.py index 3b71c88bc..0bae8e169 100755 --- a/qa/rpc-tests/wallet_ironwood_migration.py +++ b/qa/rpc-tests/wallet_ironwood_migration.py @@ -85,12 +85,14 @@ # The most `z_advancepoolmigration` steps to drive before giving up: the # migration advances one transaction per call (a preparation, then one transfer -# per funding note), so a generous bound covers a many-note wallet. -MAX_ADVANCE_STEPS = 80 +# per funding note), and the transfers are spread across a randomized privacy +# schedule, so the bound must be generous enough to cover a wide schedule. +MAX_ADVANCE_STEPS = 250 # Blocks to mine between advance steps: enough to confirm the just-broadcast # transaction and to move the chain tip past each transfer's scheduled height. -ADVANCE_MINE_BLOCKS = 8 +# The transfer schedule can span many blocks, so mine a healthy batch per step. +ADVANCE_MINE_BLOCKS = 16 # A lower bound on the Orchard source balance to migrate; the fund helper shields # the available coinbase into Orchard, so the real balance is typically larger and From e13f69a92f4475995353fb75767b0edd86377b3f Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 20:55:48 +0200 Subject: [PATCH 16/29] wallet_ironwood_migration: add nothing-to-migrate and double-start scenarios Two fast validation scenarios on the shared harness: starting a migration for an account with no spendable source-pool balance is rejected cleanly (D8), and a second start while one is in progress is refused so an in-flight migration's pre-signed transactions are never discarded (D12). Both verified on a regtest zebra node. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_double_start.py | 70 +++++++++++++++++++ .../wallet_ironwood_migration_nothing.py | 58 +++++++++++++++ 2 files changed, 128 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_double_start.py create mode 100644 qa/rpc-tests/wallet_ironwood_migration_nothing.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_double_start.py b/qa/rpc-tests/wallet_ironwood_migration_double_start.py new file mode 100644 index 000000000..1a34fe9e7 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_double_start.py @@ -0,0 +1,70 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario D12: the double-start guard. +# +# Once a migration is committed (its transactions pre-signed and persisted), +# starting another for the same account must be refused: overwriting the +# in-progress migration would discard its pre-signed transactions. A wallet UI +# that lets the user tap "migrate" twice must not corrupt an in-flight run. The +# guard lifts only once the migration reaches a terminal state. +# + +from test_framework.ironwood_migration_common import ( + FROM_POOL, + IronwoodMigrationScenario, + START_RPC, + TO_POOL, +) +from test_framework.util import _RPC_EXCEPTIONS, assert_true + +SOURCE_NOTE_ZEC = 5 + + +class DoubleStartScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) + self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + # First start commits the migration (builds and pre-signs it). + started = self.start(w, acct) + migration_id = started['migration_id'] + assert_true(started['plan']['transaction_count'] > 0, + "the first start plans at least one transaction") + print(" first start committed: id={!r}.".format(migration_id)) + + # A second start, while the first is in progress, must be refused. + try: + getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + except _RPC_EXCEPTIONS as e: + message = str(e.error.get('message', '')) + print(" second start refused while one is in progress: {!r}. OK" + .format(message)) + else: + raise AssertionError( + "a second start must be refused while a migration is in progress") + + # The in-progress migration is intact (still the same one). + listed = getattr(w, 'z_listpoolmigrations')() + assert_true(len(listed) == 1, + "exactly one migration is in progress after the refused start") + print("\nDouble-start guard scenario passed.") + + +if __name__ == '__main__': + DoubleStartScenario().main() diff --git a/qa/rpc-tests/wallet_ironwood_migration_nothing.py b/qa/rpc-tests/wallet_ironwood_migration_nothing.py new file mode 100644 index 000000000..e984d3837 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_nothing.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario D8: nothing to migrate. +# +# An account with no spendable source-pool (Orchard) balance cannot be migrated. +# Starting a migration for it must fail cleanly with a clear error, not build an +# empty migration, panic, or hang. This is the degenerate input every wallet UI +# must handle (the user taps "migrate" on an empty or transparent-only account). +# + +from test_framework.ironwood_migration_common import ( + FROM_POOL, + IronwoodMigrationScenario, + START_RPC, + TO_POOL, + orchard_notes, +) +from test_framework.util import _RPC_EXCEPTIONS, assert_true + + +class NothingToMigrateScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + # Activate NU6.3 without ever funding an Orchard note, so the account has + # no source-pool balance to migrate. + self.activate_ironwood(node) + assert_true(len(orchard_notes(w)) == 0, + "the account must have no Orchard notes for this scenario") + + # Start must reject an account with nothing to migrate. + try: + getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + except _RPC_EXCEPTIONS as e: + message = str(e.error.get('message', '')) + print(" start on an empty account rejected: {!r}. OK".format( + message)) + else: + raise AssertionError( + "start should fail for an account with no source-pool balance") + + print("\nNothing-to-migrate scenario passed.") + + +if __name__ == '__main__': + NothingToMigrateScenario().main() From 5098947c4a1b19d7a4d700c50ea5e9c7d10b2586 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 20:58:11 +0200 Subject: [PATCH 17/29] wallet_ironwood_migration: add unknown-migration-id scenario Status, advance, and cancel must reject an id that does not name the wallet's migration, both with no migration in progress and while one is running, so a wallet polling or resuming with a stale id gets a clear error rather than a crash or a silent no-op. Verified on a regtest zebra node. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_unknown_id.py | 75 +++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_unknown_id.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py b/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py new file mode 100644 index 000000000..f5164a979 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario D13: status / advance / cancel on an unknown migration id. +# +# The read and drive methods must reject an id that does not name the wallet's +# migration, both when no migration exists and when one is in progress. A wallet +# that polls or resumes with a stale or wrong id must get a clear error, never a +# crash or a silent no-op that looks like success. +# + +from test_framework.ironwood_migration_common import ( + ADVANCE_RPC, + CANCEL_RPC, + IronwoodMigrationScenario, + STATUS_RPC, +) +from test_framework.util import _RPC_EXCEPTIONS, assert_true + +UNKNOWN_ID = 'no-such-migration' + + +class UnknownIdScenario(IronwoodMigrationScenario): + + def _expect_rejected(self, w, acct, label): + """status / advance / cancel on UNKNOWN_ID must all be rejected.""" + for name, call in ( + (STATUS_RPC, lambda: getattr(w, STATUS_RPC)(UNKNOWN_ID)), + (ADVANCE_RPC, lambda: getattr(w, ADVANCE_RPC)(acct, UNKNOWN_ID)), + (CANCEL_RPC, lambda: getattr(w, CANCEL_RPC)(UNKNOWN_ID)), + ): + try: + call() + except _RPC_EXCEPTIONS as e: + print(" {} ({}) rejected: {!r}. OK".format( + name, label, str(e.error.get('message', '')))) + else: + raise AssertionError( + "{} must reject an unknown migration id ({})".format( + name, label)) + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + # (a) No migration exists yet: an unknown id is rejected. + print("Probing an unknown id with no migration in progress...") + self._expect_rejected(w, acct, "no migration in progress") + + # (b) A migration is in progress: an id that is not it is still rejected. + print("Funding and starting a migration, then probing a wrong id...") + self.fund_orchard_note(node, w, taddr, acct, 5) + self.activate_ironwood(node) + self.start(w, acct) + self._expect_rejected(w, acct, "wrong id while one is in progress") + + # The real migration is still addressable by its own id. + listed = getattr(w, 'z_listpoolmigrations')() + assert_true(len(listed) == 1, + "the real migration is unaffected by the wrong-id probes") + print("\nUnknown-id scenario passed.") + + +if __name__ == '__main__': + UnknownIdScenario().main() From 841093211770d8b789859cac8d0f2417d0c3c57f Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 21:06:18 +0200 Subject: [PATCH 18/29] wallet_ironwood_migration: add next-actions / anchor-bucket scenario Drive a multi-layer migration and, at every advance step, assert the engine's machine-readable next-actions surface: a ready transaction names an action and is unblocked; a waiting one reports its blocker; and the anchor-bucket ordering holds both ways (a later layer is never ready, and is never built, while any transaction of its predecessor layer is unmined). Fold the anchor-bucket check into the per-snapshot consistency assertion (a built layer-N implies a mined layer-(N-1)), which is correct without cross-step timing. Verified end to end: a 3-layer migration completes with the ordering held across all layers. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironwood_migration_common.py | 75 ++++-------- .../wallet_ironwood_migration_next_actions.py | 112 ++++++++++++++++++ 2 files changed, 135 insertions(+), 52 deletions(-) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_next_actions.py diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py index 402a01ed8..a96aec7fc 100644 --- a/qa/rpc-tests/test_framework/ironwood_migration_common.py +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -255,59 +255,30 @@ def assert_next_actions_consistent(self, w, migration_id): assert_true(len(unmined) > 0, "a dependency-blocked transaction must have an " "unmined dependency: {}".format(t)) - # Anchor-bucket ordering: no layer-N (>0) transaction is ready while any - # layer-(N-1) preparation transaction is unmined. + # Anchor-bucket ordering, two ways: + # (1) readiness: no layer-N (>0) transaction is ready while any + # layer-(N-1) preparation transaction is unmined; + # (2) build order: if any layer-N transaction has been BUILT (its state + # is past `planned`), every layer-(N-1) transaction must be mined, + # because a later layer is signed only once its predecessor mines and + # `mined` is terminal. This is the anchor-bucket invariant, checkable + # within a single snapshot (no cross-step timing to get wrong). prep = [t for t in txs if t['kind'] == 'preparation'] - layers = sorted({t['layer'] for t in prep}) - for layer in layers: + by_layer = {} + for t in prep: + by_layer.setdefault(t['layer'], []).append(t) + built = ('signed', 'proved', 'broadcast', 'mined') + for layer in sorted(by_layer): if layer == 0: continue - prior_unmined = any( - t['state'] != 'mined' for t in prep if t['layer'] == layer - 1) - if prior_unmined: - for t in prep: - if t['layer'] == layer: - assert_true(not t['ready'], - "layer {} must not be ready while layer {} " - "is unmined: {}".format(layer, layer - 1, t)) + prior_all_mined = all( + t['state'] == 'mined' for t in by_layer.get(layer - 1, [])) + for t in by_layer[layer]: + if not prior_all_mined: + assert_true(not t['ready'], + "layer {} must not be ready while layer {} is " + "unmined: {}".format(layer, layer - 1, t)) + assert_true(t['state'] not in built, + "layer {} was built before layer {} fully " + "mined: {}".format(layer, layer - 1, t)) return st - - def assert_layers_signed_in_separate_buckets(self, seen_states): - """Given the sequence of per-layer states observed across the drive, - assert every later preparation layer only became `signed` AFTER its - predecessor layer was fully `mined` (distinct anchor buckets). `seen_states` - is the list of status snapshots captured per step.""" - # For each layer > 0, find the first step at which any of its transactions - # was `signed`, and assert that at the prior step every predecessor-layer - # transaction was already `mined`. - for snap_prev, snap in zip(seen_states, seen_states[1:]): - prev_by_layer = self._prep_by_layer(snap_prev) - cur_by_layer = self._prep_by_layer(snap) - for layer, txs in cur_by_layer.items(): - if layer == 0: - continue - became_signed = any( - t['state'] in ('signed', 'proved') for t in txs) and all( - self._was_planned(layer, t['id'], snap_prev) for t in txs - if t['state'] in ('signed', 'proved')) - if became_signed: - prior = prev_by_layer.get(layer - 1, []) - assert_true(prior and all( - t['state'] == 'mined' for t in prior), - "layer {} was signed before layer {} fully mined" - .format(layer, layer - 1)) - - @staticmethod - def _prep_by_layer(snapshot): - out = {} - for t in snapshot['transactions']: - if t['kind'] == 'preparation': - out.setdefault(t['layer'], []).append(t) - return out - - @staticmethod - def _was_planned(layer, tx_id, snapshot): - for t in snapshot['transactions']: - if t['id'] == tx_id: - return t['state'] == 'planned' - return False diff --git a/qa/rpc-tests/wallet_ironwood_migration_next_actions.py b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py new file mode 100644 index 000000000..81525721b --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario B1/B2 + C1: the multi-layer anchor-bucket invariant and the mobile +# wallet's machine-readable "what do I sign next" surface. +# +# A large Orchard balance fans its preparation out across several dependent +# layers. Because a later layer spends the feeder notes an earlier layer mints, +# and those feeders are witnessable only once that earlier layer is mined, each +# layer must be signed and broadcast in a DIFFERENT anchor bucket: layer N is +# never actionable until its whole predecessor layer (layer N-1) has mined. +# +# The migration STATE MACHINE that enforces this lives in the librustzcash +# engine (a mobile wallet drives it directly); this scenario asserts that +# behavior end to end through zallet's regtest RPCs. At EVERY advance step it +# checks the per-transaction status surface for internal consistency and for the +# anchor-bucket ordering, and it records the per-layer state sequence to confirm +# no later layer was signed before its predecessor fully mined. +# + +from test_framework.ironwood_migration_common import IronwoodMigrationScenario +from test_framework.util import assert_true + +# A balance large enough to force a multi-layer preparation (the engine fans a +# whale out across dependent layers). The exact layer count is a function of the +# planner; the scenario only requires more than one layer. +SOURCE_NOTE_ZEC = 78 + + +class NextActionsScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + # Bring the wallet up to the chain tip so account RPCs are accepted. + self.sync_all() + + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding a whale Orchard source ({} ZEC)...".format( + SOURCE_NOTE_ZEC)) + orchard_before = self.fund_orchard_note(node, w, taddr, acct, + SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + preview = self.preview(w, acct) + layers = preview['preparation']['layer_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + layers, preview['funding_note_count'])) + assert_true(layers > 1, + "this scenario needs a multi-layer preparation to exercise " + "the anchor-bucket ordering; got {} layer(s)".format(layers)) + + started = self.start(w, acct) + migration_id = started['migration_id'] + print(" started: id={!r} transaction_count={}".format( + migration_id, started['plan']['transaction_count'])) + + # The freshly started migration must already expose a consistent + # next-actions surface: layer 0 ready to broadcast, later layers blocked + # on their dependencies. + st = self.assert_next_actions_consistent(w, migration_id) + ready0 = [t for t in st['transactions'] if t['ready']] + assert_true(len(ready0) > 0, + "at least one layer-0 transaction is ready at start") + assert_true(all(t['kind'] == 'preparation' and t['layer'] == 0 + for t in ready0), + "only layer-0 preparation transactions are ready at start") + blocked_later = [t for t in st['transactions'] + if t['kind'] == 'preparation' and t['layer'] > 0] + assert_true(all(t['blocked_on'] == 'dependencies' for t in blocked_later), + "later preparation layers start blocked on dependencies") + print(" start surface OK: {} ready (layer 0), {} later-layer " + "transactions blocked on dependencies.".format( + len(ready0), len(blocked_later))) + + # Drive to completion, asserting the surface's consistency and the + # anchor-bucket ordering (both readiness and build order) at every step. + def on_step(step, adv): + self.assert_next_actions_consistent(w, migration_id) + if step % 20 == 0 or adv['phase'] == 'completed': + print(" step {}: phase={} {}/{} - {}".format( + step, adv['phase'], + adv['progress']['completed_transactions'], + adv['progress']['total_transactions'], adv['status'])) + + print("Driving the migration, asserting the next-actions surface each " + "step...") + completed = self.drive_to_completion(w, node, acct, migration_id, + on_step=on_step) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + ironwood, orchard_after = self.assert_value_crossed(w, acct, + orchard_before) + print(" {} Ironwood note(s); Orchard {} -> {} zat. Anchor-bucket " + "ordering held across all layers. OK".format( + len(ironwood), orchard_before, orchard_after)) + print("\nNext-actions / anchor-bucket scenario passed.") + + +if __name__ == '__main__': + NextActionsScenario().main() From 8085f35e171702fdb3fdcccc9d7e20e55be7e373 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 21:27:44 +0200 Subject: [PATCH 19/29] wallet_ironwood_migration: add pre-activation scenario Starting a migration before NU6.3 is active is rejected with a clear error that names the required upgrade, even when the account holds a real Orchard note. Verified on a regtest zebra node. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...allet_ironwood_migration_pre_activation.py | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_pre_activation.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py b/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py new file mode 100644 index 000000000..72552cc49 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario D10: starting a migration before NU6.3 is active. +# +# The Orchard -> Ironwood migration is enabled by NU6.3; attempting it before +# activation must be rejected with a clear error that names the required +# upgrade, not build a migration that could never be mined. The account is +# funded with a real Orchard note (in the Orchard era) but the chain is left +# BELOW the activation height. +# + +from test_framework.ironwood_migration_common import ( + FROM_POOL, + IRONWOOD_HEIGHT, + IronwoodMigrationScenario, + START_RPC, + TO_POOL, +) +from test_framework.util import _RPC_EXCEPTIONS, assert_true + +SOURCE_NOTE_ZEC = 5 + + +class PreActivationScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + # Fund an Orchard source note, but do NOT cross the activation boundary. + print("Funding an Orchard source ({} ZEC), staying pre-NU6.3..." + .format(SOURCE_NOTE_ZEC)) + self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "the chain must remain below NU6.3 activation") + + # Start must be rejected while NU6.3 is not active. + try: + getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + except _RPC_EXCEPTIONS as e: + message = str(e.error.get('message', '')) + print(" start before NU6.3 rejected: {!r}. OK".format(message)) + else: + raise AssertionError( + "start must be rejected before NU6.3 is active") + + print("\nPre-activation scenario passed.") + + +if __name__ == '__main__': + PreActivationScenario().main() From ff0ff359958326829096cb45eeeed56e95b36252 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 21:49:28 +0200 Subject: [PATCH 20/29] wallet_ironwood_migration: add cancel scenario (found a real engine bug) Cancel a migration mid-flight (after some layer-0 transactions are broadcast), then assert it becomes terminal (phase 'cancelled') and a subsequent advance is a no-op that does not resurrect it. This surfaced a real engine bug, now fixed: recompute_status revived a cancelled (Failed) migration to InProgress on the next advance. Verified on a regtest zebra node with the fixed engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_cancel.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_cancel.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_cancel.py b/qa/rpc-tests/wallet_ironwood_migration_cancel.py new file mode 100644 index 000000000..d43addd45 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_cancel.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario D3: cancel a migration mid-flight. +# +# After some of a migration's transactions have been broadcast, cancelling it +# must be honored: the migration becomes terminal, it is not resurrected by a +# later advance, and no crash or dangling in-progress state is left behind. A +# wallet UI's "cancel" must always be a clean exit. +# + +from test_framework.ironwood_migration_common import ( + ADVANCE_RPC, + CANCEL_RPC, + IronwoodMigrationScenario, +) +from test_framework.util import _RPC_EXCEPTIONS, assert_true + +SOURCE_NOTE_ZEC = 8 +# Advance a few steps before cancelling, to broadcast part of layer 0. +STEPS_BEFORE_CANCEL = 3 + + +class CancelScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) + self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + started = self.start(w, acct) + migration_id = started['migration_id'] + print(" started: id={!r}.".format(migration_id)) + + # Advance a few steps to broadcast some of the preparation. + for _ in range(STEPS_BEFORE_CANCEL): + adv = self.advance(w, acct, migration_id) + if adv['phase'] == 'completed': + break + node.generate(self.ADVANCE_MINE_BLOCKS) + self.sync_all() + print(" advanced {} step(s); now cancelling.".format( + STEPS_BEFORE_CANCEL)) + + # Cancel must succeed. + getattr(w, CANCEL_RPC)(migration_id) + + # The migration is now terminal: its status is neither in progress nor + # completed (a cancelled migration), or the status RPC rejects the id. + try: + st = self.status(w, migration_id) + except _RPC_EXCEPTIONS as e: + print(" status after cancel rejects the id (terminal): {!r}. OK" + .format(str(e.error.get('message', '')))) + else: + phase = st['phase'] + assert_true(phase not in ('in_progress', 'completed'), + "a cancelled migration is terminal, not in-progress or " + "completed; got phase={!r}".format(phase)) + print(" status after cancel: phase={!r} (terminal). OK".format( + phase)) + + # A later advance must not resurrect the migration. + try: + adv = getattr(w, ADVANCE_RPC)(acct, migration_id) + except _RPC_EXCEPTIONS as e: + print(" advance after cancel rejected (not resurrected): {!r}. OK" + .format(str(e.error.get('message', '')))) + else: + assert_true(adv['phase'] not in ('in_progress',), + "advance must not resurrect a cancelled migration; got " + "phase={!r}".format(adv['phase'])) + print(" advance after cancel is a no-op (phase={!r}). OK".format( + adv['phase'])) + + print("\nCancel scenario passed.") + + +if __name__ == '__main__': + CancelScenario().main() From 3fce0a7b53a0531af3761a762e9c0acd80182861 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sat, 18 Jul 2026 22:13:24 +0200 Subject: [PATCH 21/29] wallet_ironwood_migration: inline explicit balance and note checks Move the balance correctness into each scenario file: the harness provides only data helpers (ironwood_balance_zat, orchard_balance_zat), and each scenario asserts inline the source balance, every individual Ironwood note value (via the valueZat field), that the note count equals the plan's crossing count, value conservation, the fee bound, and the sub-dust residual, so a reviewer is convinced by reading one file. Verified: a 137.5 ZEC source crosses into the canonical 1-2-5 note set summing to the source minus a bounded fee and residual. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironwood_migration_common.py | 24 +++++---- .../wallet_ironwood_migration_next_actions.py | 51 ++++++++++++++++--- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py index a96aec7fc..8db5fc15f 100644 --- a/qa/rpc-tests/test_framework/ironwood_migration_common.py +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -153,6 +153,10 @@ def fund_orchard_notes(self, node, w, taddr, acct, amounts_zec): assert_true(txid is not None, "Sapling -> Orchard send should succeed") node.generate(1) wait_for_tx_scanned(w, txid) + # Wait for the Sapling change from this send to become spendable + # before the next send draws on it (otherwise a later send can see a + # zero balance while the change note is still being scanned). + wait_account_settled(w, acct) assert_true(node.getblockcount() < IRONWOOD_HEIGHT, "the Orchard notes must be minted before NU6.3 activates") @@ -214,15 +218,17 @@ def drive_to_completion(self, w, node, acct, migration_id, # ---- assertions: cross-cutting invariants ------------------------------ - def assert_value_crossed(self, w, acct, orchard_before): - """After a completed migration, Ironwood notes exist and the Orchard - balance has drained. (Denomination shape is deliberately not pinned.)""" - ironwood = ironwood_notes(w) - assert_true(len(ironwood) > 0, "the migration produced Ironwood notes") - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) - assert_true(orchard_after < orchard_before, - "the Orchard balance drained as value crossed to Ironwood") - return ironwood, orchard_after + def ironwood_balance_zat(self, w): + """The account's total spendable Ironwood balance, in zatoshi, summed + from its Ironwood notes (each z_listunspent note reports its value in the + `valueZat` field). A plain data helper: each scenario asserts the concrete + balance relationship (source, Ironwood, residual, fees, note count) + INLINE, so the file is self-contained and reviewable.""" + return sum(int(n['valueZat']) for n in ironwood_notes(w)) + + def orchard_balance_zat(self, w, acct): + """The account's spendable Orchard balance in zatoshi (data helper).""" + return account_spendable_zat(w, acct, Pool.ORCHARD) # ---- assertions: the next-actions / anchor-bucket surface -------------- diff --git a/qa/rpc-tests/wallet_ironwood_migration_next_actions.py b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py index 81525721b..f44eb7f39 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_next_actions.py +++ b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py @@ -22,7 +22,13 @@ # from test_framework.ironwood_migration_common import IronwoodMigrationScenario -from test_framework.util import assert_true +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) # A balance large enough to force a multi-layer preparation (the engine fans a # whale out across dependent layers). The exact layer count is a function of the @@ -53,16 +59,18 @@ def run_test(self): preview = self.preview(w, acct) layers = preview['preparation']['layer_count'] + expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( - layers, preview['funding_note_count'])) + layers, expected_crossings)) assert_true(layers > 1, "this scenario needs a multi-layer preparation to exercise " "the anchor-bucket ordering; got {} layer(s)".format(layers)) started = self.start(w, acct) migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] print(" started: id={!r} transaction_count={}".format( - migration_id, started['plan']['transaction_count'])) + migration_id, total_txs)) # The freshly started migration must already expose a consistent # next-actions surface: layer 0 ready to broadcast, later layers blocked @@ -100,11 +108,38 @@ def on_step(step, adv): "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) - ironwood, orchard_after = self.assert_value_crossed(w, acct, - orchard_before) - print(" {} Ironwood note(s); Orchard {} -> {} zat. Anchor-bucket " - "ordering held across all layers. OK".format( - len(ironwood), orchard_before, orchard_after)) + # ---- assert the exact balances INLINE (self-contained) -------------- + # A reviewer reads this file alone and sees the source balance, every + # individual Ironwood note balance, the count, the residual, and the fee. + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + # One Ironwood note per scheduled crossing, each with a positive balance. + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + # Value is conserved: nothing created, and the fee is non-negative and + # bounded by the transaction count (each transaction pads to at most + # PREP_TX_ACTIONS actions at the ZIP-317 marginal fee; 200000 zat is a + # safe per-transaction ceiling). + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + # Only a sub-dust residual (< 0.01 ZEC) remains in Orchard. + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print(" Anchor-bucket ordering held across all layers. OK") print("\nNext-actions / anchor-bucket scenario passed.") From b7f2cfab7eee3c962b07163158f9755d6f26edb3 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 10:07:16 +0200 Subject: [PATCH 22/29] wallet_ironwood_migration: add monotonic-progress scenario + split helper Add the monotonic-progress scenario (C4): drive a multi-layer migration and assert at every step that the completed count never decreases and the phase never regresses, then assert the inline exact balances. Add split_orchard_notes to the harness: it peels distinct Orchard notes via self-sends (the change stays in Orchard), so a scenario can build a many-note or dust-heavy source on top of the funded balance, which a direct shielded multi-send cannot. Both verified on a regtest zebra node. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironwood_migration_common.py | 28 ++++- .../wallet_ironwood_migration_monotonic.py | 113 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_monotonic.py diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py index 8db5fc15f..310a582a2 100644 --- a/qa/rpc-tests/test_framework/ironwood_migration_common.py +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -171,9 +171,35 @@ def fund_orchard_notes(self, node, w, taddr, acct, amounts_zec): return orchard_zat def fund_orchard_note(self, node, w, taddr, acct, zec): - """Mint a single Orchard source note of `zec` ZEC (the common case).""" + """Fund the account's Orchard balance (the common case). A shielded send + routes its change back into Orchard, so this lands the whole shielded + coinbase in Orchard across a handful of notes; `zec` sizes only the first + output. Use it when the scenario cares about the total balance, not the + exact note shape.""" return self.fund_orchard_notes(node, w, taddr, acct, [zec]) + def split_orchard_notes(self, node, w, acct, split_amounts): + """Split off one distinct Orchard note of each value in `split_amounts` + (ZEC) via Orchard self-sends: each self-send peels a note of that value + and keeps the change in Orchard, so the wallet ends with those notes plus + the change notes. Used to build a many-note or dust-heavy shape on top of + an already-funded Orchard balance. Returns the total spendable Orchard + balance in zat.""" + ua = self._orchard_ua(w, acct) + for amount in split_amounts: + opid = w.z_sendmany( + ua, [{'address': ua, 'amount': Decimal(amount)}], 1, None, + PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) + txid = wait_and_assert_operationid_status(w, opid) + assert_true(txid is not None, "Orchard self-send should succeed") + node.generate(1) + wait_for_tx_scanned(w, txid) + wait_account_settled(w, acct) + assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + "the Orchard notes must be split before NU6.3 activates") + wait_account_settled(w, acct) + return account_spendable_zat(w, acct, Pool.ORCHARD) + def activate_ironwood(self, node): """Advance the chain past the NU6.3 activation height so the migration is allowed (the source notes must already be minted).""" diff --git a/qa/rpc-tests/wallet_ironwood_migration_monotonic.py b/qa/rpc-tests/wallet_ironwood_migration_monotonic.py new file mode 100644 index 000000000..6da921435 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_monotonic.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario C4: progress reporting monotonicity. +# +# A wallet renders a progress indicator from the migration status. Under normal +# operation progress must only move FORWARD: the count of completed (mined) +# transactions never decreases, the phase never regresses from in_progress back +# to a pre-start phase, and completion happens exactly when every transaction is +# mined. This scenario drives a multi-layer migration and asserts monotonicity +# at every step. +# + +from test_framework.ironwood_migration_common import IronwoodMigrationScenario +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +SOURCE_NOTE_ZEC = 78 + +# The ordered lifecycle phases, so a regression can be detected by index. +PHASE_ORDER = ['not_started', 'in_progress', 'completed'] + + +class MonotonicProgressScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) + orchard_before = self.fund_orchard_note(node, w, taddr, acct, + SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + expected_crossings = self.preview(w, acct)['funding_note_count'] + started = self.start(w, acct) + migration_id = started['migration_id'] + total = started['plan']['transaction_count'] + print(" started: {} transactions, {} crossings.".format( + total, expected_crossings)) + + state = {'completed': 0, 'phase_idx': 0} + + def on_step(step, adv): + progress = adv['progress'] + completed = progress['completed_transactions'] + assert_true(completed >= state['completed'], + "completed count regressed: {} -> {}".format( + state['completed'], completed)) + assert_true(progress['total_transactions'] == total, + "the total transaction count must not change mid-run") + phase = adv['phase'] + if phase in PHASE_ORDER: + idx = PHASE_ORDER.index(phase) + assert_true(idx >= state['phase_idx'], + "phase regressed: {} -> {}".format( + PHASE_ORDER[state['phase_idx']], phase)) + state['phase_idx'] = idx + state['completed'] = completed + if phase == 'completed': + assert_true(completed == total, + "completion must mean every transaction is mined") + + completed = self.drive_to_completion(w, node, acct, migration_id, + on_step=on_step) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the exact balances INLINE (self-contained) -------------- + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total)) + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total * 200000, + "fees {} zat within the {} zat bound".format( + fees, total * 200000)) + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print(" progress advanced monotonically to {}/{}. OK".format( + total, total)) + print("\nMonotonic-progress scenario passed.") + + +if __name__ == '__main__': + MonotonicProgressScenario().main() From 31dbbb1d1515fd8908717665191529bb5eab98fb Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 10:33:40 +0200 Subject: [PATCH 23/29] wallet_ironwood_migration: add exchange (many-note source) scenario An exchange hot-wallet shape: fund the Orchard balance, then split it into many distinct notes via Orchard self-sends, so the migration's preparation must consolidate a many-note source. Drive to completion and assert the exact balances inline (individual Ironwood note values via valueZat, count equals the crossing count, value conserved with a bounded fee, sub-dust residual). Verified on a regtest zebra node: a 9-note source crosses into the canonical 1-2-5 set. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_exchange.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_exchange.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_exchange.py b/qa/rpc-tests/wallet_ironwood_migration_exchange.py new file mode 100644 index 000000000..54e625404 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_exchange.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario A4: exchange / hot-wallet shape (many Orchard source notes). +# +# An exchange hot wallet holds its balance across many notes rather than one. +# This scenario funds the account and then splits it into many distinct Orchard +# notes, so the migration's preparation must consolidate a many-note source. It +# drives the migration to completion and asserts the exact balances inline: the +# Ironwood note count equals the plan's crossing count, every note is positive, +# and value is conserved (source = Ironwood + residual + a bounded fee). +# + +from test_framework.ironwood_migration_common import ( + IronwoodMigrationScenario, + orchard_notes, +) +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# Split the funded balance into ten extra 5-ZEC notes, giving a many-note source. +SPLIT_NOTES = [5] * 10 + + +class ExchangeScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard balance and splitting it into a many-note " + "shape...") + self.fund_orchard_note(node, w, taddr, acct, 1) + orchard_before = self.split_orchard_notes(node, w, acct, SPLIT_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print(" {} Orchard source notes = {} zat".format( + len(source_notes), source_notes)) + assert_true(len(source_notes) >= 8, + "the source is a many-note wallet: {}".format(len(source_notes))) + + self.activate_ironwood(node) + preview = self.preview(w, acct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(w, acct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + print(" started: transaction_count={}".format(total_txs)) + + completed = self.drive_to_completion(w, node, acct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the exact balances INLINE (self-contained) -------------- + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print("\nExchange scenario passed.") + + +if __name__ == '__main__': + ExchangeScenario().main() From f015cde4109868461c702e52b453f60e01f2d7b4 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 10:35:31 +0200 Subject: [PATCH 24/29] wallet_ironwood_migration: add dust-heavy source scenario Split the funded Orchard balance into many sub-funding-value dust notes, so the migration's preparation must consolidate dust rather than strand it. Drive to completion and assert the exact balances inline (individual Ironwood note values, count equals the crossing count, value conserved with a bounded fee, sub-dust residual). Verified on a regtest zebra node: an 11-note dust source crosses into the canonical 1-2-5 set with the dust swept. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../wallet_ironwood_migration_dust_heavy.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py b/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py new file mode 100644 index 000000000..ada0a2519 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario A5: dust-heavy wallet (many small Orchard notes). +# +# A wallet whose Orchard balance is fragmented into many sub-funding-value dust +# notes stresses the migration's preparation, which must consolidate the dust +# rather than strand it. This scenario funds the account and splits off many +# dust notes, then drives the migration to completion and asserts the exact +# balances inline: the Ironwood note count equals the plan's crossing count, +# every note is positive, value is conserved, and only a sub-dust residual is +# left in Orchard (the dust was swept, not stranded). +# + +from decimal import Decimal + +from test_framework.ironwood_migration_common import ( + IronwoodMigrationScenario, + orchard_notes, +) +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# Split off twelve dust notes (0.02 ZEC each), giving a dust-heavy source. +DUST_NOTES = [Decimal('0.02')] * 12 + + +class DustHeavyScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard balance and splitting off dust notes...") + self.fund_orchard_note(node, w, taddr, acct, 1) + orchard_before = self.split_orchard_notes(node, w, acct, DUST_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print(" {} Orchard source notes = {} zat".format( + len(source_notes), source_notes)) + assert_true(len(source_notes) >= 8, + "the source is a dust-heavy many-note wallet: {}".format( + len(source_notes))) + + self.activate_ironwood(node) + preview = self.preview(w, acct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(w, acct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + print(" started: transaction_count={}".format(total_txs)) + + completed = self.drive_to_completion(w, node, acct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the exact balances INLINE (self-contained) -------------- + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + assert_true(orchard_after < 1000000, + "the dust was swept; only a sub-dust residual remains: {} " + "zat".format(orchard_after)) + print("\nDust-heavy scenario passed.") + + +if __name__ == '__main__': + DustHeavyScenario().main() From 3ef754496e87556980c32ed1aa93ff34ccb4f297 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 10:43:55 +0200 Subject: [PATCH 25/29] wallet_ironwood_migration: add whale-plus-dust source scenario Split the funded Orchard balance into one whale note plus a tail of dust, a mixed shape the preparation must both fan out and consolidate. Drive to completion and assert the exact balances inline. Verified on a regtest zebra node: the source crosses into the canonical 1-2-5 set. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...llet_ironwood_migration_whale_plus_dust.py | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py b/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py new file mode 100644 index 000000000..52df2cce8 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario A6: a whale note plus a tail of dust. +# +# A common real shape: one large Orchard note alongside several tiny ones. The +# migration must both fan the whale out and consolidate the dust in the same +# run. This scenario builds that shape by splitting off one large note and a +# handful of dust notes, then drives the migration to completion and asserts the +# exact balances inline. +# + +from decimal import Decimal + +from test_framework.ironwood_migration_common import ( + IronwoodMigrationScenario, + orchard_notes, +) +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# One whale note plus a dust tail, split off the funded balance. +SPLIT_NOTES = [40, Decimal('0.02'), Decimal('0.02'), Decimal('0.05'), + Decimal('0.05'), Decimal('0.1'), Decimal('0.1')] + + +class WhalePlusDustScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard balance and splitting a whale note plus a " + "dust tail...") + self.fund_orchard_note(node, w, taddr, acct, 1) + orchard_before = self.split_orchard_notes(node, w, acct, SPLIT_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print(" {} Orchard source notes = {} zat".format( + len(source_notes), source_notes)) + assert_true(len(source_notes) >= 8, + "the source is a many-note whale+dust wallet: {}".format( + len(source_notes))) + + self.activate_ironwood(node) + preview = self.preview(w, acct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(w, acct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + print(" started: transaction_count={}".format(total_txs)) + + completed = self.drive_to_completion(w, node, acct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the exact balances INLINE (self-contained) -------------- + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print("\nWhale-plus-dust scenario passed.") + + +if __name__ == '__main__': + WhalePlusDustScenario().main() From bf083ebe1ec0b4a19a8ee08d46640a2c82b05aa2 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 10:54:27 +0200 Subject: [PATCH 26/29] wallet_ironwood_migration: add schedule and denomination scenarios E1: assert the transfer transactions carry distinct scheduled heights spanning a range of blocks, so the crossings are broadcast spread out for privacy rather than in a single burst (verified: 10 transfers across 584 blocks). E2: assert every Ironwood note value, reduced by its factors of ten, is 1, 2, or 5 (the canonical crossing denominations), alongside the usual conservation checks. Both verified on a regtest zebra node. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...wallet_ironwood_migration_denominations.py | 100 ++++++++++++++++++ .../wallet_ironwood_migration_schedule.py | 64 +++++++++++ 2 files changed, 164 insertions(+) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_denominations.py create mode 100644 qa/rpc-tests/wallet_ironwood_migration_schedule.py diff --git a/qa/rpc-tests/wallet_ironwood_migration_denominations.py b/qa/rpc-tests/wallet_ironwood_migration_denominations.py new file mode 100644 index 000000000..7e2613003 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_denominations.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario E2: crossing denomination shape. +# +# The migration crosses value in canonical 1-2-5 denominations (each crossing is +# d * 10^k ZEC for d in {1, 2, 5}), which is what gives the on-chain footprint +# its intended anonymity. This scenario migrates a balance and asserts inline +# that every Ironwood note value, reduced by its factors of ten, is 1, 2, or 5, +# in addition to the usual conservation checks. +# + +from test_framework.ironwood_migration_common import IronwoodMigrationScenario +from test_framework.util import ( + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +SOURCE_NOTE_ZEC = 60 + + +def _leading_digit(value_zat): + """The significant digit of a value once its trailing zeros are removed; for + a 1-2-5 * 10^k denomination this is 1, 2, or 5.""" + v = int(value_zat) + while v % 10 == 0: + v //= 10 + return v + + +class DenominationsScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard balance ({} ZEC nominal)...".format( + SOURCE_NOTE_ZEC)) + orchard_before = self.fund_orchard_note(node, w, taddr, acct, + SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + preview = self.preview(w, acct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(w, acct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + + completed = self.drive_to_completion(w, node, acct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the exact balances and denominations INLINE ------------- + notes = ironwood_notes(w) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + digits = sorted({_leading_digit(v) for v in note_values}) + print(" source Orchard: {} zat".format(orchard_before)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" leading digits: {}".format(digits)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + # Every Ironwood note is a canonical 1-2-5 denomination. + assert_true(all(_leading_digit(v) in (1, 2, 5) for v in note_values), + "every Ironwood note is a 1-2-5 denomination: {}".format( + note_values)) + # One Ironwood note per crossing; value conserved with a bounded fee. + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within bound".format(fees)) + assert_true(orchard_after < 1000000, + "sub-dust Orchard residual: {} zat".format(orchard_after)) + print("\nDenominations scenario passed.") + + +if __name__ == '__main__': + DenominationsScenario().main() diff --git a/qa/rpc-tests/wallet_ironwood_migration_schedule.py b/qa/rpc-tests/wallet_ironwood_migration_schedule.py new file mode 100644 index 000000000..6f1cb4fea --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_schedule.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario E1: the transfer broadcast schedule is spread for privacy. +# +# To avoid linking the crossings, the migration does not broadcast every transfer +# at once: each is scheduled at a different height across a window. This scenario +# starts a migration and asserts inline that the transfer transactions carry +# distinct scheduled heights spanning a range of blocks, so a driver broadcasts +# them spread out rather than in a single burst. +# + +from test_framework.ironwood_migration_common import IronwoodMigrationScenario +from test_framework.util import assert_true + +SOURCE_NOTE_ZEC = 60 + + +class ScheduleScenario(IronwoodMigrationScenario): + + def run_test(self): + node = self.nodes[0] + w = self.wallets[0] + taddr = self.miner_addresses[0] + + self.sync_all() + if self.skip_if_rpcs_absent(w): + return + + acct = w.z_listaccounts()[0]['account_uuid'] + + print("Funding an Orchard balance...") + self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) + self.activate_ironwood(node) + + started = self.start(w, acct) + migration_id = started['migration_id'] + + st = self.status(w, migration_id) + transfers = [t for t in st['transactions'] if t['kind'] == 'transfer'] + heights = sorted(t['scheduled_height'] for t in transfers) + print(" {} transfers; scheduled heights: {}".format( + len(transfers), heights)) + + assert_true(len(transfers) >= 2, + "a multi-crossing migration has several transfers") + # The transfers are spread across a range of heights, not all at once. + assert_true(len(set(heights)) > 1, + "transfers are scheduled across multiple heights: {}".format( + heights)) + span = heights[-1] - heights[0] + assert_true(span > 0, + "the transfer schedule spans a range of blocks (span {})" + .format(span)) + print(" transfers spread across {} distinct heights, spanning {} " + "blocks. OK".format(len(set(heights)), span)) + print("\nSchedule scenario passed.") + + +if __name__ == '__main__': + ScheduleScenario().main() From 3d34fa2545cc406ea084747f5cda87383213472b Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 11:03:37 +0200 Subject: [PATCH 27/29] rpc-tests: activate the Orchard -> Ironwood migration scenario suite Move wallet_ironwood_migration.py and wallet_ironwood_migration_preview.py out of DISABLED_SCRIPTS (they self-skip cleanly when the pool-migration RPCs are absent and otherwise run on the Z3 stack), and register the twelve new migration scenarios in NEW_SCRIPTS: the persona and lifecycle cases (next-actions / anchor-bucket ordering, monotonic progress, exchange, dust-heavy, whale-plus-dust, schedule, denominations, cancel) and the rejection cases (nothing-to-migrate, pre-activation, double-start, unknown-id). wallet_ironwood_reorg.py and wallet_ironwood_birthday.py stay disabled: they hang on the zebra backend after invalidateblock / birthday recovery, unrelated to the migration engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/pull-tester/rpc-tests.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index 09da4336a..1ac664230 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -130,8 +130,6 @@ 'wallet_orchard_init.py', # no zallet equiv yet: resendwallettransactions 'wallet_orchard_persistence.py', # deprecated; z_getbalanceforaccount->z_getbalances 'wallet_orchard_reindex.py', # deprecated; z_getbalanceforaccount->z_getbalances - 'wallet_ironwood_migration.py', # no zallet equiv yet: generic pool-migration RPC (z_startpoolmigration &c, zcash_ironwood_migration_backend note-split planner still evolving); scaffolding self-skips - 'wallet_ironwood_migration_preview.py', # needs a pinned zallet exposing z_previewpoolmigration (the note-split planning-preview RPC); Stage-0 self-skips on older builds 'wallet_ironwood_reorg.py', # zebra-backend: wait_for_wallet_sync never converges after invalidateblock (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_ironwood_birthday.py', # zebra-backend: recovered account's wait_for_wallet_sync(timeout=300) still times out (still hangs as of zallet@d168efe, past zallet#560/#563/#576) 'wallet_overwintertx.py', # deprecated; getnewaddress->z_getaddressforaccount, z_getnewaddress->z_getaddressforaccount @@ -285,6 +283,22 @@ 'wallet_ironwood_crosspool.py', 'wallet_ironwood_migration.py', 'wallet_ironwood_migration_preview.py', + # Orchard -> Ironwood migration scenario suite (personas, lifecycle, and the + # multi-layer anchor-bucket ordering). Each drives a real migration on the + # Z3 stack and asserts the exact balances inline; the heavier drive-to- + # completion cases fan a whale across several dependent layers. + 'wallet_ironwood_migration_next_actions.py', + 'wallet_ironwood_migration_monotonic.py', + 'wallet_ironwood_migration_exchange.py', + 'wallet_ironwood_migration_dust_heavy.py', + 'wallet_ironwood_migration_whale_plus_dust.py', + 'wallet_ironwood_migration_schedule.py', + 'wallet_ironwood_migration_denominations.py', + 'wallet_ironwood_migration_cancel.py', + 'wallet_ironwood_migration_nothing.py', + 'wallet_ironwood_migration_pre_activation.py', + 'wallet_ironwood_migration_double_start.py', + 'wallet_ironwood_migration_unknown_id.py', 'wallet_ironwood_spending.py', 'wallet_ironwood_invariants.py', # vv Tests less than 5m vv From e8780933e48b1724b6b13fe465e4ef91dd756dbc Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 16:35:45 +0200 Subject: [PATCH 28/29] wallet_ironwood_migration: fund faithful exact balances via a faucet wallet Rework the migration scenario suite so each subject holds a TRUTHFUL balance instead of the whole shielded coinbase. A regtest wallet that mines its own coinbase always ends up holding ~137 ZEC (a shielded send routes its change back into the account), so a "2 ZEC small holder" previously migrated 137 ZEC. The harness now separates the MINER from the SUBJECT: wallet 0 is a FAUCET that mines and shields the coinbase, and wallets 1.. are SUBJECTS the faucet funds with exact amounts (keeping its own change), so a 2-ZEC holder really holds 2 ZEC. A reviewer can now confirm the exactness of each scenario by reading its inline balance checks alone. IronwoodMigrationScenario gains NUM_SUBJECTS (num_nodes = num_wallets = 1 + NUM_SUBJECTS), fund_exact_note / fund_exact_orchard, and a concurrent drive_many_to_completion. Every persona and lifecycle scenario is converted to this model with exact inline assertions (small holder 2 ZEC, retail 15 ZEC, exchange ten 5-ZEC notes, dust-heavy, whale-plus-dust, a multi-layer whale, and the rejection cases). A new wallet_ironwood_migration_concurrent.py migrates several parties at once on one shared chain, exercising the anchor-bucket ordering concurrently. Two multi-node adaptations were required. The subject broadcasts to its own node but the faucet node mines, so the drivers sync mempools BEFORE mining (else the miner produces blocks without the subject's transaction and it never confirms). zebra's regtest multi-peer block propagation stalls past two nodes (zebra #10329 / #10332), so setup_network peers every subject node directly to the faucet node in a STAR and sync_all retries with peer reconnects, which is what makes a many-subject topology converge. Co-Authored-By: Claude Opus 4.8 (1M context) --- qa/pull-tester/rpc-tests.py | 13 +- .../ironwood_migration_common.py | 286 ++++++++++++------ .../wallet_ironwood_migration_cancel.py | 37 +-- .../wallet_ironwood_migration_concurrent.py | 125 ++++++++ ...wallet_ironwood_migration_denominations.py | 45 ++- .../wallet_ironwood_migration_double_start.py | 28 +- .../wallet_ironwood_migration_dust_heavy.py | 52 ++-- .../wallet_ironwood_migration_exchange.py | 56 ++-- .../wallet_ironwood_migration_monotonic.py | 41 +-- .../wallet_ironwood_migration_next_actions.py | 78 +++-- .../wallet_ironwood_migration_nothing.py | 20 +- ...allet_ironwood_migration_pre_activation.py | 30 +- .../wallet_ironwood_migration_retail.py | 98 ++++++ .../wallet_ironwood_migration_schedule.py | 31 +- .../wallet_ironwood_migration_small_holder.py | 101 +++++++ .../wallet_ironwood_migration_unknown_id.py | 24 +- ...llet_ironwood_migration_whale_plus_dust.py | 53 ++-- 17 files changed, 761 insertions(+), 357 deletions(-) create mode 100644 qa/rpc-tests/wallet_ironwood_migration_concurrent.py create mode 100644 qa/rpc-tests/wallet_ironwood_migration_retail.py create mode 100644 qa/rpc-tests/wallet_ironwood_migration_small_holder.py diff --git a/qa/pull-tester/rpc-tests.py b/qa/pull-tester/rpc-tests.py index 1ac664230..e81caf58d 100755 --- a/qa/pull-tester/rpc-tests.py +++ b/qa/pull-tester/rpc-tests.py @@ -284,16 +284,21 @@ 'wallet_ironwood_migration.py', 'wallet_ironwood_migration_preview.py', # Orchard -> Ironwood migration scenario suite (personas, lifecycle, and the - # multi-layer anchor-bucket ordering). Each drives a real migration on the - # Z3 stack and asserts the exact balances inline; the heavier drive-to- - # completion cases fan a whale across several dependent layers. + # multi-layer anchor-bucket ordering). Each uses a faucet wallet to fund one + # or more SUBJECT wallets with EXACT balances, then drives a real migration on + # the Z3 stack and asserts the exact balances inline; the heavier drive-to- + # completion cases fan a whale across several dependent layers, and the + # concurrent case migrates three parties at once on one shared chain. + 'wallet_ironwood_migration_concurrent.py', 'wallet_ironwood_migration_next_actions.py', 'wallet_ironwood_migration_monotonic.py', 'wallet_ironwood_migration_exchange.py', 'wallet_ironwood_migration_dust_heavy.py', 'wallet_ironwood_migration_whale_plus_dust.py', - 'wallet_ironwood_migration_schedule.py', 'wallet_ironwood_migration_denominations.py', + 'wallet_ironwood_migration_retail.py', + 'wallet_ironwood_migration_small_holder.py', + 'wallet_ironwood_migration_schedule.py', 'wallet_ironwood_migration_cancel.py', 'wallet_ironwood_migration_nothing.py', 'wallet_ironwood_migration_pre_activation.py', diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py index 310a582a2..b920fdd38 100644 --- a/qa/rpc-tests/test_framework/ironwood_migration_common.py +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -13,6 +13,18 @@ zallet is a thin consumer, so these scenarios exercise shared-engine behavior through zallet's regtest JSON-RPC. +FAUCET model +------------ +A regtest wallet that mines its own coinbase always ends up holding the WHOLE +shielded coinbase (~137 ZEC), because a shielded send routes its change back +into the account's preferred pool; the nominal "amount" cannot shrink it. To +give each subject a TRUTHFUL balance (a 2 ZEC small holder really holding 2 ZEC), +the harness separates the MINER from the SUBJECT: wallet 0 is a FAUCET that mines +and shields the coinbase, and wallets 1.. are SUBJECTS the faucet funds with +EXACT amounts. The faucet keeps the change, so a subject holds precisely what it +was sent. This also enables MULTIPLE subjects migrating concurrently on one +shared chain (`NUM_SUBJECTS` > 1, driven by `drive_many_to_completion`). + Assertion helpers cover the cross-cutting invariants (value conservation, pool exclusivity), the multi-layer ANCHOR-BUCKET ordering (a later preparation layer is never actionable until its whole predecessor layer has mined), and the @@ -28,13 +40,16 @@ _RPC_EXCEPTIONS, account_spendable_zat, assert_true, + connect_nodes_bi, ironwood_notes, nu_activation_ironwood_at, shield_coinbase, + sync_blocks_with_reconnect, + sync_mempools, wait_account_settled, - wait_and_assert_operationid_status, wait_for_account_spendable, wait_for_tx_scanned, + wait_and_assert_operationid_status, ) from test_framework.util_ironwood import IronwoodTestFramework @@ -68,11 +83,16 @@ def orchard_notes(wallet, minconf=1): class IronwoodMigrationScenario(IronwoodTestFramework): """Base class for one migration scenario. - Subclasses set any scenario-specific configuration and implement `run_test`, - typically: fund a wallet shape, `preview`, `start`, `drive_to_completion` (or - a partial drive for resume/cancel scenarios), then assert with the helpers. + Subclasses set `NUM_SUBJECTS` (default 1), implement `run_test`, and + typically: fund each subject with an exact Orchard balance via the faucet + (`fund_exact_orchard`), `preview`, `start`, `drive_to_completion` (or the + concurrent `drive_many_to_completion`), then assert with the helpers. """ + # How many SUBJECT wallets the scenario migrates (in addition to the faucet + # wallet 0). One faucet plus `NUM_SUBJECTS` subjects share one peered chain. + NUM_SUBJECTS = 1 + # A generous cap: the migration advances one transaction per call, and the # transfers are spread across a randomized privacy schedule, so a wide bound # covers a many-note wallet with a spread schedule. @@ -83,9 +103,67 @@ class IronwoodMigrationScenario(IronwoodTestFramework): def __init__(self): super().__init__() + # One node per wallet (each wallet indexes into its own node), all + # peered onto one chain by the base framework: wallet 0 is the faucet, + # wallets 1.. are subjects. + self.num_nodes = 1 + self.NUM_SUBJECTS + self.num_wallets = 1 + self.NUM_SUBJECTS # Deferred activation gives an Orchard era in which to mint the source # notes the migration consumes. self.activation_heights = nu_activation_ironwood_at(IRONWOOD_HEIGHT) + # Lazily shielded on first fund; the faucet's coinbase is shielded once. + self._faucet_ready = False + + def setup_network(self, split=False, do_mempool_sync=True): + """Peer every node directly to the faucet node (node 0) in a STAR, rather + than the base framework's linear CHAIN (0-1-2-3). The faucet mines every + block, so a star lets each subject node receive the faucet's blocks in one + hop; a multi-hop chain stalls on the zebra regtest backend (its + multi-peer block propagation does not converge past two nodes: zebra + #10329 / #10332), which is what makes a many-subject topology viable.""" + self.prepare_wallets() + self.nodes = self.setup_nodes() + for i in range(1, len(self.nodes)): + connect_nodes_bi(self.nodes, 0, i) + self.is_network_split = split + self.prepare_chain() + self.sync_all(do_mempool_sync) + self.zainos = self.setup_indexers() + self.wallets = self.setup_wallets() + + def sync_all(self, do_mempool_sync=True): + """Converge every node's chain on the faucet node's (node 0), retrying + with peer reconnects. zebra's regtest multi-peer block propagation + intermittently stalls a subject node's download (zebra #10329 / #10332), + so a plain one-shot sync flakes with more than two nodes; reconnecting + the subject nodes to the faucet and retrying is what makes a + many-subject topology converge reliably.""" + sync_blocks_with_reconnect(self.nodes, 0) + if do_mempool_sync: + sync_mempools(self.nodes, self.wallets) + + # ---- faucet / subject accessors ---------------------------------------- + + @property + def faucet(self): + """The miner wallet (wallet 0) that funds the subjects.""" + return self.wallets[0] + + @property + def faucet_node(self): + """The node the faucet mines on (node 0).""" + return self.nodes[0] + + def subject(self, i=0): + """Subject wallet `i` (0-based; wallet i+1).""" + return self.wallets[1 + i] + + def subject_account(self, i=0): + """Subject `i`'s account UUID.""" + return self.subject(i).z_listaccounts()[0]['account_uuid'] + + def _orchard_ua(self, w, acct): + return w.z_getaddressforaccount(acct, ['orchard'])['address'] # ---- capability probing ------------------------------------------------ @@ -98,7 +176,12 @@ def _is_method_not_found(e): def migration_rpcs_present(self, w): """Whether the pool-migration RPC surface is wired in this build. Probes `start` with deliberately invalid arguments: an unknown method rejects - the CALL (method-not-found); a wired method rejects the ARGUMENTS.""" + the CALL (method-not-found); a wired method rejects the ARGUMENTS. The + wallet RPC surface is registered synchronously before the endpoint + accepts connections, so a single probe is authoritative. (When the run + uses the zaino backend, whose binary lacks these methods, this correctly + reports absent and the scenario self-skips; set ZALLET_BACKEND=zebra to + exercise the migration surface.)""" try: getattr(w, START_RPC)('__invalid_pool__', '__invalid_pool__') except _RPC_EXCEPTIONS as e: @@ -113,101 +196,82 @@ def skip_if_rpcs_absent(self, w): return True return False - # ---- funding wallet shapes --------------------------------------------- - - def _orchard_ua(self, w, acct): - return w.z_getaddressforaccount(acct, ['orchard'])['address'] - - def _sapling_ua(self, w, acct): - return w.z_getaddressforaccount(acct, ['sapling'])['address'] - - def fund_orchard_notes(self, node, w, taddr, acct, amounts_zec): - """Mint one spendable v2 Orchard note per amount in `amounts_zec` - (Decimal or number, in ZEC), all pre-NU6.3, and return the account's - total spendable Orchard value in zat. - - A direct many-UTXO Orchard shield can be left unspendable by fee - estimation, so shield into Sapling first, then pay the Orchard receiver - once per note (sequential sends, so each mint is a distinct note and no - single transaction has a duplicated recipient). - """ - amounts = [Decimal(a) for a in amounts_zec] - ua = self._orchard_ua(w, acct) - sapling_ua = self._sapling_ua(w, acct) - + # ---- funding: the faucet mints EXACT subject balances ------------------ + + def _ensure_faucet_funded(self): + """Shield the faucet's coinbase once (into the faucet's Sapling pool), + giving it a large spendable balance to hand out to subjects. Idempotent: + later subject funding draws on the faucet's remaining balance.""" + if self._faucet_ready: + return + node = self.faucet_node + facct = self.faucet.z_listaccounts()[0]['account_uuid'] + taddr = self.miner_addresses[0] assert_true(node.getblockcount() < IRONWOOD_HEIGHT, "must still be in the Orchard era before funding") - # A per-note fee buffer so the Sapling shield covers every send. - needed = sum(amounts) + Decimal('0.001') * len(amounts) + faucet_sapling_ua = self.faucet.z_getaddressforaccount( + facct, ['sapling'])['address'] _, sapling_zat = shield_coinbase( - node, w, taddr, sapling_ua, acct, Pool.SAPLING) - assert_true(Decimal(sapling_zat) > needed * COIN, - "shielded enough Sapling to fund {} source note(s)" - .format(len(amounts))) - - for amount in amounts: - opid = w.z_sendmany( - sapling_ua, [{'address': ua, 'amount': amount}], 1, None, - PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) - txid = wait_and_assert_operationid_status(w, opid) - assert_true(txid is not None, "Sapling -> Orchard send should succeed") + node, self.faucet, taddr, faucet_sapling_ua, facct, Pool.SAPLING) + assert_true(sapling_zat > 0, "the faucet shielded a spendable balance") + self._faucet_ua = self.faucet.z_getaddressforaccount(facct)['address'] + self._faucet_acct = facct + self._faucet_ready = True + + def fund_exact_orchard(self, subject_idx, amounts_zec): + """Fund subject `subject_idx` with one distinct Orchard note per amount + in `amounts_zec` (ZEC), EXACTLY: the faucet sends each amount to the + subject's Orchard receiver, keeping the change itself, so the subject + holds precisely `sum(amounts_zec)` and nothing more. All sends happen + pre-NU6.3 so the notes are Orchard (the migration source). Returns the + subject's exact spendable Orchard balance in zat.""" + self._ensure_faucet_funded() + node = self.faucet_node + subject = self.subject(subject_idx) + sacct = self.subject_account(subject_idx) + subj_ua = self._orchard_ua(subject, sacct) + + for amount in amounts_zec: + opid = self.faucet.z_sendmany( + self._faucet_ua, + [{'address': subj_ua, 'amount': Decimal(amount)}], + 1, None, PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) + txid = wait_and_assert_operationid_status(self.faucet, opid) + assert_true(txid is not None, + "faucet -> subject send should succeed") node.generate(1) - wait_for_tx_scanned(w, txid) - # Wait for the Sapling change from this send to become spendable - # before the next send draws on it (otherwise a later send can see a - # zero balance while the change note is still being scanned). - wait_account_settled(w, acct) + self.sync_all() + wait_for_tx_scanned(self.faucet, txid) + # Let the faucet's change settle before the next draw. + wait_account_settled(self.faucet, self._faucet_acct) assert_true(node.getblockcount() < IRONWOOD_HEIGHT, - "the Orchard notes must be minted before NU6.3 activates") - - wait_for_account_spendable(w, acct, Pool.ORCHARD, min_zat=1) - wait_account_settled(w, acct) - orchard_zat = account_spendable_zat(w, acct, Pool.ORCHARD) - assert_true(orchard_zat > 0, "the Orchard notes should be spendable") - assert_true(len(orchard_notes(w)) >= len(amounts), - "expected {} Orchard source note(s)".format(len(amounts))) - assert_true(len(ironwood_notes(w)) == 0, + "the subject's notes must be minted before NU6.3") + + wait_for_account_spendable(subject, sacct, Pool.ORCHARD, min_zat=1) + wait_account_settled(subject, sacct) + orchard_zat = account_spendable_zat(subject, sacct, Pool.ORCHARD) + assert_true(len(orchard_notes(subject)) >= len(amounts_zec), + "expected {} distinct Orchard source note(s)".format( + len(amounts_zec))) + assert_true(len(ironwood_notes(subject)) == 0, "no Ironwood notes should exist before migration") return orchard_zat - def fund_orchard_note(self, node, w, taddr, acct, zec): - """Fund the account's Orchard balance (the common case). A shielded send - routes its change back into Orchard, so this lands the whole shielded - coinbase in Orchard across a handful of notes; `zec` sizes only the first - output. Use it when the scenario cares about the total balance, not the - exact note shape.""" - return self.fund_orchard_notes(node, w, taddr, acct, [zec]) - - def split_orchard_notes(self, node, w, acct, split_amounts): - """Split off one distinct Orchard note of each value in `split_amounts` - (ZEC) via Orchard self-sends: each self-send peels a note of that value - and keeps the change in Orchard, so the wallet ends with those notes plus - the change notes. Used to build a many-note or dust-heavy shape on top of - an already-funded Orchard balance. Returns the total spendable Orchard - balance in zat.""" - ua = self._orchard_ua(w, acct) - for amount in split_amounts: - opid = w.z_sendmany( - ua, [{'address': ua, 'amount': Decimal(amount)}], 1, None, - PrivacyPolicy.ALLOW_REVEALED_AMOUNTS) - txid = wait_and_assert_operationid_status(w, opid) - assert_true(txid is not None, "Orchard self-send should succeed") - node.generate(1) - wait_for_tx_scanned(w, txid) - wait_account_settled(w, acct) - assert_true(node.getblockcount() < IRONWOOD_HEIGHT, - "the Orchard notes must be split before NU6.3 activates") - wait_account_settled(w, acct) - return account_spendable_zat(w, acct, Pool.ORCHARD) + def fund_exact_note(self, subject_idx, zec): + """Fund subject `subject_idx` with a single exact Orchard note of `zec` + ZEC. Returns the subject's exact spendable Orchard balance in zat.""" + return self.fund_exact_orchard(subject_idx, [zec]) - def activate_ironwood(self, node): + def activate_ironwood(self): """Advance the chain past the NU6.3 activation height so the migration is - allowed (the source notes must already be minted).""" + allowed (the source notes must already be minted). Mines on the faucet + node and syncs every peer.""" + node = self.faucet_node while node.getblockcount() < IRONWOOD_HEIGHT: node.generate(1) self.sync_all() - # ---- lifecycle ---------------------------------------------------------- + # ---- lifecycle (per subject wallet) ------------------------------------- def preview(self, w, acct): """The migration plan preview (read-only).""" @@ -225,23 +289,61 @@ def advance(self, w, acct, migration_id): """Advance the migration one step.""" return getattr(w, ADVANCE_RPC)(acct, migration_id) - def drive_to_completion(self, w, node, acct, migration_id, - on_step=None): - """Advance one step per call, mining and syncing between steps, until the - migration completes or the step cap is reached. `on_step(step, adv)` is - called after each advance (for per-step assertions). Returns True if the - migration completed.""" + def drive_to_completion(self, w, acct, migration_id, on_step=None): + """Advance one subject's migration one step per call, mining on the + faucet node and syncing between steps, until it completes or the step + cap is reached. `on_step(step, adv)` runs after each advance. Returns + True if the migration completed. + + The subject broadcasts to its OWN node, but mining happens on the faucet + node, so mempools are synced BEFORE mining (`sync_all` propagates the + just-broadcast transaction to the miner) and again after (to carry the + block back to the subject); otherwise the miner would produce blocks + without the subject's transaction and it would never confirm.""" + node = self.faucet_node for step in range(self.MAX_ADVANCE_STEPS): adv = self.advance(w, acct, migration_id) if on_step is not None: on_step(step, adv) if adv['phase'] == 'completed': return True + self.sync_all() node.generate(self.ADVANCE_MINE_BLOCKS) self.sync_all() wait_account_settled(w, acct) return False + def drive_many_to_completion(self, subjects, on_step=None): + """Drive SEVERAL subjects' migrations CONCURRENTLY on one shared chain: + round-robin one advance step per still-running subject, then mine once on + the faucet node and sync, so every subject's schedule advances together + (as they would on the real network). `subjects` is a list of + `(wallet, account, migration_id)` tuples. `on_step(step, idx, adv)` runs + after each subject's advance. Returns a list of per-subject completion + booleans.""" + node = self.faucet_node + done = [False] * len(subjects) + for step in range(self.MAX_ADVANCE_STEPS): + for idx, (w, acct, mid) in enumerate(subjects): + if done[idx]: + continue + adv = self.advance(w, acct, mid) + if on_step is not None: + on_step(step, idx, adv) + if adv['phase'] == 'completed': + done[idx] = True + if all(done): + return done + # Propagate every subject's just-broadcast transaction to the miner + # before mining, mine one batch that confirms them all together, then + # carry the block back to every subject. + self.sync_all() + node.generate(self.ADVANCE_MINE_BLOCKS) + self.sync_all() + for w, acct, _ in subjects: + wait_account_settled(w, acct) + return done + # ---- assertions: cross-cutting invariants ------------------------------ def ironwood_balance_zat(self, w): diff --git a/qa/rpc-tests/wallet_ironwood_migration_cancel.py b/qa/rpc-tests/wallet_ironwood_migration_cancel.py index d43addd45..28acbbdfb 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_cancel.py +++ b/qa/rpc-tests/wallet_ironwood_migration_cancel.py @@ -9,7 +9,8 @@ # After some of a migration's transactions have been broadcast, cancelling it # must be honored: the migration becomes terminal, it is not resurrected by a # later advance, and no crash or dangling in-progress state is left behind. A -# wallet UI's "cancel" must always be a clean exit. +# wallet UI's "cancel" must always be a clean exit. A faucet funds the subject +# with an exact balance. # from test_framework.ironwood_migration_common import ( @@ -19,7 +20,7 @@ ) from test_framework.util import _RPC_EXCEPTIONS, assert_true -SOURCE_NOTE_ZEC = 8 +SOURCE_ZEC = 8 # Advance a few steps before cancelling, to broadcast part of layer 0. STEPS_BEFORE_CANCEL = 3 @@ -27,41 +28,41 @@ class CancelScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) + node = self.faucet_node - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) - self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with EXACTLY {} ZEC...".format( + SOURCE_ZEC)) + self.fund_exact_note(0, SOURCE_ZEC) + self.activate_ironwood() - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] print(" started: id={!r}.".format(migration_id)) - # Advance a few steps to broadcast some of the preparation. + # Advance a few steps to broadcast some of the preparation. Sync mempools + # before mining so the subject's broadcast transactions reach the miner. for _ in range(STEPS_BEFORE_CANCEL): - adv = self.advance(w, acct, migration_id) + adv = self.advance(subject, sacct, migration_id) if adv['phase'] == 'completed': break + self.sync_all() node.generate(self.ADVANCE_MINE_BLOCKS) self.sync_all() print(" advanced {} step(s); now cancelling.".format( STEPS_BEFORE_CANCEL)) # Cancel must succeed. - getattr(w, CANCEL_RPC)(migration_id) + getattr(subject, CANCEL_RPC)(migration_id) # The migration is now terminal: its status is neither in progress nor # completed (a cancelled migration), or the status RPC rejects the id. try: - st = self.status(w, migration_id) + st = self.status(subject, migration_id) except _RPC_EXCEPTIONS as e: print(" status after cancel rejects the id (terminal): {!r}. OK" .format(str(e.error.get('message', '')))) @@ -75,7 +76,7 @@ def run_test(self): # A later advance must not resurrect the migration. try: - adv = getattr(w, ADVANCE_RPC)(acct, migration_id) + adv = getattr(subject, ADVANCE_RPC)(sacct, migration_id) except _RPC_EXCEPTIONS as e: print(" advance after cancel rejected (not resurrected): {!r}. OK" .format(str(e.error.get('message', '')))) diff --git a/qa/rpc-tests/wallet_ironwood_migration_concurrent.py b/qa/rpc-tests/wallet_ironwood_migration_concurrent.py new file mode 100644 index 000000000..6f82e7c8d --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_concurrent.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario F1: multiple parties migrating concurrently on one shared chain. +# +# On the real network, many wallets migrate Orchard -> Ironwood at the same time. +# This scenario runs THREE independent subject wallets (a small holder, a retail +# holder, and a multi-note whale whose preparation fans across several layers), +# each funded with an EXACT balance by one shared faucet, and drives all three +# migrations CONCURRENTLY: one advance step per subject per round, then a single +# mining batch that confirms every subject's just-broadcast transaction together +# (as one block on the shared chain would). It asserts that each migration +# completes independently, that value is conserved per subject, and that the +# per-subject anchor-bucket ordering holds throughout (a later preparation layer +# is never actionable until its predecessor layer has mined). +# + +from decimal import Decimal + +from test_framework.ironwood_migration_common import IronwoodMigrationScenario +from test_framework.util import ( + COIN, + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# Three parties with distinct, faithful balances. The whale is many notes so its +# preparation fans across several dependent layers, exercising the anchor-bucket +# ordering concurrently with the two single-layer migrations. +PARTIES = [ + ("small holder", [Decimal('2')]), + ("whale", [Decimal('12')] * 10), +] + + +class ConcurrentMigrationScenario(IronwoodMigrationScenario): + + NUM_SUBJECTS = 2 + + def run_test(self): + self.sync_all() + if self.skip_if_rpcs_absent(self.subject(0)): + return + + # Fund each subject with its exact balance from the shared faucet. + info = [] + for idx, (label, notes) in enumerate(PARTIES): + before = self.fund_exact_orchard(idx, notes) + expected_zat = int(sum(notes) * COIN) + assert_equal(before, expected_zat, + "{} funded with exactly {} ZEC".format( + label, sum(notes))) + print(" {}: {} note(s), {} zat.".format( + label, len(notes), before)) + info.append({'label': label, 'acct': self.subject_account(idx), + 'before': before, 'idx': idx}) + + self.activate_ironwood() + + # Preview and start every subject's migration. + subjects = [] + for d in info: + subject = self.subject(d['idx']) + pv = self.preview(subject, d['acct']) + d['crossings'] = pv['funding_note_count'] + d['layers'] = pv['preparation']['layer_count'] + started = self.start(subject, d['acct']) + d['migration_id'] = started['migration_id'] + d['total_txs'] = started['plan']['transaction_count'] + subjects.append((subject, d['acct'], d['migration_id'])) + print(" started {}: {} layer(s), {} crossings, {} txs.".format( + d['label'], d['layers'], d['crossings'], d['total_txs'])) + assert_true(any(d['layers'] > 1 for d in info), + "at least one party (the whale) must be multi-layer to " + "exercise concurrent anchor-bucket ordering") + + # Drive all three concurrently, asserting each subject's next-actions + # surface stays consistent at every step (the anchor-bucket ordering is + # checked per snapshot inside assert_next_actions_consistent). + def on_step(step, idx, adv): + self.assert_next_actions_consistent( + self.subject(info[idx]['idx']), info[idx]['migration_id']) + + print("Driving all {} migrations concurrently...".format(len(subjects))) + done = self.drive_many_to_completion(subjects, on_step=on_step) + assert_true(all(done), + "every concurrent migration completed within {} steps: {}" + .format(self.MAX_ADVANCE_STEPS, + {info[i]['label']: done[i] for i in range(len(done))})) + + # ---- assert the EXACT per-subject balances INLINE (self-contained) --- + for d in info: + subject = self.subject(d['idx']) + notes = ironwood_notes(subject) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(subject, d['acct'], + Pool.ORCHARD) + fees = d['before'] - ironwood_zat - orchard_after + print(" [{}] source={} zat Ironwood={} = {} zat residual={} " + "fees={} over {} txs".format( + d['label'], d['before'], note_values, ironwood_zat, + orchard_after, fees, d['total_txs'])) + assert_equal(len(note_values), d['crossings'], + "{}: one Ironwood note per crossing".format(d['label'])) + assert_true(all(v > 0 for v in note_values), + "{}: every Ironwood note is positive".format(d['label'])) + assert_true(ironwood_zat + orchard_after <= d['before'], + "{}: no value was created".format(d['label'])) + assert_true(0 <= fees <= d['total_txs'] * 200000, + "{}: fees {} within bound".format(d['label'], fees)) + assert_true(orchard_after < 1000000, + "{}: only a sub-dust Orchard residual remains".format( + d['label'])) + print("\nConcurrent multi-party migration scenario passed.") + + +if __name__ == '__main__': + ConcurrentMigrationScenario().main() diff --git a/qa/rpc-tests/wallet_ironwood_migration_denominations.py b/qa/rpc-tests/wallet_ironwood_migration_denominations.py index 7e2613003..ce16035e0 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_denominations.py +++ b/qa/rpc-tests/wallet_ironwood_migration_denominations.py @@ -8,13 +8,14 @@ # # The migration crosses value in canonical 1-2-5 denominations (each crossing is # d * 10^k ZEC for d in {1, 2, 5}), which is what gives the on-chain footprint -# its intended anonymity. This scenario migrates a balance and asserts inline -# that every Ironwood note value, reduced by its factors of ten, is 1, 2, or 5, -# in addition to the usual conservation checks. +# its intended anonymity. A faucet funds the subject with an EXACT balance; the +# scenario migrates it and asserts inline that every Ironwood note value, reduced +# by its factors of ten, is 1, 2, or 5, in addition to the conservation checks. # from test_framework.ironwood_migration_common import IronwoodMigrationScenario from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -22,7 +23,8 @@ ironwood_notes, ) -SOURCE_NOTE_ZEC = 60 +SOURCE_ZEC = 60 +SOURCE_ZAT = SOURCE_ZEC * COIN def _leading_digit(value_zat): @@ -37,44 +39,41 @@ def _leading_digit(value_zat): class DenominationsScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard balance ({} ZEC nominal)...".format( - SOURCE_NOTE_ZEC)) - orchard_before = self.fund_orchard_note(node, w, taddr, acct, - SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with EXACTLY {} ZEC...".format( + SOURCE_ZEC)) + orchard_before = self.fund_exact_note(0, SOURCE_ZEC) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") - preview = self.preview(w, acct) + self.activate_ironwood() + preview = self.preview(subject, sacct) expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( preview['preparation']['layer_count'], expected_crossings)) - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] total_txs = started['plan']['transaction_count'] - completed = self.drive_to_completion(w, node, acct, migration_id) + completed = self.drive_to_completion(subject, sacct, migration_id) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances and denominations INLINE ------------- - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after digits = sorted({_leading_digit(v) for v in note_values}) - print(" source Orchard: {} zat".format(orchard_before)) + print(" source Orchard: {} zat ({} ZEC)".format( + orchard_before, SOURCE_ZEC)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) print(" leading digits: {}".format(digits)) print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( @@ -84,7 +83,7 @@ def run_test(self): assert_true(all(_leading_digit(v) in (1, 2, 5) for v in note_values), "every Ironwood note is a 1-2-5 denomination: {}".format( note_values)) - # One Ironwood note per crossing; value conserved with a bounded fee. + assert_equal(orchard_before, SOURCE_ZAT, "the subject started at 60 ZEC") assert_equal(len(note_values), expected_crossings, "one Ironwood note per crossing") assert_true(ironwood_zat + orchard_after <= orchard_before, diff --git a/qa/rpc-tests/wallet_ironwood_migration_double_start.py b/qa/rpc-tests/wallet_ironwood_migration_double_start.py index 1a34fe9e7..01b5d446a 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_double_start.py +++ b/qa/rpc-tests/wallet_ironwood_migration_double_start.py @@ -10,7 +10,8 @@ # starting another for the same account must be refused: overwriting the # in-progress migration would discard its pre-signed transactions. A wallet UI # that lets the user tap "migrate" twice must not corrupt an in-flight run. The -# guard lifts only once the migration reaches a terminal state. +# guard lifts only once the migration reaches a terminal state. A faucet funds +# the subject with an exact balance. # from test_framework.ironwood_migration_common import ( @@ -21,28 +22,25 @@ ) from test_framework.util import _RPC_EXCEPTIONS, assert_true -SOURCE_NOTE_ZEC = 5 +SOURCE_ZEC = 5 class DoubleStartScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) - self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with EXACTLY {} ZEC...".format( + SOURCE_ZEC)) + self.fund_exact_note(0, SOURCE_ZEC) + self.activate_ironwood() # First start commits the migration (builds and pre-signs it). - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] assert_true(started['plan']['transaction_count'] > 0, "the first start plans at least one transaction") @@ -50,7 +48,7 @@ def run_test(self): # A second start, while the first is in progress, must be refused. try: - getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + getattr(subject, START_RPC)(sacct, FROM_POOL, TO_POOL) except _RPC_EXCEPTIONS as e: message = str(e.error.get('message', '')) print(" second start refused while one is in progress: {!r}. OK" @@ -60,7 +58,7 @@ def run_test(self): "a second start must be refused while a migration is in progress") # The in-progress migration is intact (still the same one). - listed = getattr(w, 'z_listpoolmigrations')() + listed = getattr(subject, 'z_listpoolmigrations')() assert_true(len(listed) == 1, "exactly one migration is in progress after the refused start") print("\nDouble-start guard scenario passed.") diff --git a/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py b/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py index ada0a2519..b4d9be1be 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py +++ b/qa/rpc-tests/wallet_ironwood_migration_dust_heavy.py @@ -8,11 +8,11 @@ # # A wallet whose Orchard balance is fragmented into many sub-funding-value dust # notes stresses the migration's preparation, which must consolidate the dust -# rather than strand it. This scenario funds the account and splits off many -# dust notes, then drives the migration to completion and asserts the exact -# balances inline: the Ironwood note count equals the plan's crossing count, -# every note is positive, value is conserved, and only a sub-dust residual is -# left in Orchard (the dust was swept, not stranded). +# rather than strand it. A faucet funds the subject with one small base note plus +# twelve dust notes (EXACT amounts), then the migration is driven to completion +# and the exact balances are asserted inline: the Ironwood note count equals the +# plan's crossing count, every note is positive, value is conserved, and only a +# sub-dust residual is left in Orchard (the dust was swept, not stranded). # from decimal import Decimal @@ -22,6 +22,7 @@ orchard_notes, ) from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -29,54 +30,51 @@ ironwood_notes, ) -# Split off twelve dust notes (0.02 ZEC each), giving a dust-heavy source. -DUST_NOTES = [Decimal('0.02')] * 12 +# One base note plus twelve dust notes (0.02 ZEC each): a dust-heavy source. +SOURCE_NOTES = [Decimal('1')] + [Decimal('0.02')] * 12 +SOURCE_ZAT = int(sum(SOURCE_NOTES) * COIN) class DustHeavyScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard balance and splitting off dust notes...") - self.fund_orchard_note(node, w, taddr, acct, 1) - orchard_before = self.split_orchard_notes(node, w, acct, DUST_NOTES) - source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print("Faucet funds the subject with a base note plus dust notes...") + orchard_before = self.fund_exact_orchard(0, SOURCE_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(subject)) print(" {} Orchard source notes = {} zat".format( len(source_notes), source_notes)) - assert_true(len(source_notes) >= 8, - "the source is a dust-heavy many-note wallet: {}".format( - len(source_notes))) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") + assert_equal(len(source_notes), len(SOURCE_NOTES), + "the source is a dust-heavy {}-note wallet".format( + len(SOURCE_NOTES))) - self.activate_ironwood(node) - preview = self.preview(w, acct) + self.activate_ironwood() + preview = self.preview(subject, sacct) expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( preview['preparation']['layer_count'], expected_crossings)) - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] total_txs = started['plan']['transaction_count'] print(" started: transaction_count={}".format(total_txs)) - completed = self.drive_to_completion(w, node, acct, migration_id) + completed = self.drive_to_completion(subject, sacct, migration_id) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances INLINE (self-contained) -------------- - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after print(" source Orchard: {} zat".format(orchard_before)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_exchange.py b/qa/rpc-tests/wallet_ironwood_migration_exchange.py index 54e625404..2ab6f6891 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_exchange.py +++ b/qa/rpc-tests/wallet_ironwood_migration_exchange.py @@ -6,19 +6,22 @@ # # Scenario A4: exchange / hot-wallet shape (many Orchard source notes). # -# An exchange hot wallet holds its balance across many notes rather than one. -# This scenario funds the account and then splits it into many distinct Orchard -# notes, so the migration's preparation must consolidate a many-note source. It -# drives the migration to completion and asserts the exact balances inline: the -# Ironwood note count equals the plan's crossing count, every note is positive, -# and value is conserved (source = Ironwood + residual + a bounded fee). +# An exchange hot wallet holds its balance across many notes rather than one. A +# faucet funds the subject with ten distinct 5-ZEC Orchard notes (EXACT), so the +# migration's preparation must consolidate a many-note source. It drives the +# migration to completion and asserts the exact balances inline: the Ironwood +# note count equals the plan's crossing count, every note is positive, and value +# is conserved (source = Ironwood + residual + a bounded fee). # +from decimal import Decimal + from test_framework.ironwood_migration_common import ( IronwoodMigrationScenario, orchard_notes, ) from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -26,54 +29,51 @@ ironwood_notes, ) -# Split the funded balance into ten extra 5-ZEC notes, giving a many-note source. -SPLIT_NOTES = [5] * 10 +# Ten distinct 5-ZEC notes: a many-note hot-wallet source. +SOURCE_NOTES = [Decimal('5')] * 10 +SOURCE_ZAT = int(sum(SOURCE_NOTES) * COIN) class ExchangeScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard balance and splitting it into a many-note " - "shape...") - self.fund_orchard_note(node, w, taddr, acct, 1) - orchard_before = self.split_orchard_notes(node, w, acct, SPLIT_NOTES) - source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print("Faucet funds the subject with {} distinct 5-ZEC notes...".format( + len(SOURCE_NOTES))) + orchard_before = self.fund_exact_orchard(0, SOURCE_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(subject)) print(" {} Orchard source notes = {} zat".format( len(source_notes), source_notes)) - assert_true(len(source_notes) >= 8, - "the source is a many-note wallet: {}".format(len(source_notes))) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") + assert_equal(len(source_notes), len(SOURCE_NOTES), + "the source is a {}-note wallet".format(len(SOURCE_NOTES))) - self.activate_ironwood(node) - preview = self.preview(w, acct) + self.activate_ironwood() + preview = self.preview(subject, sacct) expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( preview['preparation']['layer_count'], expected_crossings)) - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] total_txs = started['plan']['transaction_count'] print(" started: transaction_count={}".format(total_txs)) - completed = self.drive_to_completion(w, node, acct, migration_id) + completed = self.drive_to_completion(subject, sacct, migration_id) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances INLINE (self-contained) -------------- - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after print(" source Orchard: {} zat".format(orchard_before)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_monotonic.py b/qa/rpc-tests/wallet_ironwood_migration_monotonic.py index 6da921435..2509f3e01 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_monotonic.py +++ b/qa/rpc-tests/wallet_ironwood_migration_monotonic.py @@ -10,12 +10,15 @@ # operation progress must only move FORWARD: the count of completed (mined) # transactions never decreases, the phase never regresses from in_progress back # to a pre-start phase, and completion happens exactly when every transaction is -# mined. This scenario drives a multi-layer migration and asserts monotonicity -# at every step. +# mined. A faucet funds the subject with many exact notes (a multi-layer +# migration), and this scenario asserts monotonicity at every step. # +from decimal import Decimal + from test_framework.ironwood_migration_common import IronwoodMigrationScenario from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -23,7 +26,10 @@ ironwood_notes, ) -SOURCE_NOTE_ZEC = 78 +# Many source notes -> a multi-layer, multi-transaction migration to exercise +# progress monotonicity over a long run. +SOURCE_NOTES = [Decimal('12')] * 10 +SOURCE_ZAT = int(sum(SOURCE_NOTES) * COIN) # The ordered lifecycle phases, so a regression can be detected by index. PHASE_ORDER = ['not_started', 'in_progress', 'completed'] @@ -32,23 +38,20 @@ class MonotonicProgressScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard source ({} ZEC)...".format(SOURCE_NOTE_ZEC)) - orchard_before = self.fund_orchard_note(node, w, taddr, acct, - SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with {} exact notes ({} ZEC)...".format( + len(SOURCE_NOTES), int(sum(SOURCE_NOTES)))) + orchard_before = self.fund_exact_orchard(0, SOURCE_NOTES) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") + self.activate_ironwood() - expected_crossings = self.preview(w, acct)['funding_note_count'] - started = self.start(w, acct) + expected_crossings = self.preview(subject, sacct)['funding_note_count'] + started = self.start(subject, sacct) migration_id = started['migration_id'] total = started['plan']['transaction_count'] print(" started: {} transactions, {} crossings.".format( @@ -76,17 +79,17 @@ def on_step(step, adv): assert_true(completed == total, "completion must mean every transaction is mined") - completed = self.drive_to_completion(w, node, acct, migration_id, + completed = self.drive_to_completion(subject, sacct, migration_id, on_step=on_step) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances INLINE (self-contained) -------------- - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after print(" source Orchard: {} zat".format(orchard_before)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_next_actions.py b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py index f44eb7f39..2471107a1 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_next_actions.py +++ b/qa/rpc-tests/wallet_ironwood_migration_next_actions.py @@ -7,22 +7,26 @@ # Scenario B1/B2 + C1: the multi-layer anchor-bucket invariant and the mobile # wallet's machine-readable "what do I sign next" surface. # -# A large Orchard balance fans its preparation out across several dependent -# layers. Because a later layer spends the feeder notes an earlier layer mints, -# and those feeders are witnessable only once that earlier layer is mined, each -# layer must be signed and broadcast in a DIFFERENT anchor bucket: layer N is -# never actionable until its whole predecessor layer (layer N-1) has mined. +# A many-note Orchard balance fans its preparation out across several dependent +# layers (multi-layer preparation is driven by the source note count: many notes +# must be consolidated across layers). Because a later layer spends the feeder +# notes an earlier layer mints, and those feeders are witnessable only once that +# earlier layer is mined, each layer must be signed and broadcast in a DIFFERENT +# anchor bucket: layer N is never actionable until its whole predecessor layer +# (layer N-1) has mined. # -# The migration STATE MACHINE that enforces this lives in the librustzcash -# engine (a mobile wallet drives it directly); this scenario asserts that -# behavior end to end through zallet's regtest RPCs. At EVERY advance step it -# checks the per-transaction status surface for internal consistency and for the -# anchor-bucket ordering, and it records the per-layer state sequence to confirm -# no later layer was signed before its predecessor fully mined. +# The migration STATE MACHINE that enforces this lives in the librustzcash engine +# (a mobile wallet drives it directly); this scenario asserts that behavior end to +# end through zallet's regtest RPCs. A faucet funds the subject with many exact +# notes. At EVERY advance step it checks the per-transaction status surface for +# internal consistency and for the anchor-bucket ordering. # +from decimal import Decimal + from test_framework.ironwood_migration_common import IronwoodMigrationScenario from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -30,34 +34,28 @@ ironwood_notes, ) -# A balance large enough to force a multi-layer preparation (the engine fans a -# whale out across dependent layers). The exact layer count is a function of the -# planner; the scenario only requires more than one layer. -SOURCE_NOTE_ZEC = 78 +# Many source notes force a multi-layer preparation (the engine fans the +# consolidation out across dependent layers). Ten notes yields several layers. +SOURCE_NOTES = [Decimal('12')] * 10 +SOURCE_ZAT = int(sum(SOURCE_NOTES) * COIN) class NextActionsScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - - # Bring the wallet up to the chain tip so account RPCs are accepted. self.sync_all() - - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding a whale Orchard source ({} ZEC)...".format( - SOURCE_NOTE_ZEC)) - orchard_before = self.fund_orchard_note(node, w, taddr, acct, - SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with {} exact notes ({} ZEC)...".format( + len(SOURCE_NOTES), int(sum(SOURCE_NOTES)))) + orchard_before = self.fund_exact_orchard(0, SOURCE_NOTES) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") - preview = self.preview(w, acct) + self.activate_ironwood() + preview = self.preview(subject, sacct) layers = preview['preparation']['layer_count'] expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( @@ -66,7 +64,7 @@ def run_test(self): "this scenario needs a multi-layer preparation to exercise " "the anchor-bucket ordering; got {} layer(s)".format(layers)) - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] total_txs = started['plan']['transaction_count'] print(" started: id={!r} transaction_count={}".format( @@ -75,7 +73,7 @@ def run_test(self): # The freshly started migration must already expose a consistent # next-actions surface: layer 0 ready to broadcast, later layers blocked # on their dependencies. - st = self.assert_next_actions_consistent(w, migration_id) + st = self.assert_next_actions_consistent(subject, migration_id) ready0 = [t for t in st['transactions'] if t['ready']] assert_true(len(ready0) > 0, "at least one layer-0 transaction is ready at start") @@ -93,7 +91,7 @@ def run_test(self): # Drive to completion, asserting the surface's consistency and the # anchor-bucket ordering (both readiness and build order) at every step. def on_step(step, adv): - self.assert_next_actions_consistent(w, migration_id) + self.assert_next_actions_consistent(subject, migration_id) if step % 20 == 0 or adv['phase'] == 'completed': print(" step {}: phase={} {}/{} - {}".format( step, adv['phase'], @@ -102,40 +100,32 @@ def on_step(step, adv): print("Driving the migration, asserting the next-actions surface each " "step...") - completed = self.drive_to_completion(w, node, acct, migration_id, + completed = self.drive_to_completion(subject, sacct, migration_id, on_step=on_step) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances INLINE (self-contained) -------------- - # A reviewer reads this file alone and sees the source balance, every - # individual Ironwood note balance, the count, the residual, and the fee. - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after print(" source Orchard: {} zat".format(orchard_before)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( orchard_after, fees, total_txs)) - # One Ironwood note per scheduled crossing, each with a positive balance. assert_equal(len(note_values), expected_crossings, "one Ironwood note per crossing") assert_true(all(v > 0 for v in note_values), "every Ironwood note holds a positive balance") - # Value is conserved: nothing created, and the fee is non-negative and - # bounded by the transaction count (each transaction pads to at most - # PREP_TX_ACTIONS actions at the ZIP-317 marginal fee; 200000 zat is a - # safe per-transaction ceiling). assert_true(ironwood_zat + orchard_after <= orchard_before, "no value was created") assert_true(0 <= fees <= total_txs * 200000, "fees {} zat within the {} zat bound".format( fees, total_txs * 200000)) - # Only a sub-dust residual (< 0.01 ZEC) remains in Orchard. assert_true(orchard_after < 1000000, "only a sub-dust residual remains in Orchard: {} zat".format( orchard_after)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_nothing.py b/qa/rpc-tests/wallet_ironwood_migration_nothing.py index e984d3837..e75a17205 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_nothing.py +++ b/qa/rpc-tests/wallet_ironwood_migration_nothing.py @@ -10,6 +10,7 @@ # Starting a migration for it must fail cleanly with a clear error, not build an # empty migration, panic, or hang. This is the degenerate input every wallet UI # must handle (the user taps "migrate" on an empty or transparent-only account). +# The subject is simply never funded. # from test_framework.ironwood_migration_common import ( @@ -25,24 +26,21 @@ class NothingToMigrateScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - # Activate NU6.3 without ever funding an Orchard note, so the account has - # no source-pool balance to migrate. - self.activate_ironwood(node) - assert_true(len(orchard_notes(w)) == 0, + # Activate NU6.3 without ever funding the subject, so it has no + # source-pool balance to migrate. + self.activate_ironwood() + assert_true(len(orchard_notes(subject)) == 0, "the account must have no Orchard notes for this scenario") # Start must reject an account with nothing to migrate. try: - getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + getattr(subject, START_RPC)(sacct, FROM_POOL, TO_POOL) except _RPC_EXCEPTIONS as e: message = str(e.error.get('message', '')) print(" start on an empty account rejected: {!r}. OK".format( diff --git a/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py b/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py index 72552cc49..11a9f1dce 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py +++ b/qa/rpc-tests/wallet_ironwood_migration_pre_activation.py @@ -7,10 +7,10 @@ # Scenario D10: starting a migration before NU6.3 is active. # # The Orchard -> Ironwood migration is enabled by NU6.3; attempting it before -# activation must be rejected with a clear error that names the required -# upgrade, not build a migration that could never be mined. The account is -# funded with a real Orchard note (in the Orchard era) but the chain is left -# BELOW the activation height. +# activation must be rejected with a clear error that names the required upgrade, +# not build a migration that could never be mined. A faucet funds the subject +# with a real Orchard note (in the Orchard era) but the chain is left BELOW the +# activation height. # from test_framework.ironwood_migration_common import ( @@ -22,32 +22,28 @@ ) from test_framework.util import _RPC_EXCEPTIONS, assert_true -SOURCE_NOTE_ZEC = 5 +SOURCE_ZEC = 5 class PreActivationScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return - - acct = w.z_listaccounts()[0]['account_uuid'] + sacct = self.subject_account(0) # Fund an Orchard source note, but do NOT cross the activation boundary. - print("Funding an Orchard source ({} ZEC), staying pre-NU6.3..." - .format(SOURCE_NOTE_ZEC)) - self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) - assert_true(node.getblockcount() < IRONWOOD_HEIGHT, + print("Faucet funds the subject with {} ZEC, staying pre-NU6.3..." + .format(SOURCE_ZEC)) + self.fund_exact_note(0, SOURCE_ZEC) + assert_true(self.faucet_node.getblockcount() < IRONWOOD_HEIGHT, "the chain must remain below NU6.3 activation") # Start must be rejected while NU6.3 is not active. try: - getattr(w, START_RPC)(acct, FROM_POOL, TO_POOL) + getattr(subject, START_RPC)(sacct, FROM_POOL, TO_POOL) except _RPC_EXCEPTIONS as e: message = str(e.error.get('message', '')) print(" start before NU6.3 rejected: {!r}. OK".format(message)) diff --git a/qa/rpc-tests/wallet_ironwood_migration_retail.py b/qa/rpc-tests/wallet_ironwood_migration_retail.py new file mode 100644 index 000000000..26dc1bd9d --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_retail.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario A2: retail, one mid-sized note holding an EXACT balance. +# +# A separate faucet wallet funds the subject with exactly 15 ZEC in one Orchard +# note (the faucet keeps its coinbase change, so the subject holds precisely +# 15 ZEC). The subject migrates to completion, exercising the common consumer +# case. The balance checks are inline and EXACT so a reviewer confirms from this +# file alone that a real 15-ZEC holder migrated its 15 ZEC into Ironwood. +# + +from test_framework.ironwood_migration_common import ( + IronwoodMigrationScenario, + orchard_notes, +) +from test_framework.util import ( + COIN, + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# The subject's exact starting balance: a single 15-ZEC Orchard note. +SOURCE_ZEC = 15 +SOURCE_ZAT = SOURCE_ZEC * COIN + + +class RetailScenario(IronwoodMigrationScenario): + + def run_test(self): + self.sync_all() + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): + return + sacct = self.subject_account(0) + + print("Faucet funds the subject with EXACTLY {} ZEC (one Orchard " + "note)...".format(SOURCE_ZEC)) + orchard_before = self.fund_exact_note(0, SOURCE_ZEC) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(subject)) + print(" subject Orchard: {} zat in notes {}".format( + orchard_before, source_notes)) + assert_equal(orchard_before, SOURCE_ZAT, + "the subject holds EXACTLY {} ZEC".format(SOURCE_ZEC)) + assert_equal(len(source_notes), 1, "a single mid-sized source note") + + self.activate_ironwood() + preview = self.preview(subject, sacct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(subject, sacct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + print(" started: transaction_count={}".format(total_txs)) + + completed = self.drive_to_completion(subject, sacct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the EXACT balances INLINE (self-contained) -------------- + notes = ironwood_notes(subject) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat ({} ZEC)".format( + orchard_before, SOURCE_ZEC)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + assert_equal(orchard_before, SOURCE_ZAT, "the subject started at 15 ZEC") + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print("\nRetail scenario passed.") + + +if __name__ == '__main__': + RetailScenario().main() diff --git a/qa/rpc-tests/wallet_ironwood_migration_schedule.py b/qa/rpc-tests/wallet_ironwood_migration_schedule.py index 6f1cb4fea..742de8c5f 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_schedule.py +++ b/qa/rpc-tests/wallet_ironwood_migration_schedule.py @@ -7,39 +7,36 @@ # Scenario E1: the transfer broadcast schedule is spread for privacy. # # To avoid linking the crossings, the migration does not broadcast every transfer -# at once: each is scheduled at a different height across a window. This scenario -# starts a migration and asserts inline that the transfer transactions carry -# distinct scheduled heights spanning a range of blocks, so a driver broadcasts -# them spread out rather than in a single burst. +# at once: each is scheduled at a different height across a window. A faucet funds +# the subject with an EXACT balance; the scenario starts a migration and asserts +# inline that the transfer transactions carry distinct scheduled heights spanning +# a range of blocks, so a driver broadcasts them spread out rather than at once. # from test_framework.ironwood_migration_common import IronwoodMigrationScenario from test_framework.util import assert_true -SOURCE_NOTE_ZEC = 60 +SOURCE_ZEC = 60 class ScheduleScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard balance...") - self.fund_orchard_note(node, w, taddr, acct, SOURCE_NOTE_ZEC) - self.activate_ironwood(node) + print("Faucet funds the subject with EXACTLY {} ZEC...".format( + SOURCE_ZEC)) + self.fund_exact_note(0, SOURCE_ZEC) + self.activate_ironwood() - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] - st = self.status(w, migration_id) + st = self.status(subject, migration_id) transfers = [t for t in st['transactions'] if t['kind'] == 'transfer'] heights = sorted(t['scheduled_height'] for t in transfers) print(" {} transfers; scheduled heights: {}".format( diff --git a/qa/rpc-tests/wallet_ironwood_migration_small_holder.py b/qa/rpc-tests/wallet_ironwood_migration_small_holder.py new file mode 100644 index 000000000..359adcb57 --- /dev/null +++ b/qa/rpc-tests/wallet_ironwood_migration_small_holder.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Zcash developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or https://www.opensource.org/licenses/mit-license.php . + +# +# Scenario A1: small holder, a single clean note holding an EXACT small balance. +# +# A separate faucet wallet funds the subject with exactly 2 ZEC in one Orchard +# note (the faucet keeps its coinbase change, so the subject holds precisely +# 2 ZEC, not the whole coinbase). The subject migrates to completion. The balance +# checks are inline and EXACT so a reviewer confirms from this file alone that a +# real 2-ZEC holder migrated its 2 ZEC into Ironwood. +# + +from test_framework.ironwood_migration_common import ( + IronwoodMigrationScenario, + orchard_notes, +) +from test_framework.util import ( + COIN, + Pool, + account_spendable_zat, + assert_equal, + assert_true, + ironwood_notes, +) + +# The subject's exact starting balance: a single clean 2-ZEC Orchard note. +SOURCE_ZEC = 2 +SOURCE_ZAT = SOURCE_ZEC * COIN + + +class SmallHolderScenario(IronwoodMigrationScenario): + + def run_test(self): + self.sync_all() + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): + return + sacct = self.subject_account(0) + + print("Faucet funds the subject with EXACTLY {} ZEC (one Orchard " + "note)...".format(SOURCE_ZEC)) + orchard_before = self.fund_exact_note(0, SOURCE_ZEC) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(subject)) + print(" subject Orchard: {} zat in notes {}".format( + orchard_before, source_notes)) + assert_equal(orchard_before, SOURCE_ZAT, + "the subject holds EXACTLY {} ZEC, not the whole coinbase" + .format(SOURCE_ZEC)) + assert_equal(len(source_notes), 1, "a single clean source note") + + self.activate_ironwood() + preview = self.preview(subject, sacct) + expected_crossings = preview['funding_note_count'] + print(" preview: {} layer(s), {} funding note(s).".format( + preview['preparation']['layer_count'], expected_crossings)) + + started = self.start(subject, sacct) + migration_id = started['migration_id'] + total_txs = started['plan']['transaction_count'] + print(" started: transaction_count={}".format(total_txs)) + + completed = self.drive_to_completion(subject, sacct, migration_id) + assert_true(completed, + "the migration completed within {} advance steps".format( + self.MAX_ADVANCE_STEPS)) + + # ---- assert the EXACT balances INLINE (self-contained) -------------- + notes = ironwood_notes(subject) + note_values = sorted(int(n['valueZat']) for n in notes) + ironwood_zat = sum(note_values) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) + fees = orchard_before - ironwood_zat - orchard_after + print(" source Orchard: {} zat ({} ZEC)".format( + orchard_before, SOURCE_ZEC)) + print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) + print(" Orchard residual: {} zat; fees: {} zat over {} txs".format( + orchard_after, fees, total_txs)) + + # A genuine 2-ZEC migration: one Ironwood note per crossing, each + # positive, value conserved, and fees within the per-transaction bound. + assert_equal(orchard_before, SOURCE_ZAT, "the subject started at 2 ZEC") + assert_equal(len(note_values), expected_crossings, + "one Ironwood note per crossing") + assert_true(all(v > 0 for v in note_values), + "every Ironwood note holds a positive balance") + assert_true(ironwood_zat + orchard_after <= orchard_before, + "no value was created") + assert_true(0 <= fees <= total_txs * 200000, + "fees {} zat within the {} zat bound".format( + fees, total_txs * 200000)) + assert_true(orchard_after < 1000000, + "only a sub-dust residual remains in Orchard: {} zat".format( + orchard_after)) + print("\nSmall-holder scenario passed.") + + +if __name__ == '__main__': + SmallHolderScenario().main() diff --git a/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py b/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py index f5164a979..f6e4ab7a5 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py +++ b/qa/rpc-tests/wallet_ironwood_migration_unknown_id.py @@ -9,7 +9,7 @@ # The read and drive methods must reject an id that does not name the wallet's # migration, both when no migration exists and when one is in progress. A wallet # that polls or resumes with a stale or wrong id must get a clear error, never a -# crash or a silent no-op that looks like success. +# crash or a silent no-op that looks like success. A faucet funds the subject. # from test_framework.ironwood_migration_common import ( @@ -43,29 +43,25 @@ def _expect_rejected(self, w, acct, label): name, label)) def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return - - acct = w.z_listaccounts()[0]['account_uuid'] + sacct = self.subject_account(0) # (a) No migration exists yet: an unknown id is rejected. print("Probing an unknown id with no migration in progress...") - self._expect_rejected(w, acct, "no migration in progress") + self._expect_rejected(subject, sacct, "no migration in progress") # (b) A migration is in progress: an id that is not it is still rejected. print("Funding and starting a migration, then probing a wrong id...") - self.fund_orchard_note(node, w, taddr, acct, 5) - self.activate_ironwood(node) - self.start(w, acct) - self._expect_rejected(w, acct, "wrong id while one is in progress") + self.fund_exact_note(0, 5) + self.activate_ironwood() + self.start(subject, sacct) + self._expect_rejected(subject, sacct, "wrong id while one is in progress") # The real migration is still addressable by its own id. - listed = getattr(w, 'z_listpoolmigrations')() + listed = getattr(subject, 'z_listpoolmigrations')() assert_true(len(listed) == 1, "the real migration is unaffected by the wrong-id probes") print("\nUnknown-id scenario passed.") diff --git a/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py b/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py index 52df2cce8..4263cbecd 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py +++ b/qa/rpc-tests/wallet_ironwood_migration_whale_plus_dust.py @@ -7,10 +7,10 @@ # Scenario A6: a whale note plus a tail of dust. # # A common real shape: one large Orchard note alongside several tiny ones. The -# migration must both fan the whale out and consolidate the dust in the same -# run. This scenario builds that shape by splitting off one large note and a -# handful of dust notes, then drives the migration to completion and asserts the -# exact balances inline. +# migration must both fan the whale out and consolidate the dust in the same run. +# A faucet funds the subject with one large note plus a dust tail (EXACT +# amounts), then the migration is driven to completion and the exact balances are +# asserted inline. # from decimal import Decimal @@ -20,6 +20,7 @@ orchard_notes, ) from test_framework.util import ( + COIN, Pool, account_spendable_zat, assert_equal, @@ -27,56 +28,52 @@ ironwood_notes, ) -# One whale note plus a dust tail, split off the funded balance. -SPLIT_NOTES = [40, Decimal('0.02'), Decimal('0.02'), Decimal('0.05'), - Decimal('0.05'), Decimal('0.1'), Decimal('0.1')] +# One whale note plus a dust tail. +SOURCE_NOTES = [Decimal('40'), Decimal('0.02'), Decimal('0.02'), + Decimal('0.05'), Decimal('0.05'), Decimal('0.1'), Decimal('0.1')] +SOURCE_ZAT = int(sum(SOURCE_NOTES) * COIN) class WhalePlusDustScenario(IronwoodMigrationScenario): def run_test(self): - node = self.nodes[0] - w = self.wallets[0] - taddr = self.miner_addresses[0] - self.sync_all() - if self.skip_if_rpcs_absent(w): + subject = self.subject(0) + if self.skip_if_rpcs_absent(subject): return + sacct = self.subject_account(0) - acct = w.z_listaccounts()[0]['account_uuid'] - - print("Funding an Orchard balance and splitting a whale note plus a " - "dust tail...") - self.fund_orchard_note(node, w, taddr, acct, 1) - orchard_before = self.split_orchard_notes(node, w, acct, SPLIT_NOTES) - source_notes = sorted(int(n['valueZat']) for n in orchard_notes(w)) + print("Faucet funds the subject with a whale note plus a dust tail...") + orchard_before = self.fund_exact_orchard(0, SOURCE_NOTES) + source_notes = sorted(int(n['valueZat']) for n in orchard_notes(subject)) print(" {} Orchard source notes = {} zat".format( len(source_notes), source_notes)) - assert_true(len(source_notes) >= 8, - "the source is a many-note whale+dust wallet: {}".format( - len(source_notes))) + assert_equal(orchard_before, SOURCE_ZAT, "exact source balance") + assert_equal(len(source_notes), len(SOURCE_NOTES), + "a whale note plus a {}-note dust tail".format( + len(SOURCE_NOTES) - 1)) - self.activate_ironwood(node) - preview = self.preview(w, acct) + self.activate_ironwood() + preview = self.preview(subject, sacct) expected_crossings = preview['funding_note_count'] print(" preview: {} layer(s), {} funding note(s).".format( preview['preparation']['layer_count'], expected_crossings)) - started = self.start(w, acct) + started = self.start(subject, sacct) migration_id = started['migration_id'] total_txs = started['plan']['transaction_count'] print(" started: transaction_count={}".format(total_txs)) - completed = self.drive_to_completion(w, node, acct, migration_id) + completed = self.drive_to_completion(subject, sacct, migration_id) assert_true(completed, "the migration completed within {} advance steps".format( self.MAX_ADVANCE_STEPS)) # ---- assert the exact balances INLINE (self-contained) -------------- - notes = ironwood_notes(w) + notes = ironwood_notes(subject) note_values = sorted(int(n['valueZat']) for n in notes) ironwood_zat = sum(note_values) - orchard_after = account_spendable_zat(w, acct, Pool.ORCHARD) + orchard_after = account_spendable_zat(subject, sacct, Pool.ORCHARD) fees = orchard_before - ironwood_zat - orchard_after print(" source Orchard: {} zat".format(orchard_before)) print(" Ironwood notes: {} = {} zat".format(note_values, ironwood_zat)) From a16200a4539c0c35f812805d3ed94225184769c1 Mon Sep 17 00:00:00 2001 From: Danny Willems Date: Sun, 19 Jul 2026 17:50:18 +0200 Subject: [PATCH 29/29] wallet_ironwood_migration: host every wallet on one shared node Replace the multi-node topology (one node per wallet, peered) with a single shared node that every wallet follows. The framework binds one node per wallet, so the faucet-plus-subjects model previously needed several peered zebra regtest nodes; those do not converge past two (multi-peer block-propagation bug, zebra #10329 / #10332), which forced a fragile star-topology-plus-reconnect workaround. Instead, run ONE node and point every wallet's zallet config at it: setup_wallets starts each wallet against node 0's validator RPC, gRPC indexer, and read-only state directory (each wallet keeps its own data directory and RPC port). Multiple zallets opening the node's zebra state read-only is fine (RocksDB allows concurrent read-only opens), and since node 0 mines every block and its mempool already holds each wallet's broadcast, there is no inter-node peering to stall and no cross-node propagation to sync. This makes the many-subject concurrent migration reliable: the scenario now runs three parties (small holder, retail, and a multi-layer whale) at once, each migrating its exact balance to a sub-dust residual. A custom sync_all waits for every wallet's committed tip to equal the shared node's tip; the framework's sync_blocks cannot be used with several wallets on one node because it compares a length-1 node-tip list against a length-N wallet-tip list (it assumes one wallet per node) and so never matches even when all tips are equal. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ironwood_migration_common.py | 152 ++++++++++++------ .../wallet_ironwood_migration_concurrent.py | 7 +- 2 files changed, 108 insertions(+), 51 deletions(-) diff --git a/qa/rpc-tests/test_framework/ironwood_migration_common.py b/qa/rpc-tests/test_framework/ironwood_migration_common.py index b920fdd38..193514dbd 100644 --- a/qa/rpc-tests/test_framework/ironwood_migration_common.py +++ b/qa/rpc-tests/test_framework/ironwood_migration_common.py @@ -31,6 +31,7 @@ machine-readable NEXT-ACTIONS surface a mobile wallet renders. """ +import time from decimal import Decimal from test_framework.util import ( @@ -40,12 +41,9 @@ _RPC_EXCEPTIONS, account_spendable_zat, assert_true, - connect_nodes_bi, ironwood_notes, nu_activation_ironwood_at, shield_coinbase, - sync_blocks_with_reconnect, - sync_mempools, wait_account_settled, wait_for_account_spendable, wait_for_tx_scanned, @@ -90,7 +88,7 @@ class IronwoodMigrationScenario(IronwoodTestFramework): """ # How many SUBJECT wallets the scenario migrates (in addition to the faucet - # wallet 0). One faucet plus `NUM_SUBJECTS` subjects share one peered chain. + # wallet 0). One faucet plus `NUM_SUBJECTS` subjects all share ONE node. NUM_SUBJECTS = 1 # A generous cap: the migration advances one transaction per call, and the @@ -103,10 +101,13 @@ class IronwoodMigrationScenario(IronwoodTestFramework): def __init__(self): super().__init__() - # One node per wallet (each wallet indexes into its own node), all - # peered onto one chain by the base framework: wallet 0 is the faucet, - # wallets 1.. are subjects. - self.num_nodes = 1 + self.NUM_SUBJECTS + # A SINGLE shared node hosts every wallet. The framework normally binds + # one node per wallet, but multiple peered zebra regtest nodes do not + # converge past two (multi-peer propagation bug, zebra #10329 / #10332). + # Instead all wallets follow ONE node (node 0) and submit to its mempool, + # so there is no inter-node peering to stall: wallet 0 is the faucet (and + # node 0 mines to its address); wallets 1.. are subjects. + self.num_nodes = 1 self.num_wallets = 1 + self.NUM_SUBJECTS # Deferred activation gives an Orchard era in which to mint the source # notes the migration consumes. @@ -115,32 +116,91 @@ def __init__(self): self._faucet_ready = False def setup_network(self, split=False, do_mempool_sync=True): - """Peer every node directly to the faucet node (node 0) in a STAR, rather - than the base framework's linear CHAIN (0-1-2-3). The faucet mines every - block, so a star lets each subject node receive the faucet's blocks in one - hop; a multi-hop chain stalls on the zebra regtest backend (its - multi-peer block propagation does not converge past two nodes: zebra - #10329 / #10332), which is what makes a many-subject topology viable.""" + """Single-node setup: start the one shared node, then start every wallet + against it (see setup_wallets). A freshly started wallet does not report a + committed wallet_tip until it has scanned a block, so mine one block and + wait for every wallet to reach the node's tip before the scenario runs. + There is no peering (one node), so no cross-node sync is involved.""" self.prepare_wallets() self.nodes = self.setup_nodes() - for i in range(1, len(self.nodes)): - connect_nodes_bi(self.nodes, 0, i) self.is_network_split = split self.prepare_chain() - self.sync_all(do_mempool_sync) self.zainos = self.setup_indexers() self.wallets = self.setup_wallets() + self.nodes[0].generate(1) + self.sync_all() def sync_all(self, do_mempool_sync=True): - """Converge every node's chain on the faucet node's (node 0), retrying - with peer reconnects. zebra's regtest multi-peer block propagation - intermittently stalls a subject node's download (zebra #10329 / #10332), - so a plain one-shot sync flakes with more than two nodes; reconnecting - the subject nodes to the faucet and retrying is what makes a - many-subject topology converge reliably.""" - sync_blocks_with_reconnect(self.nodes, 0) - if do_mempool_sync: - sync_mempools(self.nodes, self.wallets) + """Wait until every wallet has committed the shared node's tip. The + framework's `sync_blocks` cannot be used here: it compares a length-1 + node-tip list against a length-N wallet-tip list (it assumes one wallet + per node), so it never matches when N wallets share one node, even though + each wallet's tip does equal the node's. No mempool sync is needed either + (every wallet submits to the single node's mempool, which mines it).""" + node = self.nodes[0] + deadline = time.time() + 90 + while time.time() < deadline: + tip = node.getbestblockhash() + synced = True + for w in (self.wallets or []): + try: + wt = w.getwalletstatus().get('wallet_tip') + except _RPC_EXCEPTIONS: + synced = False + break + if not wt or wt.get('blockhash') != tip: + synced = False + break + if synced: + return + time.sleep(0.25) + raise AssertionError("wallets did not reach the node tip") + + def setup_wallets(self): + """Start every wallet against the SINGLE shared node (node 0), rather than + the framework's default one-node-per-wallet binding. All wallets read node + 0's state (read-only) and submit to its validator RPC, so they share one + chain and one mempool with no inter-node peering (which is what avoids the + zebra regtest multi-peer bug). Node 0 mines every block and its mempool + already holds each wallet's broadcast, so no cross-node propagation is + needed. Each wallet keeps its OWN data directory and RPC port; only the + validator connection (RPC, gRPC indexer, and read-only state path) is + shared. The wallet databases were already created by `prepare_wallets`.""" + import subprocess + + from test_framework.config import ZalletArgs + from test_framework.util import ( + get_rpc_auth_proxy, + indexer_rpc_port, + node_dir, + rpc_port, + rpc_url_wallet, + update_zallet_conf, + wait_for_wallet_start, + wallet_dir, + wallet_rpc_port, + zallet_binary, + zallet_processes, + ) + + dirname = self.options.tmpdir + binary = zallet_binary() + wallets = [] + for i in range(self.num_wallets): + datadir = wallet_dir(dirname, i) + zallet_args = ZalletArgs(activation_heights=self.activation_heights) + # Point this wallet at node 0's validator RPC, gRPC indexer, and + # read-only state directory (all wallets share node 0). + update_zallet_conf( + datadir, rpc_port(0), wallet_rpc_port(i), zallet_args, + indexer_port=indexer_rpc_port(0), + zebra_state_dir=node_dir(dirname, 0)) + zallet_processes[i] = subprocess.Popen( + [binary, "-d=" + datadir, "start"]) + url = rpc_url_wallet(i) + wait_for_wallet_start(zallet_processes[i], url, i) + wallets.append(get_rpc_auth_proxy(url, i)) + return wallets # ---- faucet / subject accessors ---------------------------------------- @@ -264,8 +324,8 @@ def fund_exact_note(self, subject_idx, zec): def activate_ironwood(self): """Advance the chain past the NU6.3 activation height so the migration is - allowed (the source notes must already be minted). Mines on the faucet - node and syncs every peer.""" + allowed (the source notes must already be minted). Mines on the shared + node and waits for every wallet to reach the tip.""" node = self.faucet_node while node.getblockcount() < IRONWOOD_HEIGHT: node.generate(1) @@ -290,16 +350,13 @@ def advance(self, w, acct, migration_id): return getattr(w, ADVANCE_RPC)(acct, migration_id) def drive_to_completion(self, w, acct, migration_id, on_step=None): - """Advance one subject's migration one step per call, mining on the - faucet node and syncing between steps, until it completes or the step - cap is reached. `on_step(step, adv)` runs after each advance. Returns - True if the migration completed. - - The subject broadcasts to its OWN node, but mining happens on the faucet - node, so mempools are synced BEFORE mining (`sync_all` propagates the - just-broadcast transaction to the miner) and again after (to carry the - block back to the subject); otherwise the miner would produce blocks - without the subject's transaction and it would never confirm.""" + """Advance one subject's migration one step per call, mining on the shared + node and letting the wallets catch up between steps, until it completes or + the step cap is reached. `on_step(step, adv)` runs after each advance. + Returns True if the migration completed. All wallets share node 0, so the + subject's just-broadcast transaction is already in the node's mempool when + it mines; only a post-mine `sync_all` (to let the wallet see the block) is + needed.""" node = self.faucet_node for step in range(self.MAX_ADVANCE_STEPS): adv = self.advance(w, acct, migration_id) @@ -307,20 +364,19 @@ def drive_to_completion(self, w, acct, migration_id, on_step=None): on_step(step, adv) if adv['phase'] == 'completed': return True - self.sync_all() node.generate(self.ADVANCE_MINE_BLOCKS) self.sync_all() wait_account_settled(w, acct) return False def drive_many_to_completion(self, subjects, on_step=None): - """Drive SEVERAL subjects' migrations CONCURRENTLY on one shared chain: - round-robin one advance step per still-running subject, then mine once on - the faucet node and sync, so every subject's schedule advances together - (as they would on the real network). `subjects` is a list of - `(wallet, account, migration_id)` tuples. `on_step(step, idx, adv)` runs - after each subject's advance. Returns a list of per-subject completion - booleans.""" + """Drive SEVERAL subjects' migrations CONCURRENTLY on the one shared node: + round-robin one advance step per still-running subject, then mine one + batch that confirms every subject's just-broadcast transaction together + (they all submit to the same node's mempool), and let the wallets catch + up. `subjects` is a list of `(wallet, account, migration_id)` tuples. + `on_step(step, idx, adv)` runs after each subject's advance. Returns a + list of per-subject completion booleans.""" node = self.faucet_node done = [False] * len(subjects) for step in range(self.MAX_ADVANCE_STEPS): @@ -334,10 +390,6 @@ def drive_many_to_completion(self, subjects, on_step=None): done[idx] = True if all(done): return done - # Propagate every subject's just-broadcast transaction to the miner - # before mining, mine one batch that confirms them all together, then - # carry the block back to every subject. - self.sync_all() node.generate(self.ADVANCE_MINE_BLOCKS) self.sync_all() for w, acct, _ in subjects: diff --git a/qa/rpc-tests/wallet_ironwood_migration_concurrent.py b/qa/rpc-tests/wallet_ironwood_migration_concurrent.py index 6f82e7c8d..f58987b90 100644 --- a/qa/rpc-tests/wallet_ironwood_migration_concurrent.py +++ b/qa/rpc-tests/wallet_ironwood_migration_concurrent.py @@ -33,15 +33,20 @@ # Three parties with distinct, faithful balances. The whale is many notes so its # preparation fans across several dependent layers, exercising the anchor-bucket # ordering concurrently with the two single-layer migrations. +# Amounts chosen so each party's balance decomposes fully into 1-2-5 crossings +# (a sub-dust residual): the whale's ten 12-ZEC notes force a multi-layer +# preparation, and the three together stay under the single shielded coinbase the +# faucet holds (~137 ZEC). PARTIES = [ ("small holder", [Decimal('2')]), + ("retail", [Decimal('12')]), ("whale", [Decimal('12')] * 10), ] class ConcurrentMigrationScenario(IronwoodMigrationScenario): - NUM_SUBJECTS = 2 + NUM_SUBJECTS = 3 def run_test(self): self.sync_all()