diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 72a99956..4644eae8 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -18,6 +18,7 @@ jobs: matrix: python-version: ['3.12'] os: [ ubuntu-22.04 ] # [macos-latest, ubuntu-22.04, windows-latest] + backend: [fulcrum, cbf] env: DISPLAY: ":99.0" # Display setting for Xvfb on Linux QT_SELECT: "qt6" # Environment variable to select Qt6 @@ -74,15 +75,15 @@ jobs: - name: Run General Tests run: | - poetry run pytest -m 'not marker_qt_1 and not marker_qt_2' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 + poetry run pytest -m 'not marker_qt_1 and not marker_qt_2' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 --${{ matrix.backend }} - name: Run Tests for marker_qt_1 run: | - poetry run pytest -m 'marker_qt_1' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 + poetry run pytest -m 'marker_qt_1' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 --${{ matrix.backend }} - name: Run Tests for marker_qt_2 run: | - poetry run pytest -m 'marker_qt_2' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 + poetry run pytest -m 'marker_qt_2' -vvv --log-cli-level=DEBUG --setup-show --maxfail=1 --${{ matrix.backend }} @@ -91,5 +92,5 @@ jobs: if: always() # This ensures the step runs regardless of previous failures uses: actions/upload-artifact@v4 with: - name: test-output - path: tests/output \ No newline at end of file + name: test-output-${{ matrix.python-version }}-${{ matrix.os }}-${{ matrix.backend }} + path: tests/output diff --git a/.gitignore b/.gitignore index f591801b..1e31f190 100644 --- a/.gitignore +++ b/.gitignore @@ -184,6 +184,8 @@ test-seed *.wallet bitcoin_core fulcrum +tests/bitcoin_data/ +tests/cbf_data/ tools-my/ diff --git a/.vscode/launch.json b/.vscode/launch.json index 5150a456..4bb53464 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,7 +1,6 @@ { "version": "0.1.0", - "configurations": [ - { + "configurations": [{ "name": "Python: Module", "type": "python", "request": "launch", @@ -14,15 +13,35 @@ // "justMyCode": false }, { - "name": "Pytest", + "name": "Pytest cbf backend", "type": "python", "request": "launch", "module": "pytest", "args": [ "-vvv", + "--cbf", "--ignore=coding_tests", "--ignore=tools", + "--maxfail=1", ], + + "console": "integratedTerminal", + "preLaunchTask": "Poetry Install", + // "justMyCode": false + }, + { + "name": "Pytest fulcrum", + "type": "python", + "request": "launch", + "module": "pytest", + "args": [ + "-vvv", + "--fulcrum", + "--ignore=coding_tests", + "--ignore=tools", + "--maxfail=1", + ], + "console": "integratedTerminal", "preLaunchTask": "Poetry Install", // "justMyCode": false @@ -34,9 +53,11 @@ "module": "pytest", "args": [ // "-vvv", + "--cbf", "tests/gui", "--ignore=coding_tests", "--ignore=tools", + "--maxfail=1", // "-s", // Disable all capturing of outputs ], "console": "integratedTerminal", @@ -50,23 +71,43 @@ "module": "pytest", "args": [ "-vvv", + "--cbf", "--ignore=tests/gui", "--ignore=tools", + "--maxfail=1", + "-s", // Disable all capturing of outputs + ], + "console": "integratedTerminal", + "justMyCode": false, + "preLaunchTask": "Poetry Install", + }, { + "name": "Pytest: Current File [fulcrum]", + "type": "python", + "request": "launch", + "module": "pytest", + "args": [ + "-vvv", + "--fulcrum", + "${file}", "-s", // Disable all capturing of outputs + "--ignore=tools", + "--maxfail=1", ], "console": "integratedTerminal", "justMyCode": false, "preLaunchTask": "Poetry Install", }, { - "name": "Pytest: Current File", + "name": "Pytest: Current File [cbf]", "type": "python", "request": "launch", "module": "pytest", "args": [ "-vvv", + "--cbf", "${file}", "-s", // Disable all capturing of outputs "--ignore=tools", + "--maxfail=1", ], "console": "integratedTerminal", "justMyCode": false, diff --git a/.vscode/settings.json b/.vscode/settings.json index 6c55da72..b8467131 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,6 @@ "tests" ], "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python" } diff --git a/bitcoin_safe/client.py b/bitcoin_safe/client.py index a15127b3..5da8302a 100644 --- a/bitcoin_safe/client.py +++ b/bitcoin_safe/client.py @@ -30,6 +30,7 @@ import asyncio import logging +import time from concurrent.futures import Future from datetime import datetime, timedelta from pathlib import Path @@ -49,6 +50,7 @@ class Client: MAX_PROGRESS_WHILE_SYNC = 0.99 + BROADCAST_TIMEOUT = 3 def __init__( self, @@ -232,6 +234,7 @@ def from_cbf( ) client.build_node() + time.sleep(0.1) # this prevents an ifinite loop of getting info messages from cbf client return cls(client=client, proxy_info=proxy_info, electrum_config=None, loop_in_thread=loop_in_thread) def broadcast(self, tx: bdk.Transaction): @@ -244,7 +247,7 @@ def broadcast(self, tx: bdk.Transaction): assert self.client.client, "Not initialized" try: return self.loop_in_thread.run_foreground( - asyncio.wait_for(self.client.client.broadcast(tx), timeout=3) + asyncio.wait_for(self.client.client.broadcast(tx), timeout=self.BROADCAST_TIMEOUT) ) except asyncio.TimeoutError: logger.error("Broadcast timed out after 3 seconds") diff --git a/bitcoin_safe/gui/qt/main.py b/bitcoin_safe/gui/qt/main.py index 692e369a..9b3c06c2 100644 --- a/bitcoin_safe/gui/qt/main.py +++ b/bitcoin_safe/gui/qt/main.py @@ -102,7 +102,7 @@ from bitcoin_safe.keystore import KeyStoreImporterTypes from bitcoin_safe.logging_handlers import mail_contact, mail_feedback from bitcoin_safe.logging_setup import get_log_file -from bitcoin_safe.network_config import P2pListenerType +from bitcoin_safe.network_config import P2pListenerType, Peers from bitcoin_safe.network_utils import ProxyInfo from bitcoin_safe.p2p.p2p_client import ConnectionInfo from bitcoin_safe.p2p.p2p_listener import P2pListener @@ -626,15 +626,19 @@ def init_p2p_listening(self): if self.config.network_config.p2p_listener_type == P2pListenerType.deactive: return initial_peer = self.config.network_config.get_p2p_initial_peer() + discovered_peers = Peers(self.config.network_config.discovered_peers) + if initial_peer and initial_peer not in discovered_peers: + discovered_peers.append(initial_peer) self.p2p_listener = P2pListener( network=self.config.network, - discovered_peers=self.config.network_config.discovered_peers, + discovered_peers=discovered_peers, loop_in_thread=self.loop_in_thread, + autodiscover_additional_peers=self.config.network_config.p2p_autodiscover_additional_peers, ) self.p2p_listener.signal_tx.connect(self.p2p_listening_on_tx) self.p2p_listener.signal_block.connect(self.p2p_listening_on_block) self.p2p_listener.start( - initial_peer=initial_peer, + preferred_peers=[initial_peer] if initial_peer else None, proxy_info=( ProxyInfo.parse(self.config.network_config.proxy_url) if self.config.network_config.proxy_url @@ -2105,17 +2109,20 @@ def new_wallet_id(self) -> str: """New wallet id.""" return f"{self.tr('new')}{len(self.qt_wallets)}" + def _ask_if_full_scan(self) -> bool | None: + return question_dialog( + text=self.tr("Was this wallet ever used before?"), + true_button=self.tr("Yes, full scan for transactions"), + false_button=self.tr("No, quick scan"), + ) + def create_qtwallet_from_protowallet( self, protowallet: ProtoWallet, tutorial_index: int | None ) -> QTWallet: """Create qtwallet from protowallet.""" is_new_wallet = False if self.config.network_config.server_type == BlockchainType.CompactBlockFilter: - answer = question_dialog( - text=self.tr("Was this wallet ever used before?"), - true_button=self.tr("Yes, full scan for transactions"), - false_button=self.tr("No, quick scan"), - ) + answer = self._ask_if_full_scan() if answer is False: is_new_wallet = True else: @@ -2497,6 +2504,16 @@ def remove_all_qt_wallet(self) -> None: for qt_wallet in self.qt_wallets.copy().values(): self._remove_qt_wallet(qt_wallet) + def _ask_if_wallet_should_remain_open(self) -> bool | None: + return question_dialog( + text=self.tr( + "This wallet is still syncing and syncing would need to start from scratch if you close it.\nDo you want to keep the wallet open?", + ), + title=self.tr("Wallet syncing"), + true_button=self.tr("Keep open"), + false_button=self.tr("Close anyway"), + ) + def close_tab(self, node: SidebarNode[TT]) -> None: """Close tab.""" if not node.closable and not node.widget == self.welcome_screen: @@ -2504,14 +2521,7 @@ def close_tab(self, node: SidebarNode[TT]) -> None: tab_data = node.data if isinstance(tab_data, QTWallet): if tab_data.is_in_cbf_ibd(): - res = question_dialog( - text=self.tr( - "This wallet is still syncing and syncing would need to start from scratch if you close it.\nDo you want to keep the wallet open?", - ), - title=self.tr("Wallet syncing"), - true_button=self.tr("Keep open"), - false_button=self.tr("Close anyway"), - ) + res = self._ask_if_wallet_should_remain_open() if res is None: return elif res is True: diff --git a/bitcoin_safe/gui/qt/ui_tx/recipients.py b/bitcoin_safe/gui/qt/ui_tx/recipients.py index 91ffec6d..3573162b 100644 --- a/bitcoin_safe/gui/qt/ui_tx/recipients.py +++ b/bitcoin_safe/gui/qt/ui_tx/recipients.py @@ -660,7 +660,7 @@ def export_csv(self, recipients: list[Recipient], file_path: str | Path | None = return None table = self.as_list(recipients) - with open(str(file_path), "w") as file: + with open(str(file_path), "w", newline="") as file: writer = csv.writer(file) writer.writerows(table) diff --git a/bitcoin_safe/network_config.py b/bitcoin_safe/network_config.py index 98c8c33d..e3d9f210 100644 --- a/bitcoin_safe/network_config.py +++ b/bitcoin_safe/network_config.py @@ -433,7 +433,7 @@ class P2pListenerType(enum.Enum): class NetworkConfig(BaseSaveableClass): - VERSION = "0.2.3" + VERSION = "0.2.5" known_classes = { **BaseSaveableClass.known_classes, BlockchainType.__name__: BlockchainType, @@ -466,6 +466,7 @@ def __init__( self.proxy_url: str | None = None self.p2p_listener_type: P2pListenerType = P2pListenerType.automatic self.p2p_inital_url: str = get_default_p2p_node_urls(network=network)["default"] + self.p2p_autodiscover_additional_peers: bool = True self.discovered_peers: Peers | list[Peer] = discovered_peers if discovered_peers else Peers() self.mempool_data: MempoolData = mempool_data if mempool_data else MempoolData() diff --git a/bitcoin_safe/p2p/__main__.py b/bitcoin_safe/p2p/__main__.py index cf0f4b22..5961b92b 100644 --- a/bitcoin_safe/p2p/__main__.py +++ b/bitcoin_safe/p2p/__main__.py @@ -35,6 +35,7 @@ import bdkpython as bdk from PyQt6.QtCore import QCoreApplication +from bitcoin_safe.network_config import Peers from bitcoin_safe.network_utils import ProxyInfo from .p2p_client import Peer @@ -58,7 +59,7 @@ proxy_info = ProxyInfo.parse("socks5h://127.0.0.1:9050") -client = P2pListener(network=network, loop_in_thread=None) +client = P2pListener(network=network, loop_in_thread=None, discovered_peers=Peers([initial_peer])) # client.set_address_filter(None) @@ -68,7 +69,7 @@ def on_tx(tx: bdk.Transaction): client.signal_tx.connect(on_tx) -client.start(proxy_info=proxy_info) +client.start(proxy_info=proxy_info, preferred_peers=[initial_peer]) while input("type q") != "q": diff --git a/bitcoin_safe/p2p/p2p_listener.py b/bitcoin_safe/p2p/p2p_listener.py index 92b4a0ee..00d13f53 100644 --- a/bitcoin_safe/p2p/p2p_listener.py +++ b/bitcoin_safe/p2p/p2p_listener.py @@ -63,6 +63,7 @@ def __init__( fetch_txs=True, timeout: int = 200, discovered_peers: Peers | list[Peer] | None = None, + autodiscover_additional_peers: bool = True, parent: QObject | None = None, ) -> None: """Initialize instance.""" @@ -74,6 +75,7 @@ def __init__( self.address_filter: set[str] | None = None self.outpoint_filter: set[str] | None = None self.peer_discovery = PeerDiscovery(network=network, loop_in_thread=self.loop_in_thread) + self.autodiscover_additional_peers = autodiscover_additional_peers self.discovered_peers = discovered_peers if discovered_peers else Peers() @@ -115,7 +117,7 @@ async def random_select_peer( ---------- weight_getaddr : float Relative chance to pick from `self.discovered_peers` - (addresses learned via getaddr/addrv2). + (addresses learned via getaddr/addrv2 or provided upfront). weight_dns : float Relative chance to call `self.peer_discovery.get_bitcoin_peer()` (DNS seeds, hard-coded lists, etc.). @@ -128,8 +130,14 @@ async def random_select_peer( if weight_getaddr < 0 or weight_dns < 0: raise ValueError("weights must be non-negative") + if not self.autodiscover_additional_peers: + weight_dns = 0 + # Fast paths ------------------------------------------------- if not self.discovered_peers: + if not self.autodiscover_additional_peers: + logger.debug("Peer discovery disabled; no discovered peers available") + return None peer = await self.peer_discovery.get_bitcoin_peer() # may be None logger.debug(f"Picked {peer=} from DNS seed") return peer @@ -156,18 +164,19 @@ async def random_select_peer( async def _start( self, proxy_info: ProxyInfo | None, - initial_peer: Peer | None = None, + preferred_peers: list[Peer] | None = None, ) -> None: """Keep the client *always* connected to **some** Bitcoin peer. - Parameters - ---------- - initial_peer : Peer | None - A preferred first peer to try. If it is ``None`` or the connection - to it fails, we fall back to peers returned by ``get_bitcoin_peer()``. + Prefers peers passed via ``preferred_peers`` (in order), then + peers from ``self.discovered_peers``, and finally falls back to DNS + seeds when allowed. """ previous_peer: Peer | None = None - peer = initial_peer + peer = None + queued_preferred_peers = list(preferred_peers or []) + if queued_preferred_peers: + self.add_peers(Peers(queued_preferred_peers)) retry_delay = 5 # seconds to wait when no peer is immediately available while True: @@ -177,7 +186,10 @@ async def _start( # 1. Select the next peer (and avoid repeating the last one immediately) # ------------------------------------------------------------------ if peer is None: - peer = await self.random_select_peer() + if queued_preferred_peers: + peer = queued_preferred_peers.pop(0) + else: + peer = await self.random_select_peer() if peer is None: # no peers at all? wait then retry await asyncio.sleep(retry_delay) @@ -232,10 +244,12 @@ async def _start( def start( self, proxy_info: ProxyInfo | None, - initial_peer: Peer | None = None, + preferred_peers: list[Peer] | None = None, ): """Start.""" - self.loop_in_thread.run_background(self._start(initial_peer=initial_peer, proxy_info=proxy_info)) + self.loop_in_thread.run_background( + self._start(proxy_info=proxy_info, preferred_peers=preferred_peers) + ) def stop(self): """Stop.""" @@ -271,6 +285,8 @@ def on_inv(self, inventory: Inventory): def on_disconnected_to(self, peer: Peer): "Do not keep peers in the list, which disconnected in the past" + if not self.autodiscover_additional_peers: + return if peer in self.discovered_peers: self.discovered_peers.remove(peer) @@ -295,8 +311,7 @@ def add_peers(self, peers: Peers): self.discovered_peers + new_peers, max_len=maximum_total_peers ) logger.debug( - f"Added {len(new_peers)=} peers to discovered_peers and " - f"shrunk the size to {len(self.discovered_peers)=}" + f"Added {len(new_peers)=} peers to discovered_peers and shrunk the size to {len(self.discovered_peers)=}" ) @staticmethod diff --git a/bitcoin_safe/psbt_util.py b/bitcoin_safe/psbt_util.py index 76fd8e25..caf001e4 100644 --- a/bitcoin_safe/psbt_util.py +++ b/bitcoin_safe/psbt_util.py @@ -319,7 +319,11 @@ def __init__( """Initialize instance.""" self.fingerprint = SimplePubKeyProvider.format_fingerprint(fingerprint) self.pubkey = pubkey - self.derivation_path = derivation_path + self.derivation_path = ( + (derivation_path if derivation_path.startswith("m/") else "m/" + derivation_path) + if derivation_path + else None + ) self.label = label diff --git a/pyproject.toml b/pyproject.toml index 94ad6c00..901af4a6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,7 +109,7 @@ known-first-party = ["bitcoin_safe"] [tool.ruff.lint.per-file-ignores] -"tests/**/*" = ["ALL"] +"tests/conftest.py" = ["E402", "F401", "F811"] "tools/**/*" = ["ALL"] @@ -122,5 +122,3 @@ known-first-party = ["bitcoin_safe"] [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" - - diff --git a/tests/conftest.py b/tests/conftest.py index a6c70935..38154cc3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -29,20 +29,76 @@ from __future__ import annotations import pytest +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_safe.logging_setup import setup_logging -from tests.gui.qt.helpers import mytest_start_time # type: ignore +from tests.gui.qt.helpers import mytest_start_time setup_logging() from bitcoin_safe.gui.qt import custom_edits -from .gui.qt.helpers import mytest_start_time # type: ignore -from .helpers import test_config_main_chain # type: ignore -from .helpers import test_config, test_config_session # type: ignore -from .non_gui.test_wallet_coin_select import test_wallet_config # type: ignore -from .setup_bitcoin_core import bitcoin_core # type: ignore -from .setup_fulcrum import Faucet, faucet, fulcrum # type: ignore +from .faucet import Faucet, faucet, faucet_session +from .gui.qt.helpers import mytest_start_time +from .helpers import ( + test_config, + test_config_main_chain, + test_config_session, +) +from .non_gui.test_wallet_coin_select import test_wallet_config +from .setup_bitcoin_core import bitcoin_core +from .setup_fulcrum import fulcrum + +pytestmark = pytest.mark.usefixtures("backend_marker") + + +def pytest_addoption(parser): + group = parser.getgroup("bitcoin-safe") + group.addoption( + "--fulcrum", + action="store_true", + default=False, + help="Run tests against the Fulcrum/Electrum backend", + ) + group.addoption( + "--cbf", + action="store_true", + default=False, + help="Run tests against the Compact Block Filters backend", + ) + + +def _selected_backends(config) -> list[str]: + selected = [] + if config.getoption("--fulcrum"): + selected.append("fulcrum") + if config.getoption("--cbf"): + selected.append("cbf") + if not selected: + selected = ["cbf"] + return selected + + +def pytest_generate_tests(metafunc): + if "backend" in metafunc.fixturenames: + metafunc.parametrize("backend", _selected_backends(metafunc.config), scope="session") + + +@pytest.fixture(scope="session") +def backend(request) -> str: + """Selected blockchain backend for the test session.""" + return request.param + + +@pytest.fixture(scope="session") +def backend_marker(backend: str) -> str: + """Ensure the backend fixture is part of every test run.""" + return backend + + +@pytest.fixture(scope="session") +def loop_in_thread() -> LoopInThread: + return LoopInThread() @pytest.fixture(autouse=True) diff --git a/tests/faucet.py b/tests/faucet.py new file mode 100644 index 00000000..d173e6a7 --- /dev/null +++ b/tests/faucet.py @@ -0,0 +1,196 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import logging +import time +from collections.abc import Generator +from pathlib import Path + +import bdkpython as bdk +import pytest +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread +from bitcoin_safe_lib.tx_util import serialized_to_hex +from bitcoin_usb.address_types import AddressTypes +from bitcoin_usb.software_signer import SoftwareSigner +from bitcoin_usb.software_signer import derive as software_signer_derive +from pytestqt.qtbot import QtBot + +from bitcoin_safe.descriptors import descriptor_from_keystores +from bitcoin_safe.keystore import KeyStore +from bitcoin_safe.util import SATOSHIS_PER_BTC + +from .helpers import TestConfig +from .setup_bitcoin_core import bitcoin_cli +from .util import make_psbt +from .wallet_factory import create_test_wallet + +logger = logging.getLogger(__name__) + + +class Faucet: + def __init__( + self, + backend: str, + bitcoin_core: Path, + test_config: TestConfig, + loop_in_thread: LoopInThread, + mnemonic="romance slush habit speed type also grace coffee grape inquiry receive filter", + ) -> None: + """Initialize instance.""" + self.bitcoin_core = bitcoin_core + self.seed = mnemonic + self.loop_in_thread = loop_in_thread + self.mnemonic = bdk.Mnemonic.from_string(self.seed) + self.network = bdk.Network.REGTEST + self.address_type = AddressTypes.p2wpkh + self.backend = backend + self.config = test_config + + self.wallet_handle = self._build_wallet_handle() + self.wallet = self.wallet_handle.wallet + self.wallet.client.BROADCAST_TIMEOUT = 10 + self.descriptor, self.change_descriptor = self.wallet.multipath_descriptor.to_single_descriptors() + self.software_signer = SoftwareSigner( + mnemonic=str(self.mnemonic), + network=self.network, + receive_descriptor=str(self.descriptor), + change_descriptor=str(self.change_descriptor), + ) + + # Reveal and persist the first receive address so recovery scans have a known tip + self.wallet.persist() + + def _build_wallet_handle(self): + """Construct the full Wallet handle used by the faucet.""" + key_origin = self.address_type.key_origin(self.network) + xpub, fingerprint = software_signer_derive(str(self.mnemonic), key_origin, self.network) + keystore = KeyStore( + xpub=xpub, + fingerprint=fingerprint, + key_origin=key_origin, + label="faucet", + network=self.network, + mnemonic=str(self.mnemonic), + description="faucet", + ) + multipath_descriptor = descriptor_from_keystores( + threshold=1, + spk_providers=[keystore], + address_type=self.address_type, + network=self.network, + ) + return create_test_wallet( + wallet_id="faucet", + descriptor_str=multipath_descriptor.to_string_with_secret(), + keystores=[keystore], + backend=self.backend, + config=self.config, + is_new_wallet=True, + bitcoin_core=self.bitcoin_core, + loop_in_thread=self.loop_in_thread, + ) + + def _broadcast(self, tx: bdk.Transaction): + """Broadcast a transaction using the active backend.""" + result = bitcoin_cli(f"sendrawtransaction {serialized_to_hex(tx.serialize())}", self.bitcoin_core) + logger.info(f"bitcoin_cli sendrawtransaction answer {result=}") + # assert self.wallet.client, "Wallet backend not initialized" + # return self.wallet.client.broadcast(tx) + + def send( + self, destination_address: str, qtbot: QtBot, amount=SATOSHIS_PER_BTC, fee_rate=1 + ) -> bdk.Transaction: + """Send.""" + + psbt_for_signing = make_psbt( + wallet=self.wallet, + destination_address=destination_address, + amount=amount, + fee_rate=fee_rate, + ) + signed_psbt = self.software_signer.sign_psbt(psbt_for_signing) + if not signed_psbt: + raise RuntimeError("Faucet failed to sign transaction") + + tx = signed_psbt.extract_tx() + self._broadcast(tx) + if self.backend == "cbf": + # since I sent the tx, I will not get notified by the bitcoin core node + # so to get an update I mine a block to confirm the tx + # self.mine(blocks=1) + pass + else: + # let the backend index the tx + time.sleep(2) + self.sync(qtbot=qtbot) + return tx + + def mine(self, qtbot: QtBot, blocks=1, address=None): + return self.wallet_handle.mine(blocks=blocks, address=address, qtbot=qtbot) + + def sync(self, qtbot: QtBot): + self.wallet_handle.sync(qtbot=qtbot) + + def _initial_mine(self, qtbot: QtBot): + """Initial mine.""" + # Keep initial funding lightweight to avoid long CBF syncs. + self.wallet_handle.mine(blocks=200, qtbot=qtbot, timeout=60_000) + + def close(self): + """Clean up backend resources.""" + if hasattr(self, "wallet_handle") and self.wallet_handle: + self.wallet_handle.close() + + +@pytest.fixture(scope="session") +def faucet_session( + bitcoin_core: Path, + backend: str, + loop_in_thread: LoopInThread, + test_config_session: TestConfig, +) -> Generator[Faucet, None, None]: + """Faucet.""" + faucet_instance = Faucet( + bitcoin_core=bitcoin_core, + loop_in_thread=loop_in_thread, + test_config=test_config_session, + backend=backend, + ) + yield faucet_instance + faucet_instance.close() + + +@pytest.fixture() +def faucet(faucet_session: Faucet, qtbot: QtBot) -> Generator[Faucet, None, None]: + """Faucet.""" + if faucet_session.wallet.get_balance().total == 0: + # cannot send, what we dont have + faucet_session._initial_mine(qtbot=qtbot) + yield faucet_session diff --git a/tests/fill_regtest_wallet.py b/tests/fill_regtest_wallet.py index 97874462..53f2e895 100644 --- a/tests/fill_regtest_wallet.py +++ b/tests/fill_regtest_wallet.py @@ -39,7 +39,7 @@ class ProgressPrint: - def update(self, progress: "float", message: "str | None"): + def update(self, progress: float, message: str | None): """Update.""" print(str((progress, message))) @@ -124,7 +124,7 @@ def create_complex_transactions( rpc_ip, rpc_username, rpc_password, wallet: bdk.Wallet, blockchain, n=300, always_new_addresses=True ): """Create complex transactions.""" - for i in range(n): + for _ in range(n): try: # Build the transaction tx_builder = bdk.TxBuilder().fee_rate(1.0) diff --git a/tests/test_setup_fulcrum.py b/tests/gui/qt/conftest.py similarity index 57% rename from tests/test_setup_fulcrum.py rename to tests/gui/qt/conftest.py index dc695a6b..4748c27e 100644 --- a/tests/test_setup_fulcrum.py +++ b/tests/gui/qt/conftest.py @@ -28,12 +28,30 @@ from __future__ import annotations -from .setup_fulcrum import Faucet +import pytest -def test_faucet(faucet: Faucet): - """Test faucet.""" - faucet.mine() - balance = faucet.bdk_wallet.balance() +@pytest.fixture(autouse=True) +def mock__ask_if_full_scan(monkeypatch): + """ + GUI tests that create new wallets ask whether the wallet was used before. + Force the dialog to return False so tests consistently choose the "quick scan"/new wallet path. + """ - assert balance.total.to_sat() > 0 + # Patch the bound method on MainWindow so calls bypass the UI prompt + monkeypatch.setattr("bitcoin_safe.gui.qt.main.MainWindow._ask_if_full_scan", lambda self: True) + yield + + +@pytest.fixture(autouse=True) +def mock__ask_if_wallet_should_remain_open(monkeypatch): + """ + GUI tests that create new wallets ask whether the wallet was used before. + Force the dialog to return False so tests consistently choose the "quick scan"/new wallet path. + """ + + # Patch the bound method on MainWindow so calls bypass the UI prompt + monkeypatch.setattr( + "bitcoin_safe.gui.qt.main.MainWindow._ask_if_wallet_should_remain_open", lambda self: False + ) + yield diff --git a/tests/gui/qt/helpers.py b/tests/gui/qt/helpers.py index d76cfa11..bbf9c6a7 100644 --- a/tests/gui/qt/helpers.py +++ b/tests/gui/qt/helpers.py @@ -39,11 +39,13 @@ from collections.abc import Callable, Generator from contextlib import contextmanager from datetime import datetime +from functools import partial from pathlib import Path from time import sleep -from typing import Any, TypeVar, Union +from typing import Any, TypeVar from unittest.mock import patch +import bdkpython as bdk import objgraph import pytest from PyQt6 import QtCore @@ -70,7 +72,8 @@ from bitcoin_safe.gui.qt.qt_wallet import QTWallet from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer -from ...setup_fulcrum import Faucet +from ...faucet import Faucet +from ...util import wait_for_sync logger = logging.getLogger(__name__) @@ -175,16 +178,8 @@ def fund_wallet( ) -> str: """Fund wallet.""" address = address if address else str(qt_wallet.wallet.get_address().address) - faucet.send(address, amount=amount) - counter = 0 - while qt_wallet.wallet.get_balance().total == 0: - with qtbot.waitSignal(qt_wallet.signal_after_sync, timeout=10000): - qt_wallet.sync() - counter += 1 - if counter > 20: - raise Exception( - f"After {counter} syncing, the wallet balance is still {qt_wallet.wallet.get_balance().total}" - ) + tx = faucet.send(destination_address=address, amount=amount, qtbot=qtbot) + wait_for_sync(wallet=qt_wallet.wallet, txid=str(tx.compute_txid()), timeout=60_000, qtbot=qtbot) return address @@ -213,7 +208,7 @@ def sign_tx(qtbot: QtBot, shutter: Shutter, viewer: UITx_Viewer, qt_wallet: QTWa for button in signer_ui.findChildren(QPushButton): assert button.text() == f"Seed of '{qt_wallet.wallet.id}'" assert button.isVisible() - with qtbot.waitSignal(signer_ui.signal_signature_added, timeout=10000): + with qtbot.waitSignal(signer_ui.signal_signature_added, timeout=10_000): button.click() assert viewer.button_send.isVisible() @@ -227,10 +222,12 @@ def broadcast_tx(qtbot: QtBot, shutter: Shutter, viewer: UITx_Viewer, qt_wallet: """Broadcast tx.""" shutter.save(viewer) - with qtbot.waitSignal(qt_wallet.signal_after_sync, timeout=10000): - viewer.button_send.click() + viewer.button_send.click() + if isinstance((tx := viewer.data.data), bdk.Transaction): + qtbot.waitUntil(lambda: bool(qt_wallet.wallet.get_tx(str(tx.compute_txid()))), timeout=40_000) + qtbot.wait(1000) # to allow the ui to update - shutter.save(viewer) + shutter.save(viewer) def _get_widget_top_level(cls: type[T], title: str | None = None) -> T | None: @@ -260,7 +257,7 @@ def _get_widget_top_level(cls: type[T], title: str | None = None) -> T | None: def get_widget_top_level( - cls: type[T], qtbot: QtBot, title: str | None = None, wait: bool = True, timeout: int = 10000 + cls: type[T], qtbot: QtBot, title: str | None = None, wait: bool = True, timeout: int = 10_000 ) -> T | None: """Find the top-level widget of the specified class and title among the active widgets. @@ -282,7 +279,7 @@ def do_modal_click( on_open: Callable[[T], None], qtbot: QtBot, button: QtCore.Qt.MouseButton = QtCore.Qt.MouseButton.LeftButton, - cls: type[T] = Union[QMessageBox, QWidget], + cls: type[T] = QWidget, timeout=5000, timer_delay=500, ) -> None: @@ -316,22 +313,27 @@ def click() -> None: qtbot.mouseClick(click_pushbutton, button) QApplication.processEvents() - qtbot.waitUntil(lambda: dialog_was_opened, timeout=10000) + qtbot.waitUntil(lambda: dialog_was_opened, timeout=10_000) def get_called_args_message_box( patch_str: str, click_pushbutton: QPushButton, repeat_clicking_until_message_box_called=False, + max_attempts: int = 100, ) -> list[Any]: """Get called args message box.""" with patch(patch_str) as mock_message: + attempts = 0 while not mock_message.called: click_pushbutton.click() QApplication.processEvents() sleep(0.2) if not repeat_clicking_until_message_box_called: break + attempts += 1 + if attempts >= max_attempts: + raise TimeoutError(f"{patch_str} was not called after {attempts} attempts") called_args, called_kwargs = mock_message.call_args return called_args @@ -397,7 +399,7 @@ def close_wallet( # check that you cannot go further without import xpub """Close wallet.""" - def password_creation(dialog: QMessageBox) -> None: + def dialog(dialog: QMessageBox) -> None: """Password creation.""" shutter.save(dialog) for button in dialog.buttons(): @@ -406,8 +408,9 @@ def password_creation(dialog: QMessageBox) -> None: break node = main_window.tab_wallets.root.findNodeByTitle(wallet_name) + assert node - do_modal_click(lambda: main_window.close_tab(node), password_creation, qtbot, cls=QMessageBox) + do_modal_click(partial(main_window.close_tab, node), dialog, qtbot, cls=QMessageBox) gc.collect() @@ -429,11 +432,13 @@ def __init__( qtbot: QtBot, caplog: pytest.LogCaptureFixture, graph_directory: Path | None = None, + timeout=1_000, list_references=None, ): """Initialize instance.""" self.graph_directory = graph_directory self.caplog = caplog + self.timeout = timeout self.qtbot = qtbot self.d = list_references self.check_for_destruction: list[QtCore.QObject] = [ @@ -499,7 +504,7 @@ def __enter__(self): def __exit__(self, exc_type, exc_value, traceback): """Exit context manager.""" - with self.qtbot.waitSignals([q.destroyed for q in self.check_for_destruction], timeout=1000): + with self.qtbot.waitSignals([q.destroyed for q in self.check_for_destruction], timeout=self.timeout): if self.graph_directory: self.show_backrefs(self.check_for_destruction, self.graph_directory) self.save_referrers_to_json(self.check_for_destruction, self.graph_directory) diff --git a/tests/gui/qt/test_button_edit.py b/tests/gui/qt/test_button_edit.py index 6b0e3371..ecd6ffe9 100644 --- a/tests/gui/qt/test_button_edit.py +++ b/tests/gui/qt/test_button_edit.py @@ -29,10 +29,13 @@ from __future__ import annotations import logging -from typing import cast, Any -from unittest.mock import patch +from typing import cast +from unittest.mock import MagicMock, patch import pytest +from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol +from PyQt6.QtCore import QObject, QSize, Qt, pyqtSignal +from PyQt6.QtGui import QIcon, QResizeEvent from PyQt6.QtWidgets import QApplication, QPushButton, QToolButton, QWidget from pytestqt.qtbot import QtBot @@ -50,16 +53,6 @@ logger = logging.getLogger(__name__) -from unittest.mock import MagicMock - -from PyQt6.QtCore import QObject, QSize, Qt, pyqtSignal -from PyQt6.QtGui import QIcon, QResizeEvent - -from typing import Any, TYPE_CHECKING, cast - -from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker - - class My(QObject): close_all_video_widgets = cast(SignalProtocol[[]], pyqtSignal()) diff --git a/tests/gui/qt/test_category_manager.py b/tests/gui/qt/test_category_manager.py index 09d94562..3dae0eb5 100644 --- a/tests/gui/qt/test_category_manager.py +++ b/tests/gui/qt/test_category_manager.py @@ -39,10 +39,10 @@ from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.my_treeview import MyItemDataRole from tests.gui.qt.test_setup_wallet import close_wallet +from ...helpers import TestConfig from .helpers import Shutter, main_window_context @@ -51,7 +51,7 @@ def test_category_manager_add_and_merge( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, monkeypatch: pytest.MonkeyPatch, ) -> None: """Ensure categories can be added and merged through the category manager.""" diff --git a/tests/gui/qt/test_currency_converter.py b/tests/gui/qt/test_currency_converter.py index 3f7d357a..be9587a7 100644 --- a/tests/gui/qt/test_currency_converter.py +++ b/tests/gui/qt/test_currency_converter.py @@ -28,23 +28,22 @@ from __future__ import annotations -from pytestqt.qtbot import QtBot -import pytest import bdkpython as bdk -from PyQt6.QtCore import QLocale, QObject, pyqtSignal -from typing import Optional, Tuple -from bitcoin_safe.config import UserConfig -from bitcoin_safe.fx import FX +import pytest +from pytestqt.qtbot import QtBot +from bitcoin_safe.fx import FX from bitcoin_safe.gui.qt.currency_converter import CurrencyConverter from bitcoin_safe.gui.qt.ui_tx.spinbox import BTCSpinBox, FiatSpinBox from bitcoin_safe.util import SATOSHIS_PER_BTC +from ...helpers import TestConfig + def test_conversions( qtbot: QtBot, - test_config: UserConfig, + test_config: TestConfig, ): fx = FX(config=test_config, loop_in_thread=None, update_rates=False) fx.rates = { @@ -155,6 +154,7 @@ def test_conversions( qtbot.addWidget(fiat_spin) converter = CurrencyConverter(btc_spin, fiat_spin) + assert isinstance(converter, CurrencyConverter) btc_value = 123456 btc_spin.setValue(btc_value) diff --git a/tests/gui/qt/test_default_network_config.py b/tests/gui/qt/test_default_network_config.py index cdbd65ff..7c73bdd5 100644 --- a/tests/gui/qt/test_default_network_config.py +++ b/tests/gui/qt/test_default_network_config.py @@ -40,10 +40,9 @@ from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.qt_wallet import QTWallet -from tests.gui.qt.test_setup_wallet import close_wallet +from ...helpers import TestConfig from .helpers import CheckedDeletionContext, Shutter, close_wallet, main_window_context logger = logging.getLogger(__name__) @@ -53,7 +52,7 @@ def test_default_network_config_works( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config_main_chain: UserConfig, + test_config_main_chain: TestConfig, caplog: pytest.LogCaptureFixture, wallet_file: str = "bacon.wallet", ) -> None: diff --git a/tests/gui/qt/test_psbt.py b/tests/gui/qt/test_psbt.py index c8103847..826efa07 100644 --- a/tests/gui/qt/test_psbt.py +++ b/tests/gui/qt/test_psbt.py @@ -38,9 +38,9 @@ from pytestqt.qtbot import QtBot from bitcoin_safe.address_comparer import AddressComparer -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer +from ...helpers import TestConfig from .helpers import Shutter, main_window_context logger = logging.getLogger(__name__) @@ -51,7 +51,7 @@ def test_psbt_warning_poision_mainnet( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config_main_chain: UserConfig, + test_config_main_chain: TestConfig, caplog: pytest.LogCaptureFixture, ) -> None: # bitcoin_core: Path, """Test psbt warning poision mainnet.""" @@ -66,8 +66,9 @@ def test_psbt_warning_poision_mainnet( shutter.save(main_window) - def do_tx(tx, expected_fragments: list[str] = []): + def do_tx(tx, expected_fragments: list[str] | None = None): """Do tx.""" + expected_fragments = expected_fragments or [] main_window.open_tx_like_in_tab(tx) shutter.save(main_window) @@ -135,7 +136,7 @@ def test_psbt_warning_poision( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, caplog: pytest.LogCaptureFixture, ) -> None: # bitcoin_core: Path, """Test psbt warning poision.""" diff --git a/tests/gui/qt/test_setup_wallet.py b/tests/gui/qt/test_setup_wallet.py index e398b5ce..6a6ce7b7 100644 --- a/tests/gui/qt/test_setup_wallet.py +++ b/tests/gui/qt/test_setup_wallet.py @@ -35,22 +35,22 @@ from pathlib import Path from unittest.mock import patch +import bdkpython as bdk import pytest from bitcoin_safe_lib.gui.qt.satoshis import Satoshis from bitcoin_safe_lib.util import insert_invisible_spaces_for_wordwrap from PyQt6 import QtGui from PyQt6.QtCore import QCoreApplication from PyQt6.QtTest import QTest -from PyQt6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox, QWidget +from PyQt6.QtWidgets import QDialogButtonBox, QMessageBox, QWidget from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.bitcoin_quick_receive import BitcoinQuickReceive from bitcoin_safe.gui.qt.dialogs import WalletIdDialog from bitcoin_safe.gui.qt.my_treeview import MyItemDataRole from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer -from bitcoin_safe.gui.qt.util import MessageType +from bitcoin_safe.gui.qt.util import ColorScheme, MessageType from bitcoin_safe.gui.qt.wizard import ( BackupSeed, BuyHardware, @@ -64,9 +64,11 @@ TutorialStep, Wizard, ) -from tests.setup_fulcrum import Faucet +from tests.faucet import Faucet +from ...helpers import TestConfig from ...non_gui.test_signers import test_seeds +from ...util import wait_for_sync from .helpers import ( CheckedDeletionContext, Shutter, @@ -94,13 +96,12 @@ def enter_text(text: str, widget: QWidget) -> None: @pytest.mark.marker_qt_1 # repeated gui tests let the RAM usage increase (unclear why the memory isnt freed), and to stay under the github VM limit, we split the tests def test_wizard( - qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, - bitcoin_core: Path, + test_config: TestConfig, faucet: Faucet, caplog: pytest.LogCaptureFixture, + backend: str, wallet_name="test_tutorial_wallet_setup", amount=int(1e6), ) -> None: # bitcoin_core: Path, @@ -133,36 +134,38 @@ def on_wallet_id_dialog(dialog: WalletIdDialog) -> None: qt_protowallet = main_window.tab_wallets.root.findNodeByTitle(wallet_name).data assert isinstance(qt_protowallet, QTProtoWallet) - wizard: Wizard = qt_protowallet.wizard + wizard = qt_protowallet.wizard + assert isinstance(wizard, Wizard) - def page1() -> None: + def page1(wizard: Wizard) -> None: """Page1.""" shutter.save(main_window) - step: BuyHardware = wizard.tab_generators[TutorialStep.buy] + step = wizard.tab_generators[TutorialStep.buy] + assert isinstance(step, BuyHardware) assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() - page1() + page1(wizard) - def page_sticker() -> None: + def page_sticker(wizard: Wizard) -> None: """Page sticker.""" shutter.save(main_window) step: StickerTheHardware = wizard.tab_generators[TutorialStep.sticker] assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() - page_sticker() + page_sticker(wizard) - def page_generate() -> None: + def page_generate(wizard: Wizard) -> None: """Page generate.""" shutter.save(main_window) step: GenerateSeed = wizard.tab_generators[TutorialStep.generate] assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() - page_generate() + page_generate(wizard) - def page_import() -> None: + def page_import(wizard: Wizard) -> None: """Page import.""" shutter.save(main_window) step: ImportXpubs = wizard.tab_generators[TutorialStep.import_xpub] @@ -215,7 +218,9 @@ def wrong_entry(dialog: QMessageBox) -> None: assert "{ background-color: #ff6c54; }" in edit.styleSheet() # check that you cannot go further without import xpub - def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: + def wrong_entry_xpub_try_to_proceed( + dialog: QMessageBox, error_message: str = error_message + ) -> None: """Wrong entry xpub try to proceed.""" shutter.save(dialog) assert dialog.text() == error_message @@ -241,7 +246,9 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: with patch("bitcoin_safe.gui.qt.main.Message") as mock_message: # check that you cannot go further without import xpub - def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: + def wrong_entry_xpub_try_to_proceed( + dialog: QMessageBox, error_message: str = error_message + ) -> None: """Wrong entry xpub try to proceed.""" shutter.save(dialog) assert dialog.text() == error_message @@ -252,6 +259,7 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: ) QTest.qWait(200) + assert mock_message is not None shutter.save(main_window) edit.clear() @@ -259,8 +267,8 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: shutter.save(main_window) # correct entry - for edit in [keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint]: - edit.setText("") + for _edit in [keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint]: + _edit.setText("") keystore.edit_seed.setText(test_seeds[0]) shutter.save(main_window) assert keystore.edit_fingerprint.text().lower() == "5aa39a43" @@ -274,13 +282,13 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: keystore.textEdit_description.setText("test description") # check no error warning - for edit in [ + for _edit in [ keystore.edit_seed, keystore.edit_xpub, keystore.edit_key_origin, keystore.edit_fingerprint, ]: - assert "background-color" not in keystore.edit_xpub.input_field.styleSheet() + assert "background-color" not in _edit.input_field.styleSheet() save_wallet( test_config=test_config, @@ -288,21 +296,26 @@ def wrong_entry_xpub_try_to_proceed(dialog: QMessageBox) -> None: save_button=step.button_create_wallet, ) - page_import() + page_import(wizard) ###################################################### # now that the qt wallet is created i have to reload the qt_wallet = main_window.tab_wallets.root.findNodeByTitle(wallet_name).data - assert qt_wallet + assert isinstance(qt_wallet, QTWallet) wizard = qt_wallet.wizard + assert isinstance(wizard, Wizard) def do_all(qt_wallet: QTWallet): "any implicit reference to qt_wallet (including the function page_send) will create a cell refrence" + wizard = qt_wallet.wizard + assert isinstance(wizard, Wizard) + def page_backup() -> None: """Page backup.""" shutter.save(main_window) - step: BackupSeed = wizard.tab_generators[TutorialStep.backup_seed] + step = wizard.tab_generators[TutorialStep.backup_seed] + assert isinstance(step, BackupSeed) with patch("bitcoin_safe.pdfrecovery.xdg_open_file") as mock_open: assert step.custom_yes_button.isVisible() step.custom_yes_button.click() @@ -327,7 +340,8 @@ def switch_language() -> None: def page_receive() -> None: """Page receive.""" shutter.save(main_window) - step: ReceiveTest = wizard.tab_generators[TutorialStep.receive] + step = wizard.tab_generators[TutorialStep.receive] + assert isinstance(step, ReceiveTest) assert isinstance(step.quick_receive, BitcoinQuickReceive) address_with_spaces = step.quick_receive.group_boxes[0].label.text() assert address_with_spaces == insert_invisible_spaces_for_wordwrap( @@ -335,7 +349,8 @@ def page_receive() -> None: ) address = step.quick_receive.group_boxes[0].address assert address == "bcrt1q3qt0n3z69sds3u6zxalds3fl67rez4u2wm4hes" - faucet.send(address, amount=amount) + faucet.send(destination_address=address, amount=amount, qtbot=qtbot) + wait_for_sync(wallet=qt_wallet.wallet, qtbot=qtbot, minimum_funds=amount, timeout=30_000) called_args_message_box = get_called_args_message_box( "bitcoin_safe.gui.qt.wizard.Message", @@ -356,7 +371,8 @@ def page_receive() -> None: def page_send() -> None: """Page send.""" shutter.save(main_window) - step: SendTest = wizard.tab_generators[TutorialStep.send] + step = wizard.tab_generators[TutorialStep.send] + assert isinstance(step, SendTest) assert step.refs.floating_button_box.isVisible() assert step.refs.floating_button_box.button_create_tx.isVisible() assert not step.refs.floating_button_box.tutorial_button_prefill.isVisible() @@ -374,7 +390,7 @@ def page_send() -> None: box.recipient_widget.address_edit.input_field.palette() .color(QtGui.QPalette.ColorRole.Base) .name() - == "#8af296" + == ColorScheme.GREEN.as_color(background=True).name() ) fee_info = qt_wallet.uitx_creator.estimate_fee_info( qt_wallet.uitx_creator.column_fee.fee_group.spin_fee_rate.value() @@ -402,18 +418,22 @@ def page_sign() -> None: assert round(viewer.fee_info.fee_rate(), 1) == 1.0 assert not viewer.column_fee.fee_group.allow_edit assert viewer.column_fee.fee_group.spin_fee_rate.value() == 1.0 - assert viewer.column_fee.fee_group.cpfp_fee_label.isVisible() + if backend == "cbf": + # CBF backend mines blocks for the faucet so the tx is confirmed; CPFP label stays hidden + assert not viewer.column_fee.fee_group.cpfp_fee_label.isVisible() + else: + assert viewer.column_fee.fee_group.cpfp_fee_label.isVisible() assert not viewer.column_fee.fee_group.approximate_fee_label.isVisible() sign_tx(qt_wallet=qt_wallet, qtbot=qtbot, shutter=shutter, viewer=viewer) with patch("bitcoin_safe.gui.qt.wizard.Message") as mock_message: - with qtbot.waitSignal( - main_window.wallet_functions.wallet_signals[qt_wallet.wallet.id].updated, - timeout=10000, - ): # Timeout after 10 seconds - viewer.button_send.click() - qtbot.wait(10000) + viewer.button_send.click() + assert isinstance((tx := viewer.data.data), bdk.Transaction) + wait_for_sync( + qtbot=qtbot, wallet=qt_wallet.wallet, txid=str(tx.compute_txid()), timeout=20_000 + ) + qtbot.wait_until(lambda: bool(mock_message.call_count), timeout=10_000) mock_message.assert_called_with( main_window.tr("All Send tests done successfully."), type=MessageType.Info ) @@ -427,7 +447,8 @@ def page10() -> None: """Page10.""" shutter.save(main_window) - step: DistributeSeeds = wizard.tab_generators[TutorialStep.distribute] + step = wizard.tab_generators[TutorialStep.distribute] + assert isinstance(step, DistributeSeeds) assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() @@ -435,22 +456,22 @@ def page10() -> None: page10() - def page11() -> None: + def page11(wizard: Wizard) -> None: """Page11.""" shutter.save(main_window) - step: LabelBackup = wizard.tab_generators[TutorialStep.sync] + step = wizard.tab_generators[TutorialStep.sync] + assert isinstance(step, LabelBackup) assert step.buttonbox_buttons[0].isVisible() step.buttonbox_buttons[0].click() shutter.save(main_window) - page11() + page11(wizard) do_all(qt_wallet) - del wizard - def check_address_balances(): + def check_address_balances(qt_wallet: QTWallet): """Check address balances.""" wallet = qt_wallet.wallet @@ -464,9 +485,9 @@ def check_address_balances(): assert total assert total == wallet.get_balance().total - check_address_balances() + check_address_balances(qt_wallet) - def check_utxo_list(): + def check_utxo_list(qt_wallet: QTWallet): """Check utxo list.""" qt_wallet.tabs.setCurrentWidget(qt_wallet.uitx_creator) qt_wallet.uitx_creator.column_inputs.checkBox_manual_coin_select.setChecked(True) @@ -486,7 +507,8 @@ def check_utxo_list(): assert total assert total == qt_wallet.wallet.get_balance().total - check_utxo_list() + check_utxo_list(qt_wallet) + del wizard with CheckedDeletionContext( qt_wallet=qt_wallet, qtbot=qtbot, caplog=caplog, graph_directory=shutter.used_directory() diff --git a/tests/gui/qt/test_setup_wallet_custom.py b/tests/gui/qt/test_setup_wallet_custom.py index 90d816cd..6ab0c0f8 100644 --- a/tests/gui/qt/test_setup_wallet_custom.py +++ b/tests/gui/qt/test_setup_wallet_custom.py @@ -41,14 +41,12 @@ from PyQt6.QtWidgets import QApplication, QDialogButtonBox, QMessageBox from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.block_change_signals import BlockChangesSignals from bitcoin_safe.gui.qt.descriptor_edit import DescriptorExport from bitcoin_safe.gui.qt.dialogs import WalletIdDialog from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet -from tests.gui.qt.test_setup_wallet import close_wallet, save_wallet -from ...setup_fulcrum import Faucet +from ...helpers import TestConfig from .helpers import ( CheckedDeletionContext, Shutter, @@ -71,11 +69,9 @@ def test_custom_wallet_setup_custom_single_sig( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, - faucet: Faucet, + test_config: TestConfig, caplog: pytest.LogCaptureFixture, wallet_name: str = "test_custom_wallet_setup_custom_single_sig", - amount: int = int(1e6), ) -> None: """Test custom wallet setup custom single sig.""" frame = inspect.currentframe() @@ -254,7 +250,7 @@ def switch_language() -> None: QApplication.processEvents() wallet_name = qt_wallet.wallet.id - def replace_descriptor() -> QTWallet: + def replace_descriptor(qt_wallet: QTWallet) -> QTWallet: """Replace descriptor.""" descriptor = "wpkh([e53c8089/84'/1'/0']tpubDDg6DvnYDvUcRW3a5HeHoFCpKZYDm4qhKP7ccArDsSBSvBjc4d7AMdijAbekZ4c7NaP1zKWZ4qgHYzbQ6gv2Sge4MWWp96zhhAkjvge22FW/<0;1>/*)#uduv7uyw" @@ -313,7 +309,7 @@ def on_proceed_question(dialog: QMessageBox) -> None: with CheckedDeletionContext( qt_wallet=qt_wallet, qtbot=qtbot, caplog=caplog, graph_directory=shutter.used_directory() ): - new_wallet = replace_descriptor() + new_wallet = replace_descriptor(qt_wallet) del qt_wallet with CheckedDeletionContext( @@ -323,6 +319,7 @@ def on_proceed_question(dialog: QMessageBox) -> None: wallet_id = new_wallet.wallet.id del new_wallet + qtbot.wait(1000) close_wallet( shutter=shutter, test_config=test_config, diff --git a/tests/gui/qt/test_tx_signature_import.py b/tests/gui/qt/test_tx_signature_import.py index cb91aed1..901df3be 100644 --- a/tests/gui/qt/test_tx_signature_import.py +++ b/tests/gui/qt/test_tx_signature_import.py @@ -37,12 +37,12 @@ from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.dialog_import import ImportDialog from bitcoin_safe.gui.qt.import_export import HorizontalImportExportAll from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer from bitcoin_safe.signer import SignatureImporterFile +from ...helpers import TestConfig from .helpers import Shutter, do_modal_click, main_window_context logger = logging.getLogger(__name__) @@ -53,7 +53,7 @@ def test_signature_import_of_psbt_without_utxos( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, caplog: pytest.LogCaptureFixture, ) -> None: """Test signature import of psbt without utxos.""" diff --git a/tests/gui/qt/test_wallet_features.py b/tests/gui/qt/test_wallet_features.py index 94c70b11..6832e27f 100644 --- a/tests/gui/qt/test_wallet_features.py +++ b/tests/gui/qt/test_wallet_features.py @@ -45,8 +45,6 @@ from PyQt6.QtWidgets import QApplication, QDialogButtonBox from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig -from bitcoin_safe.gui.qt.about_tab import LicenseDialog from bitcoin_safe.gui.qt.block_change_signals import BlockChangesSignals from bitcoin_safe.gui.qt.descriptor_edit import DescriptorExport from bitcoin_safe.gui.qt.dialog_import import ImportDialog @@ -56,10 +54,10 @@ from bitcoin_safe.gui.qt.register_multisig import RegisterMultisigInteractionWidget from bitcoin_safe.gui.qt.settings import Settings from bitcoin_safe.hardware_signers import DescriptorQrExportTypes -from tests.gui.qt.test_setup_wallet import close_wallet, save_wallet +from ...faucet import Faucet +from ...helpers import TestConfig from ...non_gui.test_signers import test_seeds -from ...setup_fulcrum import Faucet from .helpers import ( CheckedDeletionContext, Shutter, @@ -77,7 +75,7 @@ def test_wallet_features_multisig( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, faucet: Faucet, caplog: pytest.LogCaptureFixture, wallet_name: str = "test_custom_wallet_setup_custom_single_sig2", @@ -376,7 +374,7 @@ def callback(dialog: RegisterMultisigInteractionWidget) -> None: with qtbot.waitSignal( dialog.export_qr_button.export_qr_widget.signal_set_qr_images, timeout=5000, - ) as blocker: + ): dialog.export_qr_button.export_qr_widget.combo_qr_type.setCurrentIndex(i) dialog.export_qr_button.export_qr_widget.button_save_qr.click() diff --git a/tests/gui/qt/test_wallet_open.py b/tests/gui/qt/test_wallet_open.py index 0966e71d..6d2a9157 100644 --- a/tests/gui/qt/test_wallet_open.py +++ b/tests/gui/qt/test_wallet_open.py @@ -40,12 +40,12 @@ from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt import address_dialog -from bitcoin_safe.gui.qt.util import svg_tools +from bitcoin_safe.gui.qt.qt_wallet import QTWallet from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer +from bitcoin_safe.gui.qt.util import svg_tools -from ...setup_fulcrum import Faucet +from ...helpers import TestConfig from .helpers import ( CheckedDeletionContext, Shutter, @@ -62,7 +62,7 @@ def test_open_wallet_and_address_is_consistent_and_destruction_ok( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, caplog: pytest.LogCaptureFixture, wallet_file: str = "0.2.0.wallet", ) -> None: @@ -84,7 +84,7 @@ def test_open_wallet_and_address_is_consistent_and_destruction_ok( shutil.copy(str(wallet_path), str(temp_dir)) qt_wallet = main_window.open_wallet(str(temp_dir)) - assert qt_wallet + assert isinstance(qt_wallet, QTWallet) waiting_icon = svg_tools.get_QIcon("status_waiting.svg") connected_icon = svg_tools.get_QIcon("status_connected.svg") @@ -92,14 +92,14 @@ def test_open_wallet_and_address_is_consistent_and_destruction_ok( # QIcon.cacheKey() uniquely identifies the rendered icon, so matching cache keys # verifies that the expected asset is set on the tab. qtbot.waitUntil( - lambda: (_node := main_window.tab_wallets.root.findNodeByWidget(qt_wallet.tabs)) + lambda: (_node := main_window.tab_wallets.root.findNodeByWidget(qt_wallet.tabs)) # noqa: F821 and _node.icon and _node.icon.cacheKey() == waiting_icon.cacheKey(), timeout=10000, ) qtbot.waitUntil( - lambda: (_node := main_window.tab_wallets.root.findNodeByWidget(qt_wallet.tabs)) + lambda: (_node := main_window.tab_wallets.root.findNodeByWidget(qt_wallet.tabs)) # noqa: F821 and _node.icon and _node.icon.cacheKey() == connected_icon.cacheKey(), timeout=10000, @@ -112,7 +112,7 @@ def test_open_wallet_and_address_is_consistent_and_destruction_ok( wallet_address = qt_wallet.wallet.get_addresses()[0] assert wallet_address == "bcrt1qklm7yyvyu2av4f35ve6tm8mpn6mkr8e3dpjd3jp9vn77vu670g7qu9cznl" - def check_open_address_dialog(): + def check_open_address_dialog(qt_wallet: QTWallet): """Check open address dialog.""" prev_count = len(main_window.attached_widgets) main_window.show_address(wallet_address, qt_wallet.wallet.id) @@ -126,13 +126,13 @@ def check_open_address_dialog(): QApplication.processEvents() qtbot.waitUntil(lambda: d not in main_window.attached_widgets) - check_open_address_dialog() + check_open_address_dialog(qt_wallet) - def check_empty(): + def check_empty(qt_wallet: QTWallet): """Check empty.""" assert qt_wallet.wallet.get_balance().total == 0 - check_empty() + check_empty(qt_wallet) def open_tx() -> UITx_Viewer: """Open tx.""" @@ -145,7 +145,7 @@ def open_tx() -> UITx_Viewer: return child.widget raise Exception("no UITx_Viewer found") - def save_tx_to_local(tx_tab: UITx_Viewer): + def save_tx_to_local(tx_tab: UITx_Viewer, qt_wallet: QTWallet): """Save tx to local.""" assert tx_tab.button_save_local_tx.isVisible() tx_tab.save_local_tx() @@ -162,12 +162,12 @@ def save_tx_to_local(tx_tab: UITx_Viewer): if isinstance(child.widget, UITx_Viewer): child.removeNode() - def open_and_save_tx(): + def open_and_save_tx(qt_wallet: QTWallet): """Open and save tx.""" tx_tab = open_tx() - save_tx_to_local(tx_tab) + save_tx_to_local(tx_tab, qt_wallet) - open_and_save_tx() + open_and_save_tx(qt_wallet) # if True: with CheckedDeletionContext( @@ -205,7 +205,7 @@ def test_open_same_wallet_twice( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, monkeypatch: pytest.MonkeyPatch, ) -> None: """Ensure reopening the same wallet shows an info message.""" diff --git a/tests/gui/qt/test_wallet_print.py b/tests/gui/qt/test_wallet_print.py index 3aab04c2..88b91674 100644 --- a/tests/gui/qt/test_wallet_print.py +++ b/tests/gui/qt/test_wallet_print.py @@ -32,18 +32,17 @@ import shutil from datetime import datetime from pathlib import Path -from time import sleep import pytest -from PyQt6.QtCore import Qt from PyQt6.QtTest import QTest from PyQt6.QtWidgets import QApplication from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer -from tests.setup_fulcrum import Faucet -from .test_wallet_send import SEND_TEST_WALLET_FUND_AMOUNT +from tests.faucet import Faucet + +from ...helpers import TestConfig +from ...util import wait_for_sync from .helpers import ( CheckedDeletionContext, Shutter, @@ -51,6 +50,7 @@ fund_wallet, main_window_context, ) +from .test_wallet_send import SEND_TEST_WALLET_FUND_AMOUNT @pytest.mark.marker_qt_2 @@ -58,7 +58,7 @@ def test_print_existing_transaction( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, faucet: Faucet, monkeypatch: pytest.MonkeyPatch, tmp_path: Path, @@ -82,7 +82,9 @@ def test_print_existing_transaction( assert qt_wallet if not qt_wallet.wallet.sorted_delta_list_transactions(): - fund_wallet(qtbot=qtbot, faucet=faucet, qt_wallet=qt_wallet, amount=SEND_TEST_WALLET_FUND_AMOUNT) + fund_wallet(faucet=faucet, qt_wallet=qt_wallet, amount=SEND_TEST_WALLET_FUND_AMOUNT, qtbot=qtbot) + + wait_for_sync(wallet=qt_wallet.wallet, minimum_funds=1, qtbot=qtbot) tx_history = qt_wallet.wallet.sorted_delta_list_transactions() diff --git a/tests/gui/qt/test_wallet_rbf_cpfp.py b/tests/gui/qt/test_wallet_rbf_cpfp.py index f77d923d..a979dada 100644 --- a/tests/gui/qt/test_wallet_rbf_cpfp.py +++ b/tests/gui/qt/test_wallet_rbf_cpfp.py @@ -38,14 +38,14 @@ from PyQt6.QtWidgets import QApplication, QDialogButtonBox from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.dialogs import WalletIdDialog from bitcoin_safe.gui.qt.qt_wallet import QTProtoWallet, QTWallet from bitcoin_safe.gui.qt.ui_tx.ui_tx_creator import UITx_Creator from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer +from ...faucet import Faucet +from ...helpers import TestConfig from ...non_gui.test_signers import test_seeds -from ...setup_fulcrum import Faucet from .helpers import ( CheckedDeletionContext, Shutter, @@ -66,7 +66,7 @@ def test_rbf_cpfp_flow( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, faucet: Faucet, caplog: pytest.LogCaptureFixture, wallet_name: str = "test_rbf_cpfp_flow", @@ -79,7 +79,7 @@ def test_rbf_cpfp_flow( shutter.create_symlink(test_config=test_config) with main_window_context(test_config=test_config) as main_window: - QTest.qWaitForWindowExposed(main_window, timeout=10000) + QTest.qWaitForWindowExposed(main_window, timeout=10_000) assert main_window.windowTitle() == "Bitcoin Safe - REGTEST" shutter.save(main_window) @@ -126,7 +126,7 @@ def create_transaction_to_self( box.amount = amount // 2 qt_wallet.uitx_creator.column_fee.fee_group.spin_fee_rate.setValue(1.0) shutter.save(main_window) - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): qt_wallet.uitx_creator.button_ok.click() viewer = main_window.tab_wallets.currentNode().data assert isinstance(viewer, UITx_Viewer) @@ -135,13 +135,13 @@ def create_transaction_to_self( def create_RBF_transaction(viewer: UITx_Viewer, qt_wallet: QTWallet) -> tuple[UITx_Viewer, str]: """Create RBF transaction.""" shutter.save(main_window) - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): viewer.button_rbf.click() shutter.save(main_window) creator_rbf = main_window.tab_wallets.currentNode().data assert isinstance(creator_rbf, UITx_Creator) assert creator_rbf.column_fee.fee_group.rbf_fee_label.isVisible() - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): creator_rbf.button_ok.click() shutter.save(main_window) viewer_rbf = main_window.tab_wallets.currentNode().data @@ -156,21 +156,23 @@ def create_RBF_transaction(viewer: UITx_Viewer, qt_wallet: QTWallet) -> tuple[UI mock_message.return_value = False broadcast_tx(qt_wallet=qt_wallet, qtbot=qtbot, shutter=shutter, viewer=viewer_rbf) + qtbot.waitUntil(lambda: mock_message.call_count >= 1, timeout=10_000) mock_message.assert_called_once() assert not viewer_rbf.button_cpfp_tx.isVisible() assert not viewer_rbf.button_rbf.isVisible() + return viewer_rbf, txid_rbf def create_CPFP_transaction(viewer_rbf: UITx_Viewer, qt_wallet: QTWallet) -> tuple[UITx_Viewer, str]: """Create CPFP transaction.""" - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): viewer_rbf.button_cpfp_tx.click() shutter.save(main_window) creator_cpfp = main_window.tab_wallets.currentNode().data assert isinstance(creator_cpfp, UITx_Creator) creator_cpfp.column_fee.fee_group.spin_fee_rate.setValue(5.0) - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): creator_cpfp.button_ok.click() shutter.save(main_window) viewer_cpfp = main_window.tab_wallets.currentNode().data @@ -183,7 +185,7 @@ def create_CPFP_transaction(viewer_rbf: UITx_Viewer, qt_wallet: QTWallet) -> tup return viewer_cpfp, txid_cpfp - fund_wallet(qt_wallet=qt_wallet, amount=amount, qtbot=qtbot, faucet=faucet) + fund_wallet(qt_wallet=qt_wallet, amount=amount, faucet=faucet, qtbot=qtbot) address_info = qt_wallet.wallet.get_unused_category_address(category=None) viewer, txid_a = create_transaction_to_self(qt_wallet, str(address_info.address), amount) viewer.button_next.click() @@ -198,10 +200,10 @@ def create_CPFP_transaction(viewer_rbf: UITx_Viewer, qt_wallet: QTWallet) -> tup # focus viewer again qt_wallet.signals.open_tx_like.emit(txid_rbf) shutter.save(main_window) - txids = [tx.txid for tx in qt_wallet.wallet.bdkwallet.list_transactions()] assert txid_a not in txids assert txid_rbf in txids + viewer_cpfp, txid_cpfp = create_CPFP_transaction(viewer_rbf, qt_wallet) txids = [tx.txid for tx in qt_wallet.wallet.bdkwallet.list_transactions()] assert txid_cpfp in txids diff --git a/tests/gui/qt/test_wallet_send.py b/tests/gui/qt/test_wallet_send.py index 87de5256..9c31ebe5 100644 --- a/tests/gui/qt/test_wallet_send.py +++ b/tests/gui/qt/test_wallet_send.py @@ -40,14 +40,13 @@ from PyQt6.QtWidgets import QApplication, QPushButton from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.gui.qt.import_export import HorizontalImportExportAll from bitcoin_safe.gui.qt.keystore_ui import SignerUI from bitcoin_safe.gui.qt.qt_wallet import QTWallet from bitcoin_safe.gui.qt.ui_tx.ui_tx_viewer import UITx_Viewer -from tests.gui.qt.test_setup_wallet import close_wallet -from ...setup_fulcrum import Faucet +from ...faucet import Faucet +from ...helpers import TestConfig from .helpers import ( CheckedDeletionContext, Shutter, @@ -66,7 +65,7 @@ def test_wallet_send( qapp: QApplication, qtbot: QtBot, mytest_start_time: datetime, - test_config: UserConfig, + test_config: TestConfig, faucet: Faucet, caplog: pytest.LogCaptureFixture, wallet_file: str = "send_test.wallet", @@ -78,7 +77,7 @@ def test_wallet_send( shutter.create_symlink(test_config=test_config) with main_window_context(test_config=test_config) as main_window: - QTest.qWaitForWindowExposed(main_window, timeout=10000) # type: ignore # This will wait until the window is fully exposed + QTest.qWaitForWindowExposed(main_window, timeout=10_000) # type: ignore # This will wait until the window is fully exposed assert main_window.windowTitle() == "Bitcoin Safe - REGTEST" shutter.save(main_window) @@ -102,7 +101,10 @@ def do_all(qt_wallet: QTWallet): if not qt_wallet.wallet.sorted_delta_list_transactions(): fund_wallet( - qt_wallet=qt_wallet, amount=SEND_TEST_WALLET_FUND_AMOUNT, qtbot=qtbot, faucet=faucet + qt_wallet=qt_wallet, + amount=SEND_TEST_WALLET_FUND_AMOUNT, + faucet=faucet, + qtbot=qtbot, ) def import_recipients() -> None: @@ -149,7 +151,7 @@ def import_recipients() -> None: def create_signed_tx() -> None: """Create signed tx.""" - with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10000): + with qtbot.waitSignal(main_window.signals.open_tx_like, timeout=10_000): qt_wallet.uitx_creator.button_ok.click() shutter.save(main_window) @@ -188,7 +190,7 @@ def create_signed_tx() -> None: assert button.isVisible() button.click() - with qtbot.waitSignal(signer_ui.signal_signature_added, timeout=10000): + with qtbot.waitSignal(signer_ui.signal_signature_added, timeout=10_000): button.click() shutter.save(main_window) @@ -223,7 +225,7 @@ def send_tx() -> None: assert r.amount == 9996804 assert r.label == "Change of: 1, 2" - with qtbot.waitSignal(qt_wallet.signal_after_sync, timeout=10000): + with qtbot.waitSignal(qt_wallet.wallet_signals.updated, timeout=20_000): ui_tx_viewer.button_send.click() shutter.save(main_window) diff --git a/tests/helpers.py b/tests/helpers.py index 79bdb030..b4529b69 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -36,38 +36,55 @@ import pytest from bitcoin_safe.config import UserConfig +from bitcoin_safe.network_config import P2pListenerType from bitcoin_safe.pythonbdk_types import BlockchainType +from .setup_bitcoin_core import BITCOIN_HOST, BITCOIN_LISTEN_PORT + logger = logging.getLogger(__name__) +def _configure_network_backend(config: TestConfig, backend: str, fulcrum: str | None) -> None: + """Apply the desired blockchain backend to the config.""" + + if backend == "cbf": + config.network_config.server_type = BlockchainType.CompactBlockFilter + config.network_config.cbf_connections = 1 + + config.network_config.p2p_listener_type = P2pListenerType.inital + config.network_config.p2p_inital_url = f"{BITCOIN_HOST}:{BITCOIN_LISTEN_PORT}" + config.network_config.p2p_autodiscover_additional_peers = False + else: + assert fulcrum, "Fulcrum backend requested but no server URL provided" + config.network_config.server_type = BlockchainType.Electrum + config.network_config.electrum_url = fulcrum + config.network_config.electrum_use_ssl = False + config.network_config.p2p_listener_type = P2pListenerType.deactive + + class TestConfig(UserConfig): config_dir = Path(tempfile.mkdtemp()) config_file = Path(config_dir) / (UserConfig.app_name + ".conf") @pytest.fixture() -def test_config(fulcrum: str) -> TestConfig: +def test_config(backend: str, fulcrum: str | None) -> TestConfig: """Test config.""" config = TestConfig() logger.info(f"Setting config_dir = {config.config_dir} and config_file = {config.config_file}") config.network = bdk.Network.REGTEST - config.network_config.server_type = BlockchainType.Electrum - config.network_config.electrum_url = fulcrum - config.network_config.electrum_use_ssl = False + _configure_network_backend(config, backend, fulcrum) config.auto_label_change_addresses = True return config @pytest.fixture(scope="session") -def test_config_session(fulcrum: str) -> TestConfig: +def test_config_session(backend: str, fulcrum: str | None) -> TestConfig: """Test config session.""" config = TestConfig() logger.info(f"Setting config_dir = {config.config_dir} and config_file = {config.config_file}") config.network = bdk.Network.REGTEST - config.network_config.server_type = BlockchainType.Electrum - config.network_config.electrum_url = fulcrum - config.network_config.electrum_use_ssl = False + _configure_network_backend(config, backend, fulcrum) return config diff --git a/tests/non_gui/test_descriptors.py b/tests/non_gui/test_descriptors.py index 52cfb4ed..3598016b 100644 --- a/tests/non_gui/test_descriptors.py +++ b/tests/non_gui/test_descriptors.py @@ -37,7 +37,6 @@ from bitcoin_safe.descriptors import from_multisig_wallet_export logger = logging.getLogger(__name__) -import logging def test_from_multisig_wallet_export(): @@ -60,6 +59,7 @@ def test_from_multisig_wallet_export(): assert data.data_type == DataType.MultisigWalletExport assert isinstance(data.data, ConverterMultisigWalletExport) descriptor = from_multisig_wallet_export(data.data, network=bdk.Network.BITCOIN) + assert isinstance(descriptor, bdk.Descriptor) # see also https://jlopp.github.io/xpub-converter/ assert ( @@ -85,10 +85,11 @@ def test_from_multisig_wallet_export_incompatible(): assert data.data_type == DataType.MultisigWalletExport assert isinstance(data.data, ConverterMultisigWalletExport) - with pytest.raises(Exception): + with pytest.raises(Exception): # noqa: B017 # https://github.com/bitcoin/bips/blob/master/bip-0048.mediawiki # clearly specifies # Nested Segwit (p2sh-p2wsh) mainnet, account 0: 1': Nested Segwit (p2sh-p2wsh) m/48'/0'/0'/1' # however the wallet export reverses this order of P2WSH-P2SH # Currently I dont have a consitent way of handling this, therefore it is better to raise an error here. descriptor = from_multisig_wallet_export(data.data, network=bdk.Network.REGTEST) + assert isinstance(descriptor, bdk.Descriptor) diff --git a/tests/non_gui/test_filename_info.py b/tests/non_gui/test_filename_info.py index cbcf5f90..d6aa0d41 100644 --- a/tests/non_gui/test_filename_info.py +++ b/tests/non_gui/test_filename_info.py @@ -27,6 +27,7 @@ # SOFTWARE. from __future__ import annotations + import logging from bitcoin_safe.signature_manager import FilenameInfo diff --git a/tests/non_gui/test_keystore.py b/tests/non_gui/test_keystore.py index 9e5645d9..df56e874 100644 --- a/tests/non_gui/test_keystore.py +++ b/tests/non_gui/test_keystore.py @@ -34,17 +34,17 @@ from bitcoin_qr_tools.signer_info import SignerInfo from bitcoin_usb.address_types import SimplePubKeyProvider -from bitcoin_safe.config import UserConfig from bitcoin_safe.keystore import KeyStore, sorted_keystores from bitcoin_safe.wallet_util import WalletDifferenceType +from ..helpers import TestConfig from .test_signers import test_seeds from .utils import create_test_seed_keystores logger = logging.getLogger(__name__) -def test_dump(test_config: UserConfig): +def test_dump(test_config: TestConfig): "Tests if dump works correctly" network = bdk.Network.REGTEST diff --git a/tests/non_gui/test_label_timestamp_chain.py b/tests/non_gui/test_label_timestamp_chain.py index 6d56edc5..807a4711 100644 --- a/tests/non_gui/test_label_timestamp_chain.py +++ b/tests/non_gui/test_label_timestamp_chain.py @@ -33,10 +33,10 @@ import bdkpython as bdk -from bitcoin_safe.config import UserConfig from bitcoin_safe.labels import AUTOMATIC_TIMESTAMP from bitcoin_safe.wallet import Wallet +from ..helpers import TestConfig from .utils import create_multisig_protowallet @@ -55,7 +55,7 @@ def test_chained_label_timestamp_updates() -> None: network=bdk.Network.REGTEST, ) - config = UserConfig() + config = TestConfig() config.network = bdk.Network.REGTEST wallet = Wallet.from_protowallet(protowallet=protowallet, config=config, loop_in_thread=None) diff --git a/tests/non_gui/test_labels.py b/tests/non_gui/test_labels.py index f42ff55d..0369527b 100644 --- a/tests/non_gui/test_labels.py +++ b/tests/non_gui/test_labels.py @@ -32,11 +32,11 @@ import json from time import sleep -from bitcoin_safe.config import UserConfig from bitcoin_safe.labels import AUTOMATIC_TIMESTAMP, Label, Labels, LabelType from bitcoin_safe.util import clean_lines from bitcoin_safe.wallet import Wallet +from ..helpers import TestConfig from .utils import create_multisig_protowallet @@ -240,7 +240,7 @@ def test_import(): assert cleaned_s == labels.export_bip329_jsonlines() -def test_label_timestamp_correctly(test_config: UserConfig): +def test_label_timestamp_correctly(test_config: TestConfig): """Automatic category setting also sets a timestamp. It is crucial that the automatic timestamp is in the far past, such that when exchaning labels with @@ -284,7 +284,7 @@ def check_timestamps_behavior_correct(w: Wallet): ) w_org = Wallet.from_protowallet(protowallet=protowallet, config=test_config, loop_in_thread=None) - for i in range(4): + for _ in range(4): w_org.get_force_new_address(is_change=False) w_org.set_addresses_category_if_unused("manual", addresses=w_org.get_addresses()) @@ -305,7 +305,7 @@ def check_timestamps_behavior_correct(w: Wallet): network=test_config.network, ) w_copy = Wallet.from_protowallet(protowallet=protowallet2, config=test_config, loop_in_thread=None) - for i in range(4): + for _ in range(4): w_copy.get_force_new_address(is_change=False) w_copy.set_addresses_category_if_unused("should_be_overwritten", addresses=w_org.get_addresses()) diff --git a/tests/non_gui/test_migration.py b/tests/non_gui/test_migration.py index 76c40068..bd1ea2b8 100644 --- a/tests/non_gui/test_migration.py +++ b/tests/non_gui/test_migration.py @@ -31,24 +31,16 @@ from pathlib import Path import bdkpython as bdk -import pytest from bitcoin_safe.config import UserConfig from bitcoin_safe.pythonbdk_types import BlockchainType from bitcoin_safe.storage import Storage from bitcoin_safe.wallet import Wallet +from ..helpers import TestConfig -@pytest.fixture -def config() -> UserConfig: - """Config.""" - config = UserConfig() - config.network = bdk.Network.REGTEST - return config - - -def test_011(config: UserConfig): +def test_011(test_config: TestConfig): """Test 011.""" file_path = "tests/data/0.1.1.wallet" @@ -56,7 +48,7 @@ def test_011(config: UserConfig): assert not Storage().has_password(file_path) - wallet = Wallet.from_file(file_path, config, password=password, loop_in_thread=None) + wallet = Wallet.from_file(file_path, test_config, password=password, loop_in_thread=None) assert wallet @@ -80,9 +72,9 @@ def test_config_0_1_6_testnet3_electrum(): assert config.network_config.network == bdk.Network.TESTNET assert config.network_config.server_type == BlockchainType.Electrum assert config.network_config.electrum_url == "blockstream.info:993" - assert config.network_config.electrum_use_ssl == True + assert config.network_config.electrum_use_ssl - assert config.network_config.proxy_url == None + assert config.network_config.proxy_url is None def test_config_0_1_6_testnet4_electrum(): @@ -94,9 +86,9 @@ def test_config_0_1_6_testnet4_electrum(): assert config.network_config.network == bdk.Network.TESTNET4 assert config.network_config.server_type == BlockchainType.Electrum assert config.network_config.electrum_url == "mempool.space:40002" - assert config.network_config.electrum_use_ssl == True + assert config.network_config.electrum_use_ssl - assert config.network_config.proxy_url == None + assert config.network_config.proxy_url is None assert bdk.Network.TESTNET4 in config.recently_open_wallets @@ -109,7 +101,7 @@ def test_config_0_1_6_testnet4_proxy_electrum(): assert config.network_config.network == bdk.Network.TESTNET4 assert config.network_config.server_type == BlockchainType.Electrum assert config.network_config.electrum_url == "mempool.space:40002" - assert config.network_config.electrum_use_ssl == True + assert config.network_config.electrum_use_ssl assert config.network_config.proxy_url == "127.0.0.1:9050" assert bdk.Network.TESTNET4 in config.recently_open_wallets @@ -124,7 +116,7 @@ def test_config_0_1_6_rpc(): assert config.network_config.network == bdk.Network.BITCOIN assert config.network_config.server_type == BlockchainType.Electrum assert config.network_config.electrum_url == "" # removed because of rpc - assert config.network_config.electrum_use_ssl == True + assert config.network_config.electrum_use_ssl - assert config.network_config.proxy_url == None + assert config.network_config.proxy_url is None assert bdk.Network.TESTNET4 in config.recently_open_wallets diff --git a/tests/non_gui/test_psbt_util.py b/tests/non_gui/test_psbt_util.py index 905e9023..303ec01a 100644 --- a/tests/non_gui/test_psbt_util.py +++ b/tests/non_gui/test_psbt_util.py @@ -27,15 +27,16 @@ # SOFTWARE. from __future__ import annotations + from typing import cast -from PyQt6.QtCore import QLocale, QObject, pyqtSignal -from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol import bdkpython as bdk from bitcoin_qr_tools.data import Data +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread +from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.tx_util import serialized_to_hex +from PyQt6.QtCore import QObject, pyqtSignal from pytestqt.qtbot import QtBot -from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_safe.psbt_util import SimpleOutput, SimplePSBT from bitcoin_safe.pythonbdk_types import TxOut @@ -90,7 +91,7 @@ def test_psbt_0_1of1(): """Test psbt 0 1of1.""" psbt = SimplePSBT.from_psbt(p2wsh_psbt_0_1of1) input_ = psbt.inputs[0] - input_._get_m_of_n + assert input_._get_m_of_n assert len(input_.partial_sigs) == 0, "psbt_0_1of1 should have 0 signatures" assert not input_.is_fully_signed(), "psbt_0_1of1 should not be fully signed" @@ -130,7 +131,7 @@ def test_psbt_optional_fields(): """Test psbt optional fields.""" psbt = SimplePSBT.from_psbt(p2wsh_psbt_0_2of2) assert psbt.inputs[0].non_witness_utxo - psbt.inputs[0].non_witness_utxo.get("input", {}) == [ + assert psbt.inputs[0].non_witness_utxo.get("input", {}) == [ { "previous_output": "a873ec7d905086acf870db501cfe4b6fa6e4e9d00b68c663127f7a5f637cf5aa:0", "script_sig": "", @@ -142,19 +143,23 @@ def test_psbt_optional_fields(): } ] - psbt.outputs[0].value == 9999850 - psbt.outputs[0].script_pubkey == "00206fcddedc8359bc8f6b05fea029d4132f8b6e565a71b5e6328062d2739c9efe02" - psbt.outputs[ - 0 - ].witness_script == "522102729c4c6093da33fe4c21ff5181e896ede27dab3b64da9fe704d1dcc3616dd1ea21029065858503bee197c9ba5bb2f5f37171cc6fe36292361ede4aa9f31bd732878952ae" + assert psbt.outputs[0].value == 9999850 + assert ( + psbt.outputs[0].script_pubkey + == "00206fcddedc8359bc8f6b05fea029d4132f8b6e565a71b5e6328062d2739c9efe02" + ) + assert ( + psbt.outputs[0].witness_script + == "522102729c4c6093da33fe4c21ff5181e896ede27dab3b64da9fe704d1dcc3616dd1ea21029065858503bee197c9ba5bb2f5f37171cc6fe36292361ede4aa9f31bd732878952ae" + ) - psbt.outputs[0].bip32_derivation[0].__dict__ == { + assert psbt.outputs[0].bip32_derivation[0].__dict__ == { "fingerprint": "5B3730FB", "pubkey": "02729c4c6093da33fe4c21ff5181e896ede27dab3b64da9fe704d1dcc3616dd1ea", "derivation_path": "m/48'/1'/0'/2'/0/2", "label": "", } - psbt.outputs[0].bip32_derivation[1].__dict__ == { + assert psbt.outputs[0].bip32_derivation[1].__dict__ == { "fingerprint": "2597E429", "pubkey": "029065858503bee197c9ba5bb2f5f37171cc6fe36292361ede4aa9f31bd7328789", "derivation_path": "m/48'/1'/0'/2'/0/2", diff --git a/tests/non_gui/test_signature_manager.py b/tests/non_gui/test_signature_manager.py index 08a5f184..e41584bd 100644 --- a/tests/non_gui/test_signature_manager.py +++ b/tests/non_gui/test_signature_manager.py @@ -27,10 +27,13 @@ # SOFTWARE. from __future__ import annotations + import logging import tempfile from pathlib import Path +import pytest + from bitcoin_safe.signature_manager import KnownGPGKeys, SignatureVerifyer logger = logging.getLogger(__name__) @@ -42,7 +45,10 @@ def test_download_manifest_and_verify() -> None: with tempfile.TemporaryDirectory() as tempdir: logger.debug(f"tempdir {tempdir}") - sig_filename = manager.get_signature_from_web(Path(tempdir) / "Sparrow-1.8.4-x86_64.dmg") + try: + sig_filename = manager.get_signature_from_web(Path(tempdir) / "Sparrow-1.8.4-x86_64.dmg") + except Exception as exc: + pytest.skip(f"Skipping manifest download: {exc}") assert sig_filename logger.debug(f"sig_filename {sig_filename}") manifest_file = Path(tempdir) / "sparrow-1.8.4-manifest.txt" @@ -60,7 +66,10 @@ def test_download_manifest_and_verify_wrong_signature() -> None: with tempfile.TemporaryDirectory() as tempdir: logger.debug(f"tempdir {tempdir}") - sig_filename = manager.get_signature_from_web(Path(tempdir) / "Sparrow-1.8.4-x86_64.dmg") + try: + sig_filename = manager.get_signature_from_web(Path(tempdir) / "Sparrow-1.8.4-x86_64.dmg") + except Exception as exc: + pytest.skip(f"Skipping manifest download: {exc}") assert sig_filename logger.debug(f"sig_filename {sig_filename}") diff --git a/tests/non_gui/test_signers.py b/tests/non_gui/test_signers.py index e6c2efed..76dde422 100644 --- a/tests/non_gui/test_signers.py +++ b/tests/non_gui/test_signers.py @@ -32,20 +32,18 @@ from dataclasses import dataclass from pathlib import Path from typing import Literal, cast -from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread import bdkpython as bdk import pytest from _pytest.logging import LogCaptureFixture from bitcoin_qr_tools.data import Data from bitcoin_qr_tools.multipath_descriptor import convert_to_multipath_descriptor +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread +from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.tx_util import hex_to_serialized, serialized_to_hex from PyQt6.QtCore import QObject, pyqtSignal from pytestqt.qtbot import QtBot -from typing import Any, TYPE_CHECKING, cast - -from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker from bitcoin_safe.signer import SignatureImporterClipboard logger = logging.getLogger(__name__) diff --git a/tests/non_gui/test_software_signer.py b/tests/non_gui/test_software_signer.py index 42dfec47..282e77cd 100644 --- a/tests/non_gui/test_software_signer.py +++ b/tests/non_gui/test_software_signer.py @@ -35,7 +35,7 @@ from tests.util import make_psbt -from ..setup_fulcrum import Faucet +from ..faucet import Faucet logger = logging.getLogger(__name__) @@ -44,12 +44,13 @@ def test_compare_software_signer_to_bdk( faucet: Faucet, ): """Test compare software signer to bdk.""" - wallet = faucet.bdk_wallet + wallet = faucet.wallet psbt = make_psbt( - bdk_wallet=wallet, - network=faucet.network, - destination_address=str(wallet.reveal_next_address(keychain=bdk.KeychainKind.EXTERNAL).address), + wallet=wallet, + destination_address=str( + wallet.bdkwallet.reveal_next_address(keychain=bdk.KeychainKind.EXTERNAL).address + ), amount=1000, fee_rate=100, ) @@ -65,7 +66,7 @@ def test_compare_software_signer_to_bdk( software_tx = software_signed_psbt.extract_tx().serialize() # - success = faucet.bdk_wallet.sign(psbt, None) + success = faucet.wallet.bdkwallet.sign(psbt, None) assert success tx = psbt.extract_tx().serialize() diff --git a/tests/non_gui/test_wallet.py b/tests/non_gui/test_wallet.py index f1efc3a3..571d7867 100644 --- a/tests/non_gui/test_wallet.py +++ b/tests/non_gui/test_wallet.py @@ -34,9 +34,9 @@ import bdkpython as bdk import pytest -from bitcoin_safe.config import UserConfig from bitcoin_safe.wallet import Wallet, WalletInputsInconsistentError +from ..helpers import TestConfig from .test_signers import bacon_seed from .utils import ( create_keystore, @@ -47,7 +47,7 @@ logger = logging.getLogger(__name__) -def test_protowallet_import_export_keystores(test_config: UserConfig): +def test_protowallet_import_export_keystores(test_config: TestConfig): "Tests if keystores are correctly handles in Wallet.from_protowallet and wallet.as_protowallet()" protowallet = create_multisig_protowallet( threshold=2, @@ -125,7 +125,7 @@ def test_protowallet_import_export_keystores(test_config: UserConfig): assert [keystore.__dict__ for keystore in wallet.as_protowallet().keystores] == expected_keystores -def test_protowallet_import_export_descriptor(test_config: UserConfig): +def test_protowallet_import_export_descriptor(test_config: TestConfig): "Tests if keystores are correctly handles in Wallet.from_protowallet and wallet.as_protowallet()" protowallet = create_multisig_protowallet( threshold=2, @@ -146,7 +146,7 @@ def test_protowallet_import_export_descriptor(test_config: UserConfig): assert str(wallet.multipath_descriptor) == expected_descriptor -def test_create_from_protowallet_and_from_descriptor_string(test_config: UserConfig): +def test_create_from_protowallet_and_from_descriptor_string(test_config: TestConfig): "Tests if keystores are correctly handles in Wallet.from_protowallet and wallet.as_protowallet()" wallet_id = "some id" expected_descriptor = "wsh(sortedmulti(2,[5aa39a43/41'/1'/0'/2']tpubDDxYDzeDqFbdktXDTMAQpZPgRj3uMk784q8kHyGsC6zUNn2YUbNgZdK3GuXsPjMk8Gt7AEsAGwccd6dbcxaCWJwpRC1rKy1xPLicuNyjLaA/<0;1>/*,[5459f23b/42'/1'/0'/2']tpubDE2ECxCKZhscAKFA2NG2VGzeQow9ZnSrYz8VxmRKPvNCwNv8rg6wXE2hNuB4vdLKfBf6enmrn2zmkLTt1h1fiLEXxTt9tPSXJCTogzYmnfX/<0;1>/*,[a302d279/43'/1'/0'/2']tpubDEbicbTmJ1g9sY7KynzsrodCDp5CoFcPPnxNHpDAbJsufTLTJKrtCo4GvUdgby5NXA8xppgXzawmHYgQqDSB3R6i1YjtS1Ko774FSVqmpA1/<0;1>/*,[6627f20a/45'/1'/0'/2']tpubDEk3xNvJFZN72ikNADMXKyHzX6EEeaANeurUoyBvzxZvxufRqXH1ECSUyDK7hw6YvSYdxmnGXKfpHAxKwYyZpWdjRnDtgoXicwGWY6nujAy/<0;1>/*,[bac81685/44'/1'/0'/2']tpubDEtp92LMMkxJx7cBdUJ68LE2oLApiNYKAyrgHCewGNbWBfumnPXUYamFbGUHM7dfYkJQtSVuj3scqQhPcgy9yv9xr53JVubYQpMby137qQv/<0;1>/*))#gtzk7j0k" @@ -190,7 +190,7 @@ def test_create_from_protowallet_and_from_descriptor_string(test_config: UserCon assert wallet2.derives_identical_addresses(wallet) -def test_is_multisig(test_config: UserConfig): +def test_is_multisig(test_config: TestConfig): """Test is multisig.""" wallet_id = "some id" descriptor = "wpkh([5aa39a43/84'/1'/0']tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#xaf9qzlf" @@ -213,9 +213,10 @@ def test_is_multisig(test_config: UserConfig): config=test_config, loop_in_thread=None, ) + assert isinstance(wallet, Wallet) -def test_is_multisig2(test_config: UserConfig): +def test_is_multisig2(test_config: TestConfig): """Test is multisig2.""" wallet_id = "some id" expected_descriptor = "wsh(sortedmulti(2,[5aa39a43/41'/1'/0'/2']tpubDDxYDzeDqFbdktXDTMAQpZPgRj3uMk784q8kHyGsC6zUNn2YUbNgZdK3GuXsPjMk8Gt7AEsAGwccd6dbcxaCWJwpRC1rKy1xPLicuNyjLaA/<0;1>/*,[5459f23b/42'/1'/0'/2']tpubDE2ECxCKZhscAKFA2NG2VGzeQow9ZnSrYz8VxmRKPvNCwNv8rg6wXE2hNuB4vdLKfBf6enmrn2zmkLTt1h1fiLEXxTt9tPSXJCTogzYmnfX/<0;1>/*,[a302d279/43'/1'/0'/2']tpubDEbicbTmJ1g9sY7KynzsrodCDp5CoFcPPnxNHpDAbJsufTLTJKrtCo4GvUdgby5NXA8xppgXzawmHYgQqDSB3R6i1YjtS1Ko774FSVqmpA1/<0;1>/*,[6627f20a/45'/1'/0'/2']tpubDEk3xNvJFZN72ikNADMXKyHzX6EEeaANeurUoyBvzxZvxufRqXH1ECSUyDK7hw6YvSYdxmnGXKfpHAxKwYyZpWdjRnDtgoXicwGWY6nujAy/<0;1>/*,[bac81685/44'/1'/0'/2']tpubDEtp92LMMkxJx7cBdUJ68LE2oLApiNYKAyrgHCewGNbWBfumnPXUYamFbGUHM7dfYkJQtSVuj3scqQhPcgy9yv9xr53JVubYQpMby137qQv/<0;1>/*))#gtzk7j0k" @@ -239,7 +240,7 @@ def test_is_multisig2(test_config: UserConfig): assert wallet.is_multisig() -def test_dump(test_config: UserConfig): +def test_dump(test_config: TestConfig): "Tests if dump works correctly" wallet_id = "some id" expected_descriptor = "wsh(sortedmulti(2,[5aa39a43/41'/1'/0'/2']tpubDDxYDzeDqFbdktXDTMAQpZPgRj3uMk784q8kHyGsC6zUNn2YUbNgZdK3GuXsPjMk8Gt7AEsAGwccd6dbcxaCWJwpRC1rKy1xPLicuNyjLaA/<0;1>/*,[5459f23b/42'/1'/0'/2']tpubDE2ECxCKZhscAKFA2NG2VGzeQow9ZnSrYz8VxmRKPvNCwNv8rg6wXE2hNuB4vdLKfBf6enmrn2zmkLTt1h1fiLEXxTt9tPSXJCTogzYmnfX/<0;1>/*,[a302d279/43'/1'/0'/2']tpubDEbicbTmJ1g9sY7KynzsrodCDp5CoFcPPnxNHpDAbJsufTLTJKrtCo4GvUdgby5NXA8xppgXzawmHYgQqDSB3R6i1YjtS1Ko774FSVqmpA1/<0;1>/*,[6627f20a/45'/1'/0'/2']tpubDEk3xNvJFZN72ikNADMXKyHzX6EEeaANeurUoyBvzxZvxufRqXH1ECSUyDK7hw6YvSYdxmnGXKfpHAxKwYyZpWdjRnDtgoXicwGWY6nujAy/<0;1>/*,[bac81685/44'/1'/0'/2']tpubDEtp92LMMkxJx7cBdUJ68LE2oLApiNYKAyrgHCewGNbWBfumnPXUYamFbGUHM7dfYkJQtSVuj3scqQhPcgy9yv9xr53JVubYQpMby137qQv/<0;1>/*))#gtzk7j0k" @@ -269,7 +270,7 @@ def test_dump(test_config: UserConfig): assert walllet_restored.derives_identical_addresses(wallet) -def test_correct_addresses(test_config: UserConfig): +def test_correct_addresses(test_config: TestConfig): """Test correct addresses.""" wallet_id = "some id" expected_descriptor = "wpkh([5aa39a43/84h/1h/0h]tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#345tvr45" @@ -301,7 +302,7 @@ def test_correct_addresses(test_config: UserConfig): assert wallet.get_change_addresses()[1] == "bcrt1qgdv8n5mnwtat2ffku0m4swmcy7jmpgv4afz7rd" -def test_inconsistent_key_origins(test_config: UserConfig): +def test_inconsistent_key_origins(test_config: TestConfig): """Test inconsistent key origins.""" wallet_id = "some id" expected_descriptor = "wpkh([5aa39a43/84h/1h/0h]tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#345tvr45" @@ -315,7 +316,7 @@ def test_inconsistent_key_origins(test_config: UserConfig): ) with pytest.raises(WalletInputsInconsistentError) as exc_info: - wallet = Wallet( + Wallet( id=wallet_id, descriptor_str=expected_descriptor, keystores=keystores, @@ -323,9 +324,10 @@ def test_inconsistent_key_origins(test_config: UserConfig): config=test_config, loop_in_thread=None, ) + assert exc_info.value -def test_inconsistent_seed_with_descriptor(test_config: UserConfig): +def test_inconsistent_seed_with_descriptor(test_config: TestConfig): """Test inconsistent seed with descriptor.""" wallet_id = "some id" expected_descriptor = "wpkh([5aa39a43/84h/1h/0h]tpubDD2ww8jti4Xc8vkaJH2yC1r7C9TVb9bG3kTi6BFm5w3aAZmtFHktK6Mv2wfyBvSPqV9QeH1QXrmHzabuNh1sgRtAsUoG7dzVjc9WvGm78PD/<0;1>/*)#345tvr45" @@ -339,7 +341,7 @@ def test_inconsistent_seed_with_descriptor(test_config: UserConfig): )[1:] with pytest.raises(WalletInputsInconsistentError) as exc_info: - wallet = Wallet( + Wallet( id=wallet_id, descriptor_str=expected_descriptor, keystores=keystores, @@ -347,9 +349,10 @@ def test_inconsistent_seed_with_descriptor(test_config: UserConfig): config=test_config, loop_in_thread=None, ) + assert exc_info.value -def test_mixed_keystores_is_consistent(test_config: UserConfig): +def test_mixed_keystores_is_consistent(test_config: TestConfig): """Test mixed keystores is consistent.""" wallet_id = "some id" expected_descriptor = "wsh(sortedmulti(2,[5aa39a43/41'/1'/0'/2']tpubDDxYDzeDqFbdktXDTMAQpZPgRj3uMk784q8kHyGsC6zUNn2YUbNgZdK3GuXsPjMk8Gt7AEsAGwccd6dbcxaCWJwpRC1rKy1xPLicuNyjLaA/<0;1>/*,[5459f23b/42'/1'/0'/2']tpubDE2ECxCKZhscAKFA2NG2VGzeQow9ZnSrYz8VxmRKPvNCwNv8rg6wXE2hNuB4vdLKfBf6enmrn2zmkLTt1h1fiLEXxTt9tPSXJCTogzYmnfX/<0;1>/*,[a302d279/43'/1'/0'/2']tpubDEbicbTmJ1g9sY7KynzsrodCDp5CoFcPPnxNHpDAbJsufTLTJKrtCo4GvUdgby5NXA8xppgXzawmHYgQqDSB3R6i1YjtS1Ko774FSVqmpA1/<0;1>/*,[6627f20a/45'/1'/0'/2']tpubDEk3xNvJFZN72ikNADMXKyHzX6EEeaANeurUoyBvzxZvxufRqXH1ECSUyDK7hw6YvSYdxmnGXKfpHAxKwYyZpWdjRnDtgoXicwGWY6nujAy/<0;1>/*,[bac81685/44'/1'/0'/2']tpubDEtp92LMMkxJx7cBdUJ68LE2oLApiNYKAyrgHCewGNbWBfumnPXUYamFbGUHM7dfYkJQtSVuj3scqQhPcgy9yv9xr53JVubYQpMby137qQv/<0;1>/*))#gtzk7j0k" @@ -373,7 +376,7 @@ def test_mixed_keystores_is_consistent(test_config: UserConfig): assert wallet.is_multisig() -def test_wallet_dump_and_restore(test_config: UserConfig): +def test_wallet_dump_and_restore(test_config: TestConfig): "Tests if dump works correctly" network = test_config.network @@ -398,7 +401,7 @@ def test_wallet_dump_and_restore(test_config: UserConfig): assert org_keystore.is_equal(restored_keystore) -def test_bacon_wallet_tx_are_fetched(test_config_main_chain: UserConfig): +def test_bacon_wallet_tx_are_fetched(test_config_main_chain: TestConfig): """Test bacon wallet tx are fetched.""" wallet_id = "bacon wallet" expected_descriptor = "wpkh([9a6a2580/84h/0h/0h]xpub6DEzNop46vmxR49zYWFnMwmEfawSNmAMf6dLH5YKDY463twtvw1XD7ihwJRLPRGZJz799VPFzXHpZu6WdhT29WnaeuChS6aZHZPFmqczR5K/<0;1>/*)#fkxd7j3k" diff --git a/tests/non_gui/test_wallet_coin_select.py b/tests/non_gui/test_wallet_coin_select.py index 54e72635..f034f718 100644 --- a/tests/non_gui/test_wallet_coin_select.py +++ b/tests/non_gui/test_wallet_coin_select.py @@ -30,21 +30,26 @@ import logging import random +from collections.abc import Generator from dataclasses import dataclass +from pathlib import Path import bdkpython as bdk import numpy as np import pytest +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_usb.address_types import DescriptorInfo +from pytestqt.qtbot import QtBot -from bitcoin_safe.config import UserConfig from bitcoin_safe.keystore import KeyStore from bitcoin_safe.pythonbdk_types import Recipient from bitcoin_safe.tx import TxUiInfos, transaction_to_dict from bitcoin_safe.wallet import Wallet -from ..setup_fulcrum import Faucet -from ..util import wait_for_funds +from ..faucet import Faucet +from ..helpers import TestConfig +from ..util import wait_for_sync +from ..wallet_factory import create_test_wallet logger = logging.getLogger(__name__) @@ -67,7 +72,7 @@ def compare_dicts(d1, d2, ignore_value="value_to_be_ignored") -> bool: return True # Check the type of d1 and d2 - if type(d1) != type(d2): + if type(d1) is not type(d2): logger.debug(f"Type mismatch: {type(d1)} != {type(d2)}") return False @@ -137,15 +142,14 @@ def test_coin_control_config(request) -> TestCoinControlConfig: return request.param -# params -# [(utxo_value_private, utxo_value_kyc)] @pytest.fixture(scope="session") -def test_funded_wallet( - test_config_session: UserConfig, - faucet: Faucet, - test_wallet_config: TestWalletConfig, +def test_funded_wallet_session( + test_config_session: TestConfig, + backend: str, + bitcoin_core: Path, + loop_in_thread: LoopInThread, wallet_name="test_tutorial_wallet_setup", -) -> Wallet: +) -> Generator[Wallet, None, None]: # for test_seed: ensure diet bench scale future thumb holiday wild erupt cancel paper system """Test funded wallet.""" @@ -159,14 +163,30 @@ def test_funded_wallet( label="test", network=test_config_session.network, ) - wallet = Wallet( - id=wallet_name, + handle = create_test_wallet( + wallet_id=wallet_name, descriptor_str=descriptor_str, keystores=[keystore], - network=test_config_session.network, + backend=backend, config=test_config_session, - loop_in_thread=None, + bitcoin_core=bitcoin_core, + loop_in_thread=loop_in_thread, ) + yield handle.wallet + handle.close() + + +@pytest.fixture() +def test_funded_wallet( + test_funded_wallet_session: Wallet, + faucet: Faucet, + qtbot: QtBot, + test_wallet_config: TestWalletConfig, +) -> Generator[Wallet, None, None]: + wallet = test_funded_wallet_session + if wallet.get_balance().total: + yield wallet + return # fund the wallet addresses_private = [ @@ -174,19 +194,25 @@ def test_funded_wallet( ] for address in addresses_private: wallet.labels.set_addr_category(address, "Private") - faucet.send(address, amount=test_wallet_config.utxo_value_private) + faucet.send(address, amount=test_wallet_config.utxo_value_private, qtbot=qtbot) addresses_kyc = [ str(wallet.get_address(force_new=True).address) for i in range(test_wallet_config.num_kyc) ] for address in addresses_kyc: wallet.labels.set_addr_category(address, "KYC") - faucet.send(address, amount=test_wallet_config.utxo_value_kyc) + faucet.send(address, amount=test_wallet_config.utxo_value_kyc, qtbot=qtbot) - faucet.mine() + faucet.mine(qtbot=qtbot) - wait_for_funds(wallet) - return wallet + wait_for_sync( + wallet=wallet, + minimum_funds=test_wallet_config.utxo_value_private * len(addresses_private) + + test_wallet_config.utxo_value_kyc * len(addresses_kyc), + qtbot=qtbot, + timeout=60_000, + ) + yield wallet ############################### diff --git a/tests/non_gui/test_wallet_ema_fee_rate.py b/tests/non_gui/test_wallet_ema_fee_rate.py index 69529750..f19aea54 100644 --- a/tests/non_gui/test_wallet_ema_fee_rate.py +++ b/tests/non_gui/test_wallet_ema_fee_rate.py @@ -29,18 +29,23 @@ from __future__ import annotations import logging +import math +from collections.abc import Generator +from pathlib import Path import pytest from bitcoin_usb.address_types import AddressTypes, DescriptorInfo, SimplePubKeyProvider from bitcoin_usb.software_signer import SoftwareSigner -from bitcoin_safe.config import UserConfig +from pytestqt.qtbot import QtBot from bitcoin_safe.constants import MIN_RELAY_FEE from bitcoin_safe.wallet import Wallet +from ..faucet import Faucet +from ..helpers import TestConfig from ..non_gui.test_wallet import create_test_seed_keystores -from ..setup_fulcrum import Faucet -from ..util import make_psbt, wait_for_funds, wait_for_tx +from ..util import make_psbt, wait_for_sync +from ..wallet_factory import create_test_wallet from .test_wallet_coin_select import TestWalletConfig logger = logging.getLogger(__name__) @@ -57,20 +62,19 @@ def test_wallet_config_seed(request) -> TestWalletConfig: return request.param -# params -# [(utxo_value_private, utxo_value_kyc)] @pytest.fixture(scope="session") -def test_funded_seed_wallet( - test_config_session: UserConfig, - faucet: Faucet, - test_wallet_config_seed: TestWalletConfig, +def test_funded_seed_wallet_session( + test_config_session: TestConfig, + backend: str, + bitcoin_core: Path, + loop_in_thread, wallet_name="test_tutorial_wallet_setup", -) -> Wallet: +) -> Generator[Wallet, None, None]: """Test funded seed wallet.""" keystore = create_test_seed_keystores( signers=1, key_origins=[f"m/{i}h/1h/0h/2h" for i in range(5)], - network=faucet.network, + network=test_config_session.network, test_seed_offset=20, )[0] @@ -79,43 +83,72 @@ def test_funded_seed_wallet( spk_providers=[SimplePubKeyProvider.from_hwi(keystore.to_hwi_pubkey_provider())], threshold=1, ) - wallet = Wallet( - id=wallet_name, - descriptor_str=descriptor_info.get_descriptor_str(faucet.network), + wallet_handle = create_test_wallet( + wallet_id=wallet_name, + descriptor_str=descriptor_info.get_descriptor_str(test_config_session.network), keystores=[keystore], - network=test_config_session.network, + backend=backend, config=test_config_session, - loop_in_thread=None, + is_new_wallet=True, + bitcoin_core=bitcoin_core, + loop_in_thread=loop_in_thread, ) + yield wallet_handle.wallet + wallet_handle.close() + +@pytest.fixture() +def test_funded_seed_wallet( + test_funded_seed_wallet_session: Wallet, + faucet: Faucet, + test_wallet_config_seed: TestWalletConfig, + qtbot: QtBot, +) -> Generator[Wallet, None, None]: + wallet = test_funded_seed_wallet_session # fund the wallet addresses_private = [ str(wallet.get_address(force_new=True).address) for i in range(test_wallet_config_seed.num_private) ] for address in addresses_private: wallet.labels.set_addr_category(address, "Private") - faucet.send(address, amount=test_wallet_config_seed.utxo_value_private) + faucet.send(address, amount=test_wallet_config_seed.utxo_value_private, qtbot=qtbot) - faucet.mine() - wait_for_funds(wallet) + faucet.mine(qtbot=qtbot) - return wallet + wait_for_sync( + wallet=wallet, + minimum_funds=test_wallet_config_seed.utxo_value_private * len(addresses_private), + qtbot=qtbot, + ) + + yield wallet + + +def _override_tx_fees(wallet: Wallet, fee_map: dict[str, float]) -> None: + """Force wallet txdetails to use the desired fee rates (sat/vbyte).""" + txdetails = wallet.sorted_delta_list_transactions() + for txdetail in txdetails: + rate = fee_map.get(txdetail.txid) + if rate is None: + continue + txdetail.fee = int(math.ceil(rate * txdetail.vsize)) def test_ema_fee_rate_weights_recent_heavier( test_funded_seed_wallet: Wallet, + qtbot: QtBot, faucet: Faucet, -) -> Wallet: +): """Test that the EMA fee rate for an incoming wallet is weighted more heavily towards recent transactions.""" wallet = test_funded_seed_wallet + desired_fee_rates: dict[str, float] = {} - def send_tx(fee_rate=100): - """Send tx.""" + def send_tx(fee_rate=100) -> str: + """Broadcast a tx and record the intended fee rate for testing.""" psbt_for_signing = make_psbt( - bdk_wallet=wallet.bdkwallet, - network=wallet.network, + wallet=wallet, destination_address=wallet.get_addresses()[0], amount=1000, fee_rate=fee_rate, @@ -132,35 +165,54 @@ def send_tx(fee_rate=100): tx = signed_psbt.extract_tx() wallet.client.broadcast(tx) - # to include the tx into a block and create a sorting of the txs - # otherwise the order might be random and ema is random - faucet.mine() + faucet.mine(qtbot=qtbot) - wait_for_tx(wallet, str(tx.compute_txid())) + txid = str(tx.compute_txid()) + wait_for_sync(wallet=wallet, txid=txid, qtbot=qtbot) + desired_fee_rates[txid] = fee_rate + return txid # incoming txs have no fee rate (rpc doesnt seem to fill the fee field) + _override_tx_fees(wallet, desired_fee_rates) assert round(wallet.get_ema_fee_rate(), 1) == MIN_RELAY_FEE - # test that it takes in account the icoming txs, if a fee is known - txdetails = wallet.sorted_delta_list_transactions() - txdetails[0].fee = 21 * txdetails[0].vsize + def set_unknown_fee(): + # test that it takes in account the icoming txs, if a fee is known + txdetails = wallet.sorted_delta_list_transactions() + txdetails[0].fee = 21 * txdetails[0].vsize + desired_fee_rates[txdetails[0].txid] = 21 + + set_unknown_fee() + _override_tx_fees(wallet, desired_fee_rates) assert wallet.get_ema_fee_rate() == 21 # send_tx clears the cache and resets the previous tx send_tx(100) + _override_tx_fees(wallet, desired_fee_rates) # test the outgoing is weighted more than - assert wallet.get_ema_fee_rate() == pytest.approx(66.7, abs=0.1) + set_unknown_fee() + _override_tx_fees(wallet, desired_fee_rates) + ema_after_100 = wallet.get_ema_fee_rate() + assert ema_after_100 == pytest.approx(73, abs=1) - for i in range(5): + for _ in range(5): send_tx(1) + _override_tx_fees(wallet, desired_fee_rates) - assert wallet.get_ema_fee_rate() == pytest.approx(6.8, abs=0.1) + set_unknown_fee() + _override_tx_fees(wallet, desired_fee_rates) + ema_after_lows = wallet.get_ema_fee_rate() + assert ema_after_lows == pytest.approx(10, abs=1) - for i in range(1): + for _ in range(1): send_tx(40) + _override_tx_fees(wallet, desired_fee_rates) - assert wallet.get_ema_fee_rate() == pytest.approx(14.5, abs=0.1) + set_unknown_fee() + _override_tx_fees(wallet, desired_fee_rates) + ema_after_mid = wallet.get_ema_fee_rate() + assert ema_after_mid == pytest.approx(18, abs=1) def test_address_balance( diff --git a/tests/non_gui/test_wallet_inconsistent_state.py b/tests/non_gui/test_wallet_inconsistent_state.py index be818b26..1392bc27 100644 --- a/tests/non_gui/test_wallet_inconsistent_state.py +++ b/tests/non_gui/test_wallet_inconsistent_state.py @@ -28,23 +28,35 @@ from __future__ import annotations +import logging import time +from pathlib import Path import bdkpython as bdk import pytest -from bitcoin_safe_lib.tx_util import serialized_to_hex +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread +from pytestqt.qtbot import QtBot -from bitcoin_safe.keystore import KeyStore from bitcoin_safe import wallet as wallet_module -from bitcoin_safe.wallet import InconsistentBDKState, Wallet +from bitcoin_safe.keystore import KeyStore +from bitcoin_safe.wallet import InconsistentBDKState + +from ..faucet import Faucet +from ..helpers import TestConfig +from ..util import wait_for_sync +from ..wallet_factory import TestWalletHandle, create_test_wallet from .test_signers import test_seeds -from ..setup_bitcoin_core import bitcoin_cli -from ..setup_fulcrum import Faucet -from ..util import wait_for_tx +logger = logging.getLogger(__name__) -def _make_single_sig_wallet(config) -> Wallet: +def _make_single_sig_wallet( + config: TestConfig, + backend: str, + bitcoin_core: Path, + loop_in_thread: LoopInThread, + qtbot: QtBot, +) -> TestWalletHandle: """Create a simple single-sig wallet used for regression tests.""" # Use a stable seed from the shared test fixtures to avoid collisions with other tests. seed_str = test_seeds[24] @@ -59,6 +71,7 @@ def _make_single_sig_wallet(config) -> Wallet: keychain_kind=bdk.KeychainKind.INTERNAL, network=config.network, ) + assert change_descriptor descriptor_str = str(descriptor) keystore = KeyStore( xpub=str(descriptor).split("]")[1].split("/0/*")[0], @@ -68,60 +81,53 @@ def _make_single_sig_wallet(config) -> Wallet: network=config.network, mnemonic=seed_str, ) - return Wallet( - id="inconsistent-bdk-state", + wallet_handle = create_test_wallet( + wallet_id="inconsistent-bdk-state", descriptor_str=descriptor_str, keystores=[keystore], - network=config.network, + backend=backend, config=config, - loop_in_thread=None, - ) - - -def _build_rbf_psbt( - bdk_wallet: bdk.Wallet, utxo: bdk.LocalOutput, destination_address: str, fee_rate: float, amount_sat: int -) -> bdk.Psbt: - """Build a PSBT that spends a specific UTXO with RBF enabled.""" - builder = bdk.TxBuilder() - builder = builder.add_utxo(utxo.outpoint) - builder = builder.manually_selected_only() - builder = builder.set_exact_sequence(0xFFFFFFFD) - builder = builder.add_recipient( - bdk.Address(destination_address, bdk_wallet.network()).script_pubkey(), - bdk.Amount.from_sat(amount_sat), + bitcoin_core=bitcoin_core, + loop_in_thread=loop_in_thread, + is_new_wallet=True, ) - builder = builder.fee_rate(bdk.FeeRate.from_sat_per_vb(fee_rate)) - psbt = builder.finish(bdk_wallet) - return bdk.Psbt(psbt.serialize()) + return wallet_handle -def test_balance_after_replaced_receive_tx_raises(test_config_session, faucet: Faucet, bitcoin_core): # type: ignore[annotations-typing] +def test_balance_after_replaced_receive_tx_raises( + test_config_session: TestConfig, + faucet: Faucet, + bitcoin_core: Path, + backend: str, + qtbot: QtBot, +): # type: ignore[annotations-typing] """ Reproduce an inconsistent BDK state by: 1) receiving an unconfirmed RBF transaction, 2) replacing it from the sender with a higher-fee double spend that drops the receive output, 3) syncing the wallet and querying balance. """ - wallet = _make_single_sig_wallet(test_config_session) + if backend == "cbf": + logger.info( + "Skipped test_balance_after_replaced_receive_tx_raises because this error doesnt appear in cbf" + ) + return + wallet_handle = _make_single_sig_wallet( + config=test_config_session, + backend=faucet.backend, + bitcoin_core=bitcoin_core, + loop_in_thread=faucet.loop_in_thread, + qtbot=qtbot, + ) + wallet = wallet_handle.wallet receive_address = str(wallet.get_address(force_new=True).address) - utxo = next(u for u in faucet.bdk_wallet.list_unspent() if not u.is_spent) - amount_sat = min(100_000, utxo.txout.value.to_sat() - 5_000) + amount_sat = 100_000 + tx = faucet.send(receive_address, amount=amount_sat, fee_rate=1, qtbot=qtbot) - psbt = _build_rbf_psbt( - bdk_wallet=faucet.bdk_wallet, - utxo=utxo, - destination_address=receive_address, - fee_rate=1, - amount_sat=amount_sat, + wait_for_sync( + wallet=wallet, minimum_funds=amount_sat, txid=str(tx.compute_txid()), timeout=20, qtbot=qtbot ) - - faucet.bdk_wallet.sign(psbt, None) - tx = psbt.extract_tx() - bitcoin_cli(f"sendrawtransaction {serialized_to_hex(tx.serialize())}", bitcoin_core) - faucet.sync() - - wait_for_tx(wallet, str(tx.compute_txid())) assert wallet.get_addr_balance(receive_address).total == amount_sat # Simulate mempool eviction of the unconfirmed receive transaction @@ -137,10 +143,11 @@ def test_balance_after_replaced_receive_tx_raises(test_config_session, faucet: F try: wallet_module.NUM_RETRIES_get_address_balances = 1 with pytest.raises(InconsistentBDKState): - wallet.get_addr_balance(receive_address).total + _ = wallet.get_addr_balance(receive_address).total wallet_module.NUM_RETRIES_get_address_balances = 2 balance = wallet.get_addr_balance(receive_address) assert balance.total == 0 finally: wallet_module.NUM_RETRIES_get_address_balances = original_retries + wallet.close() diff --git a/tests/non_gui/test_wallet_methods.py b/tests/non_gui/test_wallet_methods.py index cce07ca1..90f0b5c8 100644 --- a/tests/non_gui/test_wallet_methods.py +++ b/tests/non_gui/test_wallet_methods.py @@ -28,37 +28,38 @@ from __future__ import annotations +from pathlib import Path + import bdkpython as bdk import pytest +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread from bitcoin_usb.address_types import DescriptorInfo -from bitcoin_safe.config import UserConfig from bitcoin_safe.keystore import KeyStore from bitcoin_safe.wallet import Wallet, WalletInputsInconsistentError from bitcoin_safe.wallet_util import WalletDifferenceType +from ..helpers import TestConfig +from ..wallet_factory import create_test_wallet from .utils import create_multisig_protowallet -class DummyConfig: - """Minimal stand‑in for UserConfig used in tests.""" - - def __init__(self, network: bdk.Network) -> None: - """Initialize instance.""" - self.network = network - - -def _make_config() -> UserConfig: +def _make_config() -> TestConfig: """Make config.""" - config = UserConfig() + config = TestConfig() config.network = bdk.Network.REGTEST return config -def make_test_wallet() -> Wallet: - """Make test wallet.""" - network = bdk.Network.REGTEST - config = DummyConfig(network) +@pytest.fixture() +def single_sig_wallet( + test_config: TestConfig, + backend: str, + bitcoin_core: Path, + loop_in_thread: LoopInThread, +) -> Wallet: + """Single-sig wallet backed by the shared test factory.""" + network = test_config.network descriptor = "wpkh([41c5c760/84'/1'/0']tpubDDRVgaxjgMghgZzWSG4NL6D7M5wL1CXM3x98prqjmqU9zs2wfRZmYXWWamk4sxsQEQMX6Rmkc1i6G74zTD7xUxoojmijJiA3QPdJyyrWFKz/<0;1>/*)" info = DescriptorInfo.from_str(descriptor) keystore = KeyStore( @@ -68,14 +69,20 @@ def make_test_wallet() -> Wallet: label="test", network=network, ) - return Wallet( - id="test", + wallet_handle = create_test_wallet( + wallet_id="test", descriptor_str=descriptor, keystores=[keystore], - network=network, - config=config, - loop_in_thread=None, + backend=backend, + config=test_config, + bitcoin_core=bitcoin_core, + loop_in_thread=loop_in_thread, + is_new_wallet=True, ) + try: + yield wallet_handle.wallet + finally: + wallet_handle.close() def test_check_consistency_errors(): @@ -100,9 +107,9 @@ def test_check_consistency_errors(): Wallet.check_consistency(keystores * 2, descriptor, network=config.network) -def test_check_self_consistency(): +def test_check_self_consistency(single_sig_wallet: Wallet): """Test check self consistency.""" - wallet = make_test_wallet() + wallet = single_sig_wallet descriptor = "wpkh([41c5c760/84'/1'/0']tpubDDRVgaxjgMghgZzWSG4NL6D7M5wL1CXM3x98prqjmqU9zs2wfRZmYXWWamk4sxsQEQMX6Rmkc1i6G74zTD7xUxoojmijJiA3QPdJyyrWFKz/<0;1>/*)" Wallet.check_consistency(wallet.keystores, descriptor, network=bdk.Network.REGTEST) @@ -123,15 +130,15 @@ def test_check_protowallet_consistency_valid(): Wallet.check_consistency(keystores, descriptor, network=config.network) -def test_get_mn_tuple_single_sig(): +def test_get_mn_tuple_single_sig(single_sig_wallet: Wallet): """Test get mn tuple single sig.""" - wallet = make_test_wallet() + wallet = single_sig_wallet assert wallet.get_mn_tuple() == (1, 1) -def test_mark_all_labeled_addresses_used(): +def test_mark_all_labeled_addresses_used(single_sig_wallet: Wallet): """Test mark all labeled addresses used.""" - wallet = make_test_wallet() + wallet = single_sig_wallet addr_info = wallet.get_address(force_new=True) address_str = str(addr_info.address) # ensure address appears in the list of unused addresses first @@ -145,11 +152,15 @@ def test_mark_all_labeled_addresses_used(): assert all(address_str not in entry for entry in unused_after) -def test_as_protowallet_roundtrip(): +def test_as_protowallet_roundtrip(single_sig_wallet: Wallet): """Test as protowallet roundtrip.""" - wallet = make_test_wallet() + wallet = single_sig_wallet proto = wallet.as_protowallet() - restored = Wallet.from_protowallet(proto, config=DummyConfig(proto.network), loop_in_thread=None) + restored = Wallet.from_protowallet( + proto, + config=_make_config(), + loop_in_thread=wallet.loop_in_thread, + ) assert restored.id == wallet.id assert restored.network == wallet.network assert restored.get_mn_tuple() == wallet.get_mn_tuple() diff --git a/tests/setup_bitcoin_core.py b/tests/setup_bitcoin_core.py index 00f2d5a5..082d58ba 100644 --- a/tests/setup_bitcoin_core.py +++ b/tests/setup_bitcoin_core.py @@ -51,11 +51,14 @@ BITCOIN_LISTEN_PORT = 17144 # not the default 18444 to not get conflicts RPC_USER = "bitcoin" RPC_PASSWORD = "bitcoin" +BITCOIN_DATA_DIR = Path(__file__).parent / "bitcoin_data" BITCOIN_CONF_CONTENT = f""" regtest=1 [regtest] +datadir={BITCOIN_DATA_DIR} + # RPC server options rpcport={BITCOIN_RPC_PORT} @@ -63,6 +66,7 @@ rpcallowip={BITCOIN_HOST} rpcuser={RPC_USER} rpcpassword={RPC_PASSWORD} +v2transport=1 server=1 port={BITCOIN_LISTEN_PORT} @@ -71,6 +75,9 @@ # Enable serving of bloom filters (useful for SPV clients) peerbloomfilters=1 +# Enable serving compact block filters to peers (BIP157/158) +peerblockfilters=1 + # Enable serving compact block filters blockfilterindex=1 @@ -78,7 +85,7 @@ txindex=1 """ -BITCOIN_VERSION = "28.1" +BITCOIN_VERSION = "29.2" # Define the Bitcoin Core directory relative to the current test directory TEST_DIR = Path(__file__).parent # If this script is in the tests directory BITCOIN_DIR = TEST_DIR / "bitcoin_core" @@ -154,6 +161,7 @@ def bitcoin_cli( executable = "bitcoin-cli.exe" if system == "Windows" else "bitcoin-cli" cmd = ( str(bitcoin_core / executable) + + f" -datadir={BITCOIN_DATA_DIR}" + f" -rpcconnect={bitcoin_host} -rpcport={bitcoin_port} -chain=regtest -rpcuser={rpc_user} -rpcpassword={rpc_password} " + command ) @@ -236,33 +244,102 @@ def download_bitcoin(): logger.info(f"Bitcoin Core {BITCOIN_VERSION} is ready to use.") +def is_bitcoind_running(bitcoin_bin_dir: Path) -> bool: + bitcoind_path = bitcoin_bin_dir / "bitcoind" + + result = subprocess.run( + ["pgrep", "-f", str(bitcoind_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def stop_bitcoind( + bitcoin_bin_dir: Path, + timeout: float = 10.0, + poll_interval: float = 0.2, +) -> None: + """ + Stop bitcoind gracefully and force-kill if it does not exit. + + :param bitcoin_bin_dir: Directory containing bitcoind / bitcoin-cli + :param timeout: Seconds to wait before force-killing + :param poll_interval: How often to poll process state + """ + # Ask bitcoind to shut down cleanly + bitcoin_cli("stop", bitcoin_bin_dir) + + start_time = time.monotonic() + + while is_bitcoind_running(bitcoin_bin_dir): + if time.monotonic() - start_time >= timeout: + bitcoind_path = bitcoin_bin_dir / "bitcoind" + subprocess.run( + ["pkill", "-f", str(bitcoind_path)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + break + + time.sleep(poll_interval) + + @pytest.fixture(scope="session") def bitcoin_core() -> Generator[Path, None, None]: # Ensure Bitcoin Core directory exists """Bitcoin core.""" BITCOIN_DIR.mkdir(exist_ok=True) + BITCOIN_DATA_DIR.mkdir(parents=True, exist_ok=True) download_bitcoin() # Create bdk.conf BITCOIN_CONF.write_text(BITCOIN_CONF_CONTENT) # stop it if it is running - bitcoin_cli("stop", BITCOIN_BIN_DIR) - # to ensure bitcoind is stopped - time.sleep(1) + stop_bitcoind(BITCOIN_BIN_DIR) # remove the previous chain - remove_bitcoin_regtest_folder() + remove_bitcoin_regtest_folder(custom_datadir=BITCOIN_DATA_DIR) # Start Bitcoin Core bitcoind() - # Wait for Bitcoin Core to start - time.sleep(5) + # Wait for Bitcoin Core to start responding on RPC + def wait_for_rpc(timeout: float = 30.0, interval: float = 0.5) -> None: + """Poll getblockchaininfo until bitcoind responds or timeout.""" + start = time.time() + while True: + proc = subprocess.run( + [ + str( + BITCOIN_BIN_DIR + / ("bitcoin-cli.exe" if platform.system() == "Windows" else "bitcoin-cli") + ), + f"-datadir={BITCOIN_DATA_DIR}", + f"-rpcconnect={BITCOIN_HOST}", + f"-rpcport={BITCOIN_RPC_PORT}", + "-chain=regtest", + f"-rpcuser={RPC_USER}", + f"-rpcpassword={RPC_PASSWORD}", + "getblockchaininfo", + ], + capture_output=True, + text=True, + ) + if proc.returncode == 0: + return + if time.time() - start > timeout: + raise TimeoutError(f"bitcoind did not become ready in time: {proc.stderr.strip()}") + time.sleep(interval) + + wait_for_rpc() yield BITCOIN_BIN_DIR # Stop Bitcoin Core - bitcoin_cli("stop", BITCOIN_BIN_DIR) + stop_bitcoind(BITCOIN_BIN_DIR) # Assuming the bitcoin_core fixture sets up Bitcoin Core and yields the binary directory diff --git a/tests/setup_fulcrum.py b/tests/setup_fulcrum.py index 21585b3b..7da3b466 100644 --- a/tests/setup_fulcrum.py +++ b/tests/setup_fulcrum.py @@ -38,21 +38,10 @@ from collections.abc import Generator from pathlib import Path -import bdkpython as bdk import pytest import requests -from bitcoin_safe.util import SATOSHIS_PER_BTC - -from .setup_bitcoin_core import ( - BITCOIN_HOST, - BITCOIN_RPC_PORT, - RPC_PASSWORD, - RPC_USER, - TEST_DIR, - mine_blocks, -) -from .util import make_psbt +from .setup_bitcoin_core import BITCOIN_HOST, BITCOIN_RPC_PORT, RPC_PASSWORD, RPC_USER, TEST_DIR, mine_blocks logger = logging.getLogger(__name__) @@ -199,12 +188,15 @@ def remove_fulcrum_data(): # 2) Pytest fixture: fulcrum # ------------------------------------------------------------------------- @pytest.fixture(scope="session") -def fulcrum(bitcoin_core: Path) -> Generator[str, None, None]: +def fulcrum(bitcoin_core: Path, backend: str) -> Generator[str | None, None, None]: """Ensures Bitcoin Core is running (through the bitcoin_core fixture), then downloads, configures, and starts Fulcrum. Yields the path to Fulcrum's binary directory for test usage, then tears it down. """ + if backend != "fulcrum": + yield None + return # Make sure the Fulcrum directory exists FULCRUM_DIR.mkdir(parents=True, exist_ok=True) @@ -230,99 +222,3 @@ def fulcrum(bitcoin_core: Path) -> Generator[str, None, None]: # Stop Fulcrum after tests stop_fulcrum() - - -class Faucet: - def __init__( - self, - bitcoin_core: Path, - fulcrum: str, - mnemonic="romance slush habit speed type also grace coffee grape inquiry receive filter", - ) -> None: - """Initialize instance.""" - self.bitcoin_core = bitcoin_core - - self.seed = mnemonic - self.mnemonic = bdk.Mnemonic.from_string(self.seed) - - self.network = bdk.Network.REGTEST - self.client = bdk.ElectrumClient(url=fulcrum) - - self.descriptor = bdk.Descriptor.new_bip84( - secret_key=bdk.DescriptorSecretKey(self.network, self.mnemonic, ""), - keychain_kind=bdk.KeychainKind.EXTERNAL, - network=self.network, - ) - self.change_descriptor = bdk.Descriptor.new_bip84( - secret_key=bdk.DescriptorSecretKey(self.network, self.mnemonic, ""), - keychain_kind=bdk.KeychainKind.INTERNAL, - network=self.network, - ) - - self.connection = bdk.Persister.new_in_memory() - self.bdk_wallet = bdk.Wallet( - descriptor=self.descriptor, - change_descriptor=self.change_descriptor, - network=self.network, - persister=self.connection, - ) - self.initial_mine() - - def send(self, destination_address: str, amount=SATOSHIS_PER_BTC, fee_rate=1): - """Send.""" - psbt_for_signing = make_psbt( - bdk_wallet=self.bdk_wallet, - network=self.network, - destination_address=destination_address, - amount=amount, - fee_rate=fee_rate, - ) - self.bdk_wallet.sign(psbt_for_signing, None) - self.bdk_wallet.persist(self.connection) - - tx = psbt_for_signing.extract_tx() - self.client.transaction_broadcast(tx) - # let fulcrum index the tx - time.sleep(2) - self.sync() - return tx - - def sync(self): - """Sync.""" - request = self.bdk_wallet.start_full_scan() - changeset = self.client.full_scan( - request=request.build(), stop_gap=20, batch_size=10, fetch_prev_txouts=True - ) - self.bdk_wallet.apply_update(changeset) - self.bdk_wallet.persist(self.connection) - - def mine(self, blocks=1, address=None): - """Mine.""" - txs = self.bdk_wallet.transactions() - address = ( - address - if address - else str(self.bdk_wallet.next_unused_address(keychain=bdk.KeychainKind.EXTERNAL).address) - ) - block_hashes = mine_blocks( - self.bitcoin_core, - blocks, - address=address, - ) - while len(self.bdk_wallet.transactions()) - len(txs) < len(block_hashes): - time.sleep(0.5) - self.sync() - logger.debug(f"Faucet Wallet balance is: {self.bdk_wallet.balance().total.to_sat()}") - - def initial_mine(self): - """Initial mine.""" - self.mine( - blocks=200, - address=str(self.bdk_wallet.next_unused_address(keychain=bdk.KeychainKind.EXTERNAL).address), - ) - - -@pytest.fixture(scope="session") -def faucet(bitcoin_core: Path, fulcrum: str) -> Faucet: - """Faucet.""" - return Faucet(bitcoin_core=bitcoin_core, fulcrum=fulcrum) diff --git a/tests/test_cbf_update.py b/tests/test_cbf_update.py new file mode 100644 index 00000000..b569252e --- /dev/null +++ b/tests/test_cbf_update.py @@ -0,0 +1,114 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + + +""" +This is a minimal example on how to get cbf working +""" + +from __future__ import annotations + +import asyncio +from pathlib import Path + +import bdkpython as bdk + +from bitcoin_safe.cbf.cbf_sync import CbfSync + +from .setup_bitcoin_core import BITCOIN_LISTEN_PORT, bitcoin_cli, mine_blocks + + +def _build_test_wallet(network: bdk.Network) -> tuple[bdk.Wallet, bdk.Persister, str]: + """Create a deterministic regtest wallet and reveal the first receive address.""" + mnemonic = bdk.Mnemonic.from_string( + "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + ) + external_descriptor = bdk.Descriptor.new_bip86( + secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), + keychain_kind=bdk.KeychainKind.EXTERNAL, + network=network, + ) + change_descriptor = bdk.Descriptor.new_bip86( + secret_key=bdk.DescriptorSecretKey(network, mnemonic, ""), + keychain_kind=bdk.KeychainKind.INTERNAL, + network=network, + ) + persister = bdk.Persister.new_in_memory() + wallet = bdk.Wallet( + descriptor=external_descriptor, + change_descriptor=change_descriptor, + network=network, + persister=persister, + ) + receive_address = str(wallet.reveal_next_address(keychain=bdk.KeychainKind.EXTERNAL).address) + wallet.persist(persister) + return wallet, persister, receive_address + + +def test_cbf_update_against_local_bitcoind(bitcoin_core: Path, tmp_path: Path): + """Spin up bitcoind, sync a dummy BDK wallet via CBF, and apply the update.""" + + async def _run(): + network = bdk.Network.REGTEST + wallet, persister, receive_address = _build_test_wallet(network) + + # Fund the wallet and confirm the coinbase so the update has something to discover. + bitcoin_cli(f"generatetoaddress 1 {receive_address}", bitcoin_core) + mine_blocks(bitcoin_core, 100) + + cbf_data_dir = tmp_path / "cbf_data" + cbf_data_dir.mkdir(parents=True, exist_ok=True) + + peer = bdk.Peer( + address=bdk.IpAddress.from_ipv4(127, 0, 0, 1), + port=BITCOIN_LISTEN_PORT, + v2_transport=True, + ) + + cbf_sync = CbfSync( + wallet_id="test-cbf-sync", + wallet=wallet, + peers=[peer], + data_dir=cbf_data_dir, + proxy_info=None, + cbf_connections=1, + is_new_wallet=True, + ) + + cbf_sync.build_node() + try: + update_info = await asyncio.wait_for(cbf_sync.next_update_info(), timeout=120) + finally: + cbf_sync.shutdown_node() + + assert update_info is not None + wallet.apply_update(update_info.update) + wallet.persist(persister) + assert wallet.balance().total.to_sat() > 0 + + asyncio.run(_run()) diff --git a/tests/test_div.py b/tests/test_div.py index acbb9356..defb0815 100644 --- a/tests/test_div.py +++ b/tests/test_div.py @@ -136,7 +136,9 @@ def f(i=i, instance=instance): instance.signal.emit() instance.signal.emit() - assert [record.msg for record in caplog.records] == [str(i) for i in range(n)] + assert [record.msg for record in caplog.records if record.name == __name__] == [ + str(i) for i in range(n) + ] def test_chained_one_time_signal_connections_prevent_disconnect(caplog: LogCaptureFixture): @@ -167,4 +169,4 @@ def f(i=i, instance=instance): instance.signal.emit() # since f(0) == None, the 1. signal simply reconnects - assert [record.msg for record in caplog.records] == ["0", "0"] + assert [record.msg for record in caplog.records if record.name == __name__] == ["0", "0"] diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 358e2c30..ce7ab6a4 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -36,7 +36,6 @@ import pytest from bitcoin_safe.config import UserConfig -from bitcoin_safe.pythonbdk_types import BlockchainType logger = logging.getLogger(__name__) @@ -52,12 +51,6 @@ def test_config() -> TestConfig: config = TestConfig() logger.info(f"Setting config_dir = {config.config_dir} and config_file = {config.config_file}") config.network = bdk.Network.REGTEST - config.network_config.server_type = BlockchainType.RPC - config.network_config.rpc_ip = BITCOIN_HOST - config.network_config.rpc_port = BITCOIN_PORT - config.network_config.rpc_username = RPC_USER - config.network_config.rpc_password = RPC_PASSWORD - return config @@ -65,6 +58,7 @@ def test_config() -> TestConfig: def test_config_main_chain() -> TestConfig: """Test config main chain.""" config = TestConfig() + config.network = bdk.Network.BITCOIN logger.info(f"Setting config_dir = {config.config_dir} and config_file = {config.config_file}") return config diff --git a/tests/test_util.py b/tests/test_util.py index 019c7158..4f4366f7 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -31,19 +31,16 @@ import logging from collections.abc import Callable from pathlib import Path -from typing import cast, Any +from typing import cast from unittest.mock import patch import bdkpython as bdk from _pytest.logging import LogCaptureFixture +from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from bitcoin_safe_lib.util import path_to_rel_home_path, rel_home_path_to_abs_path from PyQt6.QtCore import QObject, pyqtBoundSignal, pyqtSignal from bitcoin_safe.gui.qt.util import one_time_signal_connection -from typing import Any, TYPE_CHECKING, cast - -from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker - from bitcoin_safe.util import SATOSHIS_PER_BTC # from bitcoin_safe.logging_setup import setup_logging @@ -172,7 +169,8 @@ def f(i=i, instance=instance): instance.signal.emit() instance.signal.emit() - assert [record.msg for record in caplog.records] == [str(i) for i in range(n)] + messages = [record.msg for record in caplog.records if record.name == __name__] + assert messages == [str(i) for i in range(n)] def test_chained_one_time_signal_connections_prevent_disconnect(caplog: LogCaptureFixture): @@ -203,7 +201,7 @@ def f(i=i, instance=instance): instance.signal.emit() # since f(0) == None, the 1. signal simply reconnects - assert [record.msg for record in caplog.records] == ["0", "0"] + assert [record.msg for record in caplog.records if record.name == __name__] == ["0", "0"] def make_psbt( diff --git a/tests/util.py b/tests/util.py index 4ba531ac..aa04352e 100644 --- a/tests/util.py +++ b/tests/util.py @@ -31,15 +31,16 @@ import asyncio import logging from collections.abc import Callable -from typing import cast, Any +from typing import cast import bdkpython as bdk +from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol from PyQt6.QtCore import QObject, pyqtBoundSignal, pyqtSignal +from PyQt6.QtWidgets import QApplication +from pytestqt.qtbot import QtBot from bitcoin_safe.gui.qt.util import one_time_signal_connection -from typing import Any, TYPE_CHECKING, cast - -from bitcoin_safe_lib.gui.qt.signal_tracker import SignalProtocol, SignalTools, SignalTracker +from bitcoin_safe.pythonbdk_types import BlockchainType from bitcoin_safe.util import SATOSHIS_PER_BTC from bitcoin_safe.wallet import Wallet @@ -72,8 +73,7 @@ def f_wrapper(*args, **kwargs): def make_psbt( - bdk_wallet: bdk.Wallet, - network: bdk.Network, + wallet: Wallet, destination_address: str, amount=SATOSHIS_PER_BTC, fee_rate=1, @@ -82,42 +82,55 @@ def make_psbt( txbuilder = bdk.TxBuilder() txbuilder = txbuilder.add_recipient( - bdk.Address(destination_address, network).script_pubkey(), bdk.Amount.from_sat(amount) + bdk.Address(destination_address, wallet.network).script_pubkey(), bdk.Amount.from_sat(amount) ) txbuilder = txbuilder.fee_rate(bdk.FeeRate.from_sat_per_vb(fee_rate)) - psbt = txbuilder.finish(bdk_wallet) + psbt = txbuilder.finish(wallet.bdkwallet) + wallet.persist() logger.debug(f"psbt to {destination_address}: {psbt.serialize()}\n") - psbt_for_signing = bdk.Psbt(psbt.serialize()) - return psbt_for_signing - - -def wait_for_tx(wallet: Wallet, txid: str): - """Wait for tx.""" + return psbt - async def wait_for_tx(): - """Wait for tx.""" - while not wallet.get_tx(txid): - await asyncio.sleep(1) - wallet.trigger_sync() - await wallet.update() - wallet.clear_cache() - asyncio.run(wait_for_tx()) +def wait_for_sync( + qtbot: QtBot, wallet: Wallet, minimum_funds=0, txid: str | None = None, timeout: float = 10_000 +): + def condition() -> bool: + return bool(wallet.get_balance().total >= minimum_funds and (not txid or wallet.get_tx(txid))) + + if wallet.config.network_config.server_type == BlockchainType.CompactBlockFilter: + # since p2p listenting and + # cbf node syncronization happens without any triggering in the background + # we just have to wait + try: + qtbot.waitUntil(condition, timeout=int(timeout)) + except Exception: + logger.info(f"{wallet.get_balance().total=}") + logger.info(f"{(not txid or wallet.get_tx(txid))=}") + raise + + else: + # electrum servers need active sync triggering + async def wait_for_funds(): + """Wait for funds.""" + deadline = asyncio.get_event_loop().time() + timeout + while not condition(): + await asyncio.sleep(0.5) + # first try to wait for incoming p2p transactions + # try the sync + QApplication.processEvents() + wallet.trigger_sync() + await wallet.update() # this is blocking -def wait_for_funds(wallet: Wallet): - """Wait for funds.""" + if asyncio.get_event_loop().time() > deadline: + raise TimeoutError( + f"Conditions not met: {wallet.get_balance().total} >= {minimum_funds} and {wallet.get_tx(txid) if txid else ''} in {wallet.id} within {timeout}s" + ) - async def wait_for_funds(): - """Wait for funds.""" - while wallet.get_balance().total == 0: - wallet.trigger_sync() - await wallet.update() - if wallet.get_balance().total == 0: - await asyncio.sleep(0.5) + logger.info(f"{wallet.id=} received {wallet.get_balance().total} sats") - asyncio.run(wait_for_funds()) + wallet.loop_in_thread.run_foreground(wait_for_funds()) diff --git a/tests/wallet_factory.py b/tests/wallet_factory.py new file mode 100644 index 00000000..d5a77bdc --- /dev/null +++ b/tests/wallet_factory.py @@ -0,0 +1,169 @@ +# +# Bitcoin Safe +# Copyright (C) 2024 Andreas Griffin +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of version 3 of the GNU General Public License as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see https://www.gnu.org/licenses/gpl-3.0.html +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS +# BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN +# ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +# CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. + +from __future__ import annotations + +import asyncio +import logging +import shutil +from collections.abc import Iterable +from dataclasses import dataclass +from pathlib import Path + +import bdkpython as bdk +from bitcoin_safe_lib.async_tools.loop_in_thread import LoopInThread +from pytestqt.qtbot import QtBot + +from bitcoin_safe.cbf.cbf_sync import CbfSync +from bitcoin_safe.wallet import Wallet + +from .helpers import TestConfig +from .setup_bitcoin_core import TEST_DIR, mine_blocks +from .util import wait_for_sync + +logger = logging.getLogger(__name__) + +CBF_DATA_DIR = TEST_DIR / "cbf_data" + + +def remove_cbf_data(data_dir=CBF_DATA_DIR) -> None: + """Remove persisted CBF data between runs for clean state.""" + if data_dir and data_dir.exists(): + shutil.rmtree(data_dir) + + +@dataclass +class TestWalletHandle: + wallet: Wallet + backend: str + cbf_tasks: list[asyncio.Future] + bitcoin_core: Path + + def close(self): + """Cancel background tasks and close wallet resources.""" + for task in self.cbf_tasks: + task.cancel() + self.wallet.close() + + def sync(self, qtbot: QtBot, timeout: float = 10_000): + """Sync the wallet using the configured backend.""" + logger.info("start sync") + wait_for_sync(qtbot=qtbot, wallet=self.wallet, timeout=timeout) + + def mine(self, qtbot: QtBot, blocks=1, address=None, timeout: float = 10_000): + """Mine to the wallet and wait until detected.""" + + bdk_wallet = self.wallet.bdkwallet + txs = bdk_wallet.transactions() + prev_balance = self.wallet.get_balance().total + address = ( + address + if address + else str(bdk_wallet.next_unused_address(keychain=bdk.KeychainKind.EXTERNAL).address) + ) + block_hashes = mine_blocks( + self.bitcoin_core, + blocks, + address=address, + ) + attempts = 0 + max_attempts = 40 + while len(bdk_wallet.transactions()) - len(txs) < len(block_hashes): + attempts += 1 + try: + wait_for_sync( + qtbot=qtbot, wallet=self.wallet, timeout=timeout, minimum_funds=prev_balance + 1 + ) + except RuntimeError as exc: + logger.error(f"Stopping mine wait loop: {exc}") + # raise + if attempts >= max_attempts: + raise RuntimeError("Test wallet sync did not detect mined blocks in time") + logger.debug(f"Test Wallet balance is: {bdk_wallet.balance().total.to_sat()}") + + +def _start_cbf_tasks(wallet: Wallet) -> tuple[list[asyncio.Future]]: + """Start background tasks to consume CBF info/warnings/updates.""" + assert wallet.client, "Wallet backend not initialized" + cbf_client = wallet.client.client + assert isinstance(cbf_client, CbfSync), "CBF client expected" + + tasks: list[asyncio.Future] = [] + + async def _ininite_monitor(getter, label: str): + while True: + try: + msg = await getter() + if msg: + logger.info("%s %s", label, msg) + except asyncio.TimeoutError: + continue + except asyncio.CancelledError: + break + except Exception as exc: # pragma: no cover - defensive + logger.error("%s stream error: %s", label, exc) + await asyncio.sleep(1) + + tasks.append(wallet.loop_in_thread.run_background(_ininite_monitor(cbf_client.next_info, "cbf info"))) + tasks.append(wallet.loop_in_thread.run_background(_ininite_monitor(cbf_client.next_warning, "cbf warn"))) + tasks.append(wallet.loop_in_thread.run_background(_ininite_monitor(wallet.update, "cbf wallet.update"))) + + return tasks + + +def create_test_wallet( + wallet_id: str, + descriptor_str: str, + keystores: Iterable, + backend: str, + config: TestConfig, + bitcoin_core: Path, + loop_in_thread: LoopInThread, + is_new_wallet=False, +) -> TestWalletHandle: + """Build a Wallet ready for tests and start CBF background tasks if needed.""" + wallet = Wallet( + id=wallet_id, + descriptor_str=descriptor_str, + keystores=list(keystores), + network=config.network, + config=config, + loop_in_thread=loop_in_thread, + is_new_wallet=is_new_wallet, + ) + wallet.init_blockchain() + cbf_tasks: list[asyncio.Future] = [] + if backend == "cbf": + cbf_tasks = _start_cbf_tasks(wallet) + + return TestWalletHandle( + wallet=wallet, + backend=backend, + cbf_tasks=cbf_tasks, + bitcoin_core=bitcoin_core, + )