diff --git a/qa/rpc-tests/wallet_z_shieldcoinbase.py b/qa/rpc-tests/wallet_z_shieldcoinbase.py index 96219884f..e40dbf71d 100755 --- a/qa/rpc-tests/wallet_z_shieldcoinbase.py +++ b/qa/rpc-tests/wallet_z_shieldcoinbase.py @@ -56,55 +56,54 @@ def mature_transparent_utxos(wallet): return [u for u in utxos if u.get('pool') == 'transparent'] -def wait_for_mature_coinbase_count(wallet, expected_count, timeout=240): +# Seconds the mature-coinbase count must hold steady before a sweep. +# z_listunspent (tip-change-driven) shows new coinbase before zallet's +# recover_history scan task (30s idle tick, not woken on tip change) makes +# it spendable to z_shieldcoinbase, so the window must outlast that tick. +# Drop once recover_history wakes on tip change / a sync RPC lands (#316). +COINBASE_SETTLE_SECS = 35 + + +def wait_for_mature_coinbase_count(wallet, expected_count, + timeout=300, settle_secs=COINBASE_SETTLE_SECS): """ - Wait until the wallet's view of mature transparent coinbase UTXOs is - exactly `expected_count`, and return that snapshot. - - Used as a sync barrier before each sweep. With no mining in flight and - no in-flight tx for the wallet to ingest, the wallet's mature-coinbase - set converges from below to its final value as it scans toward the - chain tip. Pinning the exact post-sync count lets later assertions on - `shieldingUTXOs` / `shieldingValue` be exact rather than ranged. - - Zallet's `z_listunspent` reflects only what the wallet has scanned and - committed to its local SQLite database; after `node.generate(N)` there - is a non-trivial delay (block fetch + scan + commit) before the view - updates, and `sync_all` does not synchronize wallets (no - `getwalletstatus` RPC yet: https://github.com/zcash/wallet/issues/316). + Return the wallet's mature transparent coinbase UTXOs once the count has + held at `expected_count` for `settle_secs` consecutive seconds. + + The steady-count requirement is the sync barrier: z_listunspent reaches a + new tip before the proposal builder's spendable view does, so we wait for + the views to converge (no getwalletstatus RPC yet, zcash/wallet#316). """ deadline = time.time() + timeout last_count = -1 + stable_secs = 0 transparent = [] while time.time() < deadline: try: transparent = mature_transparent_utxos(wallet) - last_count = len(transparent) - if last_count == expected_count: + count = len(transparent) + if count == expected_count and last_count == expected_count: + stable_secs += 1 + else: + stable_secs = 0 + last_count = count + if count == expected_count and stable_secs >= settle_secs: return transparent except Exception: pass time.sleep(1) raise AssertionError( - "wait_for_mature_coinbase_count: timeout after {}s; saw {} mature " - "transparent UTXOs (wanted exactly {})".format( - timeout, last_count, expected_count)) + "wait_for_mature_coinbase_count: timeout after {}s; last saw {} mature " + "transparent UTXOs (wanted exactly {} stable for {}s)".format( + timeout, last_count, expected_count, settle_secs)) def wait_for_tx_scanned(wallet, txid, timeout=120): """ - Wait until the wallet has scanned the block containing `txid`, then - return its `z_viewtransaction` view. - - Acts as the single post-confirm sync barrier for a sweep. Once the - wallet exposes the tx via `z_viewtransaction` with a populated `fee` - field, the same scan has updated every other view that depends on - it (`z_gettotalbalance`, `z_listunspent`, ...), so they can be read - synchronously without a second wait. - - Until `getwalletstatus` lands (zcash/wallet#316) there is no - synchronous wallet-sync primitive; this poll is the workaround. + Return `txid`'s `z_viewtransaction` view once it carries a `fee`, i.e. the + confirming block is scanned. Other scan-dependent views (balance, + z_listunspent) are then current too, so they can be read without a wait. """ deadline = time.time() + timeout last_err = None @@ -328,49 +327,48 @@ def _first_transparent_receiver(wallet, ua): def run_functional_tests(self, node, w0, w0_taddr, w0_account_uuid, w0_zaddr, w0_extra_zaddr): - # Running tally of mature coinbase UTXOs spent so far. Combined - # with `node.getblockcount()`, this pins the exact size of the - # unspent-mature set the wallet should observe before each sweep: - # - # expected = (tip - COINBASE_MATURITY) - mature_spent - # - # `tip - COINBASE_MATURITY` is the chain's all-time mature-coinbase - # count given all blocks are coinbase to `w0_taddr` and our - # confirmation filter is `minconf = COINBASE_MATURITY + 1`. The - # wallet's view converges to this from below as it scans; the - # sync-barrier helper waits for that convergence so the count - # assertions below are exact rather than ranged. + # Expected unspent-mature COUNT before each sweep: all coinbase goes + # to w0_taddr, so it's (tip - COINBASE_MATURITY) minus what we've spent. + # Counts are reliable; VALUES are not derived from the snapshot: + # z_listunspent and the proposal use different maturity tips, so across + # the regtest subsidy halving (6.25 -> 3.125) a summed snapshot can + # disagree with the reported value. Value assertions therefore use the + # operation's own shieldingValue and the balance delta instead. mature_spent = 0 def expected_unspent_mature(): return node.getblockcount() - COINBASE_MATURITY - mature_spent + def confirm_and_check_balance(txid, pre_private, shielding_value): + """Confirm the sweep and assert balance grew by value - fee. + Returns (fee, post_private, tx_details).""" + node.generate(1) + tx_details = wait_for_tx_scanned(w0, txid) + fee = Decimal(tx_details['fee']) + post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) + assert_equal(post_private, pre_private + shielding_value - fee) + return fee, post_private, tx_details + # ---- F1: explicit single-t-addr sweep ----------------------- print("Test F1: explicit single-t-addr sweep (response shape + balance moves)...") pre_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_expected = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) - print(" [diag] mature coinbase UTXO count={}, total {}".format( - n_expected, expected_value)) + n_expected = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) + print(" [diag] mature coinbase UTXO count={}".format(n_expected)) result = w0.z_shieldcoinbase(w0_taddr, w0_zaddr) self._assert_preflight_shape(result) assert_equal(result['shieldingUTXOs'], n_expected) - assert_equal(Decimal(result['shieldingValue']), expected_value) assert_equal(result['remainingUTXOs'], 0) assert_equal(Decimal(result['remainingValue']), Decimal('0')) + shielding_value = Decimal(result['shieldingValue']) + assert_true(shielding_value > 0, "Expected positive shielding value") txid = wait_and_assert_operationid_status(w0, result['opid']) assert_true(txid is not None, "Shielding tx should have succeeded") print(" Shielding tx: {}".format(txid)) - node.generate(1) - fee = Decimal(wait_for_tx_scanned(w0, txid)['fee']) - expected_post = pre_private + expected_value - fee - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) + fee, post_private, _ = confirm_and_check_balance(txid, pre_private, shielding_value) mature_spent += n_expected print(" Balance {} -> {} ZEC (fee {}). PASSED".format( pre_private, post_private, fee)) @@ -378,31 +376,22 @@ def expected_unspent_mature(): # ---- F2: account-UUID sweep --------------------------------- print("Test F2: account-UUID sweep (happy path)...") - # The UUID form resolves via `get_transparent_receivers(account, true, true)`, - # which (with `include_change=true`) returns both EXTERNAL and INTERNAL - # transparent receivers of the account's registered UAs. The mining - # address provisioned by `generate-account-and-miner-address` is at - # `KeyScope::INTERNAL`, so it is reachable through this path. + # UUID form sweeps every transparent receiver of the account, + # including the INTERNAL-scope mining address. node.generate(COINBASE_MATURITY + 10) pre_private = post_private - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_expected = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) + n_expected = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) result = w0.z_shieldcoinbase(w0_account_uuid, w0_zaddr) self._assert_preflight_shape(result) assert_equal(result['shieldingUTXOs'], n_expected, "UUID-form should sweep every mature coinbase") - assert_equal(Decimal(result['shieldingValue']), expected_value) assert_equal(result['remainingUTXOs'], 0) assert_equal(Decimal(result['remainingValue']), Decimal('0')) + shielding_value = Decimal(result['shieldingValue']) txid = wait_and_assert_operationid_status(w0, result['opid']) assert_true(txid is not None) - node.generate(1) - fee = Decimal(wait_for_tx_scanned(w0, txid)['fee']) - expected_post = pre_private + expected_value - fee - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) + fee, post_private, _ = confirm_and_check_balance(txid, pre_private, shielding_value) mature_spent += n_expected print(" PASSED ({} UTXOs swept, fee {})".format(n_expected, fee)) @@ -411,30 +400,19 @@ def expected_unspent_mature(): print("Test F3: shield into a UA on a different account (not the source)...") node.generate(COINBASE_MATURITY + 10) pre_private = post_private - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_expected = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) - - # toaddress belongs to a different account in the same wallet. - # The new API design imposes no ownership relationship between - # `fromaddress` and `toaddress` — only that toaddress has a - # shielded receiver. Source remains account 0. + n_expected = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) + + # toaddress is a UA on a different account; fromaddress need not own it. result = w0.z_shieldcoinbase(w0_taddr, w0_extra_zaddr) self._assert_preflight_shape(result) assert_equal(result['shieldingUTXOs'], n_expected) - assert_equal(Decimal(result['shieldingValue']), expected_value) assert_equal(result['remainingUTXOs'], 0) assert_equal(Decimal(result['remainingValue']), Decimal('0')) + shielding_value = Decimal(result['shieldingValue']) txid = wait_and_assert_operationid_status(w0, result['opid']) assert_true(txid is not None) - node.generate(1) - fee = Decimal(wait_for_tx_scanned(w0, txid)['fee']) - # Source and destination are both in this wallet (different - # accounts), so total `private` grows by the full net-of-fee - # value just as for an in-account shield. - expected_post = pre_private + expected_value - fee - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) + # Both accounts are in this wallet, so `private` grows by the net value. + fee, post_private, _ = confirm_and_check_balance(txid, pre_private, shielding_value) mature_spent += n_expected print(" PASSED") @@ -443,68 +421,73 @@ def expected_unspent_mature(): print("Test F4: limit truncation (limit limit, "Need >{} mature coinbase UTXOs to exercise truncation, got {}".format( limit, n_eligible)) - expected_shielding_value = sum( - Decimal(u['value']) for u in sorted_by_value[:limit]) - expected_remaining_value = sum( - Decimal(u['value']) for u in sorted_by_value[limit:]) + # Sweep 1: capped at `limit`; the rest are reported as remaining. # Positional signature: (fromaddress, toaddress, fee, limit, ...) - result = w0.z_shieldcoinbase(w0_taddr, w0_zaddr, None, limit) - self._assert_preflight_shape(result) - assert_equal(result['shieldingUTXOs'], limit) - assert_equal(result['remainingUTXOs'], n_eligible - limit) - assert_equal(Decimal(result['shieldingValue']), expected_shielding_value) - assert_equal(Decimal(result['remainingValue']), expected_remaining_value) - txid = wait_and_assert_operationid_status(w0, result['opid']) - assert_true(txid is not None) + result1 = w0.z_shieldcoinbase(w0_taddr, w0_zaddr, None, limit) + self._assert_preflight_shape(result1) + assert_equal(result1['shieldingUTXOs'], limit) + assert_equal(result1['remainingUTXOs'], n_eligible - limit) + shielding_value1 = Decimal(result1['shieldingValue']) + remaining_utxos1 = result1['remainingUTXOs'] + remaining_value1 = Decimal(result1['remainingValue']) + assert_true(remaining_value1 > 0, + "Truncation must leave positive remaining value") + txid1 = wait_and_assert_operationid_status(w0, result1['opid']) + assert_true(txid1 is not None) + + # Sweep 2: no limit, before sweep 1 is mined. The proposal excludes + # sweep 1's now-spent inputs, so it must drain exactly what sweep 1 + # reported as remaining — backend-anchored, no snapshot summing. + result2 = w0.z_shieldcoinbase(w0_taddr, w0_zaddr) + self._assert_preflight_shape(result2) + assert_equal(result2['shieldingUTXOs'], remaining_utxos1, + "2nd sweep must drain exactly sweep 1's remaining UTXOs") + assert_equal(Decimal(result2['shieldingValue']), remaining_value1, + "2nd sweep value must equal sweep 1's reported remainingValue") + assert_equal(result2['remainingUTXOs'], 0) + assert_equal(Decimal(result2['remainingValue']), Decimal('0')) + shielding_value2 = Decimal(result2['shieldingValue']) + txid2 = wait_and_assert_operationid_status(w0, result2['opid']) + assert_true(txid2 is not None) + + # One block confirms both sweeps. node.generate(1) - fee = Decimal(wait_for_tx_scanned(w0, txid)['fee']) - expected_post = pre_private + expected_shielding_value - fee + fee1 = Decimal(wait_for_tx_scanned(w0, txid1)['fee']) + fee2 = Decimal(wait_for_tx_scanned(w0, txid2)['fee']) post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) - mature_spent += limit - print(" PASSED ({}/{} selected, {} remaining)".format( - limit, n_eligible, n_eligible - limit)) + assert_equal( + post_private, + pre_private + shielding_value1 + shielding_value2 - fee1 - fee2) + mature_spent += n_eligible + print(" PASSED ({}/{} selected, {} swept in follow-up)".format( + limit, n_eligible, remaining_utxos1)) # ---- F5: limit > eligible is harmless ----------------------- print("Test F5: limit greater than eligible is a no-op cap...") node.generate(COINBASE_MATURITY + 10) pre_private = post_private - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_eligible = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) + n_eligible = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) huge_limit = n_eligible + 1000 result = w0.z_shieldcoinbase(w0_taddr, w0_zaddr, None, huge_limit) self._assert_preflight_shape(result) - # Limit exceeds eligible, so the cap has no effect: every mature - # UTXO is swept and nothing remains. + # Cap above eligible has no effect: everything is swept. assert_equal(result['shieldingUTXOs'], n_eligible) - assert_equal(Decimal(result['shieldingValue']), expected_value) assert_equal(result['remainingUTXOs'], 0) assert_equal(Decimal(result['remainingValue']), Decimal('0')) + shielding_value = Decimal(result['shieldingValue']) txid = wait_and_assert_operationid_status(w0, result['opid']) assert_true(txid is not None) - node.generate(1) - fee = Decimal(wait_for_tx_scanned(w0, txid)['fee']) - expected_post = pre_private + expected_value - fee - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) + fee, post_private, _ = confirm_and_check_balance(txid, pre_private, shielding_value) mature_spent += n_eligible print(" PASSED") @@ -513,37 +496,27 @@ def expected_unspent_mature(): print("Test F6: memo propagation...") node.generate(COINBASE_MATURITY + 10) pre_private = post_private - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_expected = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) + n_expected = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) - # 1024-character hex string = 512 bytes. Leading bytes spell - # "c0ffee" in ASCII so we can eyeball matches if the assertion - # fails. + # 512-byte memo (max); leading bytes spell "c0ffee" for eyeballing. my_memo = '633066666565' + '0' * (1024 - 12) # Positional signature: (fromaddress, toaddress, fee, limit, memo, ...) result = w0.z_shieldcoinbase(w0_taddr, w0_zaddr, None, None, my_memo) self._assert_preflight_shape(result) assert_equal(result['shieldingUTXOs'], n_expected) - assert_equal(Decimal(result['shieldingValue']), expected_value) assert_equal(result['remainingUTXOs'], 0) assert_equal(Decimal(result['remainingValue']), Decimal('0')) + shielding_value = Decimal(result['shieldingValue']) txid = wait_and_assert_operationid_status(w0, result['opid']) assert_true(txid is not None) - node.generate(1) - tx_details = wait_for_tx_scanned(w0, txid) - fee = Decimal(tx_details['fee']) - expected_post = pre_private + expected_value - fee - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - assert_equal(post_private, expected_post) + fee, post_private, tx_details = confirm_and_check_balance( + txid, pre_private, shielding_value) mature_spent += n_expected shielded_outputs = [o for o in tx_details.get('outputs', []) if o.get('pool') in ('sapling', 'orchard')] - # The sweep produces exactly one shielded payment carrying the - # user-supplied memo (change notes, if any, use an empty memo - # and would not match this hex blob). + # Exactly one shielded payment carries the memo (change uses empty). matching = [o for o in shielded_outputs if o.get('memo') == my_memo] assert_equal( len(matching), 1, @@ -557,14 +530,13 @@ def expected_unspent_mature(): print("Test F7: operation lifecycle (status -> result -> cleared)...") node.generate(COINBASE_MATURITY + 10) pre_private = post_private - snapshot = wait_for_mature_coinbase_count(w0, expected_unspent_mature()) - n_expected = len(snapshot) - expected_value = sum(Decimal(u['value']) for u in snapshot) + n_expected = len(wait_for_mature_coinbase_count(w0, expected_unspent_mature())) result = w0.z_shieldcoinbase(w0_taddr, w0_zaddr) self._assert_preflight_shape(result) assert_equal(result['shieldingUTXOs'], n_expected) - assert_equal(Decimal(result['shieldingValue']), expected_value) + assert_equal(result['remainingUTXOs'], 0) + assert_equal(Decimal(result['remainingValue']), Decimal('0')) opid = result['opid'] assert_true(opid.startswith("opid-"), "Expected opid- prefix, got {!r}".format(opid)) diff --git a/qa/rpc-tests/wallet_z_shieldcoinbase_multi_taddr.py b/qa/rpc-tests/wallet_z_shieldcoinbase_multi_taddr.py index 361016d7f..7c635fcd4 100644 --- a/qa/rpc-tests/wallet_z_shieldcoinbase_multi_taddr.py +++ b/qa/rpc-tests/wallet_z_shieldcoinbase_multi_taddr.py @@ -71,6 +71,13 @@ # restart; shallower blocks live in memory and are lost on shutdown. ZEBRA_MAX_REORG_HEIGHT = 99 +# Seconds the 1+1 mature-coinbase state must hold steady before a sweep. +# z_listunspent shows new coinbase before zallet's recover_history scan +# task (30s idle tick, not woken on tip change) makes it spendable to +# z_shieldcoinbase, so the window must outlast that tick. See +# wallet_z_shieldcoinbase.py; drop once #316 lands. +COINBASE_SETTLE_SECS = 35 + def first_transparent_receiver(wallet, ua): receivers = wallet.z_listunifiedreceivers(ua) @@ -100,7 +107,10 @@ def setup_nodes(self): return start_nodes(self.num_nodes, self.options.tmpdir, args) def _mature_coinbase_on(self, wallet, taddr): - utxos = wallet.z_listunspent(COINBASE_MATURITY + 1) + # minconf = COINBASE_MATURITY matches the proposal (coinbase is + # spendable at exactly 100 confirmations); minconf+1 would miss a + # boundary UTXO the sweep selects. + utxos = wallet.z_listunspent(COINBASE_MATURITY) return [u for u in utxos if u.get('pool') == 'transparent' and u.get('address') == taddr] @@ -201,32 +211,33 @@ def run_test(self): w0 = self.wallets[0] # ---- Phase 3: mine to B until exactly one B coinbase matures. - # Post-restart zebrad has just block 1. Mine COINBASE_MATURITY+1 - # (101) more so: - # tip = 102 - # block 1 (A) confirmations = 102 → mature - # block 2 (B) confirmations = 101 → mature - # block 3 (B) confirmations = 100 → NOT mature - # i.e. exactly one mature coinbase on each receiver. - print("Mining {} blocks at miner=B...".format(COINBASE_MATURITY + 1)) - node.generate(COINBASE_MATURITY + 1) - - # Wait for the wallet to scan to chain tip — readiness signal - # is the expected 1+1 mature coinbase state. + # Post-restart zebrad has only block 1. Mining COINBASE_MATURITY more + # → tip 101: block 1 (A) and block 2 (B) reach maturity (101 / 100 + # confirmations), block 3 (B) does not (99). So one mature coinbase + # per receiver, and the UUID sweep selects exactly those two. + print("Mining {} blocks at miner=B...".format(COINBASE_MATURITY)) + node.generate(COINBASE_MATURITY) + + # Wait for the 1+1 state to hold for COINBASE_SETTLE_SECS (see above). print("Waiting for wallet to see exactly 1 mature coinbase on each receiver...") deadline = time.time() + 240 mature_a = [] mature_b = [] + stable_secs = 0 while time.time() < deadline: mature_a = self._mature_coinbase_on(w0, taddr_a) mature_b = self._mature_coinbase_on(w0, taddr_b) if len(mature_a) == 1 and len(mature_b) == 1: - break + stable_secs += 1 + if stable_secs >= COINBASE_SETTLE_SECS: + break + else: + stable_secs = 0 time.sleep(1) # ---- Phase 4: pre-sweep assertions (exact). ---------------- # Expect 1 mature coinbase on each receiver: block 1 (A) at - # 102 confirmations, block 2 (B) at 101 confirmations. + # 101 confirmations, block 2 (B) at 100 confirmations. assert_equal( len(mature_a), 1, "Pre-sweep: expected exactly 1 mature coinbase on A (block 1), saw {}".format( @@ -287,55 +298,55 @@ def run_test(self): "linking same-account transparent addresses.") print(" Shielding tx: {}".format(txid)) + # Pre-sweep coinbase UTXOs on each receiver; checked spent below. + spent_a = (mature_a[0]['txid'], mature_a[0]['outindex']) + spent_b = (mature_b[0]['txid'], mature_b[0]['outindex']) + # ---- Phase 6: confirm the sweep tx. ------------------------ - # Mining one block confirms the sweep tx (it lands in block 103). - # It also produces a new coinbase output to B (block 3 reaches - # 101 confirmations — i.e. just matures). That residual is from - # this test-infrastructure confirmation block, NOT a missed - # sweep input — block 1 and block 2 have both been spent. + # Wait for the balance to reach exactly shieldingValue - fee: this is + # both the sync barrier (confirming block fully scanned) and an exact + # assertion. node.generate(1) - - # Wait for shielded balance to register the sweep. - deadline = time.time() + 120 + shielding_value = Decimal(result['shieldingValue']) + deadline = time.time() + 180 + fee = None post_private = Decimal('0') while time.time() < deadline: - post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) - if post_private > 0: - break + try: + tx_details = w0.z_viewtransaction(txid) + if 'fee' in tx_details: + fee = Decimal(tx_details['fee']) + post_private = Decimal(w0.z_gettotalbalance(1, True)['private']) + if fee > 0 and post_private == shielding_value - fee: + break + except Exception: + pass time.sleep(1) # ---- Phase 7: post-sweep assertions (exact). --------------- - # Shielded balance equals the swept value minus the fee paid - # by the shielding tx. Read the fee directly off the tx. - tx_details = w0.z_viewtransaction(txid) - fee = Decimal(tx_details['fee']) - assert_true(fee > 0, "Sweep fee should be positive, got {}".format(fee)) + assert_true(fee is not None and fee > 0, + "Sweep fee should be positive, got {}".format(fee)) assert_equal( post_private, - Decimal(result['shieldingValue']) - fee, + shielding_value - fee, "Post-sweep shielded balance should equal shieldingValue ({}) - fee ({}); " - "got {}".format(result['shieldingValue'], fee, post_private)) - - # Block 1 (A's only mature coinbase) was spent in the sweep: - # no mature transparent UTXOs on A remain. - post_mature_a = self._mature_coinbase_on(w0, taddr_a) - assert_equal( - len(post_mature_a), 0, - "Post-sweep: expected 0 mature coinbase on A (block 1 was spent), " - "saw {}".format(len(post_mature_a))) - - # On B, block 2 was spent — but mining the confirmation block - # advanced the tip by one, which brought block 3 to 101 - # confirmations. The single remaining UTXO is that block 3 - # coinbase, not a sweep residual. - post_mature_b = self._mature_coinbase_on(w0, taddr_b) - assert_equal( - len(post_mature_b), 1, - "Post-sweep: expected exactly 1 mature coinbase on B (the " - "confirmation block's coinbase, not a sweep residual), saw {}".format( - len(post_mature_b))) + "got {}".format(shielding_value, fee, post_private)) + + # Both receivers' coinbase must be spent by the one UUID sweep — the + # multi-receiver regression guard (a skipped receiver B would remain). + unspent_ids = { + (u['txid'], u['outindex']) + for u in w0.z_listunspent(1) + if u.get('pool') == 'transparent' + } + assert_true( + spent_a not in unspent_ids, + "Receiver A's coinbase (block 1) must be spent by the sweep") + assert_true( + spent_b not in unspent_ids, + "Receiver B's coinbase (block 2) must be spent by the sweep") - print("PASSED ({} ZEC swept; {} ZEC shielded after {} ZEC fee)".format( + print("PASSED ({} ZEC swept from 2 receivers; {} ZEC shielded after {} ZEC fee)".format( pre_transparent_total, post_private, fee))