Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
e981cf1
test framework: scaffold the Orchard -> Ironwood migration test
dannywillems Jul 16, 2026
c5671ec
CHANGELOG: Ironwood migration test scaffolding
dannywillems Jul 16, 2026
9b888f2
test framework: target the generic pool-migration RPC in the migratio…
dannywillems Jul 16, 2026
887df6b
qa: add wallet_ironwood_migration_preview RPC test
dannywillems Jul 16, 2026
f8bf843
qa: update wallet_ironwood_migration_preview for the engine plan API
dannywillems Jul 17, 2026
2857b7c
CHANGELOG: wallet_ironwood_migration_preview and engine crate rename
dannywillems Jul 17, 2026
13ac3c3
qa: strengthen the Ironwood migration preview denomination checks
dannywillems Jul 18, 2026
c69fee6
AGENTS: require named constants (no magic numbers)
dannywillems Jul 18, 2026
a9e1c69
wallet_ironwood_migration_preview: add real-world preview scenarios
dannywillems Jul 18, 2026
c476b03
wallet_ironwood_migration_preview: cover activation, account index, m…
dannywillems Jul 18, 2026
ef3af01
wallet_ironwood_migration: assert status/list surface, fix stage-1 ti…
dannywillems Jul 18, 2026
16a6128
wallet_ironwood_migration[_preview]: use fixed RPC method names
dannywillems Jul 18, 2026
25af534
wallet_ironwood_migration: drive the funded lifecycle (start + advance)
dannywillems Jul 18, 2026
56a371e
wallet_ironwood_migration: drive multi-layer preparations end to end
dannywillems Jul 18, 2026
513b7da
wallet_ironwood_migration: raise drive budget; add scenario harness
dannywillems Jul 18, 2026
e13f69a
wallet_ironwood_migration: add nothing-to-migrate and double-start sc…
dannywillems Jul 18, 2026
5098947
wallet_ironwood_migration: add unknown-migration-id scenario
dannywillems Jul 18, 2026
8410932
wallet_ironwood_migration: add next-actions / anchor-bucket scenario
dannywillems Jul 18, 2026
8085f35
wallet_ironwood_migration: add pre-activation scenario
dannywillems Jul 18, 2026
ff0ff35
wallet_ironwood_migration: add cancel scenario (found a real engine bug)
dannywillems Jul 18, 2026
3fce0a7
wallet_ironwood_migration: inline explicit balance and note checks
dannywillems Jul 18, 2026
b7f2cfa
wallet_ironwood_migration: add monotonic-progress scenario + split he…
dannywillems Jul 19, 2026
31dbbb1
wallet_ironwood_migration: add exchange (many-note source) scenario
dannywillems Jul 19, 2026
f015cde
wallet_ironwood_migration: add dust-heavy source scenario
dannywillems Jul 19, 2026
3ef7544
wallet_ironwood_migration: add whale-plus-dust source scenario
dannywillems Jul 19, 2026
bf083eb
wallet_ironwood_migration: add schedule and denomination scenarios
dannywillems Jul 19, 2026
3d34fa2
rpc-tests: activate the Orchard -> Ironwood migration scenario suite
dannywillems Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions qa/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ 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_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.

- `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.
Expand Down
18 changes: 18 additions & 0 deletions qa/pull-tester/rpc-tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,24 @@
# 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_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
Expand Down
316 changes: 316 additions & 0 deletions qa/rpc-tests/test_framework/ironwood_migration_common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,316 @@
#!/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)
# 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")

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):
"""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)."""
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 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 --------------

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, 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']
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_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
Loading
Loading