-
Notifications
You must be signed in to change notification settings - Fork 11
Add regression test for zallet BlockConflict recovery (zcash/zallet#556/#560) #151
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Cosmos-Harry
wants to merge
1
commit into
main
Choose a base branch
from
harry/zallet-block-conflict-test
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| #!/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 . | ||
|
|
||
| # | ||
| # Regression test for zcash/zallet#556 / zcash/zallet#560: a `BlockConflict` | ||
| # (the backend's block hash at a height disagreeing with what's already | ||
| # stored in the wallet's own database) must not crash the whole wallet — it | ||
| # should search for the fork point and recover automatically instead. | ||
| # | ||
| # We can't reliably force this exact code path via a *natural* chain reorg: | ||
| # zallet's own proactive fork-point check runs before every scan and already | ||
| # resolves ordinary reorgs on its own, before a raw storage conflict could | ||
| # ever occur. Instead this reproduces the bug the same way it was originally | ||
| # verified by hand: stop the wallet, plant a deliberately-wrong block hash | ||
| # directly in its `wallet.db` at a height just ahead of its synced tip, then | ||
| # let it resume syncing into that conflict. | ||
| # | ||
|
|
||
| import os | ||
| import sqlite3 | ||
| import time | ||
|
|
||
| from test_framework.test_framework import BitcoinTestFramework | ||
| from test_framework.util import ( | ||
| assert_equal, | ||
| start_wallet, | ||
| wallet_dir, | ||
| zallet_processes, | ||
| ) | ||
|
|
||
|
|
||
| def _wait_for_wallet_sync(node, wallet, timeout=60): | ||
| """Block until the wallet reports the node's current tip.""" | ||
| deadline = time.time() + timeout | ||
| while time.time() < deadline: | ||
| target = node.getblockcount() | ||
| status = wallet.getwalletstatus() | ||
| if status.get('wallet_tip', {}).get('height') == target: | ||
| return | ||
| time.sleep(0.5) | ||
| raise AssertionError("wallet did not sync to node tip within %ss" % timeout) | ||
|
|
||
|
|
||
| class ZalletBlockConflictTest(BitcoinTestFramework): | ||
| def __init__(self): | ||
| super().__init__() | ||
| self.cache_behavior = 'clean' | ||
| self.num_nodes = 1 | ||
| self.num_wallets = 1 | ||
|
|
||
| def run_test(self): | ||
| node = self.nodes[0] | ||
| wallet = self.wallets[0] | ||
|
|
||
| # Get the wallet synced up a few blocks. | ||
| node.generate(5) | ||
| _wait_for_wallet_sync(node, wallet) | ||
| synced_height = wallet.getwalletstatus()['wallet_tip']['height'] | ||
| assert_equal(synced_height, node.getblockcount()) | ||
|
|
||
| # Mine one more real block that the wallet hasn't scanned yet — this | ||
| # is the height we'll plant a conflicting record at ahead of time. | ||
| conflict_height = synced_height + 1 | ||
| node.generate(1) | ||
| real_hash = node.getblockhash(conflict_height) | ||
| assert_equal(node.getblockcount(), conflict_height) | ||
|
|
||
| # Stop the wallet cleanly and wait for the process to fully exit | ||
| # (the RPC call itself just requests shutdown) before touching its | ||
| # database file directly, so we don't race its own file handle. | ||
| wallet.stop() | ||
| zallet_processes[0].wait(timeout=30) | ||
|
|
||
| db_path = os.path.join(wallet_dir(self.options.tmpdir, 0), "wallet.db") | ||
| conn = sqlite3.connect(db_path) | ||
| try: | ||
| # The non-height/hash columns (tree state, output/action counts, | ||
| # etc.) must be a validly-consistent set, not arbitrary/empty | ||
| # values, or the wallet fails on an unrelated deserialization | ||
| # error before it ever gets to the hash check we're actually | ||
| # testing. Clone the real, already-synced tip's row wholesale and | ||
| # only override height and hash. | ||
| columns = [ | ||
| row[1] for row in conn.execute("PRAGMA table_info(blocks)") | ||
| if row[1] not in ("height", "hash") | ||
| ] | ||
| real_row = conn.execute( | ||
| "SELECT %s FROM blocks WHERE height = ?" % ", ".join(columns), | ||
| (synced_height,), | ||
| ).fetchone() | ||
|
|
||
| fake_hash = bytes([0xFF] * 32) | ||
| conn.execute( | ||
| "INSERT INTO blocks (height, hash, %s) VALUES (?, ?, %s)" % ( | ||
| ", ".join(columns), ", ".join("?" * len(columns)), | ||
| ), | ||
| (conflict_height, fake_hash, *real_row), | ||
| ) | ||
| conn.commit() | ||
| finally: | ||
| conn.close() | ||
|
|
||
| # Restart the wallet. It should hit the planted conflict as soon as | ||
| # it tries to scan `conflict_height`, log a recovery warning instead | ||
| # of crashing, and resync cleanly to the node's real tip. | ||
| restarted_wallet = start_wallet(0, self.options.tmpdir) | ||
| self.wallets[0] = restarted_wallet | ||
|
|
||
| _wait_for_wallet_sync(node, restarted_wallet, timeout=60) | ||
|
|
||
| final_status = restarted_wallet.getwalletstatus() | ||
| assert_equal(final_status['wallet_tip']['height'], node.getblockcount()) | ||
| assert_equal(final_status['wallet_tip']['blockhash'], real_hash) | ||
|
|
||
|
|
||
| if __name__ == '__main__': | ||
| ZalletBlockConflictTest().main() | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nit: we should have a branch for the exception, just to detect exceptions that are raised. If an exception is raised, it will disappear in the finally branch.